做业务系统配置这东西绕不开。小到一个 SMTP 邮件服务器地址大到一整套限流规则、开关策略我们都习惯放到 appsettings.json 里代码定义一个 EmailOptions在 Program.cs 里 Bind 一下。然后业务 Service 在代码里用 IOptions 注入进来用框架帮我们绑定好而且强类型有智能提示用起来很舒服。但用久了会发现一个尴尬的地方appsettings.json 里的东西改一次就得重新发布或者重启一次应用。开发阶段无所谓可上了生产这事就让人觉得很离谱。有小伙伴可能会说配置要改还不简单appsettings.json 改一改配合 IOptionsMonitor 不就能热更新、不用重启了吗没错如果你的诉求只是改个配置文件不重启就生效IOptionsMonitor 确实够用了这篇你可以不用往下看。因为它能覆盖的仅仅是一份配置全局生效这种最简单的情况。真正让人头疼的是这种场景。假设我们在做一个多租户的 SaaS 系统卖给N家公司用。有个再常见不过的功能——发邮件、发验证码、发通知。那么问题来了1.A公司想用自己的企业邮箱服务器发件显示自己的发件人抬头。2.B公司没有邮件服务器就用我们平台默认的。3.C公司是大客户买了独立 SMTP 通道增值服务配置又会不一样。就是说同样一段发邮件的代码EmailOptions 该取哪个值取决于当前这个请求是哪个租户进来的。 而且这些配置是租户自己在后台管理页面里改存在数据库里随时可能变。这时候咱们再看 IOptionsMonitor会发现它歇菜了因为它是全局单例只有一份配置程序找他读取A租户的 SMTP 是什么它根本没有当前是谁这个概念。因为它只针对配置文件但是租户配置压根不在配置文件里而在数据库里一个租户一行数。这就是原生 IOptions 的天花板它能解决的是配置从哪来但解决不了配置要不要随上下文租户 、 用户 、 请求变化。 不过这些恰恰是稍微复杂点的业务系统都会遇到的真实需求——多租户只是最典型的一个我之前就遇到过还有其它像按用户等级给不同的限流阈值按当前组织读不同的审批流配置本质是同一类问题。下面就来分享一下这个问题是怎么来的顺便看看 ABP 框架里 AbpDynamicOptionsManager 的设计是怎么用很少的代码解决这个问题的也是 ABP 做到同一个 IOptions不同租户拿到不同值的核心原理。然后我们不依赖 ABP自己动手实现一套大概不到 100 行代码。在开始之前我们如果理解了下面2个问题后面会顺很多这里不展开讲1.Options 模式解决了什么问题2.IOptions、IOptionsSnapshot、IOptionsMonitor 他们有啥区别2.原生 Options 是怎么工作的我们平时在代码中这样写services.AddOptions().Bind(configuration.GetSection(“Email”));代码背后NETCORE框架偷偷帮我们干了几件事注册一个绑定器负责把 appsettings.json 里 Email 这个节点的值塞进 EmailOptions 对象。注册一个负责造对象的工厂 IOptionsFactory。把 IOptions 和 IOptionsSnapshot 的默认实现都指向同一个类OptionsManager。第三步这个 OptionsManager 它内部的逻辑如下这里我简化public class OptionsManager : IOptions, IOptionsSnapshotwhere TOptions : class{private readonly IOptionsFactory _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.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 单例 永不变第一次算完用到进程结束 启动后不变的配置IOptionsSnapshot 作用域每请求一份 每个新请求重新算一次 每个请求可能不同的配置IOptionsMonitor 单例 配置文件变了会自动重新算 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; } “yuxlqq.com”;public bool EnableSsl { get; set; }}我们的需求是1.appsettings.json 里有一套平台默认邮件配置租户没自己配的时候就用它兜底。2.每个租户能在自己的后台里配一套专属的SMTP存在数据库里改完立即生效不需要重启。3.业务代码里发邮件的 EmailService希望还是正常注入 IOptions 就能用不用为了多租户到处改。就这个需求如果用原生的方式就会有如下几个问题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, 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.需要一个按当前上下文同步的开关我们真正想要的其实是这样的流程image请求进来带着租户身份某个地方统一同步一下这个请求里所有用到配置的服务都读到当前租户的值关键就是中间那个统一同步它得知道当前是谁然后把对应的值刷进来。原生 Options 没给我们这个开关因为IOptions.Value 第一次访问就定死了我们需要的不是重启程序而是在一次请求里根据当前租户从数据库拿到它对应的配置值把已经缓存好的 Options 对象覆盖掉然后让请求里所有的服务使用到的配置全部更新。不过这个就是设计要解决的事。4.ABP是怎么解决的它定义了一个类叫 AbpDynamicOptionsManager它的实现思路在我看了之后不禁觉得他们开发者对原生NETCORE框架的了解程度不另起炉灶而是继承原生的 OptionsManager然后在它缓存好的那个对象动手改。 核心就三个文件代码很少很少。1.继承 OptionsManager 的抽象基类public abstract class AbpDynamicOptionsManager : OptionsManagerwhere T : class{protected AbpDynamicOptionsManager(IOptionsFactory 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.注册扩展public static IServiceCollection AddAbpDynamicOptionsTOptions, TManager(this IServiceCollection services)where TOptions : classwhere TManager : AbpDynamicOptionsManager{services.Replace(ServiceDescriptor.Scoped(typeof(IOptions), typeof(TManager)));services.Replace(ServiceDescriptor.Scoped(typeof(IOptionsSnapshot), typeof(TManager)));return services;}这一步非常重要它直接替换了默认实现从原生的 OptionsManager 换成我们自己的 Manager。换完之后业务代码里注入的 IOptions解析出来的是自定义 Manager但因为它继承 OptionsManager并实现了 IOptions 接口业务代码一个字都不用改。3.同步开关public static Task SetAsync(this IOptions options)where T : class{return ((AbpDynamicOptionsManager)options).SetAsync();}这就是我们前面说的需要一个按当前上下文同步的开关。用来管理员保存设置后在一个地方 await options.SetAsync()来触发了上面的 OverrideOptionsAsync从数据库把最新值刷进 Options 对象不需要重启重启程序而立即生效。恰好ABP自己也是这么用的我在看Identity Management 模块的密码策略功能时发现它就是靠这套机制把数据库里的密码最小长度这类设置同步进框架的 IdentityOptionspublic class AbpIdentityOptionsManager : AbpDynamicOptionsManager{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{“Email”: {“SmtpHost”: “smtp.default.local”,“SmtpPort”: 25,“SenderAddress”: “yuxlqq.com”,“EnableSsl”: false}}2.再写一个模拟是数据库的设置 InMemorySettingStorenamespace CodeSource.DynamicOptionsDemo;/// 模拟数据库、设置中心生产环境可以换成 EF Core 查表、Redis 等public class InMemorySettingStore{private readonly Dictionarystring, string _settings new(StringComparer.OrdinalIgnoreCase){[“Email.SmtpHost”] “smtp.dynamic.local”,[“Email.SmtpPort”] “587”,[“Email.SenderAddress”] “adminqq.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.localSmtpPort 25 587SenderAddress yuxlqq.com adminqq.comEnableSsl false true3.开始写我们自己的 EmailOptionsManager实现覆盖逻辑using CodeSource.DynamicOptions;using Microsoft.Extensions.Options;namespace CodeSource.DynamicOptionsDemo;public class EmailOptionsManager : DynamicOptionsManager{private readonly InMemorySettingStore _settingStore;public EmailOptionsManager( IOptionsFactoryEmailOptions 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;