本文的主題是簡單說說如何實現 IConfigurationSource、IConfigurationProvider 介面來自定義一個配置資訊的來源,後面老周給的示例是實現用 CSV 檔案進行應用配置。
在切入主題之前,老周忽然酒興大發,打算扯一些跟主題有關係的題外話。
關於 ASP.NET Core 的應用程式配置,以下是老周總結出來的無廢話內容:
- 配置資訊可以有多種來源。比如,用JSON檔案來配置,在記憶體中直接構建配置,用XML檔案來配置,用 .ini 檔案來配置等。
- ASP.NET Core 或 .NET 應用程式會將這些資訊來源合併到一起,主要負責人是 IConfigurationBuilder 君。
- 配置資訊是字典格式的,即 Key=Value,如果key相同,不管它來自哪,後新增的會替換先新增的配置。
- 配置資料可以認為是樹形的,它由key/value組成,但可以有小節。
- IConfiguration 介面表示配置資訊中的通用模型,你可以像字典物件那樣訪問配置,如 config["key"]。這些配置資訊都是字串型別,不管是key還是value。
- IConfigurationRoot 介面比 IConfiguration 更具體一些。它表示整個應用程式配置樹的根。它多了個 Providers 集合,你可以從集合裡找出你想單獨讀取的配置源,比如,你只想要環境變數;它還有個 Reload 方法,用來重新載入配置資訊。
- IConfigurationSource 介面表示配置資訊的來源。
- IConfigurationProvider 介面根據其來源為應用程式提供 Key / Value 形式的配置資訊。
- 上面兩位好基友的關係:IConfigurationSource 負責建立 IConfigurationProvider。讀取配置資訊靠的是 IConfigurationProvider。
- Microsoft.Extensions.Configuration 並不是只用於 ASP.NET Core 專案,其他 .NET 專案也能用,不過要引用 Nuget 庫。
- 這些傢伙的日常運作是這樣的:
-
- IConfigurationBuilder 管理生產車間(家庭小作坊),它有個 Source 集合,你可以根據需要放各種 IConfigurationSource。這就等於放各種原材料了。
- 放完材料後,builder 君會檢查所有的 source,逐個呼叫它們的 Build 方法,產生各種 IConfigurationProvider。這樣,初步加工完畢,接下來是進一步處理。
- 逐個呼叫所有 IConfigurationProvider 的 Load 方法,讓它們從各自的 source 中載入配置資訊。
- 把所有的配置資訊合併起來統一放到 IConfigurationRoot 中,然後應用程式就可以用各種姿勢來訪問配置。
好了,下面看看這些介面的預設實現類。
IConfigurationBuilder ----> ConfigurationBuilder
IConfigurationSource ----> FileConfigurationSource(抽象)、StreamConfigurationSource(抽象)、CommandLineConfigurationSource ……
IConfigurationProvider ----> ConfigurationProvider(抽象)----> MemoryConfigurationProvider ……
IConfigurationRoot、IConfiguration ----> ConfigurationRoot
我沒有全部列出來,列一部分,主要是大夥伴能明這些線路就行了。各種實現類,你看名字也能猜到幹嗎的,比如 CommandLineConfigurationSource,自然是提供命令列引數來做配置源的。
這裡不得不提一個有意思的類—— ConfigurationManager,它相當於一個複合體,同時實現 IConfigurationBuilder、IConfigurationRoot 介面。這就相當於它既能用來新增 source,載入配置,又可以直接用來訪問配置。所以使用該類,直接 Add 配置源就可以訪問了,不需要呼叫 Build 方法。
ASP.NET Core 應用程式在初始化時預設在服務容器中註冊的就是 ConfigurationManager 類,不過,在依賴注入時,你要用 IConfiguration 介面去提取。
---------------------------------------------------------------
好了,以上內容僅僅是知識準備,接下來我們們要動手幹大事了。
有大夥伴可能會問:我們直接實現這些個介面嗎?不,這顯然工作量太大了,完全沒必要。我們們要做的是根據實際需要選擇抽象類,然後實現這些抽象類就好了。我們們分別來說說 Source 和 Provider。
對於配置的 source,因為它的主要作用是產生 Provider 例項,所以,如果你不需要其他的引數和屬性,只想實現 Build 方法返回一個 Provider 例項,那麼可以直接實現 IConfigurationSource 介面。另外,有兩個抽象類我們是可以考慮的:
1、FileConfigurationSource:如果你要的配置源於檔案,就果斷實現這個抽象類,它已經包含如 Path(檔案路徑)、FileProvider 等通用屬性。我們們直接重寫 Build 方法就完事了,不用去管怎麼處理檔案路徑的事。
在重寫 Build 方法時,建立 Configuration Provider 之前最好呼叫一下 EnsureDefaults 方法。這個方法的作用是當使用者沒有提供 IFileProvider 時能獲得一個預設值。其原始碼如下:
public void EnsureDefaults(IConfigurationBuilder builder) { FileProvider ??= builder.GetFileProvider(); OnLoadException ??= builder.GetFileLoadExceptionHandler(); }
還一個方法是 ResolveFileProvider,它的作用是當找不到 IFileProvider 時,將根據 Path 屬性指定的檔案路徑建立一個 PhysicalFileProvider 物件。在向 IConfigurationBuilder 新增 source 時可以呼叫這個方法。
2、StreamConfigurationSource:如果你要的配置源是流物件,不管是記憶體流還是檔案流,或是網路流,可考慮實現此抽象類。這個類公開 Stream 屬性,用來設定要讀取的流物件。當然,Build 方法一定要重寫,因為它是抽象方法,用來返回你自定義的 Provider。
------------------------------------------------------------------------------------------------------
接著看 IConfigurationProvider,它的實現類中有個通用抽象類—— ConfigurationProvider。這個類有個 Data 屬性,型別是 IDictionary<string, string>,看到吧,是字典型別。不過這個屬性只允許派生類訪問。
比較重要的是 Load 方法,這是個虛方法,派生類中我們重寫它,然後在方法裡面從配置源讀取資料,並把處理好的配置資料放進 Data 屬性中。這就是載入配置的核心步驟。
為了便於我們自定義,ConfigurationProvider 類又派生出兩個抽象類:
1、FileConfigurationProvider :它封裝了開啟檔案、讀檔案等細節,然後直給你一個抽象的 Load 方法,把已載入的流物件傳遞進去,然後你實現這個方法,在裡面讀取配置。意思就是:舞臺都幫你搭好了(燈光、音響等都不用你管),請開始你的表演。
public abstract void Load(Stream stream);
2、StreamConfigurationProvider :跟 FileConfigurationProvider 一個鳥樣,只不過它針對的源是流物件。該類同樣有個抽象方法 Load,用途和簽名一樣。在這個方法裡面實現讀取配置。
public abstract void Load(Stream stream);
分析完之後,你會發現個規律:FileConfigurationSource 和 FileConfigurationProvider 是一對的,StreamConfigurationSource 和 StreamConfigurationProvider 是一對。如果配置源於檔案,選擇實現第一對;若源是流物件就實現第二對。
這些型別的關係不算複雜,為了節約腦細胞,老周就不畫它們的關係圖,老周相信大夥伴們的理解能力的。
-----------------------------------------------------------------------------------------
現在開始本期節目的最後一環節——寫程式碼。開場白中老周說過,這一次我們們的示例會實現從 CSV 檔案中讀配置資訊。CSV 就是一種簡單的資料檔案,嗯,文字檔案。它的結構是每一行就是一條資料記錄,欄位用逗號分隔(一般用逗號,也可以用其他符號,主要看你的程式碼怎麼實現了)。這裡老周不打算搞太複雜,所以假設欄位只用逗號分隔。
規則是這樣的:第一行表示配置資訊的 Key 列表,第二行是 Key 列表對應的值。比如
appTitle, appID, root 貪食蛇, TS-333, /usr/bin
把上面的內容解析成配置資訊就是:
appTitle = 貪食蛇 appID = TS-333 root = /usr/bin
【注】這些配置在讀取時是不區分大小寫的,即 appTitle 和 apptitle 相同。
不過,老周也考慮有多套配置的情況,假設以下配置用來設定HTML頁面的皮膚樣式的。
headerColor, tableLine, fontSize black, 2, 15 red, 1.5, 16
按照規則,第一行是 Key 表列,那麼二、三行就是 Value。所以這個應用程式就可以用兩套 UI 皮膚了。
headerColor = black tableLine = 2 fontSize = 15 ----------------------------- headerColor = red tableLine = 1.5 fontSize = 16
那麼,要是把兩套配置都載入了,那怎麼表示呢。不怕,因為它可以分層(或者說分節點),每個節點之間用冒號隔開。我們假設第一套皮膚配置的索引為 0, 第二套皮膚配置的索引為 1。這樣就可以區分它們了。
headerColor:0 = black tableLine:0 = 2 fontSize:0 = 15 -------------------------------- headerColor:1 = red tableLine:1 = 1.5 fontSize:1 = 16
要使用第二套皮膚的字型大小,就訪問 config["fontSize:1"]。
現在開工,想一下,我們們這個配置是來自 csv 檔案,所以要實現自定義,應當選 FileConfigurationSource 和 FileConfigurationProvider 這兩個類來實現。
動手,先寫 CSVConfigurationSource 類,很簡單,直接實現 Build 方法就完事。
public sealed class CSVConfigurationSource : FileConfigurationSource { public override IConfigurationProvider Build(IConfigurationBuilder builder) { EnsureDefaults(builder); //呼叫一下這個 return new CSVConfigurationProvider(this); } }
EnsureDefaults 方法記得呼叫一下,防止程式碼呼叫方沒提供 FileProvider。重點是直接返回 CSVConfigurationProvider 例項,它接收當前 CSVConfigurationSource 物件作為建構函式引數。
接下來寫 CSVConfigurationProvider 類,這個主要是實現 Load 方法。
public sealed class CSVConfigurationProvider : FileConfigurationProvider { public CSVConfigurationProvider(CSVConfigurationSource source) : base(source) { } public override void Load(Stream stream) { using StreamReader reader = new(stream); try { // 先讀第一行,確定一下欄位名(Key) string? strLine = reader.ReadLine(); if (string.IsNullOrEmpty(strLine)) { throw new FormatException("檔案是空的?"); } string[] keys = GetParts(strLine).ToArray(); // 欄位數量 // 這個很重要,後面讀取值的時候要看看數量是否匹配 int keyLen = keys.Length; // 迴圈取值 int index = 0; // 臨時存放 Dictionary<string, string> tempData = new Dictionary<string, string>(); for(strLine = reader.ReadLine(); !string.IsNullOrEmpty(strLine); strLine = reader.ReadLine()) { // 分割 var valparts = GetParts(strLine).ToArray(); // 分割出來的值個數是否等於欄位數 if(valparts.Length != keyLen) { throw new FormatException("值與欄位的數量不一致"); } // key - value 按順序來 // key:<index> = value for(int n = 0; n < keyLen; n++) { string key = keys[n]; // 加上索引 key = ConfigurationPath.Combine(key, index.ToString()); tempData[key] = valparts[n]; } index++; // 索引要++ } // 讀完資料後還要整理一下 // 如果 index-1 為0,表示代表配置值的只有一行 // 這種情況下沒必要加索引 if(index - 1 == 0) { foreach(string ik in tempData.Keys) { string value = tempData[ik]; // 去掉索引 string key = ConfigurationPath.GetParentPath(ik); // 正式儲存 Data[key] = value; } } else { foreach(string key in tempData.Keys) { // 這種情況下直接copy Data[key] = tempData[key]; } } // 臨時存放的字典不需要了,清一下 tempData.Clear(); } catch { throw; } } #region 私有成員 private IEnumerable<string> GetParts(string line) { // 拆分並去掉空格 var parts = from seg in line.Split(',') select seg.Trim(); // 提取 foreach(string x in parts) { if(x is null or { Length: 0 } ) { throw new FormatException("咦,怎麼有個值是空的?"); } yield return x; //這樣返回比較方便 } } #endregion }
GetParts 是私有方法,功能是把一行文字按照逗號分隔出一組值來。
Load 方法的實現線路:
1、先讀第一行,確定配置的 Key 列表。
2、從第二行開始讀,每讀一行就增加一次索引。因為允許一組 Key 對應一組 Value。
3、如果 Value 組只有一行,就不要加索引了,直接 key1、key2、key3就行了;如果有多組 Value,就要用索引,變成 key1:0、key2:0、key3:0;key1:1、key2:1、key3:1;key1:2、key2:2、key3:2。
4、載入的配置都存放到 Data 屬性中。
程式碼中老周用了個臨時的 Dictionary。
Dictionary<string, string> tempData = new Dictionary<string, string>();
因為在一行一行地讀時,你不能事先知道這檔案裡面有多少行。如果只有兩行,那表明 Value 只有一組,它的索引是0。可實際上,只有一組值的話,索引是多餘的,沒必要。只有大於一組值的時候才需要。
因為讀的時候我們不會去算出檔案中有多少行,所以我就假設它有很多行,第二行的索引為 0,第三行為 1,第四行為 2……。不管值是一行還是多行,我都給它加上索引,存放到臨時的字典中。
等到整個檔案讀完了,我再看 index 變數,如果它的值是 1 (每讀一行++,如果是 1 ,說明只讀了一行),說明只讀了第二行,這時候值只有一組,再把索引刪去;要是讀到的值有N行,那就保留索引。
if(index - 1 == 0) { foreach(string ik in tempData.Keys) { string value = tempData[ik]; // 去掉索引 string key = ConfigurationPath.GetParentPath(ik); // 正式儲存 Data[key] = value; } } else { // 保留索引 foreach(string key in tempData.Keys) { // 這種情況下直接copy Data[key] = tempData[key]; } }
ConfigurationPath 有一組靜態方法,很好用的,用來合併、剪裁用“:”分隔的路徑。我們要充分利用它,可以省很多事,不用自己去合併拆分字串。這個類還定義了一個只讀的欄位 KeyDelimiter,它的值就是一個冒號。可見,在.NET 的Configuration API 中,配置樹的路徑分隔符是在程式碼中寫死的,你只能這樣用:root : section1 : key1 = abcdefg。
到了這兒是基本完成,不過不好用,我們得寫一組擴充套件方法,就像執行庫預設給我們公開的那樣,呼叫個 AddJsonFile,AddCommandLine,AddEnvironmentVariables 那樣,多方便。
public static class CSVConfigurationExtensions { public static IConfigurationBuilder AddCsvFile(this IConfigurationBuilder builder, IFileProvider? provider, string path, bool optional, bool reloadOnChange) { return builder.Add<CSVConfigurationSource>(s => { s.FileProvider = provider; s.Path = path; s.Optional = optional; s.ReloadOnChange = reloadOnChange; s.ResolveFileProvider(); //這一行可選 }); } public static IConfigurationBuilder AddCsvFile(this IConfigurationBuilder builder, string path) => builder.AddCsvFile(null, path, false, false); public static IConfigurationBuilder AddCsvFile(this IConfigurationBuilder builder, string path, bool optional) => builder.AddCsvFile(null, path, optional, false); public static IConfigurationBuilder AddCsvFile(this IConfigurationBuilder builder, string path, bool optional, bool reloadOnChange) => builder.AddCsvFile(null, path, optional, reloadOnChange); }
基本上就是模仿 AddJsonFile、AddXmlFile 寫的。
接下來是實驗階段。在專案中加一個 csv 檔案,可以新建個文字檔案,然後改名為 test.csv。
把這個檔案的“生成操作”改為“內容”,複製行為是“如果較新則複製”。這樣在執行測試時就不用自己手動複製檔案。
hashName,keyBits,version MD5,8,1.2.0 SHA1,12,2.0 SHA256,16,0.3.5
這個配置的 Key 有:hashName,keyBits,version。值有三組(二、三、四行)。
開啟 Program.cs 檔案,在初始化程式碼中新增 test.csv 檔案。
var builder = WebApplication.CreateBuilder(args); // 新增配置 builder.Configuration.AddCsvFile("test.csv", optional: true, reloadOnChange: true); var app = builder.Build();
optional 表示這個檔案是可選的,如果找不到就不載入配置了;reloadOnChange 表示監控這個檔案,如果它被修改了,就重新載入配置。
在 app.MapGet 方法中,我們用一個除錯專用的擴充套件方法,直接列印所有配置。
app.MapGet("/", () => { IConfigurationRoot rootconfg = (IConfigurationRoot)app.Configuration; return rootconfg.GetDebugView(); });
GetDebugView 擴充套件方法很好使,執行程式後就能看到所有配置了,包括我們們自定義的 CSV 檔案中的配置。
如果我們要明確地讀取這些配置,可以這樣。
IConfigurationRoot rootconfg = (IConfigurationRoot)app.Configuration; // 第一組配置 string hash1 = rootconfg["hashName:0"]; string bits1 = rootconfg["keyBits:0"]; string version1 = rootconfg["version:0"]; // 第二組 string hash2 = rootconfg["hashName:1"]; string bits2 = rootconfg["keyBits:1"]; string version2 = rootconfg["version:1"]; // 拼接字串並返回 return $"hashName: {hash1}, keyBits: {bits1}, version: {version1}\n" + $"hashName: {hash2}, keyBits: {bits2}, version: {version2}";
執行後得到的結果:
至此,我們們這個自定義的配置源總算是實現了。
好了,今天就水到這裡了,改天老周和各位繼續水文章。