本文介绍了一个在.NET中用数据库做配置中心服务器的方式,介绍了读取配置的开源自定义ConfigurationProvider,并且讲解了主要实现原理。
1、 为什么用数据库做配置中心
在开发youzack.com这个学英语网站的时候,需要保存第三方接口AppKey、JWT等配置信息。youzack是一个由登录注册、听力精听、背单词、背单词第二版等4个子网站组成,为了保证网站的可用性,网站采用集群式部署,同一个子网站部署2台Web服务器实例,因此整个系统部署了2*4=8个Web服务实例。配置信息如果都保存到本地配置文件的话,管理特别麻烦,比如,如果一个配置项要修改的话,就要修改8个地方,因此需要保存到一个配置中心服务器上,各个应用都从配置中心服务器读取配置。
目前,有Apollo、Nacos、Spring Cloud Config等开源的配置中心可供使用,功能非常强大,不过需要单独部署维护配置中心服务器。我这个网站并不复杂,为了避免运维的麻烦,我要尽量减少网站中使用的服务的数量。
youzack所在的阿里云也有对应的配置中心服务可以用,不用自己去部署维护,但是我不想让网站依赖于特定云服务商,而且那样的话在本地开发环境也要特殊处理。
因为这些子网站都要连接数据库,因此把配置信息存到数据库里,用数据库来做配置中心服务器,最符合我的要求。
2、 项目优点
由于网站采用.NET 5开发,为了方便各个项目读取配置,我开发了一个自定义的ConfigurationProvider,名字叫做Zack.AnyDBConfigProvider。
这个Zack.AnyDBConfigProvider的优点如下:
配置保存到数据库表中,管理简单;
支持几乎所有关系数据库,只要.NET能连上的数据库都支持;
支持配置的版本化管理;
支持符合.NET配置命名规则的多级配置的覆盖;
配置项的值类型支持丰富,既支持简单的字符串、数字等类型,也支持json等格式;
采用.Net Standard2开发,因此可以支持.NET Framework、.NET Core等。
项目GitHub地址:
https://github.com/yangzhongke/Zack.AnyDBConfigProvider
3、 Zack.AnyDBConfigProvider用法
第一步:
在数据库中建一张表,默认名字是T_Configs,这个表名允许自定义为其他名字,具体见后续步骤。表必须有Id、Name、Value三个列,Id定义为整数、自动增长列,Name和Value都定义为字符串类型列,列的最大长度根据系统配置数据的长度来自行确定,Name列为配置项的名字,Value列为配置项的值。
允许具有相同Name的多行数据,其中Id值最大的一条的值生效,这样就实现了简单的配置版本管理。因此,如果不确认一个新的配置项一定成功的话,可以先新增一条同名的配置,如果出现问题,只要把这条数据删除就可以回滚到旧的配置项。
Name列的值遵循.NET中配置的“多层级数据的扁平化”(详见微软文档https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-5.0),如下都是合法的Name列的值:
Api:Jwt:Audience
Age
Api:Names:0
Api:Names:1
Value列的值用来保存Name类对应的配置的值。Value的值可以是普通的值,也可以使用json数组,也可以是json对象。比如下面都是合法的Value值:
["a","d"]
{"Secret": "afd3","Issuer":"youzack","Ids":[3,5,8]}
ffff
3
下面这个数据就是后续演示使用的数据:
第二步:
创建一个ASP.NET 项目,演示案例是使用VisualStudio 2019创建.NET Core 3.1的ASP.NETCore MVC项目,但是Zack.AnyDBConfigProvider的应用范围并不局限于这个版本。
通过NuGet安装开发包:
Install-Package Zack.AnyDBConfigProvider
第三步:配置数据库的连接字符串
虽然说项目中其他配置都可以放到数据库中了,但是数据库本身的连接字符串仍然需要单独配置。它既可以配置到本地配置文件中,也可以通过环境变量等方式配置,下面用配置到本地json文件来举例。
打开项目的appsettings.json,增加如下节点:
"ConnectionStrings": {
"conn1":"Server=127.0.0.1;database=youzack;uid=root;pwd=123456"
},
接下来在Program.cs里的CreateHostBuilder方法的webBuilder.UseStartup<Startup>();之前增加如下代码:
webBuilder.ConfigureAppConfiguration((hostCtx, configBuilder)=>{
var configRoot =configBuilder.Build();
string connStr = configRoot.GetConnectionString("conn1");
configBuilder.AddDbConfiguration(()=> newMySqlConnection(connStr),reloadOnChange:true,reloadInterval:TimeSpan.FromSeconds(2));
});
上面代码的第3行用来从本地配置中读取到数据库的连接字符串,然后第4行代码使用AddDbConfiguration来添加Zack.AnyDBConfigProvider的支持。我这里是使用MySql数据库,所以使用new MySqlConnection(connStr)创建到MySQL数据库的连接,你可以换任何你想使用的其他数据库管理系统。reloadOnChange参数表示是否在数据库中的配置修改后自动加载,默认值是false。如果把reloadOnChange设置为true,则每隔reloadInterval这个指定的时间段,程序就会扫描一遍数据库中配置表的数据,如果数据库中的配置数据有变化,就会重新加载配置数据。AddDbConfiguration方法还支持一个tableName参数,用来自定义配置表的名字,默认名称为T_Configs。
不同版本的开发工具生成的项目模板不一样,所以初始代码也不一样,所以上面的代码也许并不能原封不动的放到你的项目中,请根据自己项目的情况来定制化配置的代码。
第四步:
剩下的就是标准的.NET 中读取配置的方法了,比如我们要读取上面例子中的数据,那么就如下配置。
首先创建Ftp类(有IP、UserName、Password三个属性)、Cors类(有string[]类型的Origins、Headers两个属性)。
然后在Startup.cs的ConfigureServices方法中增加如下代码:
services.Configure<Ftp>(Configuration.GetSection("Ftp"));
services.Configure<Cors>(Configuration.GetSection("Cors"));
然后在Controller中读取配置:
public class HomeController : Controller
{
private readonlyILogger<HomeController> _logger;
private readonlyIConfiguration config;
private readonlyIOptionsSnapshot<Ftp> ftpOpt;
private readonlyIOptionsSnapshot<Cors> corsOpt;
publicHomeController(ILogger<HomeController> logger, IConfiguration config,IOptionsSnapshot<Ftp> ftpOpt, IOptionsSnapshot<Cors> corsOpt)
{
_logger = logger;
this.config =config;
this.ftpOpt =ftpOpt;
this.corsOpt =corsOpt;
}
public IActionResultIndex()
{
string redisCS = config.GetSection("RedisConnStr").Get<string>();
ViewBag.s =redisCS;
ViewBag.ftp =ftpOpt.Value;
ViewBag.cors =corsOpt.Value;
return View();
}
}
关于把读取出来的配置如何使用就不再介绍了。我这里只是把配置显示到界面上。你可以把配置修改后,再刷新界面,就可以看到修改后的配置。
4、 源码原理讲解
项目github地址:
https://github.com/yangzhongke/Zack.AnyDBConfigProvider,最核心的类是DBConfigurationProvider。
.NET中自定义配置提供者都要实现IConfigurationProvider接口,一般都直接继承自ConfigurationProvider这个抽象类。ConfigurationProvider中最重要的方法就是Load(),自定义配置提供者都要实现Load方法来加载数据,加载的数据按照键值对的形式保存到Data属性中。Data属性是IDictionary<string,string>类型,Key为配置的名字,遵循.NET的“多层级数据的扁平化”规范。如果配置项发生了改变则调用OnReload()方法来通知监听配置改变的代码。
上面介绍了ConfigurationProvider类的基本工作机制,我们下面再分析一下Zack.AnyDBConfigProvider中的DBConfigurationProvider类的主要代码的原理。
首先是DBConfigurationProvider类的构造函数:
ThreadPool.QueueUserWorkItem(obj => {
while (!isDisposed)
{
Load();
Thread.Sleep(interval);
}
});
可以看到,如果启用了ReloadOnChange,那么每隔指定的时间,就会调用Load重新加载数据。
下面是Load方法的主要代码:
public override void Load()
{
base.Load();
var clonedData =Data.Clone();
string tableName =options.TableName;
try
{
lockObj.EnterWriteLock();
Data.Clear();
using (var conn =options.CreateDbConnection())
{
conn.Open();
DoLoad(tableName,conn);
}
}
catch(DbException)
{
//if DbExceptionis thrown, restore to the original data.
this.Data =clonedData;
throw;
}
finally
{
lockObj.ExitWriteLock();
}
//OnReload cannot bebetween EnterWriteLock and ExitWriteLock, or "A read lock may not beacquired with the write lock held in this mode" will be thrown.
if(Helper.IsChanged(clonedData, Data))
{
OnReload();
}
}
Load方法的主要思路就是:首先创建Data属性的一个拷贝clonedData,用于稍后比较“数据是否修改了”。因为如果启用了ReloadOnChange,那么Load是在一个线程中被定期调用的,而读取配置的代码最终会调用TryGet方法来读取配置,为了避免TryGet读到Load加载一半的数据造成数据混乱,因此需要使用锁来控制读写的同步。因为通常读的频率高于写的频率,为了避免用普通的锁造成的性能问题,这里使用ReaderWriterLockSlim类来实现“只允许一个线程写入,但是允许多个线程读”。把加载配置写入Data属性的代码放到EnterWriteLock()、ExitWriteLock()之间,而把读取配置的代码(见TryGet方法),用EnterReadLock()和ExitReadLock()包裹起来即可。
需要注意,在Load方法中,一定要注意把OnReload()放到ExitWriteLock()之后,否则会导致运行时报“A read lock maynot be acquired with the write lock held in this mode”异常。因为OnReload方法会导致程序调用TryGet读取数据,而TryGet中用了“读锁”,这样就造成了“写锁”中嵌套“读锁”这个默认不允许的行为。
在DoLoad方法中,会从数据库中读取数据加载到Data中。在Load方法的最后,就会把之前保存的Data属性的拷贝值clonedData和加载之后的新的Data属性值比较一下,如果发现数据有变化,就调用OnReload()通知“数据变化了,来加载新数据吧”。
DoLoad方法中就是加载配置的值到Data属性了,虽然代码比较多,但是逻辑并不复杂,主要就是根据“多层级数据的扁平化”规范来解析和加载数据。因为我之前对于这个规范没有吃透,导致走了一些弯路。这块也是我的这个开源项目的一个亮点,因为如果只是按照“多层级数据的扁平化”规范来保存配置的话,数据库中的name就必须“Ftp:IP”、“Ftp:UserName”、“Cors:Origins:0”、“Cors:Origins:1”、“Cors:Origins:2”这样的方式写,但是经过我的处理,配置的值就可以用可读性非常强的json格式了(当然仍然兼容严格的“多层级数据的扁平化”规范)。
5、 结论
Zack.AnyDBConfigProvider是一个可以用数据库做配置中心服务器的开源库,让你可以在不增加额外的配置中心服务器的情况下,让项目具备简单的版本管理的配置中心,而且以一种可读性很强的格式来进行配置。希望这个开源项目能够帮助大家,欢迎使用过程中反馈问题,如果感觉好用,欢迎推荐给其他朋友。