Dart CLIアプリケーションの構築
目次
- 1. コアアーキテクチャとプロセスライフサイクル
- 2. ストリーム、診断、フォーマット
- 3. プロジェクト設定とパッケージング
- 4. 引数解析とコマンドルーティング
- 5. ネイティブ非同期とモダンスタックトレース
- 6. サブプロセス生成とAOT耐性
- 7. シグナルハンドリングと終端ティアダウン
- 8. CLIアプリケーションのテスト
- 9. モダンコンパイルと配布
- 10. ワークフローと監査チェックリスト
- 参考文献と例
1. コアアーキテクチャとプロセスライフサイクル
破壊的な終了を避ける (exit(N))
dart:ioのexit(int code)を呼び出すと、C++ランタイムでPlatform::Exit(code)が呼び出されます。これはDartスタックを巻き戻さずに即座にOSプロセスを終了させます。
- デバッガ切断:
--pause-isolates-on-exitフラグで起動した場合、VMサービスはシャットダウン前にアイソレートを一時停止し、IDEでの検査を可能にします。exit()はVMサービスが状態を一時停止または検査する前にOSプロセスを終了させます。 - カバレッジ損失:
package:coverageは、一時停止中にVMサービスのRPCを介して実行行を照会します。exit()はRPC抽出前にプロセスを破棄するため、0%のカバレッジになります。 - バッファ切り捨て:
stdoutとstderrはバッファリングされた非同期IOSinkストリームです。exit()はフラッシュされていないバイトをドロップします。 - リソースリーク:
finallyブロック(ロックの解放、一時ディレクトリの削除など)がバイパスされます。
ルール: 通常の実行中にexit(code)を直接呼び出すのは避けてください。代わりにexitCode = codeを設定するか、package:argsのCommandRunner<int>から整数の終了コードを返し、非同期main()関数が自然に戻るのを許可してください。未処理のエラーに対してexit()を呼び出さないでください。代わりに、未処理のErrorまたは例外をスローして、ランタイムがクリーンに巻き戻され、非ゼロのステータスで終了するようにしてください。
標準のPOSIX終了コード (/usr/include/sysexits.h):
0: 成功 (EX_OK/ExitCode.success.code)64: コマンドライン使用法エラー (EX_USAGE/ExitCode.usage.code)65: データ形式エラー (EX_DATAERR/ExitCode.data.code)70: 内部ソフトウェアクラッシュ (EX_SOFTWARE/ExitCode.software.code)78: 設定エラー (EX_CONFIG/ExitCode.config.code)
注: package:io/io.dartをインポートし、ExitCode定数(例: ExitCode.usage.code、ExitCode.software.code)を使用することを推奨します。マジックナンバーの整数リテラルは避けてください。パッケージ依存関係のない最小限のスタンドアロンスクリプトの場合は、標準のPOSIX整数リテラル(0、64、70)を使用してもかまいません。
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:io/io.dart' show ExitCode; // 標準のPOSIX ExitCode定数を提供
Future<void> main(List<String> args) async {
final runner = CommandRunner<int>('tool', 'CLIツールの説明。');
try {
final status = await runner.run(args);
exitCode = status ?? ExitCode.success.code;
} on UsageException catch (e) {
stderr
..writeln(e.message)
..writeln(e.usage);
exitCode = ExitCode.usage.code;
}
}
シンエントリポイントパターン (bin/ 対 lib/src/)
bin/*.dartファイルは厳密に最小限のエントリポイントトランポリン(ランナーのインスタンス化、argsの受け渡し、終了コードの待機)として保持してください。すべてのコマンド定義、引数パーサー、フォーマッター、ビジネスロジックはlib/src/内に配置してください。
- 根拠:
bin/内のコードはpackage:URIを介してクリーンにインポートできません。ロジックをlib/src/に移動することで、コマンドランナー、サブコマンド階層、ビジネスロジック全体を、OSサブプロセスを起動することなく、ミリ秒単位(< 2ms)でインメモリ単体テストできます。
// bin/my_cli.dart — シンエントリポイントトランポリン
import 'dart:io';
import 'package:my_cli/src/cli.dart';
Future<void> main(List<String> args) async {
exitCode = await runCli(args);
}
2. 出力、診断、フォーマット
- データ vs 診断: 意図したプログラム結果と機械可読データは専ら
stdoutに書き出します。警告、エラーメッセージ、デバッグログは専らstderrに書き出します。 - エラー使用法ルール: 引数解析または必須オプションエラー(
FormatException、UsageException、または必須オプションへのアクセス時にスローされるArgumentError)が発生した場合、エラーメッセージと使用法テキストの両方をstderrに書き出し、終了コード64(EX_USAGE/ExitCode.usage.code)を返さなければなりません。stdoutが使用法ヘルプを受け取るのは、ユーザーが--helpまたは-hで明示的に要求した場合のみです。 - エラーハンドラでの
print()禁止:print()はstdoutにルーティングされます。すべての失敗通知にはstderr.writeln()を使用してください。標準出力には、avoid_printリンタールール(analysis_options.yamlで明示的にavoid_print: falseを設定していない限り)に準拠するために、print()よりもstdout.writeln()を推奨します。 - 端末機能検出と
NO_COLOR: ANSIカラーまたはカーソルエスケープコードを出力する前に、stdout.hasTerminal、stdout.supportsAnsiEscapes、および!Platform.environment.containsKey('NO_COLOR')を確認してください:bool get useAnsi => stdout.hasTerminal && stdout.supportsAnsiEscapes && !Platform.environment.containsKey('NO_COLOR'); - 機械可読モード:
--jsonまたは--machineフラグが渡された場合、データをstdoutにJSONとしてフォーマットし、ログをstderrにルーティングします。
3. プロジェクト設定とパッケージング
スキャフォールディングとPubspec実行可能マッピング (executables:)
dart create -t console <package_name>を使用して新しいコマンドラインプロジェクトのスキャフォールディングを行います。これにより標準のbin/およびlib/レイアウトが初期化されます。pubspec.yamlのexecutables:の下で常に実行可能ファイルを宣言し、コマンド名をbin/内のスクリプトにマッピングします。これにより、dart run <command>(bin/...dartを指定せずに)を介したクリーンな呼び出しが可能になり、dart installのためのグローバルバイナリシンボリックリンクが設定されます:
name: my_cli
description: 高性能CLIツール。
version: 1.0.0
executables:
my_cli: # bin/my_cli.dart にマッピング
secondary_cmd: helper # bin/helper.dart にマッピング
単一ソースバージョニング (package:build_version)
bin/*.dartに--version文字列をハードコーディングしたり、定数ファイルを手動で同期したりしないでください。package:build_versionを使用して、ビルド中にpubspec.yamlから直接lib/src/version.dart(const packageVersion = 'x.y.z';を含む)を生成してください。
キャッシュ規則
一時キャッシュファイルは.dart_tool/<package_name>/に保存します。永続キャッシュファイルをプロジェクトルートに直接書き込まないでください。
4. 引数解析とコマンドルーティング
コマンドライン引数を管理するにはpackage:argsをインポートします:
- シンプルなスクリプト:
addFlag()とaddOption()を使用してArgParserを直接使用します。 - マルチコマンドツール:
CommandRunner<int>を実装し、各サブコマンドに対してCommand<int>を拡張し、POSIX終了コードを直接返します。 - 型安全なアクセサ: 手動の型キャスト(
as bool、as String?)を排除するために、マップインデックスoperator []の代わりにresults.flag('name')、results.option('name')、results.multiOption('name')(package:args2.5+で利用可能)を使用します。 - 複雑なオプションモデル: 多数のフラグを持つアプリケーションの場合は、
package:build_cliを使用して厳密に型指定されたオプションクラスを生成します。名前付きデフォルトオーバーライド(例:{String? hostDefaultOverride})を活用して、設定ファイルとCLIフラグをクリーンにマージします。
5. ネイティブ非同期とモダンスタックトレース
Chain.capture()を避ける: Dart VMはawaitサスペンションポイント間で非同期スタックフレームをネイティブに保持します。Chain.captureはイベントループをカスタムゾーンでラップし、かなりの割り当てオーバーヘッドを発生させ、ゾーン境界を越えてエラーをトラップします。Trace.from(st).terseでサニタイズする: ゾーンをキャプチャせずに、キャッチされなかったエラーに対してpackage:stack_traceの静的ユーティリティを使用します:
import 'dart:io';
import 'package:io/io.dart' show ExitCode;
import 'package:stack_trace/stack_trace.dart';
Future<void> runMain(List<String> args) async {
try {
await executeLogic(args);
exitCode = ExitCode.success.code;
} catch (e, st) {
stderr.writeln('致命的なエラー: $e');
if (args.contains('-v') || args.contains('--verbose')) {
stderr.writeln(Trace.from(st).terse);
}
exitCode = ExitCode.software.code;
}
}
6. サブプロセス生成とAOT耐性
Dart SDKサブプロセスを生成したり、他のDartツール(例: dart format、dart test、build_runner)を実行する場合:
Platform.resolvedExecutableまたはPlatform.executableがdartコマンドライン実行可能ファイルを指していると想定しないでください: スタンドアロンAOTコンパイルバイナリ(dart install/dart compile exe)では、resolvedExecutableはコンパイルされたアプリケーションバイナリ自体を指すため、再帰的な自己呼び出しループやフラグ拒否クラッシュが発生します。package:cli_utilを使用する: カスタムのPATHやディレクトリスクレイパーを記述する代わりに、cli_util.dartExecutableまたはcli_util.sdkPathを使用してDart SDK実行可能ファイルを解決します。- バージョン要件と詳細なテクニカルガイドについては、references/aot_sdk_discovery.mdを参照してください。
7. シグナルハンドリングと終端ティアダウン
CLIが端末モードを変更したり、スピナーを表示したり、リスニングソケットを開いたりする場合:
- Windowsシグナルガード: Windowsでは、
ProcessSignal.sigterm.watch()はUnsupportedErrorをスローします。sigtermはif (!Platform.isWindows)でガードしてください。 - エコーとラインモードのティアダウン:
stdin.echoMode = falseまたはstdin.lineMode = falseを設定する場合は、最初にif (!stdin.hasTerminal) return;を確認し、SIGINTリスナーとfinallyブロックをインストールして、終了後もユーザーのキーストロークが表示されたままになるように復元します。 - カーソルの可視性: ANSIカーソル非表示エスケープコード(
\x1B[?25l)を出力する場合は、終了時またはキャンセル時に必ずカーソルの可視性を復元してください(\x1B[?25h)。 - ソケットのクリーンアップ: 終了シグナルで、リスニング中の
HttpServerまたはServerSocketインスタンスを明示的に閉じて(server.close(force: true))、OSポートを即座に解放してください。 - 詳細なパターンについては、references/signals_and_terminal.mdを参照してください。
8. CLIアプリケーションのテスト
テストを2つの異なるレイヤーで構成します:
- 単体テスト(インメモリ、
< 5ms):test/内でpackage:<pkg>/src/...をインポートして、コマンドクラス、オプション解析、ビジネスロジックを直接テストします。 - 統合テスト(サブプロセス):
package:test_processとpackage:test_descriptorを使用して、エンドツーエンドのバイナリ実行、プロセスI/Oストリーミング、OS終了コードを検証します:
import 'package:test/test.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;
import 'package:test_process/test_process.dart';
void main() {
test('CLIが入力を処理し、クリーンに終了する', () async {
await d.file('input.txt', 'hello').create();
final process = await TestProcess.start('dart', [
'run',
'bin/my_cli.dart',
'--input',
d.path('input.txt'),
]);
await expectLater(process.stdout, emitsThrough('処理が完了しました。'));
await process.shouldExit(0);
});
}
9. モダンコンパイルと配布
Dart 3.12+ は、dart runとdart installを中心にCLI配布を標準化します(dart pub global activateから移行):
- 一時的実行 (JIT):
dart run <package>@<version> [args]はCLIをオンデマンドでダウンロードして実行します。 - グローバルインストール (ネイティブAOT):
dart install <package>は、パッケージのエントリポイントを~/.dart/install/bin/内の高速なネイティブスタンドアロンバイナリにコンパイルします。 - ローカル開発:
dart run <command>(pubspec.yamlのexecutables:で解決)またはdart run bin/cli.dartを使用します。 - 動的ライブラリとコードアセットのバンドル:
dart build cliを使用します。バンドルはbuild/cli/_/bundle/に出力されます。 - スタンドアロン実行可能ファイルのコンパイル:
dart compile exe bin/cli.dart -o <output_path>を使用します。
10. ワークフローと監査チェックリスト
実装ワークフロー
-
pubspec.yamlのexecutables:の下にエントリポイントを宣言します。 -
bin/*.dartはシンエントリポイントとして保持し、コマンドロジックはlib/src/に配置します。 - 整数の終了コードを返すか、
exitCode = Nを設定します。生のexit(N)は避けてください。 - 解析失敗時のエラー、警告、使用法は
stderrに出力します。 - 型安全性のために
results.flag()、results.option()、results.multiOption()を使用します。 - ANSIコードを出力する前に
useAnsi(stdout.hasTerminal、supportsAnsiEscapes、NO_COLORを確認)を検証します。 - 子ツールの生成には
cli_util.dartExecutableを使用し、決してPlatform.resolvedExecutableを使用しないでください。 - コマンドランナーをインメモリで単体テストし、
test_processでエンドツーエンドのバイナリ実行をテストします。
参考文献と例
- 単一コマンドツールテンプレート: examples/single_command_tool.dart
- マルチコマンドランナーテンプレート: examples/multi_command_runner.dart
- AOT SDK検出とサブプロセス生成: references/aot_sdk_discovery.md
- シグナルハンドリングと端末ティアダウン: references/signals_and_terminal.md