4. 配置系统与后台服务
后台服务一旦脱离控制台进入长期运行,配置就不再是“启动时读一次”的静态数据。数据库连接串、轮询间隔、批处理阈值、第三方接口地址、开关标志,都可能在上线后调整。.NET 的配置系统正是为这种场景设计的:它把多个来源合并成一个统一的键值视图,再通过强类型绑定和变更通知,让后台服务在不重启进程的前提下感知新值。理解这套机制,关键要抓住三条线——来源的优先级、绑定的生命周期、变更的传播路径。
一、配置来源与优先级:后加载者覆盖先加载者
配置系统的核心抽象是 IConfiguration,它由一组 IConfigurationSource 按顺序构建。默认的 Host.CreateApplicationBuilder 会依次加入:appsettings.json、appsettings.{Environment}.json、用户机密(开发环境)、环境变量、命令行参数。后加入的来源覆盖先加入的同名键,因此环境变量天然可以覆盖 JSON 文件,命令行又可以覆盖环境变量。这个顺序不是随意的,它对应“越靠近部署环境、越临时、越机密的设置优先级越高”的工程直觉。
环境变量在 Linux 和容器中尤其重要。由于冒号在多数 shell 中不合法,配置系统用双下划线 __ 表示层级。例如 JSON 中的 Worker:IntervalSeconds,对应环境变量 Worker__IntervalSeconds。在 Docker Compose 或 Kubernetes 的 env 段里写这一行,就能在不改镜像的前提下调整轮询间隔。需要注意:环境变量的值始终是字符串,绑定到 int、TimeSpan、bool 时依赖配置绑定器的类型转换,格式必须合法,否则会在绑定或校验阶段抛错。
二、IOptions 三种形态:选错生命周期会踩坑
把配置绑定成强类型对象,最常用的是 IOptions<T> 系列。它们名字相近,行为差别却直接影响后台服务能否热更新:
IOptions<T>:单例,进程启动后值固定。适合启动即定、运行期不变的配置,如应用名称、固定的外部端点。IOptionsSnapshot<T>:作用域(Scoped),每次从作用域解析时重新计算。适合在请求或每次任务执行时读取最新值的场景,但不能注入到单例服务中。IOptionsMonitor<T>:单例,支持CurrentValue和OnChange回调。后台服务是单例,因此它是热更新的首选。
一个常见错误是把 IOptionsSnapshot 注入 BackgroundService,运行时会直接抛出生命周期不匹配异常。正确做法是注入 IOptionsMonitor<WorkerOptions>,在每次循环中读取 CurrentValue,或注册 OnChange 在配置变化时重建内部状态。
三、热更新与变更通知:从文件到回调的链路
热更新不是自动发生的,它依赖两个条件:配置源支持重新加载,以及消费者订阅了变更。JSON 文件源默认开启 reloadOnChange: true,文件系统监视器检测到写入后触发 Reload,IConfigurationRoot 重新合并所有来源,随后 IOptionsMonitor 的 OnChange 被调用。环境变量和命令行参数在进程启动后不会变化,因此它们不参与热更新——这也是把“需要动态调整”的参数放在 JSON 或配置中心、把“部署期固定”的参数放在环境变量的一个实用分界。
下面的 SVG 展示了从配置源到后台服务消费的完整信号链,包括优先级覆盖、绑定生命周期和变更传播路径。
四、在后台服务中落地:一个可运行的配置样例
假设有一个订单对账后台任务,需要可调的轮询间隔、批量大小和启用开关。先在 appsettings.json 中定义:
{
"Reconciliation": {
"Enabled": true,
"IntervalSeconds": 60,
"BatchSize": 200,
"CutoffTime": "02:00:00"
}
}
对应的选项类与注册代码:
public sealed class ReconciliationOptions
{
public const string SectionName = "Reconciliation";
public bool Enabled { get; set; } = true;
public int IntervalSeconds { get; set; } = 60;
public int BatchSize { get; set; } = 200;
public TimeSpan CutoffTime { get; set; }
}
builder.Services
.AddOptions<ReconciliationOptions>()
.Bind(builder.Configuration.GetSection(ReconciliationOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
后台服务中注入 IOptionsMonitor<ReconciliationOptions>,每次循环读取 CurrentValue,并注册变更回调记录日志:
public sealed class ReconciliationWorker : BackgroundService
{
private readonly IOptionsMonitor<ReconciliationOptions> _options;
private readonly ILogger<ReconciliationWorker> _logger;
public ReconciliationWorker(
IOptionsMonitor<ReconciliationOptions> options,
ILogger<ReconciliationWorker> logger)
{
_options = options;
_logger = logger;
_options.OnChange(opt =>
_logger.LogInformation(
"对账配置已更新:Enabled={Enabled}, Interval={Interval}s, Batch={Batch}",
opt.Enabled, opt.IntervalSeconds, opt.BatchSize));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var opt = _options.CurrentValue;
if (opt.Enabled)
{
await RunOnceAsync(opt.BatchSize, stoppingToken);
}
await Task.Delay(TimeSpan.FromSeconds(opt.IntervalSeconds), stoppingToken);
}
}
}
这段代码体现了热更新的正确姿势:CurrentValue 在每次循环重新读取,因此修改 JSON 后下一轮即生效;OnChange 用于记录审计日志或重置内部缓存。若把 opt 在构造函数中缓存为字段,就会退化成 IOptions 的行为,热更新失效。
五、验证与排障:怎么确认配置真的生效了
验证热更新最直接的方式是运行服务后修改 appsettings.json 中的 IntervalSeconds,观察日志中 OnChange 是否打印新值,以及下一轮 Task.Delay 是否使用新间隔。若没有触发,按以下顺序排查:
- 确认文件源是否开启了
reloadOnChange,默认模板通常已开启,但手动AddJsonFile时容易漏写。 - 确认注入的是
IOptionsMonitor而非IOptions,且没有把值提前缓存到字段。 - 确认修改的是实际被加载的文件路径,容器中要区分镜像内路径与挂载卷路径。
- 确认环境变量没有覆盖同名键——如果
Reconciliation__IntervalSeconds已设置,改 JSON 不会生效,因为环境变量优先级更高。
配置系统的价值不在于读取几个键值,而在于它把来源优先级、类型绑定、生命周期和变更传播串成了一条可预测的链路。后台服务作为单例宿主,应当默认使用 IOptionsMonitor 消费配置,把 OnChange 当作状态重置和审计的入口,并在验证阶段明确区分“支持热更新的来源”和“启动后固定的来源”。做到这几点,配置变更就不再需要重启进程,运维调整也能被安全地观察和回滚。