.NET CORE 动态扩展Options-配置运行时热更新
当前位置:点晴教程→知识管理交流
→『 技术文档交流 』
1.前言
做业务系统,配置这东西绕不开。小到一个 SMTP 邮件服务器地址,大到一整套限流规则、开关策略,我们都习惯放到 appsettings.json 里,代码定义一个 EmailOptions,在 Program.cs 里 Bind 一下。然后业务 Service 在代码里用 有小伙伴可能会说配置要改还不简单, 1.A公司想用自己的企业邮箱服务器发件,显示自己的发件人抬头。 2.B公司没有邮件服务器,就用我们平台默认的。 3.C公司是大客户,买了独立 SMTP 通道增值服务,配置又会不一样。 就是说,同样一段发邮件的代码, 这就是原生 下面就来分享一下这个问题是怎么来的,顺便看看 ABP 框架里 1. 2. 2.原生 Options 是怎么工作的我们平时在代码中这样写: 代码背后NETCORE框架偷偷帮我们干了几件事:
第三步这个 看上面代码应该就明白了, 这里你可能会有疑问,通常缓存都是使用一个静态字典,或者其他静态的属性,_cache不就是个私有字段吗?怎么缓存的?其实他是依靠实例注入容器的生命周期来实现的,如果注入为单例,那么这个对象上的字段也会一直存在,如果是作用域那么没一次请求就会重新更新,如果你明白了,那恭喜你你对实例缓存和静态缓存有了更进一步的认识。 看下源码:
这里要注意的是 1.原生3种选项,都不能知道数据库里的配置变更。 2.单例的
3.真实业务场景下的问题下面我们直接把多租户场景落到代码上。还是那个发邮件的功能,配置类长这样: 我们的需求是: 1. 2.每个租户能在自己的后台里配一套专属的SMTP,存在数据库里,改完立即生效,不需要重启。 3.业务代码里发邮件的 就这个需求,如果用原生的方式就会有如下几个问题: 1. |
| 属性 | 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<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<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,把上面这些串起来演示一下
**
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 另一服务,值一样");
}
}
感兴趣的小伙伴,可以看看
我们上面在控制台验证了下实现原理,真实项目里都是在 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<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,每个请求一份,所以可以写个中间件,在每个请求一进来就同步一次,业务代码就完全不用操心同步时机:
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 都会自动从数据库刷新到最新,业务里该怎么注入还怎么注入。
回头看,这套设计巧就巧在它没有推翻原生 Options,而是对OptionsManager 精准地补了一刀:
1.继承而不是另起炉灶——完全兼容原生的 Bind、Configure 那套机制。
2.原地覆盖而不是重建对象,业务手里的引用不变,改属性就全都跟着变。
3.一个 SetAsync 当同步开关,自己决定什么时候生效。
4.用 Replace 替换,业务代码继续注入 IOptions<T>,一个字都不用改。
跟之前写认证那篇一样的感受,这些框架背后的设计,不自己去抠源码动手撸一遍,光看文档很难体会到他的牛叉之处。感兴趣的小伙伴可以把这个 Demo 拉下来跑一跑试试
阅读原文:点击这里