mcpField
D
dotnet-cli-architecture

dotnet-cli-architecture

github.com/wshaddix开发8672 次安装35 周前更新
代码仓库

关于 dotnet-cli-architecture

为 .NET 命令行应用提供架构指导,面向使用 System.CommandLine 2.0 和通用主机构建 CLI 工具的开发者。重点涵盖职责分层与符合 shell 习惯的行为。

主要功能

  1. 01命令、处理器与服务分层
  2. 02配置优先级指导
  3. 03结构化日志与详细级别
  4. 04退出代码约定
  5. 05标准流使用模式
  6. 06进程内 CLI 测试

适用场景

  1. 01设计可复用的 CLI 业务逻辑
  2. 02避免诊断信息污染管道输出
  3. 03用不同退出代码表示错误类别
  4. 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 工具使用特定的配置优先级(从低到高):

  1. 编译时默认值 —— 硬编码的回退值
  2. appsettings.json —— 随工具一同发布
  3. appsettings.{Environment}.json —— 特定环境的覆盖值
  4. 环境变量 —— 由 shell 或 CI 设置
  5. 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 常见注意事项

  1. 不要将诊断输出写入 stdout。 日志、进度和错误应输出到 stderr。Stdout 专用于可通过管道传递的数据输出。CLI 工具若将日志混入 stdout,会破坏 shell 管道。
  2. 不要对所有错误都硬编码退出代码 1。 对不同的失败类别(I/O、网络、身份验证、验证)使用不同的退出代码。调用方和脚本依靠退出代码来判断出错原因。
  3. 不要将业务逻辑放在命令处理程序中。 处理程序应负责协调对注入服务的调用并格式化输出。放在处理程序中的业务逻辑无法重复使用,也无法独立进行单元测试。
  4. 不要只通过启动进程来测试 CLI 工具。 使用 CommandLineBuilder 和 TestConsole 在进程内调用,以便快速、可靠地测试。进程级测试应保留用于已发布二进制文件的冒烟测试。
  5. 接受 stdin 时,不要忽略 Console.IsInputRedirected。 如果不检查,工具在没有管道数据的情况下调用时,可能会一直等待输入。
  6. 不要使用大于 125 的退出代码。 126-255 在 Unix shell 中有特殊含义(126 = 不可执行,127 = 未找到,128+N = 被信号 N 终止)。工具专用代码应处于 1-125 的范围内。

参考资料

评论

支持 Markdown