DartでCLIツールを作ろう

この記事はJX通信社アドベントカレンダーの19日目です。

sakebookです。最近はServer Side Kotlinをやってますが、Flutterも少し触ってます。

去年はKotlin で CLI のネタを書いたので今年はそれのDart版を書こうと思います。

tech.jxpress.net

全体の流れ

  • Dartとプロジェクトのセットアップ
  • CLIでの動作確認
  • GitHub Actionsで配布

Dartのインストール

Flutterを使っていればbundleでインストールされていますが、standalone版が必要(後述)なのでHomebrewでいれます。

$ brew tap dart-lang/dart
$ brew install dart

筆者の環境は次の通りです

$ dart --version
Dart VM version: 2.7.0 (Fri Dec 6 16:26:51 2019 +0100) on "macos_x64"

CLIツールのテンプレート作成

Stagehandでプロジェクトテンプレートを作成します(関係ないですが画像が好み)。

まずはStagehandを有効化します。

$ pub global activate stagehand

Stagehandにはいくつかのテンプレートが用意されていますが、今回はCLIツールを作りたいので console-full を選択します。

$ mkdir dart-cli-sample
$ cd dart-cli-sample
$ stagehand console-full

作成したテンプレートは次のような構成になっています。pub packageに則した構成になっています。

.
├── CHANGELOG.md
├── README.md
├── analysis_options.yaml
├── bin
│   └── main.dart
├── lib
│   └── dart_cli_sample.dart
├── pubspec.yaml
└── test
    └── dart_cli_sample_test.dart

dartファイルを見ていきます。

  • bin/main.dart
import 'package:dart_cli_sample/dart_cli_sample.dart' as dart_cli_sample;

void main(List<String> arguments) {
  print('Hello world: ${dart_cli_sample.calculate()}!');
}
  • lib/dart_cli_sample.dart
int calculate() {
  return 6 * 7;
}

mainはbinにあり、実装はlibに置くような構成になっています。

テストも作成されます。

  • test/dart_cli_sample_test.dart
import 'package:dart_cli_sample/dart_cli_sample.dart';
import 'package:test/test.dart';

void main() {
  test('calculate', () {
    expect(calculate(), 42);
  });
}

動かしてみる

初回は依存ライブラリのDLが必要です。

$ pub get
$ dart bin/main.dart
Hello world: 42!

この状態だと、Dartコードを実行しただけです。

Dartコードを変換してネイティブコードにします。

ネイティブコードの作成

dart2native コマンドを実行して作成します。このコマンドはFlutterにbundleされているDartには含まれていません。

$ dart2native bin/main.dart -o main
Generated: /YOUR_PATH/dart-cli-sample/main

作成した main を実行してみます。

$ ./main 
Hello world: 42!

無事実行できました。

制約

どこでも実行可能なものができたと思いきや、現状はホストOS用のネイティブコードしかコンパイルされません。なので、macOS, Windows, Linuxとそれぞれでコンパイルしないとダメです。

現状の制約は辛いですが、その辛さを和らげる方法があります。

GitHub Actions

GitHub Actionsでは、マトリクスビルドをサポートしています。これを利用して、OSごとに実行してそれぞれに対応したネイティブコードを作成します。

コードの修正

各OSで動かしていることがわかるように、コードを変更します。

  • bin/main.dart
import 'dart:io';

import 'package:dart_cli_sample/dart_cli_sample.dart' as dart_cli_sample;

void main(List<String> arguments) {
  stdout.writeln('Hello ${dart_cli_sample.system()}!');
  exitCode = 0;
}
  • lib/dart_cli_sample.dart
import 'dart:io';

String system() {
  return Platform.operatingSystem;
}

動作環境のOS名を返すコードです。

Actionを定義

.github/workflows/ にyamlファイルを置きます。

先に完成したものを貼っておきます。

name: Cross compile
on: [push]

jobs:
  build:
    name: Compile
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      # https://help.github.com/ja/actions/automating-your-workflow-with-github-actions/virtual-environments-for-github-hosted-runners#supported-runners-and-hardware-resources
      matrix:
        os: [windows-latest, ubuntu-latest, macos-latest]
        include:
          - os: windows-latest
            file-name: windows.exe
          - os: ubuntu-latest
            file-name: ubuntu
          - os: macos-latest
            file-name: macos
    steps:
      - name: Checkout
        uses: actions/checkout@v1
        # https://dart.dev/get-dart
      - name: Install Dart(windows)
        if: matrix.os == 'windows-latest'
        run: |
          choco install dart-sdk
      - name: Install Dart(ubuntu)
        if: matrix.os == 'ubuntu-latest'
        run: |
          sudo apt-get update
          sudo apt-get install apt-transport-https
          sudo sh -c 'wget -qO- https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -'
          sudo sh -c 'wget -qO- https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list > /etc/apt/sources.list.d/dart_stable.list'
          sudo apt-get update
          sudo apt-get install dart
      - name: Install Dart(macos)
        if: matrix.os == 'macos-latest'
        run: |
          brew tap dart-lang/dart
          brew install dart
      - name: Build(windows)
        if: matrix.os == 'windows-latest'
        run: |
          $env:ChocolateyInstall = Convert-Path "$((Get-Command choco).Path)\..\.."
          Import-Module "$env:ChocolateyInstall\helpers\chocolateyProfile.psm1"
          refreshenv
          pub get
          dart2native bin/main.dart -o bin/${{ matrix.file-name }}
      - name: Build(ubuntu)
        if: matrix.os == 'ubuntu-latest'
        run: |
          echo 'export PATH="$PATH:/usr/lib/dart/bin"' >> ~/.profile
          source ~/.profile
          pub get
          dart2native bin/main.dart -o bin/${{ matrix.file-name }}
      - name: Build(macos)
        if: matrix.os == 'macos-latest'
        run: |
          pub get
          dart2native bin/main.dart -o bin/${{ matrix.file-name }}
      - name: Upload artifact
        uses: actions/upload-artifact@v1
        with:
          name: bin
          path: bin
  execute:
    name: Run artifact
    needs: build
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [windows-latest, ubuntu-latest, macos-latest]
        include:
          - os: windows-latest
            file-name: windows.exe
          - os: ubuntu-latest
            file-name: ubuntu
          - os: macos-latest
            file-name: macos
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v1
        with:
          name: bin
      - name: Run(windows)
        if: matrix.os == 'windows-latest'
        run: |
          cd bin
          .\${{ matrix.file-name }}
      - name: Run(ubuntu)
        if: matrix.os == 'ubuntu-latest'
        run: |
          cd bin
          chmod 755 ${{ matrix.file-name }}
          ./${{ matrix.file-name }}
      - name: Run(macos)
        if: matrix.os == 'macos-latest'
        run: |
          cd bin
          chmod 755 ${{ matrix.file-name }}
          ./${{ matrix.file-name }}

Dartをインストール

ホストOSで実行させたいので、JavaScriptアクションを使います。MarketPlaceにDartのJavaScriptアクションが見当たらなかったので、愚直にDartをインストールしました。

コンパイル

インストールしたDartへのPATHを通してコンパイルします。同一フォルダに出力してartifactとしたかったので、ファイル名をincludeでそれぞれ定義しています。

実行

それぞれのOSで生成したネイティブコードをそれぞれのOSで実行してみた結果です。

  • windows

f:id:sakebook:20191218192514p:plain

  • ubuntu

f:id:sakebook:20191218192541p:plain

  • macos

f:id:sakebook:20191218192602p:plain

しっかりOS名が出力されています。

手元でそれぞれ動かしてみたい方はこちらからartifactをDLできます。

各OSでしか実行できないことがわかると思います。

まとめ

テンプレート作成だったり、今回は触れませんでしたが引数をパースするライブラリも提供されており、DartでCLIツールは作りやすいです。

まだ制約もありますが、今回の様なやり方で制約を緩和することができます。

今回動作確認したリポジトリはこちらです。

github.com

参考

Get the Dart SDK | Dart

GitHub - dart-lang/stagehand: Dart project generator - web apps, console apps, servers, and more.

Write command-line apps | Dart

dart2native | Dart

Workflow syntax for GitHub Actions - GitHub Help

installation - How to refresh the environment of a PowerShell session after a Chocolatey install without needing to open a new session - Stack Overflow