浏览知识库目录

C#

命令行、配置、依赖注入与日志

用 System.CommandLine、Generic Host、配置、依赖注入和结构化日志构建可诊断 CLI。

命令行、配置、依赖注入与日志

命令行入口负责把文本参数转换为应用调用,再把结果转换为标准输出、错误输出和退出码。它不应承载数据库细节或业务规则。Generic Host 可以统一配置、依赖注入、日志和应用生命周期,但入口仍需保持薄而明确。


一、学习目标

  • 使用 System.CommandLine 2.0.10 设计子命令
  • 区分解析校验与领域校验
  • 返回稳定的退出码并分离 stdout/stderr
  • 用 Generic Host 组装服务
  • 理解配置源和覆盖优先级
  • 使用结构化日志且不泄露秘密

二、安装入口依赖

dotnet add src/StudyTasks.Cli \
  package System.CommandLine --version 2.0.10

dotnet add src/StudyTasks.Cli \
  package Microsoft.Extensions.Hosting --version 10.0.10

dotnet add src/StudyTasks.Cli \
  package Microsoft.Extensions.Http --version 10.0.10

System.CommandLine 负责语法解析、帮助文本和基础类型转换;Generic Host 提供配置、DI、日志和受控关闭。


三、设计命令契约

study-tasks add <title>
study-tasks list [--status todo|done]
study-tasks done <id>
study-tasks delete <id>
study-tasks sync [--timeout <seconds>]

命令名是脚本会依赖的公开接口。一旦发布,随意改名、改变输出字段或退出码都可能破坏自动化。

约定:

  • 正常数据写到 stdout
  • 诊断和错误写到 stderr
  • 成功返回 0
  • 使用错误返回 2
  • 资源不存在返回 3
  • 暂时性外部故障返回 4
  • 用户取消返回 130

帮助和版本信息由框架生成。


四、定义参数与子命令

var titleArgument = new Argument<string>("title")
{
    Description = "任务标题"
};

var addCommand = new Command("add", "新增任务")
{
    Arguments = { titleArgument }
};

var statusOption = new Option<string?>("--status")
{
    Description = "按 todo 或 done 筛选"
};

var listCommand = new Command("list", "列出任务")
{
    Options = { statusOption }
};

var rootCommand = new RootCommand("StudyTasks 命令行任务管理器")
{
    Subcommands = { addCommand, listCommand }
};

框架能把 int 参数的非数字输入拒绝在解析层。标题非空、状态转换是否合法仍属于应用或领域校验。


五、绑定动作

addCommand.SetAction(parseResult =>
{
    string title = parseResult.GetValue(titleArgument)
        ?? throw new InvalidOperationException("解析器未提供标题");

    try
    {
        StudyTask task = taskService.Add(title);
        Console.WriteLine($"{task.Id}\t{task.Title}");
        return 0;
    }
    catch (ArgumentException exception)
    {
        Console.Error.WriteLine(exception.Message);
        return 2;
    }
});

return rootCommand.Parse(args).Invoke();

入口把明确异常转换为退出码。未知程序缺陷不应伪装为“输入错误”;顶层可以记录后返回通用失败码,同时保留异常供诊断。

更完整项目可把命令构建封装为 CommandFactory,通过构造函数接收服务,测试时直接解析参数而不启动真实进程。


六、Generic Host 组装

HostApplicationBuilder builder =
    Host.CreateApplicationBuilder(args);

builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ITaskRepository, SqliteTaskRepository>();
builder.Services.AddSingleton<TaskService>();
builder.Services.AddHttpClient<RemoteTaskClient>(client =>
{
    client.Timeout = TimeSpan.FromSeconds(15);
});

using IHost host = builder.Build();
TaskService taskService =
    host.Services.GetRequiredService<TaskService>();

入口项目知道具体实现并负责组装;Core 只依赖接口。不要在业务代码中调用全局 IServiceProvider.GetService,这会隐藏依赖并把错误推迟到运行时。

生命周期:

  • Singleton:整个 Host 一个实例,必须线程安全且不能捕获短生命周期服务
  • Scoped:每个显式作用域一个实例
  • Transient:每次解析创建

控制台应用不会自动为每条命令建立 scope,需要时显式创建。


七、配置来源与优先级

Host.CreateApplicationBuilder 默认加载:

  1. appsettings.json
  2. appsettings.{Environment}.json
  3. 开发环境的 User Secrets
  4. 环境变量
  5. 命令行参数

后加入的源覆盖前面的同名键。示例:

{
  "StudyTasks": {
    "Database": "tasks.db",
    "ApiUrl": "https://api.example.com/tasks",
    "TimeoutSeconds": 15
  }
}

选项类型:

public sealed class StudyTasksOptions
{
    public const string SectionName = "StudyTasks";

    public required string Database { get; init; }
    public Uri? ApiUrl { get; init; }
    public int TimeoutSeconds { get; init; } = 15;
}

绑定和启动时验证:

builder.Services
    .AddOptions<StudyTasksOptions>()
    .Bind(builder.Configuration.GetSection(
        StudyTasksOptions.SectionName))
    .Validate(options => options.TimeoutSeconds is > 0 and <= 300,
        "TimeoutSeconds 必须为 1~300")
    .ValidateOnStart();

配置解析成功不代表 URL、路径和范围符合业务规则,启动时尽早失败比第一次请求时失败更容易诊断。


八、秘密管理

Token 不进入:

  • appsettings.json 示例
  • 命令行参数历史
  • Git 仓库
  • 普通日志
  • 异常消息

开发环境使用 User Secrets,生产环境使用受控秘密存储或环境注入;层级键写作 StudyTasks__ApiToken。秘密轮换后受控重启,不要打印值来“确认是否生效”。


九、结构化日志

public sealed class SyncService(
    ILogger<SyncService> logger)
{
    public async Task<int> SyncAsync(
        CancellationToken cancellationToken)
    {
        logger.LogInformation(
            "开始同步任务,批次上限 {BatchSize}",
            100);

        int count = await SyncCoreAsync(cancellationToken);

        logger.LogInformation(
            "任务同步完成,共处理 {TaskCount} 条",
            count);
        return count;
    }
}

消息模板使用命名占位符,不先做字符串插值,日志提供程序才能保留结构字段。

不要记录:

  • API Token、Authorization 头
  • 完整连接字符串
  • 未经脱敏的任务正文
  • 完整远端响应

错误日志带异常对象:

logger.LogError(exception, "同步任务失败,阶段 {Stage}", "download");

脚本需要稳定、简洁的 stdout,日志默认写 stderr。若提供 JSON 输出,应定义显式 --output json 契约;启用详细日志不能改变业务输出格式。


十、常见错误

在命令动作中拼 SQL

入口会无法复用和单测。动作只调用应用服务并转换结果。

把 IServiceProvider 传遍项目

这形成 Service Locator。构造函数应明确列出依赖。

配置优先级不固定

不同机器可能读取不同值。使用 Host 默认顺序,并为关键覆盖写测试。

记录整个配置对象

配置可能包含秘密。采用允许字段白名单,而不是事后屏蔽。


十一、练习与自测

练习:

  1. 为五个子命令定义参数、帮助和稳定退出码。
  2. Parse 测试非法 ID 在进入服务前失败。
  3. 验证环境变量覆盖 JSON 配置。
  4. 捕获日志,证明 Token 从未进入消息和结构字段。

自测:

  • 解析校验与领域校验分别位于哪里?
  • Singleton 为什么不能捕获 Scoped 服务?
  • Host 默认配置源的覆盖顺序是什么?
  • stdout 与日志为什么必须分离?

十二、官方资料

上一篇:文件、JSON、正则与时间 | 下一篇:SQLite、事务与数据访问