.NET5 .NET Core用數(shù)據(jù)庫做配置中心加載Configuration
本文介紹了一個在.NET中用數(shù)據(jù)庫做配置中心服務器的方式,介紹了讀取配置的開源自定義ConfigurationProvider,并且講解了主要實現(xiàn)原理。
1、? 為什么用數(shù)據(jù)庫做配置中心
在開發(fā)youzack.com這個學英語網(wǎng)站的時候,需要保存第三方接口AppKey、JWT等配置信息。youzack是一個由登錄注冊、聽力精聽、背單詞、背單詞第二版等4個子網(wǎng)站組成,為了保證網(wǎng)站的可用性,網(wǎng)站采用集群式部署,同一個子網(wǎng)站部署2臺Web服務器實例,因此整個系統(tǒng)部署了2*4=8個Web服務實例。配置信息如果都保存到本地配置文件的話,管理特別麻煩,比如,如果一個配置項要修改的話,就要修改8個地方,因此需要保存到一個配置中心服務器上,各個應用都從配置中心服務器讀取配置。
目前,有Apollo、Nacos、Spring Cloud Config等開源的配置中心可供使用,功能非常強大,不過需要單獨部署維護配置中心服務器。我這個網(wǎng)站并不復雜,為了避免運維的麻煩,我要盡量減少網(wǎng)站中使用的服務的數(shù)量。
youzack所在的阿里云也有對應的配置中心服務可以用,不用自己去部署維護,但是我不想讓網(wǎng)站依賴于特定云服務商,而且那樣的話在本地開發(fā)環(huán)境也要特殊處理。
因為這些子網(wǎng)站都要連接數(shù)據(jù)庫,因此把配置信息存到數(shù)據(jù)庫里,用數(shù)據(jù)庫來做配置中心服務器,最符合我的要求。
2、? 項目優(yōu)點
由于網(wǎng)站采用.NET 5開發(fā),為了方便各個項目讀取配置,我開發(fā)了一個自定義的ConfigurationProvider,名字叫做Zack.AnyDBConfigProvider。
這個Zack.AnyDBConfigProvider的優(yōu)點如下:
1.????? 配置保存到數(shù)據(jù)庫表中,管理簡單;
2.????? 支持幾乎所有關系數(shù)據(jù)庫,只要.NET能連上的數(shù)據(jù)庫都支持;
3.????? 支持配置的版本化管理;
4.????? 支持符合.NET配置命名規(guī)則的多級配置的覆蓋;
5.????? 配置項的值類型支持豐富,既支持簡單的字符串、數(shù)字等類型,也支持json等格式;
6.????? 采用.Net Standard2開發(fā),因此可以支持.NET Framework、.NET Core等。
?
項目GitHub地址:https://github.com/yangzhongke/Zack.AnyDBConfigProvider
3、? Zack.AnyDBConfigProvider用法
第一步:
在數(shù)據(jù)庫中建一張表,默認名字是T_Configs,這個表名允許自定義為其他名字,具體見后續(xù)步驟。表必須有Id、Name、Value三個列,Id定義為整數(shù)、自動增長列,Name和Value都定義為字符串類型列,列的最大長度根據(jù)系統(tǒng)配置數(shù)據(jù)的長度來自行確定,Name列為配置項的名字,Value列為配置項的值。
允許具有相同Name的多行數(shù)據(jù),其中Id值最大的一條的值生效,這樣就實現(xiàn)了簡單的配置版本管理。因此,如果不確認一個新的配置項一定成功的話,可以先新增一條同名的配置,如果出現(xiàn)問題,只要把這條數(shù)據(jù)刪除就可以回滾到舊的配置項。
Name列的值遵循.NET中配置的“多層級數(shù)據(jù)的扁平化”(詳見微軟文檔 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數(shù)組,也可以是json對象。比如下面都是合法的Value值:
["a","d"]
{"Secret": "afd3","Issuer": "youzack","Ids":[3,5,8]}
ffff
3
下面這個數(shù)據(jù)就是后續(xù)演示使用的數(shù)據(jù):

第二步:
創(chuàng)建一個ASP.NET 項目,演示案例是使用Visual Studio 2019創(chuàng)建.NET Core 3.1的ASP.NET Core MVC項目,但是Zack.AnyDBConfigProvider的應用范圍并不局限于這個版本。
通過NuGet安裝開發(fā)包:
Install-Package Zack.AnyDBConfigProvider
?
第三步:配置數(shù)據(jù)庫的連接字符串
雖然說項目中其他配置都可以放到數(shù)據(jù)庫中了,但是數(shù)據(jù)庫本身的連接字符串仍然需要單獨配置。它既可以配置到本地配置文件中,也可以通過環(huán)境變量等方式配置,下面用配置到本地json文件來舉例。
打開項目的appsettings.json,增加如下節(jié)點:
? "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(() => new MySqlConnection(connStr),reloadOnChange:true,reloadInterval:TimeSpan.FromSeconds(2));
});
?????? 上面代碼的第3行用來從本地配置中讀取到數(shù)據(jù)庫的連接字符串,然后第4行代碼使用AddDbConfiguration來添加Zack.AnyDBConfigProvider的支持。我這里是使用MySql數(shù)據(jù)庫,所以使用new MySqlConnection(connStr)創(chuàng)建到MySQL數(shù)據(jù)庫的連接,你可以換任何你想使用的其他數(shù)據(jù)庫管理系統(tǒng)。reloadOnChange參數(shù)表示是否在數(shù)據(jù)庫中的配置修改后自動加載,默認值是false。如果把reloadOnChange設置為true,則每隔reloadInterval這個指定的時間段,程序就會掃描一遍數(shù)據(jù)庫中配置表的數(shù)據(jù),如果數(shù)據(jù)庫中的配置數(shù)據(jù)有變化,就會重新加載配置數(shù)據(jù)。AddDbConfiguration方法還支持一個tableName參數(shù),用來自定義配置表的名字,默認名稱為T_Configs。
?????? 不同版本的開發(fā)工具生成的項目模板不一樣,所以初始代碼也不一樣,所以上面的代碼也許并不能原封不動的放到你的項目中,請根據(jù)自己項目的情況來定制化配置的代碼。
?
第四步:
剩下的就是標準的.NET 中讀取配置的方法了,比如我們要讀取上面例子中的數(shù)據(jù),那么就如下配置。
首先創(chuàng)建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 readonly ILogger<HomeController> _logger;
?????? private readonly IConfiguration config;
?????? private readonly IOptionsSnapshot<Ftp> ftpOpt;
?????? private readonly IOptionsSnapshot<Cors> corsOpt;
?
?????? public HomeController(ILogger<HomeController> logger, IConfiguration config, IOptionsSnapshot<Ftp> ftpOpt, IOptionsSnapshot<Cors> corsOpt)
?????? {
????????????? _logger = logger;
????????????? this.config = config;
????????????? this.ftpOpt = ftpOpt;
????????????? this.corsOpt = corsOpt;
?????? }
?
?????? public IActionResult Index()
?????? {
????????????? 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中自定義配置提供者都要實現(xiàn)IConfigurationProvider接口,一般都直接繼承自ConfigurationProvider這個抽象類。ConfigurationProvider中最重要的方法就是Load(),自定義配置提供者都要實現(xiàn)Load方法來加載數(shù)據(jù),加載的數(shù)據(jù)按照鍵值對的形式保存到Data屬性中。Data屬性是IDictionary<string, string>類型,Key為配置的名字,遵循.NET的“多層級數(shù)據(jù)的扁平化”規(guī)范。如果配置項發(fā)生了改變則調(diào)用OnReload()方法來通知監(jiān)聽配置改變的代碼。
上面介紹了ConfigurationProvider類的基本工作機制,我們下面再分析一下Zack.AnyDBConfigProvider中的DBConfigurationProvider類的主要代碼的原理。
首先是DBConfigurationProvider類的構造函數(shù):
ThreadPool.QueueUserWorkItem(obj => {
?????? while (!isDisposed)
?????? {
????????????? Load();
????????????? Thread.Sleep(interval);
?????? }
});
?????? 可以看到,如果啟用了ReloadOnChange,那么每隔指定的時間,就會調(diào)用Load重新加載數(shù)據(jù)。
?????? 下面是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 DbException is thrown, restore to the original data.
????????????? this.Data = clonedData;
????????????? throw;
?????? }
?????? finally
?????? {
????????????? lockObj.ExitWriteLock();
?????? }
?????? //OnReload cannot be between EnterWriteLock and ExitWriteLock, or "A read lock may not be acquired with the write lock held in this mode" will be thrown.
?????? if (Helper.IsChanged(clonedData, Data))
?????? {
????????????? OnReload();
?????? }
}
?????? Load方法的主要思路就是:首先創(chuàng)建Data屬性的一個拷貝clonedData,用于稍后比較“數(shù)據(jù)是否修改了”。因為如果啟用了ReloadOnChange,那么Load是在一個線程中被定期調(diào)用的,而讀取配置的代碼最終會調(diào)用TryGet方法來讀取配置,為了避免TryGet讀到Load加載一半的數(shù)據(jù)造成數(shù)據(jù)混亂,因此需要使用鎖來控制讀寫的同步。因為通常讀的頻率高于寫的頻率,為了避免用普通的鎖造成的性能問題,這里使用ReaderWriterLockSlim類來實現(xiàn)“只允許一個線程寫入,但是允許多個線程讀”。把加載配置寫入Data屬性的代碼放到EnterWriteLock()、ExitWriteLock()之間,而把讀取配置的代碼(見TryGet方法),用EnterReadLock()和ExitReadLock()包裹起來即可。
?????? 需要注意,在Load方法中,一定要注意把OnReload()放到ExitWriteLock()之后,否則會導致運行時報“A read lock may not be acquired with the write lock held in this mode”異常。因為OnReload方法會導致程序調(diào)用TryGet讀取數(shù)據(jù),而TryGet中用了“讀鎖”,這樣就造成了“寫鎖”中嵌套“讀鎖”這個默認不允許的行為。
?????? 在DoLoad方法中,會從數(shù)據(jù)庫中讀取數(shù)據(jù)加載到Data中。在Load方法的最后,就會把之前保存的Data屬性的拷貝值clonedData和加載之后的新的Data屬性值比較一下,如果發(fā)現(xiàn)數(shù)據(jù)有變化,就調(diào)用OnReload()通知“數(shù)據(jù)變化了,來加載新數(shù)據(jù)吧”。
?????? DoLoad方法中就是加載配置的值到Data屬性了,雖然代碼比較多,但是邏輯并不復雜,主要就是根據(jù)“多層級數(shù)據(jù)的扁平化”規(guī)范來解析和加載數(shù)據(jù)。因為我之前對于這個規(guī)范沒有吃透,導致走了一些彎路。這塊也是我的這個開源項目的一個亮點,因為如果只是按照“多層級數(shù)據(jù)的扁平化”規(guī)范來保存配置的話,數(shù)據(jù)庫中的name就必須“Ftp:IP”、“Ftp:UserName”、“Cors:Origins:0”、“Cors:Origins:1”、“Cors:Origins:2”這樣的方式寫,但是經(jīng)過我的處理,配置的值就可以用可讀性非常強的json格式了(當然仍然兼容嚴格的“多層級數(shù)據(jù)的扁平化”規(guī)范)。
?
5、? 結論
Zack.AnyDBConfigProvider是一個可以用數(shù)據(jù)庫做配置中心服務器的開源庫,讓你可以在不增加額外的配置中心服務器的情況下,讓項目具備簡單的版本管理的配置中心,而且以一種可讀性很強的格式來進行配置。希望這個開源項目能夠幫助大家,歡迎使用過程中反饋問題,如果感覺好用,歡迎推薦給其他朋友。