别把 IOptionsSnapshot 注入单例:从生命周期看 .NET 配置读取
ASP.NET Core 项目里,配置一旦从几个 Configuration["Key"] 的调用增长为多个业务选项,真正容易出问题的往往不是绑定代码,而是"谁在什么时候读到哪一版配置"。微软的 Options pattern 文档把这件事拆成了三种接口:IOptions<TOptions>、IOptionsSnapshot<TOptions> 和 IOptionsMonitor<TOptions>。选择它们时,先看服务生命周期,通常比先看是否需要热更新更可靠。
先把配置绑定交给容器
假设应用有一个重试策略配置:
csharp
public sealed class RetryOptions
{
public const string SectionName = "Retry";
public bool Enabled { get; init; }
public TimeSpan Delay { get; init; }
}
builder.Services.Configure<RetryOptions>(
builder.Configuration.GetSection(RetryOptions.SectionName));
Configure<TOptions> 将指定配置节注册到 DI 容器。配置节名称不必与类型名一致;使用常量只是让重构时更容易保持一致。微软文档也区分了两类事情:ConfigurationBinder.Get<T>() 可以直接创建并绑定对象,而 Configure<TOptions> 则将绑定关系交给 Options 与依赖注入体系。后者才便于按不同生命周期消费配置。
三种接口不是"新旧程度"的排序
接口
注册生命周期
适合的读取语义
关键限制
IOptions<T>
Singleton
应用启动后的稳定配置
不支持应用启动后重新读取配置,也不支持命名选项
IOptionsSnapshot<T>
Scoped
在 scoped/transient 依赖中按请求取得快照
不能注入 Singleton
IOptionsMonitor<T>
Singleton
长期运行组件读取当前值或订阅变化
配置源本身需支持重载,回调中仍要谨慎处理并发
这里最容易被误解的是 IOptionsSnapshot<T>。它不是每次访问 Value 都重新绑定。官方文档说明:它在请求中首次被访问时计算一次,并在该请求生命周期内缓存。因此它表达的是"这次请求看到同一份配置",不是"任意时刻都看到最新配置"。
为什么单例不能依赖 IOptionsSnapshot
单例的生命周期覆盖整个应用,而 Snapshot 是 scoped 服务。把 scoped 依赖放进 singleton,会让容器无法正确建立作用域边界;这也是 Options 文档明确写出的限制。下面的构造函数不应作为设计目标:
csharp
public sealed class BackgroundWorker
{
// 不要这样设计:IOptionsSnapshot<RetryOptions> 是 scoped。
public BackgroundWorker(IOptionsSnapshot<RetryOptions> options) { }
}
后台服务、连接管理器、缓存协调器这类长期对象,如果确实需要在运行中取得更新后的配置,应改用 IOptionsMonitor<RetryOptions>:
csharp
public sealed class BackgroundWorker
{
private readonly IOptionsMonitor<RetryOptions> _options;
public BackgroundWorker(IOptionsMonitor<RetryOptions> options)
=> _options = options;
public TimeSpan CurrentDelay() => _options.CurrentValue.Delay;
}
IOptionsMonitor<T> 是 singleton,可注入任意服务生命周期;它支持命名选项、变更通知和可重载配置。是否真的会更新,仍取决于配置提供程序是否支持读取变化。例如,选用了支持重载的 JSON 配置源并不意味着所有外部配置源都具有同样行为。
按场景选择,而不是全项目统一一种接口
- **一次启动后不应变化的开关:**使用
IOptions<T>,语义最直接。 - **HTTP 请求内必须保持一致、下一请求可以采用新值:**在 scoped 或 transient 服务中使用
IOptionsSnapshot<T>。 - **单例服务、后台任务或需要响应变更的组件:**使用
IOptionsMonitor<T>,在需要时读取CurrentValue,或订阅变更通知。
若只为了"以后可能要热更新"而把所有注入都改成 Monitor,通常会模糊配置在代码中的稳定性边界。反过来,发现配置更新没有生效时,也不要先把 Snapshot 换成 Monitor:先确认当前消费者的生命周期,以及配置提供程序是否支持重载。
再补上启动时验证
绑定成功不等于配置可用。对于端口、地址、时间跨度、必填凭据标识等有业务约束的选项,应在启动阶段验证。一个简化示例:
ini
builder.Services
.AddOptions<RetryOptions>()
.Bind(builder.Configuration.GetSection(RetryOptions.SectionName))
.Validate(o => o.Delay > TimeSpan.Zero, "Retry:Delay 必须大于零")
.ValidateOnStart();
验证条件应来自应用自身的业务约束;不要把示例中的规则直接当作所有重试策略的通用结论。Options 基础设施会在创建选项实例时组合已注册的配置和后置配置,因而把默认值、绑定和验证集中在注册处,也更容易审阅。
结语
Options 模式解决的不只是"把 JSON 映射成 C# 对象"。IOptions<T>、IOptionsSnapshot<T> 与 IOptionsMonitor<T> 分别对应稳定值、请求范围快照和可观察的当前值。先画出消费者的生命周期,再决定读取接口,能避开 Snapshot 注入单例这类问题,也能让配置热更新的行为更可预期。