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.IOptions、IOptionsSnapshot、IOptionsMonitor 他们有啥区别?
2.原生 Options 是怎么工作的
我们平时在代码中这样写:
csharp
services.AddOptions<EmailOptions>().Bind(configuration.GetSection("Email"));
代码背后NETCORE框架偷偷帮我们干了几件事:
-
注册一个绑定器,负责把
appsettings.json里Email这个节点的值,塞进EmailOptions对象。 -
注册一个负责造对象的工厂
IOptionsFactory<EmailOptions>。 -
把
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.继承而不是另起炉灶 ------完全兼容原生的 Bind、Configure 那套机制。
2.原地覆盖而不是重建对象,业务手里的引用不变,改属性就全都跟着变。
3.一个 SetAsync 当同步开关,自己决定什么时候生效。
4.用 Replace 替换 ,业务代码继续注入 IOptions<T>,一个字都不用改。
跟之前写认证那篇一样的感受,这些框架背后的设计,不自己去抠源码动手撸一遍,光看文档很难体会到他的牛叉之处。感兴趣的小伙伴可以把这个 Demo 拉下来跑一跑试试