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 基础默认值 appsettings.Production.json 环境覆盖 环境变量 Worker__IntervalSeconds 命令行参数 --Worker:IntervalSeconds=30 IConfigurationRoot 合并所有来源 键值统一视图 强类型绑定与生命周期 IOptions<T> 单例 · 启动后固定 IOptionsSnapshot<T> Scoped · 每次解析重算 IOptionsMonitor<T> 单例 · OnChange 回调 BackgroundService 单例宿主 ExecuteAsync 循环 CurrentValue 热更新链路(仅对支持 Reload 的来源生效) 文件写入 appsettings.json FileSystemWatcher 检测变更 Reload() 重新合并来源 OnChange 回调触发 环境变量与命令行参数不参与热更新:进程启动后值不再变化,适合部署期固定的参数。 JSON 文件源默认 reloadOnChange: true;配置中心(如 Consul、Apollo)需接入对应 Provider。

四、在后台服务中落地:一个可运行的配置样例

假设有一个订单对账后台任务,需要可调的轮询间隔、批量大小和启用开关。先在 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 不会生效,因为环境变量优先级更高。
在容器化部署中,推荐把“需要运行期调整”的参数放在挂载的 JSON 或配置中心,把“部署期固定”的参数放在环境变量。这样既保留了热更新能力,又避免了环境变量覆盖导致的“改了文件不生效”的困惑。

配置系统的价值不在于读取几个键值,而在于它把来源优先级、类型绑定、生命周期和变更传播串成了一条可预测的链路。后台服务作为单例宿主,应当默认使用 IOptionsMonitor 消费配置,把 OnChange 当作状态重置和审计的入口,并在验证阶段明确区分“支持热更新的来源”和“启动后固定的来源”。做到这几点,配置变更就不再需要重启进程,运维调整也能被安全地观察和回滚。