在第一节中,我们实现了基本的自定义数据库配置源,从而可以读取MySql数据库的配置,但是,我们没有实现动态加载数据库配置,也就是程序一但运行起来,数据库的配置更改后就不在被更新。所以本节重点来解决这个问题。
1.基本操作
我们知道在Option模式中,要想加载更新的配置,只需要两步:
一是,添加配置的时候,将reloadChange属性设置为True;而是获取配置时,使用IOptionsSnapShot<T>:
WebHost.CreateDefaultBuilder(args)
.ConfigureAppConfiguration((hostingContext, config) =>
{
config.SetBasePath(Directory.GetCurrentDirectory());
config.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
config.AddJsonFile("appsettings.Development.json", optional: false, reloadOnChange: true);
config.AddEnvironmentVariables();
})
IOptions<T>是单例模式,所以第一次启动加载后,就不会再加载,而IOptionsSnapshot是Scope模式,
每次加载时,都会重新读取一遍。
但是我们怎么让IConfiguration对象重新读取数据库呢?我们查文档找到了一个方法:
protected void OnReload ();
官方解释是:Triggers the reload change token and creates a new one.
也就是如果调用这个函数,整个配置树都会重新建立,这也就给了我们一种办法去动态加载。
为了验证,我们用Controller做试验:
承接(一)中的代码,我们在默认的WeatherForecastController下添加一个Action:
[HttpGet,Route("ShowStudent")]
public ActionResult<string> ShowStudent()
{
var configurationRoot = HttpContext.RequestServices.GetService<IConfiguration>() as IConfigurationRoot;
if (null == configurationRoot)
{
return BadRequest();
}
configurationRoot.Reload();
var stu = HttpContext.RequestServices.GetService<IOptionsSnapshot<Student>>()?.Value;
if(stu!=null)
{
return $"{stu.Name}---{stu.Age}";
}else
{
return NotFound();
}
}
运行,不关闭程序,然后改变数据库的Wang字段:
再次执行,就会发现数据变成新修改的数据。
上面的做法虽然可行,但是如果每次获取时都要手动刷新,无疑很繁琐,我们得找找更优雅的办法。
二.思考
基于前面的分析,当数据库的数据发生改变时,肯定要重新加载一般数据,这是无法避免的,简单点一般是全部加载,如果数据库有一些特定支持,也许可以实现加载变化的内容,这里我们还是简单一点,考虑到一般配置数据不大可能有上万条之多,也就是这点数据不造成性能问题。
所以,第一步就是要能知道数据库中的数据发生变化,然后触发后续重载操作。
在查看ConfigurationProvider类时,我们发现这两个函数成员,
/// <summary>
/// Returns a <see cref="IChangeToken"/> that can be used to listen when this provider is reloaded.
/// </summary>
/// <returns>The <see cref="IChangeToken"/>.</returns>
public IChangeToken GetReloadToken()
{
return _reloadToken;
}
/// <summary>
/// Triggers the reload change token and creates a new one.
/// </summary>
protected void OnReload()
{
ConfigurationReloadToken previousToken = Interlocked.Exchange(ref _reloadToken, new ConfigurationReloadToken());
previousToken.OnReload();
}
在OnReload接口中,会调用OnReload,其实就是触发cancel操作:
/// <summary>
/// Used to trigger the change token when a reload occurs.
/// </summary>
public void OnReload() => _cts.Cancel();
也就是说,如果我们检测到数据变化,触发了Onload()函数,那么ConfigurationBuilder就会重载配置,也就达到我们的目的。
三. 重构
先给出EFConfigurationSource<TDbContext>的代码,为了考虑通用性,我将配置源类改为泛型模式。
public class EFConfigurationSource<TDbContext>: IConfigurationSource where TDbContext : DbContext
{
public readonly Action<DbContextOptionsBuilder> _optionsAction;
public readonly bool _reloadOnChange;
public readonly int _pollingInterval;
public readonly Action<EFConfigurationLoadException<TDbContext>>? OnLoadException;
public EFConfigurationSource(Action<DbContextOptionsBuilder> optionsAction,
bool reloadOnChange = false,
int pollingInterval = 5000,
Action<EFConfigurationLoadException<TDbContext>>? onLoadException = null)
{
if (pollingInterval < 500)
{
throw new ArgumentException($"{nameof(pollingInterval)} can not less than 500.");
}
_optionsAction = optionsAction;
_reloadOnChange = reloadOnChange;
_pollingInterval = pollingInterval;
OnLoadException = onLoadException;
}
public IConfigurationProvider Build(IConfigurationBuilder builder)
{
return new EFConfigurationProvider<TDbContext>(this);
}
}
新增了三个属性:
- _reloadChange: 是否开启热加载
- 数据库扫描时间间隔
- 异常处理
因为我们要在循环中不停的加载数据库数据,因此可能会出现异常,我们自定了一个异常类,当然也是泛型的:
public sealed class EFConfigurationLoadException<TDbContext> where TDbContext:DbContext
{
public Exception Exception { get; }
public bool Ignorabel { get; set; }
public EFConfigurationSource<TDbContext> Source { get; }
internal EFConfigurationLoadException(EFConfigurationSource<TDbContext> source,Exception ex)
{
Source = source;
Exception = ex;
}
}
构造函数中,我们会对时间间隔进行判断,如果设置的间隔小于0.5s,则认为时间间隔过短。
在Build函数中,我们将自身传递给了EFConfigurationProvider类。
显然EFConfigurationSource没有太多要说的,核心实现还是在EFConfigurationProvider类:
public class EFConfigurationProvider<TDbContext>:ConfigurationProvider,IDisposable where TDbContext : DbContext
{
private readonly EFConfigurationSource<TDbContext> _source;
private readonly CancellationTokenSource _cancellationTokenSource;
private byte[] _lastComputeHash;
private Task? _watchDbTask;
private bool _disposed;
public EFConfigurationProvider(EFConfigurationSource<TDbContext> configurationSource)
{
_source = configurationSource;
_cancellationTokenSource = new CancellationTokenSource();
_lastComputeHash = new byte[20];
}
public override void Load()
{
if(_watchDbTask != null)
{
return;
}
try
{
Data = GetData();
_lastComputeHash = ComputeHash(Data);
}
catch(Exception ex)
{
var exception = new EFConfigurationLoadException<TDbContext>(_source, ex);
_source.OnLoadException?.Invoke(exception);
if(!exception.Ignorabel)
{
throw;
}
}
var cancellationToken= _cancellationTokenSource.Token;
if(_source._reloadOnChange)
{
_watchDbTask = Task.Run(() => WatchDatabase(cancellationToken), cancellationToken);
}
}
public void Dispose()
{
if(_disposed)
{
return;
}
_cancellationTokenSource.Cancel();
_cancellationTokenSource.Dispose();
_disposed = true;
}
}
EFConfigurationProvider的主要实现如上,其中属性分别代表:
- _source:配置源,提供一些参数,包括数据库的配置
- _lastComputeHash:用来保存数据库字段的哈希值,以此判断两次读取是否一致
- _watchDbTask:监视任务
- _disposed:回收
不用看构造函数,直接看Load函数:
如果_watchDbTask不为空,则说明数据已经在监视中,直接返回;第一次调用,时就会调用WatchDataBase()函数,,启动监视。我们再看看这个函数:
private async Task WatchDatabase(CancellationToken cancellationToken)
{
while(!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(_source._pollingInterval, cancellationToken);
IDictionary<string, string> actualData = await GetDataAsync();
byte[] computedHash=ComputeHash(actualData);
if(!computedHash.SequenceEqual(_lastComputeHash))
{
Data = actualData;
OnReload();
}
_lastComputeHash = computedHash;
}
catch (Exception ex)
{
var exception = new EFConfigurationLoadException<TDbContext>(_source, ex);
_source.OnLoadException?.Invoke(exception);
if(!exception.Ignorabel)
{
throw;
}
}
}
}
我们会在循环中不停的读取数据库,时间间隔来自于_Source传递的参数,然后将读取的字典类型转化为字节,计算其hash值,进行对比,如果不同,则更新hash值和数据Data,并同时触发OnReload函数。如果出现异常,则根据传入的异常处理。
public async Task<IDictionary<string, string>> GetDataAsync()
{
using TDbContext dbContext=CreateDbContext();
IQueryable<ConfigurationEntity> entries=dbContext.Set<ConfigurationEntity>();
IDictionary<string, string> dict = entries.Any() ? await entries.ToDictionaryAsync(c => c.Key, c => c.Value) :
new Dictionary<string, string>();
return dict;
}
private TDbContext CreateDbContext()
{
DbContextOptionsBuilder<TDbContext> builder = new DbContextOptionsBuilder<TDbContext>();
_source._optionsAction(builder);
return (TDbContext)Activator.CreateInstance(typeof(TDbContext), new object[] { builder.Options })!;
}
private byte[] ComputeHash(IDictionary<string,string> dict)
{
List<byte> byteDict = new List<byte>();
foreach(var kvp in dict)
{
byteDict.AddRange(Encoding.Unicode.GetBytes($"{kvp.Key}{kvp.Value}"));
}
return System.Security.Cryptography.SHA1.Create().ComputeHash(byteDict.ToArray());
}
最后我们编写一个扩展方法,方方便服务加载配置源:
public static class ConfigurationBuilderExtension
{
/// <summary>
///
/// </summary>
/// <typeparam name="TDbContext">DbContext type that contains setting values.</typeparam>
/// <param name="configurationBuilder">The Microsoft.Extensions.Configuration.IConfigurationBuilder to add to.</param>
/// <param name="optionsAction">DbContextOptionsBuilder used to create related DbContext.</param>
/// <param name="reloadOnChange"></param>
/// <param name="pollingInterval"></param>
/// <param name="onLoadException"></param>
/// <returns></returns>
public static IConfigurationBuilder AddEfConfiguration<TDbContext>(this IConfigurationBuilder configurationBuilder,
Action<DbContextOptionsBuilder> optionsAction,
bool reloadOnChange=false,
int pollingInterval=5000,
Action<EFConfigurationLoadException<TDbContext>>? onLoadException =null) where TDbContext:DbContext
{
return configurationBuilder.Add(new EFConfigurationSource<TDbContext>(optionsAction,
reloadOnChange, pollingInterval, onLoadException));
}
}
然后在Main函数中调用:
var builder = WebApplication.CreateBuilder(args);
var ConnectionString = builder.Configuration.GetConnectionString("MySql");
builder.Host.ConfigureAppConfiguration((_, configBuilder) =>
{
//var config = configBuilder.Build();
//var configSource = new EFConfigurationSource(opts =>
//opts.UseMySql(ConnectionString, ServerVersion.AutoDetect(ConnectionString)));
//configBuilder.Add(configSource);
configBuilder.Sources.Clear();
configBuilder.AddEfConfiguration<ConfigurationDbContext>(
opts => opts.UseMySql(ConnectionString, ServerVersion.AutoDetect(ConnectionString)), reloadOnChange: true);
foreach(var (k,v) in configBuilder.Build().AsEnumerable().Where(t=>t.Value is not null))
{
Console.WriteLine($"{k}={v}");
}
});
同样你在后台更改数据后,就可以发现,不用调用之前的configurationRoot.Reload();就能同步更新。
自此,我们算是较好的实现了同步加载数据库配置的需求,实际上还有一些工作可以做:
- 支持数据库中不同格式的配置
- 支持跨应用更新,通过添加新的字段可以实现
- 监视函数改用Timer来简化
本章在重点参考了:Implement a complete custom configuration provider in .NET