本文介紹了一個在.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的優點如下:
1. 配置儲存到資料庫表中,管理簡單;
2. 支援幾乎所有關係資料庫,只要.NET能連上的資料庫都支援;
3. 支援配置的版本化管理;
4. 支援符合.NET配置命名規則的多級配置的覆蓋;
5. 配置項的值型別支援豐富,既支援簡單的字串、數字等型別,也支援json等格式;
6. 採用.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列的值遵循.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 專案,演示案例是使用Visual Studio 2019建立.NET Core 3.1的ASP.NET Core 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(() => new MySqlConnection(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 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中自定義配置提供者都要實現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 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方法的主要思路就是:首先建立Data屬性的一個複製clonedData,用於稍後比較“資料是否修改了”。因為如果啟用了ReloadOnChange,那麼Load是在一個執行緒中被定期呼叫的,而讀取配置的程式碼最終會呼叫TryGet方法來讀取配置,為了避免TryGet讀到Load載入一半的資料造成資料混亂,因此需要使用鎖來控制讀寫的同步。因為通常讀的頻率高於寫的頻率,為了避免用普通的鎖造成的效能問題,這裡使用ReaderWriterLockSlim類來實現“只允許一個執行緒寫入,但是允許多個執行緒讀”。把載入配置寫入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方法會導致程式呼叫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、 結論