CORE 动态扩展Options-配置运行时热更新 .前言玩了半年最近找了新工作新工作也是再一次接触到Abp vNext 框架内部系统开发也是基于这个框架的最近领导让研究学习框架身份认证权限这一套然后为现有系统做一些扩展和维护在研究的过程中发现一些很有趣的设计和代码刚好有些总结今天分享出来。做业务系统配置这东西绕不开。小到一个 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”,

相关新闻

最新新闻

Linux 官网下载速度龟速?阿里云镜像站高速获取 Ubuntu/CentOS 系统镜像

Linux 官网下载速度龟速?阿里云镜像站高速获取 Ubuntu/CentOS 系统镜像

很多使用联想小新笔记本做开发、运维学习的朋友,想要搭建虚拟机、本地服务器,需要下载 Ubuntu、CentOS、Deepin 等 Linux 系统镜像,但直接访问海外官方源下载时,常常遇到下载速度几十 KB/s、链接中断、文件损坏等问题。不少人四处…

2026/7/22 10:22:24
MibSPI DMA通道寄存器配置详解与实战避坑指南

MibSPI DMA通道寄存器配置详解与实战避坑指南

1. MibSPI DMA通道控制与寄存器配置详解在嵌入式系统,尤其是汽车电子和工业控制这类对实时性和可靠性要求极高的领域,SPI(串行外设接口)总线承担着大量传感器数据采集、执行器控制命令下发等关键任务。当数据吞吐量增大&#xff0…

2026/7/22 10:22:24
Windows 更新后提示 PIN 不可用,卡在登录界面进不去系统?官方修复流程

Windows 更新后提示 PIN 不可用,卡在登录界面进不去系统?官方修复流程

很多联想小新、ThinkPad、台式机用户在 Windows 自动更新补丁、系统大版本升级后,重启电脑直接卡在登录界面,屏幕弹出提示「出现问题,你的 PIN 不可用」,只能看到 “设置我的 PIN” 按钮,原本的数字 PIN 完全失效无法登…

2026/7/22 10:22:24
企业财务数字化转型用什么系统?2026年主流平台深度对比

企业财务数字化转型用什么系统?2026年主流平台深度对比

当财务部门成为数据中枢,系统选型决定未来三年聊财务数字化转型,很多人的第一反应就是"上系统"。但上什么系统、怎么上、先上哪个模块,这些问题远比买一套软件复杂得多。一家年营收20亿的制造企业CFO跟我聊过一件事:他们…

2026/7/22 10:22:24
天然产物靶点发现技术:HuProt™ 20K人类蛋白组芯片的应用优势

天然产物靶点发现技术:HuProt™ 20K人类蛋白组芯片的应用优势

a天然产物因其结构多样性和广泛的药理活性,一直是新药研发的重要宝库。然而,“活性明确、机制不明”常成为制约其深入研究的瓶颈。在面对海量蛋白质时,天然产物单体如何锁定靶点成为科研人员亟需解决的挑战。HuProt™ 20K人类蛋白组芯片&…

2026/7/22 10:22:24
遗憾真的是智能车竞赛主旋律吧

遗憾真的是智能车竞赛主旋律吧

01 【遗憾的魅力】 卓大您好!我们是华南赛区深圳职业技术大学飞檐走壁组。 首先我们承认比赛存在些许不足, 但整车结构、PCB硬件、控制软件均为团队独立从零研发。 我们车模自重仅98g(极限可达95g), 自主迭代十几代结构…

2026/7/22 10:17:24

月新闻