1. 程式人生 > >[ASP.NET Core 3框架揭祕] 配置[3]:配置模型總體設計

[ASP.NET Core 3框架揭祕] 配置[3]:配置模型總體設計

在《讀取配置資料》([上篇],[下篇])上面一節中,我們通過例項的方式演示了幾種典型的配置讀取方式,接下來我們從設計的維度來重寫認識配置模型。配置的程式設計模型涉及到三個核心物件,分別通過三個對應的介面(IConfiguration、IConfigurationSource和IConfigurationBuilder)來表示。如果從設計層面來審視背後的配置模型,還缺少另一個名通過IConfigurationProvider介面表示的核心物件。總的來說,配置模型由這四個核心物件組成,但是要徹底瞭解這四個核心物件之間的關係,我們先得來聊聊配置的幾種資料結構。

一、配置資料結構及其轉換

相同的資料具有不同的表現形式和承載方式,同時體現出不同的資料結構。對於配置來說,它在被應用程式消費過程中是以IConfiguration物件的形式來體現的,該物件在邏輯上具有一個樹形化層次結構,所以將它稱之為配置樹,並將這棵樹視為配置的“邏輯結構”。配置具有多種原始來源,可以是記憶體物件、物理檔案、資料庫或者其他自定義的儲存介質。如果採用物理檔案來儲存配置資料,我們還可以選擇不同的檔案格式,常見的檔案型別包括XML、JSON和INI三種,所以配置的原始資料結構是多種多樣的。配置模型的最終目的在於提取原始的配置資料並將其轉換成一個IConfiguration物件。話句話說,配置模型的使命就在於按照下圖所示的方式將配置資料從原始的結構轉換成樹形層次結構。

配置從原始結構向邏輯結構的轉換不是一蹴而就的,在它們之間具有一種“中間結構”。原始的配置資料被讀取出來之後會先統一轉換成這種中間結構的資料,那麼這種中間結構到底是一種怎樣的資料結構呢?一棵配置樹通過其葉子結點承載所有的原子配置資料, 這棵樹的結構和承載的資料完全可以利用一個簡單的資料字典來表達。具體來說,我們只需要將所有葉子節點在配置樹中的路徑作為Key,將葉子結點承載的配置資料作為Value即可。所謂的“中間結構”指的就是這樣的資料字典,我們不妨將其稱為“配置字典”。所以配置模型會按照圖6-9所示的方式將具有不同原始結構的配置資料統一轉換成基於字典的配置字典,最終再完成針對邏輯結構的轉換。

對於配置模型的四個核心物件來說,IConfiguration物件是對配置樹的體現,其他三個核心物件(IConfigurationSource、IConfigurationBuilder和IConfigurationProvider)在配置的結構轉換過程中扮演著不同的角色,至於它們究竟起到怎樣的作用,我們將在接下來的內容中對它們作專門的介紹。

二、IConfiguration

配置在應用程式中總是以一個IConfiguration物件的形式供我們使用。一個IConfiguration物件具有樹形層次化結構的意思並不是說對應的型別具有對應的資料成員定義,而是說它提供的API在邏輯上體現出樹形化層次結構,所以我們才說配置樹是一種邏輯結構。如下所示的是IConfiguration介面的完整定義,所謂的層次化邏輯結構就體現在它的成員定義上。

public interface IConfiguration
{
    IEnumerable<IConfigurationSection>     GetChildren();
    IConfigurationSection GetSection(string key);
    IChangeToken GetReloadToken();
   
    string this[string key] { get; set; }
}

一個IConfiguration物件表示配置樹的某個配置節點。對於組成整棵樹的所有配置節點來說,表示根節點的IConfiguration物件與表示其它配置節點的IConfiguration物件是不同的,所以配置模型採用不同的介面來表示它們。根節點所在的IConfiguration物件體現為一個IConfigurationRoot物件,除此之外的其他節點物件則被通過一個IConfigurationSection物件表示,IConfigurationRoot和IConfigurationSection介面都是IConfiguration的繼承者。下圖為我們展示了由一個IConfigurationRoot物件和一組 IConfigurationSection物件構成的配置樹。

如下所示的是介面IConfigurationRoot的定義,它具有的唯一方法Reload實現對配置資料的重新載入。IConfigurationRoot物件表示的配置樹的根,所以也代表了整棵配置樹,如果它被重新載入了,意味著整棵配置樹承載的所有配置資料均被重新載入了。

public interface IConfigurationRoot : IConfiguration
{
    void Reload();
}

表示非根配置節點的IConfigurationSection介面具有如下三個屬性,只讀屬性Key用來唯一標識多個具有相同父節點的ConfigurationSection物件,而Path則表示當前配置節點在配置樹中的路徑,它後組成當前路徑的所有IConfigurationSection物件的Key組成,並採用冒號(“:”)作為分隔符。Path和Key的組合體現了當前配置節在整個配置樹中的位置。

public interface IConfigurationSection : IConfiguration
{    
    string Path { get; }
    string Key { get; }
    string Value { get; set; }
}

IConfigurationSection的Value屬性表示配置節點承載的配置資料。在大部分情況下,只有配置樹的葉子結點對應的IConfigurationSection物件才具有值,非葉子節點對應的IConfigurationSection物件實際上僅僅表示存放所有子配置節點的邏輯容器,它們的Value一般返回Null。值得一體的是,這個Value屬性並不是只讀的,而是可讀可寫的,但我們寫入的值一般不會被持久化,一旦配置樹被重新載入,該值將會丟失。

在對IConfigurationRoot和IConfigurationSection具有基本瞭解情況下我們回過頭來看看定義在介面IConfiguration中的成員。它的GetChildren方法返回的IConfigurationSection集合表示它的所有子配置節,另一個方法GetSection則根據指定的Key得到一個具體的子配置節。當GetSection方法執行的時候,指定的引數將會與當前IConfigurationSection的Path進行組合以確定目標配置節點所在的路徑,所以如果在呼叫該方法的時候指定一個相對於當前配置節的路徑,我們是可以得到子節點以下的某個配置節。

var source = new Dictionary<string, string>
{
    ["A:B:C"] = "ABC"
};
            
var root = new ConfigurationBuilder()
    .AddInMemoryCollection(source)  
    .Build();

var section1 = root.GetSection("A:B:C");  //A:B:C
var section2 = root.GetSection("A:B").GetSection("C");  //A:C->C
var section3 = root.GetSection("A").GetSection("B:C");  //A->B:C

Debug.Assert(section1.Value == "ABC");
Debug.Assert(section2.Value == "ABC");
Debug.Assert(section3.Value == "ABC");

Debug.Assert(!ReferenceEquals(section1, section2));
Debug.Assert(!ReferenceEquals(section1, section3));
Debug.Assert(null != root.GetSection("D"));

如上面的程式碼片段所示,我們以不同的方式呼叫GetSection方法得到的都是路徑為“A:B:C”的IConfigurationSection物件。上面這段程式碼還體現了另一個有趣的現象,雖然這三個IConfigurationSection物件均指向配置樹的同一個節點,但是它們卻並非同一個物件。換句話說,當我們呼叫GetSection方法的時候,不論配置樹中是否存在一個與指定路徑匹配的配置節,它總是會建立新的IConfigurationSection物件。

IConfiguration還具有一個索引,我們可以指定子配置節的Key或者相對當前配置節點的路徑得到對應IConfigurationSection的值。當這個索引執行的時候,它會按照與GetSection方法完全一致的邏輯得到一個IConfigurationSection物件,並返回其Value屬性。如果配置樹中不具有匹配的配置節,該索引會返回Null而不會丟擲異常。

三、IConfigurationProvider

在《讀取配置資料[上篇]》介紹IConfigurationSource物件時,我們說它對原始配置源的體現。雖然每種不同型別的配置源都具有一個對應的IConfigurationSource實現,但是針對原始資料的讀取並不由它來提供,而是委託一個與之對應的IConfigurationProvider物件來完成。在上面介紹的配置結構轉換過程中,針對不同配置源型別的IConfigurationProvider按照如下圖所示的方式實現配置從原始結構向物理結構的轉換。

由於IConfigurationProvider物件的目的在於將配置從原始結構轉換成配置字典,所以我們會發現定義在IConfigurationProvider介面中的方法大都體現為針對字典物件的相關操作。配置資料的載入通過呼叫IConfigurationProvider的Load方法來完成。我們可以呼叫TryGet方法獲取由指定的Key所標識的配置項的值。從資料持久化的角度來講,IConfigurationProvider基本上都是隻讀的,也就是說它只負責從持久化資源中讀取配置資料,而不負責持久化更新後的配置資料,所以它提供的Set方法設定的配置資料一般只會儲存在記憶體中,不過通過實現該方法時對提供的值進行持久化也未嘗不可。

public interface IConfigurationProvider
{    
    void Load();
    void Set(string key, string value);
    bool TryGet(string key, out string value);

    IEnumerable<string> GetChildKeys(IEnumerable<string> earlierKeys, string parentPath); 
    IChangeToken GetReloadToken();
}

IConfigurationProvider的GetChildKeys方法用於獲取某個指定配置節點(對應於parentPath引數)的所有子節點的Key。當IConfiguration的GetChildren方法被呼叫時,註冊的所有IConfigurationSource對應的IConfigurationProvider的GetChildKeys方法會被呼叫。這個方法的第一個引數earlierKeys代表的Key來源於其他IConfigurationProvider,當解析出當前IConfigurationProvider提供的Key後,該方法需要對它們合併到earlierKeys集合中,合併後結果將作為方法的返回值。值得一提的是,返回的Key的集合是經過排序的。

每種型別的配置源都具有對應的IConfigurationProvider實現,它們一般不會直接實現介面IConfigurationProvider,而會選擇繼承另一個名為ConfigurationProvider的抽象類。這個抽象類的定義其實很簡單,從如下的程式碼片段可以看出它僅僅是對一個IDictionary<string, string>物件(Key不區分大小寫)的封裝,其Set和TryGetValue方法最終操作的都是這個字典物件。

public abstract class ConfigurationProvider : IConfigurationProvider
{
    protected IDictionary<string, string> Data { get; set; }
    protected ConfigurationProvider()=> Data =  new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
    public IEnumerable<string> GetChildKeys(IEnumerable<string> earlierKeys, string parentPath)
    {
        var prefix = parentPath == null ? string.Empty : $"{parentPath}:" ;      
        return Data
            .Where(it => it.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
            .Select(it => Segment(it.Key, prefix.Length))
            .Concat(earlierKeys)
            .OrderBy(it => it);
    }
    public virtual void Load() {}
    public void Set(string key, string value) => Data[key] = value;
    public bool TryGet(string key, out string value) => Data.TryGetValue(key, out value);

    private static string Segment(string key, int prefixLength)
    {
        var indexOf = key.IndexOf(":", prefixLength, StringComparison.OrdinalIgnoreCase);
        return indexOf < 0 
            ? key.Substring(prefixLength) 
            : key.Substring(prefixLength, indexOf - prefixLength);
    }
    ...
}

抽象類ConfigurationProvider實現了Load方法並將其定義成虛方法,這個方法並沒有提供具體的實現,所以它的派生類可以通過重寫這個方法從相應的資料來源中讀取配置資料,並對通過Data屬性的設定完成對配置字典的初始化。

四、IConfigurationSource

IConfiurationSource在配置模型中代表配置源,它被註冊到IConfigurationBuilder上為後者建立的IConfiguration提供原始的配置資料。由於針對原始配置資料的讀取實現在相應的IConfigurationProvider中,所以IConfigurationSource所起的作用在於提供相應的IConfigurationProvider。如下面的程式碼片段所示,IConfigurationProvider介面具有一個唯一的Build方法根據指定的IConfigurationBuilder物件提供對應的IConfigurationProvider。

public interface IConfigurationSource
{
    IConfigurationProvider Build(IConfigurationBuilder builder);
}

五、IConfigurationBuilder

IConfigurationBulder在整個配置模型中處於一個核心地位,代表原始配置源的IConfigurationSource也註冊到它上面,它的作用就在於利用後者提供的原始資料創建出供應用程式使用的IConfiguration物件。如下面的程式碼片段所示,IConfigurationBulder介面定義了兩個方法,其中Add方法用於註冊IConfigurationSource物件,最終的IConfiguration物件則通過Build方法建立,後者返回一個代表整棵配置樹的IConfigurationRoot物件。註冊的IConfigurationSource被儲存在通過Sources屬性表示的集合中,而另一個屬性Properties則以字典的形式存放任意的自定義屬性。

public interface IConfigurationBuilder
{
    IEnumerable<IConfigurationSource> Sources { get; }
    Dictionary<string, object> Properties { get; }

    IConfigurationBuilder Add(IConfigurationSource source);
    IConfigurationRoot Build();
}

配置系統提供了一個名為ConfigurationBulder的類作為IConfigurationBulder介面的預設實現。定義在它上面的Build方法體現了配置系統讀取原始配置資料並生成配置樹的預設機制。ConfigurationBulder類的Build方法返回一個型別為ConfigurationRoot的物件,對於通過該物件表示配置樹來說,每個非根配置節點均是一個型別為ConfigurationSection的物件。

本篇文章從設計和實現原理的角度對配置模型進行了詳細的介紹。總的來說,配置模型涉及到四個核心物件,包括承載配置邏輯結構的IConfiguration物件和它的建立者IConfigurationBuilder,以及與配置源相關的IConfigurationSource和IConfigurationProvider。這四個核心物件之間的關係簡單而清晰,完全可以通過一句話來概括:IConfigurationBuilder利用註冊在它上面的所有IConfigurationSource提供的IConfigurationProvider讀取原始配置資料並創建出相應的IConfiguration物件。下圖所示的UML展示了配置模型涉及的主要介面/型別以及它們之間的關係。

[ASP.NET Core 3框架揭祕] 配置[1]:讀取配置資料[上篇]
[ASP.NET Core 3框架揭祕] 配置[2]:讀取配置資料[下篇]
[ASP.NET Core 3框架揭祕] 配置[3]:配置模型總體設計
[ASP.NET Core 3框架揭祕] 配置[4]:將配置繫結為物件
[ASP.NET Core 3框架揭祕] 配置[5]:配置資料與資料來源的實時同步
[ASP.NET Core 3框架揭祕] 配置[6]:多樣化的配置源[上篇]
[ASP.NET Core 3框架揭祕] 配置[7]:多樣化的配置源[中篇]
[ASP.NET Core 3框架揭祕] 配置[8]:多樣化的配置源[下篇]
[ASP.NET Core 3框架揭祕] 配置[9]:自定義配置源