Flutterプロジェクトの場合、関連ツール(CLIアプリなど)はDartで組むと捗る🎯
Advent Calendar 2023年12月25日の記事です🎅
大抵のアプリの場合、リリースまでのフローで何かしら定型処理が発生するはずです。
手動以外で済ませる場合、とりあえずシェルスクリプトで済ませがちなことも多いだろうと思っています。
ただ、Flutterプロジェクトの場合、その開発メンバー全員がDartに慣れ親しんでいることは自明であり、Dartで書いた方が得策なことも多いように思っています。
(シェルスクリプトで組むより必ずしも優れているというわけではなく状況次第でありますが、少なくともDartで書くのとどちらが良いか検討するのは有意義だと思っています)
とはいえ、Flutterアプリを普通に組む手段としてDartコードを書いてただけだと、実際にDartをこれまでシェルスクリプトで書いてきたような処理の代わりに使おうとすると、各種処理をどう書けば良いのか迷ったり躓くことも多いだろうと思います。本記事ではそのあたりの障壁を軽減できるようなTipsを紹介していきます。
Dartプログラムはどうやって書く?
ターミナルで以下をコピペして実行すると、 hello
と出力されます。
echo "main() => print('hello');" > foo.dart
dart run foo.dart
このように、適当にmain関数が含まれるファイルを生成して、 dart run ファイル名
で呼ぶのが最も基本的な扱いです。
ただし、これだと標準SDKだけの利用になり便利なパッケージが使えなかったり静的解析なども任意のものを設定できず、実用的ではないです。
なので、まず初めに flutter create
でプロジェクトファイル群を生成するFlutterアプリと同じ要領で、 dart create
でDartプロジェクトを生成するのがまずはじめの手順となります。
dart create script
を実行した直後の結果は次のようになり、Flutterプロジェクトと大体似たような感じの構成です。
├── analysis_options.yaml
├── bin
│ └── script.dart
├── CHANGELOG.md
├── lib
│ └── script.dart
├── pubspec.lock
├── pubspec.yaml
├── README.md
└── test
└── script_test.dart
libフォルダがコア実装で、binはほぼ単純にそれを呼ぶだけ(入力コマンドを処理してlibに受け渡すだけ)という関係になっています。典型的な構成例の1つですが、必ずしもこれに絶対従わなければならないわけでもないと思います ( dart create --template cli
の生成結果には lib
フォルダは無かったりします)。
// bin/script.dart
import 'package:script/script.dart' as script;
void main(List<String> arguments) {
print('Hello world: ${script.calculate()}!');
}
// lib/script.dart
int calculate() {
return 6 * 7;
}
Flutterで見慣れた以下のファイルが含まれており、基本的にはこのまま普段の感覚で開発を進められるはずです。
pubspec.yaml
: 依存パッケージの指定などanalysis_optoins.yaml
: 静的解析の設定など
Dartプログラムの呼び出し
dart Dartファイル名
でも呼べますが、今はそれを置き換えた dart run Dartファイル名など
で呼ぶのが推奨です。
上記ドキュメントにも書いてありますが、 pubspec.yaml
で依存しているパッケージ(仮に foo
とする)の場合は、ファイル名ではなく dart run foo
で呼べます。
グローバルインストールしたものではなく pubspec.yaml
で指定された任意のバージョンで実行したい時に活用されることが多い( melos を melos
と呼ぶのではなく pubspec.yaml
でバージョン指定しつつ dart run melos
と呼ぶなど)ですが、マルチパッケージ方式で用意した自前スクリプトを呼ぶ時にも簡潔に呼べる( dart run packages/script/bin/script.dart
ではなく dart run script
で済む)ようにできて便利だったりします。
ここからはスクリプト系のDartコードの書き方について書いていきます。
任意のコマンド実行
Process.run
第一引数にコマンド、第二引数に引数群指定で、その他細かい指定もできます。
基本的にシェルスクリプトをそのままダイレクトにこれに書き換えられます。
例: touch bar.txt
→ Process.run('touch', ['bar.txt']);
結果は Future<ProcessResult>
型なので、以下などの結果をチェック・出力しながら処理を進めていきます。
final result = await Process.run('touch', ['bar.txt']);
print(result.exitCode);
print(result.stdout);
print(result.stderr);
Sync
版の存在
Process.run
にはsync版(同期版)の Process.runSync
メソッドも用意されています。
これに限らず、スクリプト系の処理でよく利用されるようなメソッド全般に共通して大抵は同様にsync版が用意されています。
これを使うと、その名の通り同期的に処理され、つまりその処理待ちの間に同一スレッドで並行処理をすることができずブロッキングすることになります。
ただ、一般的なシェルスクリプトは上の行から順に直列実行していくだけのことが多く、それと同様にDartで同等の処理を書く場合でもsync版で問題ないことが多いです。
Dartはasync/await対応言語なので非同期処理でも同期処理と似たような感じで書けてしまうとはいえ、後者のように余計なキーワード無しで済むに越したことはないと思います。
// 非同期版
Future<void> async() async {
final result = await Process.run('touch', ['bar.txt']);
}
// sync版(同期版)
void sync() {
final result = Process.runSync('touch', ['bar.txt']);
}
なので、個人的には非同期処理である必要がない(並行処理する必要がない)場面ではsync版(同期版)を使うようにしつつ、偶に並行処理が必要なことがあればその時だけ非同期版を使うのが良いかなと思っています(実際には前者の同期版利用で済むことがほとんど)。
あるいは、Flutterでいつもコード書いている感覚と合わせたいという理由で、非同期版常用するというのもありかもしれませんが。
以降は、何か指す場合はSync版については併記などせず非同期版表記のみで書きます。
Process.run 使わずに済むAPIがある場合はそれを使う
上の例ではファイル作成用途で Process.run
から touch
コマンドを呼びましたが、そういう場合は実際には File.create
を使うのが良いと思っています。
- ファイル名以外が型セーフになる(
touch
コマンド名などのtypoなどせずに済む) - 引数が分かりやすい
一方、Process.run
乱用しているとわざわざDartで書く意義が薄れていき、「これなら普通にシェルスクリプトで済ませても似たようなもんだった」というようなコードになっていってしまいます。
例えば、とあるフォルダ階層下にファイルを作りたい場合、 Process.run
で愚直に書くと次のようになります:
// --parentsオプションだと少し分かりやすくなるが
Process.runSync('mkdir', ['-p', 'foo/bar']);
Process.runSync('touch', ['foo/bar/buzz.txt']);
一方、File.create
を使うとこうなります:
File('foo/bar/buzz.txt').createSync(recursive: true);
後者のFile.create
の場合、ファイルパス文字列以外は型セーフになっていて、 recursive: true
という指定もそのフォルダが無い場合は作るだろうということがドキュメント見ずとも直感的に読み書きしやすいです。
このように、各操作(特にファイル操作など)はDartのAPI利用で型セーフに書けることが多いので、安易に Process.run
で済まさずにまずはそういうAPIがDart標準および公式や定番パッケージに用意されていないかを調べて極力活用するのが良いです。
exitCode
(終了ステータス)の扱い
各処理結果が想定外だったりでそれ以上続行不可能なエラー状態になって処理を打ち切る場合、0以外のexitCodeで終了させます。
Dartの通常のAPI利用の場合はtry-catchで例外処理することが多いですが、Process.run
の場合はエラーでも上述の通り exitCode
を包含するProcessResult型が正常に返ってくるので自前で適切にチェック・処理する必要があります(このチェックを忘れると処理が未完のまま後続の処理に進んでしまって意図しない結果に陥ったりします)。例えば返ってきたexitCodeが0以外ならばその値で終了させたい場合は次のような感じに書きます。
final result = Process.runSync('touch', ['foo/bar/buzz.txt']);
if (result.exitCode != 0) {
stderr.write(result.stderr);
exit(result.exitCode);
print('Never reached'); // 実際には書かない
}
exit関数 の戻り値はNever型で、つまり分岐でそこに辿り着いたら、後続の処理には絶対に到達しないことが型として表現されています。なので、自分で明示的に return;
などで後続の処理に行かないように制御する必要はないです。
さらに、exitCode というグローバルなsetter/getterもあり、上のコードは以下と同等です。
final result = Process.runSync('touch', ['foo/bar/buzz.txt']);
if (result.exitCode != 0) {
stderr.write(result.stderr);
exitCode = result.exitCode; // グローバルに設定
return;
}
ただ、この使い方だとあまり意味がなく、前者の exit(result.exitCode);
の方が1行で簡潔に済む素直な分かりやすい書き方です。
flutterfire_cli の以下のようにそれを設定して失敗ステータスに変えつつも、returnせずに後続の処理に続くようにする場合などが意味のある使い方だと思います。
未キャッチ例外が発生するとexitCodeは255扱いになる
try-catchの処理から漏れた未キャッチ例外が発生して終了すると、exitCodeは255になります(Dartプログラム終了後に echo $?
で確認できます)。255は範囲外の終了ステータスの意味とのことで、確かに255でしっくり来ます。この場合、エラー内容とスタックトレースが出力されて、原因およびその箇所も分かりやすいです。具体的なエラーする条件が不明確なまま念のため例外処理するよりも、未キャッチ例外発生のログに任せた方が得策なことも多いように思います(変に例外処理すると分かりにくくなったりエラー握り潰し系のことをしがち)。
もちろん、例えば引数が求めているフォーマットと食い違っていないかどうかなどのような、充分予期できるようなことについてはチェックして分かりやすいフィードバックメッセージを返すのがベターですが。
ログ出力
意外と迷うのがログ出力手段です。いくつか手段があります。
- Flutterで慣れているロガーパッケージを使う
- コマンドラインツールに適したロガーパッケージを使う
print
・stdout.write
・stderr.write
などの標準APIを使う
stdout.write
・stderr.write
などの標準APIを使う
自分のプロジェクト用のあまり凝らない処理実行では、とりあえずこれで充分なことが多い気がします。
コマンドラインツールに適したロガーパッケージを使う
この場合、公式パッケージの cli_util の https://pub.dev/documentation/cli_util/latest/cli_logging/cli_logging-library.html を使うのが良さそうです。
https://pub.dev/packages/cli_util#displaying-output-and-progress の例のように単純な文字出力の場合は特に予備知識なく直感的・簡単に使えますね。
melosのロガーはこれをラップして組まれていて、外部公開パッケージで丁寧な対応をしたい場合はこのようにするのが良さそうです。
https://github.com/invertase/melos/blob/main/packages/melos/lib/src/logging.dart
まとめると、Dartコンソールアプリのログ出力の定石は無いですが、 print
で不都合無いならそれでも良いし、cli_util など便利なパッケージを知っているならそれを使うとベターで、場合によってはさらにそれをラップするのもあり、という感じでしょうか。
その他よく使うAPIなど
挙げるとキリがないですが、よく使うものを一部抜粋します。上述の Process.run
乱用をせずにDart提供のAPIを使うイメージの足がかりになればと思います。
Directoryクラス
上でFileクラスに少し触れましたが、Directoryクラスもあります。名前の通りディレクトリ関連の操作に使いますが、特に current staticプロパティ で以下のディレクトリ移動などに使うことなどよくあると思います。
// foo相対パスに移動
Directory.current = 'foo';
// 同じ意味だが、あまり良くない
Process.run('cd', ['foo']);
pathパッケージ
Dart公式のpathパッケージは使う場面がちょくちょくあります。
特に、join関数 を使う機会が多いと思います。次のようにハイフンなどのseparator をよしなに扱ってくれます。
p.join('path', 'to', 'foo'); // -> 'path/to/foo'
p.join('path/', 'to', 'foo'); // -> 'path/to/foo'
p.join('path', '/to', 'foo'); // -> '/to/foo'
単に文字列かつseparator文字列が /
前提で良いなら、path/to/foo
と書いた方がむしろ分かりやすいですが、変数を繋げてpathを構築したい時などに末尾の /
の有無など気にせずに済んで良いです。
argsパッケージ
これもDart公式パッケージで、これを使うと、main関数に渡ってくる List<String>
型の引数を配列操作などで愚直に自前処理するのではなく、ArgParser に与えたオプション・フラグの定義に従ってスマートにパースできるようになります。
上でも少し触れた、dart create --template cli
を実行すると、これを利用した雛形が初期生成されます。
// https://github.com/dart-lang/sdk/blob/main/pkg/dartdev/lib/src/templates/cli.dart
import 'package:args/args.dart';
const String version = '0.0.1';
ArgParser buildParser() {
return ArgParser()
..addFlag(
'help',
abbr: 'h',
negatable: false,
help: 'Print this usage information.',
)
..addFlag(
'verbose',
abbr: 'v',
negatable: false,
help: 'Show additional command output.',
)
..addFlag(
'version',
negatable: false,
help: 'Print the tool version.',
);
}
void printUsage(ArgParser argParser) {
print('Usage: dart xxx.dart <flags> [arguments]');
print(argParser.usage);
}
void main(List<String> arguments) {
final ArgParser argParser = buildParser();
try {
final ArgResults results = argParser.parse(arguments);
bool verbose = false;
// Process the parsed arguments.
if (results.wasParsed('help')) {
printUsage(argParser);
return;
}
if (results.wasParsed('version')) {
print('xxx version: $version');
return;
}
if (results.wasParsed('verbose')) {
verbose = true;
}
// Act on the arguments provided.
print('Positional arguments: ${results.rest}');
if (verbose) {
print('[VERBOSE] All arguments: ${results.arguments}');
}
} on FormatException catch (e) {
// Print usage information if an invalid argument was provided.
print(e.message);
print('');
printUsage(argParser);
}
}
pub_semver パッケージ
広く普及している Semantic Versioning 2.0.0-rc.1 準拠のバージョン文字列を扱うパッケージで、 puspec.yaml のバージョンの取り扱いにも使われています。プログラム処理でバージョン文字列をパースするなどしたい場合は大抵適合するはずです。
知らないとつい自前の文字列処理で済ませちゃったりするかもしれませんが、すでにある信頼できるパッケージを活用する方が手間・バグ少なくなることが多いはずです。
cider パッケージ
pubspec.yamlやCHANGELOGの操作ができるコマンドラインツールです。
これはDart公式ではなくかつあまり有名ではないですが、個人的にはけっこう前から気に入って愛用しています。
例えば、Flutterプロジェクトの pubspec の version のビルド番号をインクリメントするという処理もこれでサクッと書けるので、Flutterアプリのデプロイ処理自動スクリプト作成などにも役立つはずです。
Riverpodの利用
Flutterでお馴染みのRiverpodも、riverpodパッケージなどはFlutter非依存のため使えます。
Dartコードの差し替えなど可能なDIコンテナー目的で使えるのはもちろん、マルチパッケージ構成のFlutterプロジェクトの特定ロジックのパッケージで定義されたProviderをDartコンソールアプリから利用したい時にも使えて良いです。
Dart単体で使う場合はこちらのexampleが参考になります:
https://github.com/rrousselGit/riverpod/blob/master/packages/riverpod/example/lib/main.dart
Grinder
Google製のgrinderというパッケージがあり、タスクランナー用途(Makefileのような感じ)で使えます。
これを使うと、Dartコンソールアプリプロジェクトとして分けずとも利用可能です。どちらが適しているかはケースバイケースで、例えば細々としたボリューム少なめの処理を多数定義する場合はGrinderで良いかもしれません。
また、便利関数も包含されているので、メイン機能のタスクランナー定義としては使わずに関数だけ利用というのもありです。
grinderのrun関数
grinderのrun関数はProcess.runをラップして次のような挙動にしているため、Process.run代わりに便利です。
- 標準エラー出力を自動でしてくれる
- exitCodeが0以外だったら例外を投げてくれる
- 戻り値は標準出力文字列のString
上でProcess.runを使ってexitCodeを取り扱うコード例を挙げましたが、run関数なら以下などで済み、意図通りの処理をシンプルに書きやすいです。
// exitCodeが0以外だったらエラーで終了して良い場合
run('touch', arguments: ['foo/bar/buzz.txt']);
// エラー内容によって何か処理したい場合
try {
run('touch', arguments: ['foo/bar/buzz.txt']);
} on ProcessException catch (e) {
// 何か適当な処理
exit(e.exitCode);
}
grinderのlog・fail関数
ログ系の関数も使いやすいものが用意されています。
Mason
また、処理内容によってはmasonも検討した方が良いかもしれません。bricksという再利用可能なテンプレートを元に自動生成するツールです。
BrickHub にあがっている各種テンプレートを弄ったりすると利用シーンのイメージが湧くと思います。
参考になるOSS
以下のように普段から触れているDart製ツールで気になる箇所を読んだり、pubspec.yamlを覗いたりすると、ちょくちょく参考になる新しい発見があってお勧めです。
いろいろ駆け足になってしまいまって分かりにくいところもあると思うので、もう少し修正・加筆するかもしれません( ´・‿・`)