后端2026年7月14日· 约 33 分钟

.NET CORE 动态扩展Options-配置运行时热更新

#配置热更新#多租户#Abp vNext#Options#热部署
Twitter 微博

.NET CORE 动态扩展Options-配置运行时热更新

1.前言#

玩了半年最近找了新工作,,新工作也是再一次接触到Abp vNext 框架,内部系统开发也是基于这个框架的,最近领导让研究学习框架身份认证权限这一套,然后为现有系统做一些扩展和维护,在研究的过程中发现一些很有趣的设计和代码,刚好有些总结,今天分享出来。

做业务系统,配置这东西绕不开。小到一个 SMTP 邮件服务器地址,大到一整套限流规则、开关策略,我们都习惯放到 appsettings.json 里,代码定义一个 EmailOptions,在 Program.cs 里 Bind 一下。然后业务 Service 在代码里用 IOptions<T> 注入进来用,框架帮我们绑定好,而且强类型有智能提示,用起来很舒服。但用久了会发现一个尴尬的地方:appsettings.json 里的东西,改一次就得重新发布或者重启一次应用。开发阶段无所谓,可上了生产,这事就让人觉得很离谱。

有小伙伴可能会说配置要改还不简单,appsettings.json 改一改,配合 IOptionsMonitor<T> 不就能热更新、不用重启了吗?没错,如果你的诉求只是改个配置文件不重启就生效,IOptionsMonitor 确实够用了,这篇你可以不用往下看。因为它能覆盖的仅仅是一份配置全局生效这种最简单的情况。真正让人头疼的是这种场景。假设我们在做一个多租户的 SaaS 系统,卖给N家公司用。有个再常见不过的功能——发邮件、发验证码、发通知。那么问题来了:

1.A公司想用自己的企业邮箱服务器发件,显示自己的发件人抬头。

2.B公司没有邮件服务器,就用我们平台默认的。

3.C公司是大客户,买了独立 SMTP 通道增值服务,配置又会不一样。

就是说,同样一段发邮件的代码,EmailOptions 该取哪个值,取决于当前这个请求是哪个租户进来的。 而且这些配置是租户自己���后台管理页面里改,存在数据库里随时可能变。这时候咱们再看 IOptionsMonitor,会发现它歇菜了因为它是全局单例,只有一份配置程序找他读取A租户的 SMTP 是什么,它根本没有当前是谁这个概念。因为它只针对配置文件,但是租户配置压根不在配置文件里,而在数据库里一个租户一行数。

这就是原生 IOptions 的天花板,它能解决的是配置从哪来,但解决不了配置要不要随上下文租户 、 用户 、 请求变化。 不过这些恰恰是稍微复杂点的业务系统都会遇到的真实需求——多租户只是最典型的一个,我之前就遇到过,还有其它像按用户等级给不同的限流阈值,按当前组织读不同的审批流配置,本质是同一类问题。

下面就来分享一下这个问题是怎么来的,顺便看看 ABP 框架里 AbpDynamicOptionsManager 的设计是怎么用很少的代码解决这个问题的,也是 ABP 做到同一个 IOptions<T>,不同租户拿到不同值的核心原理。然后我们不依赖 ABP,自己动手实现一套,大概不到 100 行代码。在开始之前,我们如果理解了下面2个问题后面会顺很多,这里不展开讲

1.Options 模式解决了什么问题?

2.IOptionsIOptionsSnapshotIOptionsMonitor 他们有啥区别?

2.原生 Options 是怎么工作的#

我们平时在代码中这样写:

services.AddOptions<EmailOptions>().Bind(configuration.GetSection("Email"));

代码背后NETCORE框架偷偷帮我们干了几件事:

  1. 注册一个绑定器,负责把 appsettings.jsonEmail 这个节点的值,塞进 EmailOptions 对象。

  2. 注册一个负责造对象的工厂 IOptionsFactory<EmailOptions>

  3. IOptions<EmailOptions>IOptionsSnapshot<EmailOptions> 的默认实现都指向同一个类:OptionsManager<EmailOptions>

第三步这个 OptionsManager 它内部的逻辑如下,这里我简化:

public class OptionsManager<TOptions> : IOptions<TOptions>, IOptionsSnapshot<TOptions>
    where TOptions : class
{
    private readonly IOptionsFactory<TOptions> _factory;
    private TOptions? _cache;   // 注意这个缓存字段
public TOptions Value =&gt; Get(Options.DefaultName);

public TOptions Get(string name)
{
    if (_cache is null)
    {
        _cache = _factory.Create(name);  class=class="hljs-string">"hljs-comment">// 第一次访问,才真正去构建
    }
    return _cache;   class=class="hljs-string">"hljs-comment">// 之后每次都返回这同一个对象
}

}

看上面代码应该就明白了,EmailOptions 这个对象,就在我们第一次访问 .Value 的时候才构建的,构建完就缓存进 _cache,之后再读拿到的都是同一个对象、同一批值。
这就是在没开热重载的情况下,为啥我们改了 appsettings.json或者改了数据库,IOptions<T>.Value 没变的原因,因为缓存了压根就没再算第二次。

这里你可能会有疑问,通常缓存都是使用一个静态字典,或者其他静态的属性,_cache不就是个私有字段吗?怎么缓存的?其实他是依靠实例注入容器的生命周期来实现的,如果注入为单例,那么这个对象上的字段也会一直存在,如果是作用域那么没一次请求就会重新更新,如果你明白了,那恭喜你你对实例缓存和静态缓存有了更进一步的认识。

看下源码:

public static IServiceCollection AddOptions(this IServiceCollection services)
{
    services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptions<>), typeof(OptionsManager<>)));
    services.TryAdd(ServiceDescriptor.Scoped(typeof(IOptionsSnapshot<>), typeof(OptionsManager<>)));
    services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptionsMonitor<>), typeof(OptionsMonitor<>))); // ← 单例
    // ...
}
接口 生命周期 值什么时候变 适合场景
IOptions<T> 单例 永不变,第一次算完用到进程结束 启动后不变的配置
IOptionsSnapshot<T> 作用域(每请求一份) 每个新请求重新算一次 每个请求可能不同的配置
IOptionsMonitor<T> 单例 配置文件变了会自动重新算 appsettings.json 热重载

这里要注意的是IOptionsMonitor并不是能感知所有变化。它关注的是 IConfiguration 也就是配置文件、环境变量。至于数据库里的设置,根本不在IConfiguration 上,所以IOptionsMonitor 对它的变化不会有反应。所以结论就是:

1.原生3种选项,都不能知道数据库里的配置变更。

2.单例的 IOptions / IOptionsMonitor,不能获取随请求上下文变化的值。

IOptionsSnapshot 是每个请求一份,但它每次也只是老老实实走一遍 IConfiguration 的绑定,还是没地方根据当前租户去数据库取值。

3.真实业务场景下的问题#

下面我们直接把多租户场景落到代码上。还是那个发邮件的功能,配置类长这样:

public class EmailOptions
{
    public string SmtpHost { get; set; } = "localhost";
    public int SmtpPort { get; set; } = 25;
    public string SenderAddress { get; set; } = "yuxl@qq.com";
    public bool EnableSsl { get; set; }
}

我们的需求是:

1.appsettings.json 里有一套平台默认邮件配置,租户没自己配的时候就用它兜底。

2.每个租户能在自己的后台里配一套专属的SMTP,存在数据库里,改完立即生效,不需要重启。

3.业务代码里发邮件的 EmailService,希望还是正常注入 IOptions<EmailOptions> 就能用,不用为了多租户到处改。

就这个需求,如果用原生的方式就会有如下几个问题:

1.IOptions 不能提供按租户区分的值

A 租户的请求和 B 租户的请求,拿到的 EmailOptions 一模一样,全是启动时那份默认值。多租户的功能直接失效了。

2.直接查库

那干脆不用 Options 了,每次发邮件都先查库拿到最新配置,总可以吧

public class EmailService
{
    private readonly ISettingStore _store;
    private readonly ICurrentTenant _currentTenant;   // 当前租户
public async Task SendAsync()
{
    var tenantId = _currentTenant.Id;
    var host = await _store.GetAsync(tenantId, class="hljs-string">"Email.SmtpHost");
    var port = await _store.GetAsync(tenantId, class="hljs-string">"Email.SmtpPort");
    var ssl  = await _store.GetAsync(tenantId, class="hljs-string">"Email.EnableSsl");
    class=class="hljs-string">"hljs-comment">// 每个用到邮件配置的地方,都得带 tenantId 这么来查一遍……
}

}

这代码问题稍微有点经验的小伙伴都知道:

1.强类型没了,全是 Email.SmtpHost 这种魔法字符串,敲错一个字母都不报错。

2.每个Service都要重复写一遍取当前租户然后读库再转类型的逻辑。

3.缓存策略各搞各的,有人按租户缓存有人不缓存,逐渐屎山化。

3.需要一个按当前上下文同步的开关

我们真正想要的,其实是这样的流程:

image

请求进来带着租户身份,某个地方统一同步一下这个请求里所有用到配置的服务,都读到当前租户的值,关键就是中间那个统一同步,它得知道当前是谁,然后把对应的值刷进来。原生 Options 没给我们这个开关,因为IOptions.Value 第一次访问就定死了,我们需要的不是重启程序,而是在一次请求里,根据当前租户从数据库拿到它对应的配置值,把已经缓存好的 Options 对象覆盖掉,然后让请求里所有的服务使用到的配置全部更新。不过这个就是设计要解决的事。

4.ABP是怎么解决的#

它定义了一个类叫 AbpDynamicOptionsManager<T>,它的实现思路在我看了之后,不禁觉得他们开发者对原生NETCORE框架的了解程度,不另起炉灶,而是继承原生的 OptionsManager,然后在它缓存好的那个对象动手改。 核心就三个文件代码很少很少。

1.继承 OptionsManager 的抽象基类

public abstract class AbpDynamicOptionsManager<T> : OptionsManager<T>
    where T : class
{
    protected AbpDynamicOptionsManager(IOptionsFactory<T> factory)
        : base(factory)
    {
    }
public Task SetAsync() =&gt; SetAsync(Options.DefaultName);

public virtual Task SetAsync(string name)
{
    return OverrideOptionsAsync(name, base.Get(name));
}

protected abstract Task OverrideOptionsAsync(string name, T options);

}

牛x的地方是 SetAsync 这几行:

1.base.Get(name)调父类的方法,把那个缓存好的 Options 对象拿出来,第一次会走 appsettings 的绑定,之后直接返回缓存。

2.OverrideOptionsAsync(name, 那个对象) 把这同一个对象丢给子类,让子类从数据库读值,往这个对象身上覆盖。

注意是原对象修改,不是new一个新的对象。因为业务代码中 _options.Value 已经拿到的就是这个对象的引用,在这个对象上改属性,引用的地方立马就看到了,要是 new 一个新的肯定就不对

2.注册扩展

public static IServiceCollection AddAbpDynamicOptions<TOptions, TManager>(this IServiceCollection services)
    where TOptions : class
    where TManager : AbpDynamicOptionsManager<TOptions>
{
    services.Replace(ServiceDescriptor.Scoped(typeof(IOptions<TOptions>), typeof(TManager)));
    services.Replace(ServiceDescriptor.Scoped(typeof(IOptionsSnapshot<TOptions>), typeof(TManager)));
    return services;
}

这一步非常重要,它直接替换了默认实现,从原生的 OptionsManager 换成我们自己的 Manager。换完之后,业务代码里注入的 IOptions<EmailOptions>,解析出来的是自定义 Manager,但因为它继承 OptionsManager并实现了 IOptions 接口,业务代码一个字都不用改。

3.同步开关

public static Task SetAsync<T>(this IOptions<T> options)
    where T : class
{
    return ((AbpDynamicOptionsManager<T>)options).SetAsync();
}

这就是我们前面说的需要一个按当前上下文同步的开关。用来管理员保存设置后,在一个地方 await options.SetAsync(),来触发了上面的 OverrideOptionsAsync,从数据库把最新值刷进 Options 对象,不需要重启重启程序而立即生效。恰好ABP自己也是这么用的,我在看Identity Management 模块的密码策略功能时发现,它就是靠这套机制,把数据库里的密码最小长度这类设置,同步进框架的 IdentityOptions

public class AbpIdentityOptionsManager : AbpDynamicOptionsManager<IdentityOptions>
{
    private readonly ISettingProvider _settingProvider;
protected override async Task OverrideOptionsAsync(string name, IdentityOptions options)
{
    options.Password.RequiredLength = await _settingProvider.GetAsync(
        IdentitySettingNames.Password.RequiredLength,
        options.Password.RequiredLength);   class=class="hljs-string">"hljs-comment">// 数据库有值就用数据库的,没有就保留默认
    class=class="hljs-string">"hljs-comment">// ……其他字段同理
}

}

管理员在后台把密码策略从 6 位改成 8 位,保存后立即对全系统生效,不需要重启,就是这么实现来的。

5.自己实现Demo#

我在看了之后觉得还是很巧妙的,不过看了几遍还是没能完全意会,然后自己就写了个Demo跑起来看看效果。Demo 里我用一个内存字典来模拟数据库,省去直接连实际数据库。

先提前说明一下,为了理解动态覆盖机制本身,我这个Demo是只有一个租户的简化版,模拟的数据库只存一套值没带 tenantId。怎么支持多租户?其实只要在覆盖逻辑里多走一步逻辑确认当前是哪个租户就行。

1.先准备静态默认配置 appsettings.json

{
  "Email": {
    "SmtpHost": "smtp.default.local",
    "SmtpPort": 25,
    "SenderAddress": "yuxl@qq.com",
    "EnableSsl": false
  }
}

2.再写一个模拟是数据库的设置 InMemorySettingStore

namespace CodeSource.DynamicOptionsDemo;

/// 模拟数据库、设置中心,生产环境可以换成 EF Core 查表、Redis 等 public class InMemorySettingStore { private readonly Dictionary<string, string> _settings = new(StringComparer.OrdinalIgnoreCase) { ["Email.SmtpHost"] = "smtp.dynamic.local", ["Email.SmtpPort"] = "587", ["Email.SenderAddress"] = "admin@qq.com", ["Email.EnableSsl"] = "true" };

public string? GetOrNull(string key) =&gt; _settings.TryGetValue(key, out var value) ? value : null;

public void Set(string key, string value) =&gt; _settings[key] = value;

}

先故意让它的值和 appsettings.json 都不一样,这样就能看出到底是哪套值在生效:

属性 appsettings(静态) 数据库(动态)
SmtpHost smtp.default.local smtp.dynamic.local
SmtpPort 25 587
SenderAddress yuxl@qq.com admin@qq.com
EnableSsl false true

3.开始写我们自己的 EmailOptionsManager实现覆盖逻辑

using CodeSource.DynamicOptions;
using Microsoft.Extensions.Options;

namespace CodeSource.DynamicOptionsDemo;

public class EmailOptionsManager : DynamicOptionsManager<EmailOptions> { private readonly InMemorySettingStore _settingStore;

public EmailOptionsManager(
    IOptionsFactory&lt;EmailOptions&gt; factory,
    InMemorySettingStore settingStore)
    : base(factory)
{
    _settingStore = settingStore;
}

protected override Task OverrideOptionsAsync(string name, EmailOptions options)
{
    class=class="hljs-string">"hljs-comment">// 数据库里有值就覆盖,没值就保留 appsettings 的默认值
    if (TryGetString(class="hljs-string">"Email.SmtpHost", out var smtpHost))
        options.SmtpHost = smtpHost;

    if (TryGetInt(class="hljs-string">"Email.SmtpPort", out var smtpPort))
        options.SmtpPort = smtpPort;

    if (TryGetString(class="hljs-string">"Email.SenderAddress", out var senderAddress))
        options.SenderAddress = senderAddress;

    if (TryGetBool(class="hljs-string">"Email.EnableSsl", out var enableSsl))
        options.EnableSsl = enableSsl;

    return Task.CompletedTask;
}

private bool TryGetString(string key, out string value)
{
    var stored = _settingStore.GetOrNull(key);
    if (stored is null) { value = string.Empty; return false; }
    value = stored;
    return true;
}

private bool TryGetInt(string key, out int value)
{
    var stored = _settingStore.GetOrNull(key);
    if (stored is null || !int.TryParse(stored, out value)) { value = class="hljs-number">0; return false; }
    return true;
}

private bool TryGetBool(string key, out bool value)
{
    var stored = _settingStore.GetOrNull(key);
    if (stored is null || !bool.TryParse(stored, out value)) { value = false; return false; }
    return true;
}

}

这里判断了数据库有没有这个 key,有才覆盖没有就不动。这样 appsettings.json 就成了兜底的默认值,数据库只负责覆盖那些管理员真正改过的项。这种静态打底 + 动态覆盖的双层结构,比单纯查库多了一层降级的策略

4.业务服务 EmailService调用,可以看到很干净

// 业务服务只认 IOptions<EmailOptions>,根本不知道背后有动态这回事
public class EmailService
{
    private readonly IOptions<EmailOptions> _options;
public EmailService(IOptions&lt;EmailOptions&gt; options)
{
    _options = options;
}

public void PrintCurrentConfig(string label)
{
    var email = _options.Value;
    Console.WriteLine($class="hljs-string">"[{label}]");
    Console.WriteLine($class="hljs-string">"  SmtpHost       : {email.SmtpHost}");
    Console.WriteLine($class="hljs-string">"  SmtpPort       : {email.SmtpPort}");
    Console.WriteLine($class="hljs-string">"  SenderAddress  : {email.SenderAddress}");
    Console.WriteLine($class="hljs-string">"  EnableSsl      : {email.EnableSsl}");
    Console.WriteLine();
}

}

EmailService 普通地注入 IOptions<EmailOptions>就可以了,跟平时写的一毛一样。这就是这套设计最巧妙和有价值的地方,动态能力是加在框架和扩展的连接处的,实际业务代码基本没有感知也不需要改动。

5.我们再Program.cs,把上面这些串起来演示一下

**


internal static class Program
{
    private static async Task Main()
    {
        var configuration = new ConfigurationBuilder().SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: false) .Build();
        var services = new ServiceCollection();
    class=class="hljs-string">"hljs-comment">// 第 class="hljs-number">1 步绑定静态配置appsettings → EmailOptions
    services.AddOptions&lt;EmailOptions&gt;().Bind(configuration.GetSection(class="hljs-string">"Email"));

    class=class="hljs-string">"hljs-comment">// 第 class="hljs-number">2 步动态设置存储(模拟数据库)
    services.AddSingleton&lt;InMemorySettingStore&gt;();

    class=class="hljs-string">"hljs-comment">// 第 class="hljs-number">3 步用我们的 Manager 替换 IOptions 默认实现
    services.AddDynamicOptions&lt;EmailOptions, EmailOptionsManager&gt;();

    class=class="hljs-string">"hljs-comment">// 第 class="hljs-number">4步 注入业务服务
    services.AddScoped&lt;EmailService&gt;();

    using var provider = services.BuildServiceProvider();
    using var scope = provider.CreateScope();   class=class="hljs-string">"hljs-comment">// 模拟一次 HTTP 请求的 Scope
    var sp = scope.ServiceProvider;

    var emailService = sp.GetRequiredService&lt;EmailService&gt;();
    var options = sp.GetRequiredService&lt;IOptions&lt;EmailOptions&gt;&gt;();
    var settingStore = sp.GetRequiredService&lt;InMemorySettingStore&gt;();

    class=class="hljs-string">"hljs-comment">// 还没同步,读到的是 appsettings 的静态值
    emailService.PrintCurrentConfig(class="hljs-string">" 仅 appsettings,未同步动态设置");

    class=class="hljs-string">"hljs-comment">// 调一次 SetAsync,数据库的值覆盖上来
    await options.SetAsync();
    emailService.PrintCurrentConfig(class="hljs-string">"调用 SetAsync() 后,动态设置已覆盖");

    class=class="hljs-string">"hljs-comment">// 模拟管理员在后台改了配置,再同步一次
    settingStore.Set(class="hljs-string">"Email.SmtpHost", class="hljs-string">"smtp.updated.local");
    await options.SetAsync();
    emailService.PrintCurrentConfig(class="hljs-string">" 设置变更后再次 SetAsync(),不需要重启");

    class=class="hljs-string">"hljs-comment">// 同一个 Scope 里再拿一个服务,值是一样的,共享同一实例
    var anotherEmailService = sp.GetRequiredService&lt;EmailService&gt;();
    anotherEmailService.PrintCurrentConfig(class="hljs-string">"同 Scope 另一服务,值一样");
}

}

感兴趣的小伙伴,可以看看

6.在Web 项目里怎么落地#

我们上面在控制台验证了下实现原理,真实项目里都是在 Web API 里用的。注册跟 Demo 一样,其实关键是什么时候调 SetAsync,这里有两种常见办法。

方法一

在保存设置的接口里调一次,管理员点保存的接口,存完库顺手同步一下:

[ApiController]
[Route("api/settings/email")]
public class EmailSettingsController : ControllerBase
{
    private readonly InMemorySettingStore _store;
    private readonly IOptions<EmailOptions> _options;
public EmailSettingsController(InMemorySettingStore store, IOptions&lt;EmailOptions&gt; options)
{
    _store = store;
    _options = options;
}

[HttpPut]
public async Task&lt;IActionResult&gt; Save([FromBody] EmailOptionsDto dto)
{
    class=class="hljs-string">"hljs-comment">// class="hljs-number">1. 先存库
    _store.Set(class="hljs-string">"Email.SmtpHost", dto.SmtpHost);
    _store.Set(class="hljs-string">"Email.SmtpPort", dto.SmtpPort.ToString());
    _store.Set(class="hljs-string">"Email.SenderAddress", dto.SenderAddress);
    _store.Set(class="hljs-string">"Email.EnableSsl", dto.EnableSsl.ToString());

    class=class="hljs-string">"hljs-comment">// class="hljs-number">2. 再同步,当前请求 Scope 里的 Options 立即更新
    await _options.SetAsync();
    return Ok(new { message = class="hljs-string">"设置已保存并生效" });
}

}

方法二

每个请求进来自动同步,因为我们把 IOptions<T> 注册成了 Scoped,每个请求一份,所以可以写个中间件,在每个请求一进来就同步一次,业务代码就完全不用操心同步时机:

public class DynamicOptionsMiddleware
{
    private readonly RequestDelegate _next;
    public DynamicOptionsMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context, IOptions&lt;EmailOptions&gt; emailOptions)
{
    await emailOptions.SetAsync();   class=class="hljs-string">"hljs-comment">// 每个请求开始时刷一次
    await _next(context);
}

}

// Program.cs app.UseMiddleware<DynamicOptionsMiddleware>();

这样每个 HTTP 请求进来,EmailOptions 都会自动从数据库刷新到最新,业务里该怎么注入还怎么注入。

7.总结#

回头看,这套设计巧就巧在它没有推翻原生 Options,而是对OptionsManager 精准地补了一刀:

1.继承而不是另起炉灶——完全兼容原生的 BindConfigure 那套机制。

2.原地覆盖而不是重建对象,业务手里的引用不变,改属性就全都跟着变。

3.一个 SetAsync 当同步开关,自己决定什么时候生效。

4.Replace 替换,业务代码继续注入 IOptions<T>,一个字都不用改。

跟之前写认证那篇一样的感受,这些框架背后的设计,不自己去抠源码动手撸一遍,光看文档很难体会到他的牛叉之处。感兴趣的小伙伴可以把这个 Demo 拉下来跑一跑试试


原文链接:https://www.cnblogs.com/yuxl01/p/21111216

评论

© 2026 松岛川树