关于 dotnet-cli-architecture
为 .NET 命令行应用提供架构指导,面向使用 System.CommandLine 2.0 和通用主机构建 CLI 工具的开发者。重点涵盖职责分层与符合 shell 习惯的行为。
主要功能
- 01命令、处理器与服务分层
- 02配置优先级指导
- 03结构化日志与详细级别
- 04退出代码约定
- 05标准流使用模式
- 06进程内 CLI 测试
适用场景
- 01设计可复用的 CLI 业务逻辑
- 02避免诊断信息污染管道输出
- 03用不同退出代码表示错误类别
- 04无需启动子进程测试命令行为
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-cli-architecture安装后在 Claude Code 中输入/dotnet-cli-architecture确认已加载。
详细介绍
dotnet-cli-architecture
适用于 .NET 的分层 CLI 应用架构:按照 clig.dev 原则划分命令、处理程序和服务;配置优先级(appsettings → 环境变量 → CLI 参数);CLI 上下文中的结构化日志;退出代码约定;stdin/stdout/stderr 模式;以及通过进程内调用和输出捕获测试 CLI 应用。
**版本假设:**基线为 .NET 8.0+。这些模式适用于使用 System.CommandLine 2.0 和通用主机构建的 CLI 工具。
**范围之外:**System.CommandLine API 细节(RootCommand、Option、中间件、托管设置)——参见 [skill:dotnet-system-commandline]。Native AOT 编译和发布流水线——参见 [skill:dotnet-native-aot]。CLI 分发、打包和发布自动化——参见 [skill:dotnet-cli-distribution] 和 [skill:dotnet-cli-packaging]。通用 CI/CD 模式——参见 [skill:dotnet-gha-patterns] 和 [skill:dotnet-ado-patterns]。DI 容器内部机制——参见 [skill:dotnet-csharp-dependency-injection]。通用测试策略——参见 [skill:dotnet-testing-strategy]。
交叉引用:[skill:dotnet-system-commandline] 介绍 System.CommandLine 2.0 API;[skill:dotnet-native-aot] 介绍 AOT 发布 CLI 工具;[skill:dotnet-csharp-dependency-injection] 介绍 DI 模式;[skill:dotnet-csharp-configuration] 介绍配置集成;[skill:dotnet-testing-strategy] 介绍通用测试模式。
.NET CLI 工具的 clig.dev 原则
命令行界面指南提供了与编程语言无关的 CLI 工具行为准则。这些准则可直接转化为 .NET 模式。
核心原则
| 原则 | 实现方式 |
|---|---|
| 默认优先面向人类的输出 | 使用 Console.Out 输出数据,使用 Console.Error 输出诊断信息 |
通过 --json 提供机器可读输出 | 添加 --json 全局选项以切换输出格式 |
| 使用 Stderr 输出状态和诊断信息 | 日志、进度条和提示词输出到 stderr |
| Stdout 仅用于数据 | 管道输出(mycli list | jq .)不得包含日志噪声 |
| 失败时返回非零退出代码 | 返回特定退出代码(参见下方约定) |
| 尽早失败,并明确报错 | 执行操作前验证输入 |
遵循 NO_COLOR | 检查 Environment.GetEnvironmentVariable("NO_COLOR") |
支持 --verbose 和 --quiet | 用于控制输出详细程度的全局选项 |
.NET 中的 Stdout 与 Stderr
// Data output -- goes to stdout (can be piped)
Console.Out.WriteLine(JsonSerializer.Serialize(result, jsonContext.Options));
// Status/diagnostic output -- goes to stderr (user sees it, pipe ignores it)
Console.Error.WriteLine("Processing 42 files...");
// With ILogger (when using hosting)
// ILogger writes to stderr via console provider by default
logger.LogInformation("Connected to {Endpoint}", endpoint);
分层的命令 → 处理程序 → 服务架构
将 CLI 职责分为三个层:
┌─────────────────────────────────────┐
│ Commands (System.CommandLine) │ Parse args, wire options
│ ─ RootCommand, Command, Option<T> │
├─────────────────────────────────────┤
│ Handlers (orchestration) │ Coordinate services, format output
│ ─ ICommandHandler implementations │
├─────────────────────────────────────┤
│ Services (business logic) │ Pure logic, no CLI concerns
│ ─ Interfaces + implementations │
└─────────────────────────────────────┘
为什么分为三层
- 命令了解 CLI 语法(选项、参数、子命令),但不了解业务逻辑
- 处理程序将 CLI 输入传递给服务调用,并为输出格式化结果
- 服务包含领域逻辑,可在 CLI 之外复用(测试、库、API)
示例结构
src/
MyCli/
MyCli.csproj
Program.cs # RootCommand + CommandLineBuilder
Commands/
SyncCommandDefinition.cs # Command, options, arguments
Handlers/
SyncHandler.cs # ICommandHandler, orchestrates services
Services/
ISyncService.cs # Business logic interface
SyncService.cs # Implementation (no CLI awareness)
Output/
ConsoleFormatter.cs # Table/JSON output formatting
命令定义层
// Commands/SyncCommandDefinition.cs
public static class SyncCommandDefinition
{
public static readonly Option<Uri> SourceOption = new(
"--source", "Source endpoint URL") { IsRequired = true };
public static readonly Option<bool> DryRunOption = new(
"--dry-run", "Preview changes without applying");
public static Command Create()
{
var command = new Command("sync", "Synchronize data from source");
command.AddOption(SourceOption);
command.AddOption(DryRunOption);
return command;
}
}
处理程序层
// Handlers/SyncHandler.cs
public class SyncHandler : ICommandHandler
{
private readonly ISyncService _syncService;
private readonly ILogger<SyncHandler> _logger;
public SyncHandler(ISyncService syncService, ILogger<SyncHandler> logger)
{
_syncService = syncService;
_logger = logger;
}
// Bound by naming convention from options
public Uri Source { get; set; } = null!;
public bool DryRun { get; set; }
public int Invoke(InvocationContext context) =>
InvokeAsync(context).GetAwaiter().GetResult();
public async Task<int> InvokeAsync(InvocationContext context)
{
var ct = context.GetCancellationToken();
_logger.LogInformation("Syncing from {Source}", Source);
var result = await _syncService.SyncAsync(Source, DryRun, ct);
if (result.HasErrors)
{
context.Console.Error.Write($"Sync failed: {result.ErrorMessage}\n");
return ExitCodes.SyncFailed;
}
context.Console.Out.Write($"Synced {result.ItemCount} items.\n");
return ExitCodes.Success;
}
}
服务层
// Services/ISyncService.cs -- no CLI dependency
public interface ISyncService
{
Task<SyncResult> SyncAsync(Uri source, bool dryRun, CancellationToken ct);
}
// Services/SyncService.cs
public class SyncService : ISyncService
{
private readonly HttpClient _httpClient;
public SyncService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<SyncResult> SyncAsync(
Uri source, bool dryRun, CancellationToken ct)
{
// Pure business logic -- testable without CLI infrastructure
var data = await _httpClient.GetFromJsonAsync<SyncData>(source, ct);
// ...
return new SyncResult(ItemCount: data.Items.Length);
}
}
配置优先级
CLI 工具使用特定的配置优先级(从低到高):
- 编译时默认值 —— 硬编码的回退值
- appsettings.json —— 随工具一同发布
- appsettings.{Environment}.json —— 特定环境的覆盖值
- 环境变量 —— 由 shell 或 CI 设置
- CLI 参数 —— 用户的显式输入(最高优先级)
使用通用主机实现
var builder = new CommandLineBuilder(rootCommand)
.UseHost(_ => Host.CreateDefaultBuilder(args), host =>
{
host.ConfigureAppConfiguration((ctx, config) =>
{
// Layers 2-3 handled by CreateDefaultBuilder:
// appsettings.json, appsettings.{env}.json, env vars
// Layer 4: User-specific config file
var configPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".mycli", "config.json");
if (File.Exists(configPath))
{
config.AddJsonFile(configPath, optional: true);
}
});
// Layer 5: CLI args override everything
// System.CommandLine options take precedence via handler binding
})
.UseDefaults()
.Build();
用户级配置
许多 CLI 工具支持用户级配置(例如,~/.mycli/config.json、~/.config/mycli/config.yaml)。请遵循平台约定:
| 平台 | 位置 |
|---|---|
| Linux/macOS | ~/.config/mycli/ 或 ~/.mycli/ |
| Windows | %APPDATA%\mycli\ |
| 符合 XDG 规范的平台 | $XDG_CONFIG_HOME/mycli/ |
CLI 上下文中的结构化日志
为 CLI 配置日志
CLI 工具需要采用不同于 Web 应用的日志方式:日志输出到 stderr,详细程度由标志控制。
host.ConfigureLogging((ctx, logging) =>
{
logging.ClearProviders();
logging.AddConsole(options =>
{
// Write to stderr, not stdout
options.LogToStandardErrorThreshold = LogLevel.Trace;
});
});
详细程度映射
将 --verbose/--quiet 标志映射到日志级别:
public static class VerbosityMapping
{
public static LogLevel ToLogLevel(bool verbose, bool quiet) => (verbose, quiet) switch
{
(true, _) => LogLevel.Debug,
(_, true) => LogLevel.Warning,
_ => LogLevel.Information // default
};
}
// In host configuration
host.ConfigureLogging((ctx, logging) =>
{
var level = VerbosityMapping.ToLogLevel(verbose, quiet);
logging.SetMinimumLevel(level);
});
退出代码约定
标准退出代码
public static class ExitCodes
{
public const int Success = 0;
public const int GeneralError = 1;
public const int InvalidUsage = 2; // Bad arguments or options
public const int IoError = 3; // File not found, permission denied
public const int NetworkError = 4; // Connection failed, timeout
public const int AuthError = 5; // Authentication/authorization failure
// Tool-specific codes start at 10+
public const int SyncFailed = 10;
public const int ValidationFailed = 11;
}
指南
- 0 = 成功(始终如此)
- 1 = 一般/未指定错误
- 2 = 用法无效(参数错误)—— System.CommandLine 会在解析错误时自动返回此代码
- 3-9 = 为常见类别预留
- 10+ = 工具专用错误代码
- 切勿使用大于 125 的退出代码(这些代码由 shell 保留;126 = 不可执行,127 = 未找到,128+N = 被信号 N 终止)
传递退出代码
public async Task<int> InvokeAsync(InvocationContext context)
{
try
{
await _service.ProcessAsync(context.GetCancellationToken());
return ExitCodes.Success;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Network error");
context.Console.Error.Write($"Error: {ex.Message}\n");
return ExitCodes.NetworkError;
}
catch (UnauthorizedAccessException ex)
{
context.Console.Error.Write($"Permission denied: {ex.Message}\n");
return ExitCodes.IoError;
}
}
Stdin/Stdout/Stderr 模式
从 Stdin 读取
支持管道输入,作为文件参数的替代方式:
public async Task<int> InvokeAsync(InvocationContext context)
{
string input;
if (InputFile is not null)
{
input = await File.ReadAllTextAsync(InputFile.FullName);
}
else if (Console.IsInputRedirected)
{
// Read from stdin: echo '{"data":1}' | mycli process
input = await Console.In.ReadToEndAsync();
}
else
{
context.Console.Error.Write("Error: Provide input via --file or stdin.\n");
return ExitCodes.InvalidUsage;
}
var result = _processor.Process(input);
context.Console.Out.Write(JsonSerializer.Serialize(result));
return ExitCodes.Success;
}
机器可读输出
// Global --json option for machine-readable output
var jsonOption = new Option<bool>("--json", "Output as JSON");
rootCommand.AddGlobalOption(jsonOption);
// In handler
if (useJson)
{
Console.Out.WriteLine(JsonSerializer.Serialize(result, jsonContext.Options));
}
else
{
// Human-friendly table format
ConsoleFormatter.WriteTable(result, context.Console);
}
将进度输出到 Stderr
// Progress reporting goes to stderr (does not pollute piped stdout)
await foreach (var item in _service.StreamAsync(ct))
{
Console.Error.Write($"\rProcessing {item.Index}/{total}...");
Console.Out.WriteLine(item.ToJson());
}
Console.Error.WriteLine(); // Clear progress line
测试 CLI 应用程序
使用 CommandLineBuilder 在进程内调用
无需启动子进程即可测试完整的 CLI 流程:
public class CliTestHarness
{
private readonly RootCommand _rootCommand;
private readonly Action<IServiceCollection>? _configureServices;
public CliTestHarness(Action<IServiceCollection>? configureServices = null)
{
_rootCommand = Program.BuildRootCommand();
_configureServices = configureServices;
}
public async Task<(int ExitCode, string Stdout, string Stderr)> InvokeAsync(
string commandLine)
{
var console = new TestConsole();
var builder = new CommandLineBuilder(_rootCommand)
.UseHost(_ => Host.CreateDefaultBuilder(), host =>
{
if (_configureServices is not null)
{
host.ConfigureServices(_configureServices);
}
})
.UseDefaults()
.Build();
var exitCode = await builder.InvokeAsync(commandLine, console);
return (exitCode, console.Out.ToString()!, console.Error.ToString()!);
}
}
使用服务模拟对象进行测试
[Fact]
public async Task Sync_WithValidSource_ReturnsZero()
{
var fakeSyncService = new FakeSyncService(
new SyncResult(ItemCount: 5));
var harness = new CliTestHarness(services =>
{
services.AddSingleton<ISyncService>(fakeSyncService);
});
var (exitCode, stdout, stderr) = await harness.InvokeAsync(
"sync --source https://api.example.com");
Assert.Equal(0, exitCode);
Assert.Contains("Synced 5 items", stdout);
}
[Fact]
public async Task Sync_WithMissingSource_ReturnsNonZero()
{
var harness = new CliTestHarness();
var (exitCode, _, stderr) = await harness.InvokeAsync("sync");
Assert.NotEqual(0, exitCode);
Assert.Contains("--source", stderr); // Parse error mentions missing option
}
退出代码断言
[Theory]
[InlineData("sync --source https://valid.example.com", 0)]
[InlineData("sync", 2)] // Missing required option
[InlineData("invalid-command", 1)]
public async Task ExitCode_MatchesExpected(string args, int expectedExitCode)
{
var harness = new CliTestHarness();
var (exitCode, _, _) = await harness.InvokeAsync(args);
Assert.Equal(expectedExitCode, exitCode);
}
测试输出格式
[Fact]
public async Task List_WithJsonFlag_OutputsValidJson()
{
var harness = new CliTestHarness(services =>
{
services.AddSingleton<IItemRepository>(
new FakeItemRepository([new Item(1, "Widget")]));
});
var (exitCode, stdout, _) = await harness.InvokeAsync("list --json");
Assert.Equal(0, exitCode);
var items = JsonSerializer.Deserialize<Item[]>(stdout);
Assert.NotNull(items);
Assert.Single(items);
}
[Fact]
public async Task List_StderrContainsLogs_StdoutContainsDataOnly()
{
var harness = new CliTestHarness();
var (_, stdout, stderr) = await harness.InvokeAsync("list --json --verbose");
// Stdout must be valid JSON (no log noise)
// xUnit: just call it -- if it throws, the test fails
var doc = JsonDocument.Parse(stdout);
Assert.NotNull(doc);
// Stderr contains diagnostic output
Assert.Contains("Connected to", stderr);
}
Agent 常见注意事项
- 不要将诊断输出写入 stdout。 日志、进度和错误应输出到 stderr。Stdout 专用于可通过管道传递的数据输出。CLI 工具若将日志混入 stdout,会破坏 shell 管道。
- 不要对所有错误都硬编码退出代码 1。 对不同的失败类别(I/O、网络、身份验证、验证)使用不同的退出代码。调用方和脚本依靠退出代码来判断出错原因。
- 不要将业务逻辑放在命令处理程序中。 处理程序应负责协调对注入服务的调用并格式化输出。放在处理程序中的业务逻辑无法重复使用,也无法独立进行单元测试。
- 不要只通过启动进程来测试 CLI 工具。 使用
CommandLineBuilder和TestConsole在进程内调用,以便快速、可靠地测试。进程级测试应保留用于已发布二进制文件的冒烟测试。 - 接受 stdin 时,不要忽略
Console.IsInputRedirected。 如果不检查,工具在没有管道数据的情况下调用时,可能会一直等待输入。 - 不要使用大于 125 的退出代码。 126-255 在 Unix shell 中有特殊含义(126 = 不可执行,127 = 未找到,128+N = 被信号 N 终止)。工具专用代码应处于 1-125 的范围内。