当前位置: 首页 > news >正文

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

做业务系统,配置这东西绕不开。小到一个 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, IOptionsSnapshot
where 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; } = “yuxl@qq.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 : OptionsManager
where 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 AddAbpDynamicOptions<TOptions, TManager>(this IServiceCollection services)
where TOptions : class
where 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 模块的密码策略功能时发现,它就是靠这套机制,把数据库里的密码最小长度这类设置,同步进框架的 IdentityOptions:

public 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”: “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) => _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实现覆盖逻辑

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

namespace CodeSource.DynamicOptionsDemo;

public class EmailOptionsManager : DynamicOptionsManager
{
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调用,可以看到很干净

// 业务服务只认 IOptions,根本不知道背后有动态这回事
public class EmailService
{
private readonly IOptions _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就可以了,跟平时写的一毛一样。这就是这套设计最巧妙和有价值的地方,动态能力是加在框架和扩展的连接处的,实际业务代码基本没有感知也不需要改动。

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();

// 第 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 另一服务,值一样"); }

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

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

方法一

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

[ApiController]
[Route(“api/settings/email”)]
public class EmailSettingsController : ControllerBase
{
private readonly InMemorySettingStore _store;
private readonly IOptions _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 注册成了 Scoped,每个请求一份,所以可以写个中间件,在每个请求一进来就同步一次,业务代码就完全不用操心同步时机:

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();
这样每个 HTTP 请求进来,EmailOptions 都会自动从数据库刷新到最新,业务里该怎么注入还怎么注入。

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

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

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

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

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

http://www.jsqmd.com/news/1228819/

相关文章:

  • Flipper Zero终极指南:从入门到精通的全方位资源宝典
  • 【粉丝福利社】可视艺术用可视化路径赋能数据分析
  • AI自然语言查询总出错?这8类语义解析陷阱90%团队从未察觉,附Prompt工程校准清单
  • M12 X-Code 8芯 PoE 相机/远程IO 针脚定义完整详解
  • Spring Boot3中分布式事务与本地事务的冲突与解决方案
  • AI音乐商用版权合规实战手册(含BBC/Spotify/网易云最新授权模板)
  • 深入解析EDMA3事件与中断寄存器:嵌入式DMA高效数据搬移的核心机制
  • 如何在code-server中搭建3种AI编程助手?从Copilot到本地模型全攻略
  • 华硕笔记本性能优化终极指南:如何用G-Helper实现一键智能控制
  • 如何在《鸣潮》中自定义游戏体验?AES密钥与模组制作完全解析
  • C++高并发内存池实现:三层架构设计与性能优化实战
  • Fun-ASR社区生态与未来发展:路线图、贡献指南与社区支持资源
  • 一週間でなれる!スパコンプログラマ:7日間でMPIと並列計算をマスターする完全ガイド
  • MNE-Python中的信号空间分离(SSS)与Maxwell滤波技术详解
  • Vio lit:高性能Python Web框架的差分更新技术解析
  • 奢源国际水光模式商城软件开发
  • MOSS-TTS-v1.5终极指南:31种语言语音合成的完整实战教程
  • 2026中国呼吸康复学术年会:技术与临床转化新进展
  • 3分钟掌握鸣潮模组:从基础使用到高级自定义的完整攻略
  • 如何高效使用IkPy:Python逆运动学库的实用指南
  • 不是训练模型,是给模型「塞小抄」
  • DCNv2终极指南:解锁PyTorch动态卷积的无限可能
  • 终极指南:使用SilentPatch修复补丁让经典GTA游戏在现代电脑上完美运行
  • Next.js 14 App Router × AI Agent实战:从零搭建可自解释、自调试的前端系统(仅限首批200名开发者获取架构图谱)
  • Pencil架构解析:开源原型工具的技术演进与设计系统
  • 【限时解密】头部AI音乐工作室不外传的变现飞轮:单曲ROI提升3.8倍的5步闭环法
  • 临汾考公机构TOP3排名:口碑与实力双优之选(2026年最新横评)
  • dms常见问题解答:解决你的媒体服务器疑惑
  • OpenNFS车辆物理系统:如何实现真实赛车动力学与碰撞检测
  • 大连市民必看:名包回收避坑指南四不五要 + 官方认证实体门店名录 - 分享测评官