「ASP.NET Core 3框架揭秘」 配置「3」:配置模型總體設計

2019-12-12   日行四善

在《讀取配置數據》([上篇],[下篇])上面一節中,我們通過實例的方式演示了幾種典型的配置讀取方式,接下來我們從設計的維度來重寫認識配置模型。配置的編程模型涉及到三個核心對象,分別通過三個對應的接口(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 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
{
["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 GetChildKeys(IEnumerable 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對象(Key不區分大小寫)的封裝,其Set和TryGetValue方法最終操作的都是這個字典對象。

public abstract class ConfigurationProvider : IConfigurationProvider
{
protected IDictionary Data { get; set; }
protected ConfigurationProvider()=> Data = new Dictionary(StringComparer.OrdinalIgnoreCase);
public IEnumerable GetChildKeys(IEnumerable 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。如下面的代碼片段所示,IConfigurationSource接口具有一個唯一的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 Sources { get; }
Dictionary Properties { get; }

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

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

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

作者:蔣金楠
微信公眾帳號:大內老A
微博:www.weibo.com/artech
如果你想及時得到個人撰寫文章以及著作的消息推送,或者想看看個人推薦的技術資料,可以掃描左邊二維碼(或者長按識別二維碼)關注個人公眾號)。
本文版權歸作者和博客園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接,否則保留追究法律責任的權利。