【ASP.NET Core】自定義的設定源

2022-07-09 21:00:41

本文的主題是簡單說說如何實現 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}";

執行後得到的結果:

 

 至此,咱們這個自定義的設定源總算是實現了。

好了,今天就水到這裡了,改天老周和各位繼續水文章。