C#
命令行、配置、依赖注入与日志
用 System.CommandLine、Generic Host、配置、依赖注入和结构化日志构建可诊断 CLI。
发布于 2026年7月23日
命令行、配置、依赖注入与日志
命令行入口负责把文本参数转换为应用调用,再把结果转换为标准输出、错误输出和退出码。它不应承载数据库细节或业务规则。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 默认加载:
appsettings.jsonappsettings.{Environment}.json- 开发环境的 User Secrets
- 环境变量
- 命令行参数
后加入的源覆盖前面的同名键。示例:
{
"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 默认顺序,并为关键覆盖写测试。
记录整个配置对象
配置可能包含秘密。采用允许字段白名单,而不是事后屏蔽。
十一、练习与自测
练习:
- 为五个子命令定义参数、帮助和稳定退出码。
- 用
Parse测试非法 ID 在进入服务前失败。 - 验证环境变量覆盖 JSON 配置。
- 捕获日志,证明 Token 从未进入消息和结构字段。
自测:
- 解析校验与领域校验分别位于哪里?
- Singleton 为什么不能捕获 Scoped 服务?
- Host 默认配置源的覆盖顺序是什么?
- stdout 与日志为什么必须分离?
十二、官方资料
上一篇:文件、JSON、正则与时间 | 下一篇:SQLite、事务与数据访问