构建 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 堆栈:
- 调试器断开:当使用
--pause-isolates-on-exit启动时,VM Service 会在关闭前暂停隔离区以允许 IDE 检查。exit()在 VM Service 可以暂停或检查状态之前终止了操作系统进程。 - 覆盖丢失:
package:coverage在暂停退出状态下通过 VM Service RPC 查询执行行。exit()在 RPC 提取之前销毁进程,导致 0% 的覆盖率。 - 缓冲区截断:
stdout和stderr是缓冲的异步IOSink流。exit()会丢弃未刷新的字节。 - 资源泄漏:
finally块(关闭锁、删除临时目录)被绕过。
规则:避免在正常执行期间直接调用 exit(code);设置 exitCode = code 或从 CommandRunner<int>(来自 package:args)返回一个整数退出码,并允许异步 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 tool description.');
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 文件严格作为最小入口点跳板(实例化 runner,传递 args,等待退出码)。将所有命令定义、参数解析器、格式化程序和业务逻辑放在 lib/src/ 中。
- 理由:
bin/中的代码不能通过package:URI 干净地导入。将逻辑移入lib/src/允许整个命令运行程序、子命令层次结构和业务逻辑在内存中进行单元测试,时间在毫秒级(< 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. 输出、诊断与格式化
- 数据与诊断:将预期的程序结果和机器可读数据专门写入
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: High-performance CLI tool.
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进行清理:在不捕获 Zone 的情况下,对未捕获的错误使用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('Fatal error: $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 更改了终端模式、显示旋转器或打开了监听套接字:
- 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)。 - 套接字清理:在终止信号上显式关闭监听的
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 processes input and exits cleanly', () 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('Processing complete.'));
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