建置 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)。它會立即終止作業系統程序,而不會 unwinding Dart 堆疊:
- 除錯器中斷連線:當使用
--pause-isolates-on-exit啟動時,VM 服務會在關機前暫停 isolates 以允許 IDE 檢查。exit()在 VM 服務可以暫停或檢查狀態之前終止了作業系統程序。 - 涵蓋率遺失:
package:coverage會在暫停結束狀態期間透過 VM 服務 RPC 查詢執行行數。exit()在 RPC 提取前銷毀程序,導致 0% 涵蓋率。 - 緩衝區截斷:
stdout和stderr是緩衝的非同步IOSink串流。exit()會丟棄未刷新的位元組。 - 資源洩漏:
finally區塊 (關閉鎖定、刪除暫存目錄) 會被繞過。
規則:避免在正常執行期間直接呼叫 exit(code);應設定 exitCode = code 或從 CommandRunner<int> (來自 package:args) 回傳整數結束代碼,並允許非同步的 main() 函數自然回傳。不要在未處理的錯誤上呼叫 exit();應擲出未處理的 Error 或例外,以便執行階段乾淨地 unwinding 並以非零狀態結束。
標準 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/ vs. lib/src/)
保持 bin/*.dart 檔案嚴格作為精簡的進入點跳板 (實例化 runner、傳遞 args、等待結束代碼)。將所有命令定義、引數解析器、格式化程式和業務邏輯放在 lib/src/ 中。
- 理由:
bin/中的程式碼無法透過package:URI 乾淨地匯入。將邏輯移到lib/src/中,允許在毫秒內 (< 2ms) 於記憶體中對整個命令 runner、子命令階層和業務邏輯進行單元測試,而無需產生作業系統子程序。
// 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,或存取缺少的mandatory: true選項時透過results.option(...)拋出的ArgumentError),錯誤訊息和使用說明文字都必須寫入stderr,並且必須回傳結束代碼64(EX_USAGE/ExitCode.usage.code)。stdout應該僅在使用者透過--help或-h明確請求時接收使用說明。 - 錯誤處理程式中不要使用
print():print()會導向stdout。對所有失敗通知使用stderr.writeln()。對於標準輸出,建議使用stdout.writeln()而不是print(),以遵守avoid_printlint 規則 (除非analysis_options.yaml明確設定了avoid_print: false)。 - 終端機功能偵測與
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標誌時,將資料格式化為 JSON 輸出到stdout,並將日誌導向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 產生包含 const packageVersion = 'x.y.z'; 的 lib/src/version.dart 檔案。
快取慣例
將暫存快取檔案儲存在 .dart_tool/<package_name>/ 中。切勿將持久性快取檔案直接寫入專案根目錄。
4. 引數解析與命令路由
匯入 package:args 來管理命令列引數:
- 簡單腳本:直接使用
ArgParser,搭配addFlag()和addOption()。 - 多命令工具:為每個子命令實作
CommandRunner<int>並繼承Command<int>,直接回傳 POSIX 結束代碼。 - 型別安全的存取器:使用
results.flag('name')、results.option('name')和results.multiOption('name')(在package:args2.5+ 中可用) 來取代地圖索引operator [],以消除手動型別轉換 (as bool、as String?)。 - 複雜選項模型:對於具有大量標誌的應用程式,使用
package:build_cli來產生強型別的選項類別。利用具名預設值覆寫 (例如{String? hostDefaultOverride}) 來乾淨地合併設定檔與 CLI 標誌。
5. 原生非同步與現代堆疊追蹤
- 避免
Chain.capture():Dart VM 原生地在await暫停點之間保留非同步堆疊幀。Chain.capture將事件迴圈包裝在自訂的 Zone 中,會產生大量的分配開銷,並可能跨 Zone 邊界捕獲錯誤。 - 使用
Trace.from(st).terse清理:在未捕獲的錯誤上使用package:stack_trace中的靜態工具,而無需捕獲 zone:
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:使用cli_util.dartExecutable或cli_util.sdkPath解析 Dart SDK 可執行檔,而不是撰寫自訂的 PATH 或目錄搜尋器。 - 請參閱 references/aot_sdk_discovery.md 中的版本要求和詳細技術指南。
7. 訊號處理與終端機清理
如果你的 CLI 會變更終端機模式、顯示旋轉動畫或開啟監聽 socket:
- Windows 訊號防護:在 Windows 上,
ProcessSignal.sigterm.watch()會擲出UnsupportedError。使用if (!Platform.isWindows)來保護sigterm。 - 回音與行模式清理:如果設定
stdin.echoMode = false或stdin.lineMode = false,請先檢查if (!stdin.hasTerminal) return;,並安裝一個SIGINT監聽器和finally區塊來恢復它們,以便使用者在結束後仍能看到按鍵輸入。 - 游標可見性:如果發出了 ANSI 隱藏游標 (
\x1B[?25l),請務必在結束或取消時恢復游標可見性 (\x1B[?25h)。 - Socket 清理:在終止訊號上明確關閉正在監聽的
HttpServer或ServerSocket實例 (server.close(force: true)),以立即釋放作業系統連接埠。 - 請參閱 references/signals_and_terminal.md 中的詳細模式。
8. 測試 CLI 應用程式
將測試結構化為兩個不同的層級:
- 單元測試 (記憶體內,
< 5ms):透過在test/中匯入package:<pkg>/src/...來直接測試命令類別、選項解析和業務邏輯。 - 整合測試 (子程序):使用
package:test_process和package:test_descriptor來驗證端對端的二進位檔執行、程序 I/O 串流和作業系統結束代碼:
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+ 將 CLI 分發標準化為 dart run 和 dart install (逐漸脫離 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。 - 在記憶體中對命令 runner 進行單元測試;使用
test_process進行端對端二進位檔執行測試。
參考資料與範例
- 單命令工具範本:examples/single_command_tool.dart
- 多命令 Runner 範本:examples/multi_command_runner.dart
- AOT SDK 探索與子程序生成:references/aot_sdk_discovery.md
- 訊號處理與終端機清理:references/signals_and_terminal.md