.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 是怎么工作的

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

csharp 复制代码
services.AddOptions<EmailOptions>().Bind(configuration.GetSection("Email"));

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

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

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

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

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

csharp 复制代码
public class OptionsManager<TOptions> : IOptions<TOptions>, IOptionsSnapshot<TOptions>
    where TOptions : class
{
    private readonly IOptionsFactory<TOptions> _factory;
    private TOptions? _cache;   // 注意这个缓存字段

    public TOptions Value => Get(Options.DefaultName);

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

看上面代码应该就明白了,EmailOptions 这个对象,就在我们第一次访问 .Value 的时候才构建的,构建完就缓存进 _cache,之后再读拿到的都是同一个对象、同一批值。

这就是在没开热重载的情况下,为啥我们改了 appsettings.json或者改了数据库,IOptions<T>.Value 没变的原因,因为缓存了压根就没再算第二次。

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

看下源码:

csharp 复制代码
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.真实业务场景下的问题

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

csharp 复制代码
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 了,每次发邮件都先查库拿到最新配置,总可以吧

csharp 复制代码
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, "Email.SmtpHost");
        var port = await _store.GetAsync(tenantId, "Email.SmtpPort");
        var ssl  = await _store.GetAsync(tenantId, "Email.EnableSsl");
        // 每个用到邮件配置的地方,都得带 tenantId 这么来查一遍......
    }
}

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

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

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

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

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

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

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

4.ABP是怎么解决的

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

1.继承 OptionsManager 的抽象基类
csharp 复制代码
public abstract class AbpDynamicOptionsManager<T> : OptionsManager<T>
    where T : class
{
    protected AbpDynamicOptionsManager(IOptionsFactory<T> factory)
        : base(factory)
    {
    }

    public Task SetAsync() => 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.注册扩展
csharp 复制代码
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.同步开关
csharp 复制代码
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

csharp 复制代码
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);   // 数据库有值就用数据库的,没有就保留默认
        // ......其他字段同理
    }
}

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

5.自己实现Demo

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

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

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

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

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

csharp 复制代码
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) => _settings.TryGetValue(key, out var value) ? value : null;

    public void Set(string key, string value) => _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实现覆盖逻辑

csharp 复制代码
using CodeSource.DynamicOptions;
using Microsoft.Extensions.Options;

namespace CodeSource.DynamicOptionsDemo;

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

    public EmailOptionsManager(
        IOptionsFactory<EmailOptions> factory,
        InMemorySettingStore settingStore)
        : base(factory)
    {
        _settingStore = settingStore;
    }

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

        if (TryGetInt("Email.SmtpPort", out var smtpPort))
            options.SmtpPort = smtpPort;

        if (TryGetString("Email.SenderAddress", out var senderAddress))
            options.SenderAddress = senderAddress;

        if (TryGetBool("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 = 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调用,可以看到很干净

csharp 复制代码
// 业务服务只认 IOptions<EmailOptions>,根本不知道背后有动态这回事
public class EmailService
{
    private readonly IOptions<EmailOptions> _options;

    public EmailService(IOptions<EmailOptions> options)
    {
        _options = options;
    }

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

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

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

**

csharp 复制代码
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();

        // 第 1 步绑定静态配置appsettings → EmailOptions
        services.AddOptions<EmailOptions>().Bind(configuration.GetSection("Email"));

        // 第 2 步动态设置存储(模拟数据库)
        services.AddSingleton<InMemorySettingStore>();

        // 第 3 步用我们的 Manager 替换 IOptions 默认实现
        services.AddDynamicOptions<EmailOptions, EmailOptionsManager>();

        // 第 4步 注入业务服务
        services.AddScoped<EmailService>();

        using var provider = services.BuildServiceProvider();
        using var scope = provider.CreateScope();   // 模拟一次 HTTP 请求的 Scope
        var sp = scope.ServiceProvider;

        var emailService = sp.GetRequiredService<EmailService>();
        var options = sp.GetRequiredService<IOptions<EmailOptions>>();
        var settingStore = sp.GetRequiredService<InMemorySettingStore>();

        // 还没同步,读到的是 appsettings 的静态值
        emailService.PrintCurrentConfig(" 仅 appsettings,未同步动态设置");

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

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

        // 同一个 Scope 里再拿一个服务,值是一样的,共享同一实例
        var anotherEmailService = sp.GetRequiredService<EmailService>();
        anotherEmailService.PrintCurrentConfig("同 Scope 另一服务,值一样");
    }
}

感兴趣的小伙伴,可以下载完整Demo代码

6.在Web 项目里怎么落地

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

方法一

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

csharp 复制代码
[ApiController]
[Route("api/settings/email")]
public class EmailSettingsController : ControllerBase
{
    private readonly InMemorySettingStore _store;
    private readonly IOptions<EmailOptions> _options;

    public EmailSettingsController(InMemorySettingStore store, IOptions<EmailOptions> options)
    {
        _store = store;
        _options = options;
    }

    [HttpPut]
    public async Task<IActionResult> Save([FromBody] EmailOptionsDto dto)
    {
        // 1. 先存库
        _store.Set("Email.SmtpHost", dto.SmtpHost);
        _store.Set("Email.SmtpPort", dto.SmtpPort.ToString());
        _store.Set("Email.SenderAddress", dto.SenderAddress);
        _store.Set("Email.EnableSsl", dto.EnableSsl.ToString());

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

方法二

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

csharp 复制代码
public class DynamicOptionsMiddleware
{
    private readonly RequestDelegate _next;
    public DynamicOptionsMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context, IOptions<EmailOptions> emailOptions)
    {
        await emailOptions.SetAsync();   // 每个请求开始时刷一次
        await _next(context);
    }
}

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

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

7.总结

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

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

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

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

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

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

相关推荐
yuyuyui8 天前
.NET CORE认证模块-注册方案与请求认证探究
认证·.net core
yuyuyui13 天前
.NET CORE 认证模块-Cookie、JWT 与自定义 Scheme
认证·.net core
hez201014 天前
在 .NET 上构建超大托管数组
c#·.net·.net core·gc·clr
.NET修仙日记1 个月前
Scrutor:.NET 依赖注入自动化的优雅实现
c#·.net·.net core·微软技术·依赖注入·scrutor
yuyuyui2 个月前
Mysql事物的持久性及原子性
mysql·.net core
wangl_922 个月前
初探 C# 15 的 Union Types
java·开发语言·算法·c#·.net·.net core
wangl_922 个月前
C# / .NET 在工业环境中的优势
开发语言·c#·.net·.netcore·.net core·visual studio
飞瀑2 个月前
ASP.NET Core 核心深度解析:Application Part 与 Feature Provider 完全指南
.net core
飞瀑2 个月前
ASP.NET Core MVC 核心架构深度解析
架构·mvc·.net core