Asp.NetCore 从数据库加载配置(二)

        在第一节中,我们实现了基本的自定义数据库配置源,从而可以读取MySql数据库的配置,但是,我们没有实现动态加载数据库配置,也就是程序一但运行起来,数据库的配置更改后就不在被更新。所以本节重点来解决这个问题。


1.基本操作

        我们知道在Option模式中,要想加载更新的配置,只需要两步:

一是,添加配置的时候,将reloadChange属性设置为True;而是获取配置时,使用IOptionsSnapShot:

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是单例模式,所以第一次启动加载后,就不会再加载,而IOptionsSnapshot是Scope模式,每次加载时,都会重新读取一遍。

        但是我们怎么让IConfiguration对象重新读取数据库呢?我们查文档找到了一个方法:

protected void OnReload ();

官方解释是:Triggers the reload change token and creates a new one.

也就是如果调用这个函数,整个配置树都会重新建立,这也就给了我们一种办法去动态加载。

为了验证,我们用Controller做试验:

承接(一)中的代码,我们在默认的WeatherForecastController下添加一个Action:

        [HttpGet,Route("ShowStudent")]
        public ActionResult ShowStudent()
        {
            var configurationRoot = HttpContext.RequestServices.GetService() as IConfigurationRoot;
            if (null == configurationRoot)
            {
                return BadRequest();
            }
            configurationRoot.Reload();
            var stu = HttpContext.RequestServices.GetService>()?.Value;
            if(stu!=null)
            {
                return $"{stu.Name}---{stu.Age}";
            }else
            {
                return NotFound();
            }
        }

运行,不关闭程序,然后改变数据库的Wang字段:

Asp.NetCore 从数据库加载配置(二)_第1张图片

 再次执行,就会发现数据变成新修改的数据。

上面的做法虽然可行,但是如果每次获取时都要手动刷新,无疑很繁琐,我们得找找更优雅的办法。


二.思考

        基于前面的分析,当数据库的数据发生改变时,肯定要重新加载一般数据,这是无法避免的,简单点一般是全部加载,如果数据库有一些特定支持,也许可以实现加载变化的内容,这里我们还是简单一点,考虑到一般配置数据不大可能有上万条之多,也就是这点数据不造成性能问题。

        所以,第一步就是要能知道数据库中的数据发生变化,然后触发后续重载操作。

在查看ConfigurationProvider类时,我们发现这两个函数成员,

  /// 
        /// Returns a  that can be used to listen when this provider is reloaded.
        /// 
        /// The .
        public IChangeToken GetReloadToken()
        {
            return _reloadToken;
        }

        /// 
        /// Triggers the reload change token and creates a new one.
        /// 
        protected void OnReload()
        {
            ConfigurationReloadToken previousToken = Interlocked.Exchange(ref _reloadToken, new ConfigurationReloadToken());
            previousToken.OnReload();
        }

在OnReload接口中,会调用OnReload,其实就是触发cancel操作:

        /// 
        /// Used to trigger the change token when a reload occurs.
        /// 
        public void OnReload() => _cts.Cancel();

也就是说,如果我们检测到数据变化,触发了Onload()函数,那么ConfigurationBuilder就会重载配置,也就达到我们的目的。

三. 重构

        先给出EFConfigurationSource的代码,为了考虑通用性,我将配置源类改为泛型模式。

public class EFConfigurationSource: IConfigurationSource where TDbContext : DbContext
    {
        public readonly Action _optionsAction;
        public readonly bool _reloadOnChange;
        public readonly int _pollingInterval;
        public readonly Action>? OnLoadException;
        public EFConfigurationSource(Action optionsAction,
            bool reloadOnChange = false,
            int pollingInterval = 5000, 
            Action>? 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(this);
        }
    }

新增了三个属性:

  1. _reloadChange: 是否开启热加载
  2. 数据库扫描时间间隔
  3. 异常处理

因为我们要在循环中不停的加载数据库数据,因此可能会出现异常,我们自定了一个异常类,当然也是泛型的:

    public sealed class EFConfigurationLoadException where TDbContext:DbContext
    {
        public Exception Exception { get;  }
        public bool Ignorabel { get; set; }
        public EFConfigurationSource Source { get; }
        internal EFConfigurationLoadException(EFConfigurationSource source,Exception ex)
        {
            Source = source;
            Exception = ex;
        }
    }

构造函数中,我们会对时间间隔进行判断,如果设置的间隔小于0.5s,则认为时间间隔过短。

在Build函数中,我们将自身传递给了EFConfigurationProvider类。

显然EFConfigurationSource没有太多要说的,核心实现还是在EFConfigurationProvider类:

public class EFConfigurationProvider:ConfigurationProvider,IDisposable where TDbContext : DbContext
    {
        private readonly EFConfigurationSource _source;
        private readonly CancellationTokenSource _cancellationTokenSource;
        private byte[] _lastComputeHash;
        private Task? _watchDbTask;
        private bool _disposed;

        public EFConfigurationProvider(EFConfigurationSource 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(_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的主要实现如上,其中属性分别代表:

  1. _source:配置源,提供一些参数,包括数据库的配置
  2. _lastComputeHash:用来保存数据库字段的哈希值,以此判断两次读取是否一致
  3. _watchDbTask:监视任务
  4. _disposed:回收

不用看构造函数,直接看Load函数:

如果_watchDbTask不为空,则说明数据已经在监视中,直接返回;第一次调用,时就会调用WatchDataBase()函数,,启动监视。我们再看看这个函数:

private async Task WatchDatabase(CancellationToken cancellationToken)
        {
            while(!cancellationToken.IsCancellationRequested)
            {
                try
                {
                    await Task.Delay(_source._pollingInterval, cancellationToken);
                    IDictionary actualData = await GetDataAsync();
                    byte[] computedHash=ComputeHash(actualData);
                    if(!computedHash.SequenceEqual(_lastComputeHash))
                    {
                        Data = actualData;
                        OnReload();
                    }
                    _lastComputeHash = computedHash;
                }
                catch (Exception ex)
                {
                    var exception = new EFConfigurationLoadException(_source, ex);
                    _source.OnLoadException?.Invoke(exception);
                    if(!exception.Ignorabel)
                    {
                        throw;
                    }
                }
            }
        }

我们会在循环中不停的读取数据库,时间间隔来自于_Source传递的参数,然后将读取的字典类型转化为字节,计算其hash值,进行对比,如果不同,则更新hash值和数据Data,并同时触发OnReload函数。如果出现异常,则根据传入的异常处理。

        public async Task> GetDataAsync()
        {
            using TDbContext dbContext=CreateDbContext();
            IQueryable entries=dbContext.Set();
            IDictionary dict = entries.Any() ? await entries.ToDictionaryAsync(c => c.Key, c => c.Value) :
                new Dictionary();
            return dict;
        }
        private TDbContext CreateDbContext()
        {
            DbContextOptionsBuilder builder = new DbContextOptionsBuilder();
            _source._optionsAction(builder);
            return (TDbContext)Activator.CreateInstance(typeof(TDbContext), new object[] { builder.Options })!;
        }
        private byte[] ComputeHash(IDictionary dict)
        {
            List byteDict = new List();
            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
    {
        /// 
        /// 
        /// 
        /// DbContext type that contains setting values.
        /// The Microsoft.Extensions.Configuration.IConfigurationBuilder to add to.
        /// DbContextOptionsBuilder used to create related DbContext.
        /// 
        /// 
        /// 
        /// 
        public static IConfigurationBuilder AddEfConfiguration(this IConfigurationBuilder configurationBuilder,
            Action optionsAction,
            bool reloadOnChange=false,
            int pollingInterval=5000,
            Action>? onLoadException =null) where TDbContext:DbContext
        {
            return configurationBuilder.Add(new EFConfigurationSource(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(
        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

完整代码在:FrameWorks/ConfigurationFromDb

你可能感兴趣的:(C#,EentityFramwork,微服务,数据库)