1. 程式人生 > >[ASP.NET Core 3框架揭祕] 配置[7]:多樣化的配置源[中篇]

[ASP.NET Core 3框架揭祕] 配置[7]:多樣化的配置源[中篇]

物理檔案是我們最常用到的原始配置載體,而最佳的配置檔案格式主要有三種,它們分別是JSON、XML和INI,對應的配置源型別分別是JsonConfigurationSource、XmlConfigurationSource和IniConfigurationSource,它們具有如下一個相同的基類FileConfigurationSource。

一、FileConfigurationSource

FileConfigurationSource總是利用一個IFileProvider物件來讀取配置檔案,我們可以利用FileProvider屬性來設定這個物件。配置檔案的路徑通過Path屬性表示,一般來說這是一個針對IFileProvider物件根目錄的相對路徑。在讀取配置檔案的時候,這個路徑將會作為引數呼叫IFileProvider物件的GetFileInfo方法得到描述配置檔案的IFileInfo物件,該物件的CreateReadStream方法最終會被呼叫來讀取檔案內容。

public abstract class FileConfigurationSource : IConfigurationSource
{
    public IFileProvider FileProvider { get; set; }
    public string Path { get; set; }
    public bool Optional { get; set; }
    public int ReloadDelay { get; set; }
    public bool ReloadOnChange { get; set; }
    public Action<FileLoadExceptionContext> OnLoadException { get; set; }

    public abstract IConfigurationProvider Build(IConfigurationBuilder builder);
    public void EnsureDefaults(IConfigurationBuilder builder);
    public void ResolveFileProvider();
}

ResolveFileProvider方法

如果FileProvider屬性並沒有被顯式賦值,而我們指定的配置檔案路徑是一個絕對路徑(比如“c:\app\appsettings.json”),那麼一個針對配置檔案所在目錄(“c:\app”)的PhysicalFileProvider將會自動創建出來作為FileProvider的屬性值,而Path屬性將被設定成配置檔名。如果指定的僅僅是一個相對路徑,FileProvider屬性將不會被自動初始化。這個邏輯實現在ResolveFileProvider方法中,並體現在如下的測試程式中。

class Program
{
    static void Main()
    {
        var source = new FakeConfigurationSource
        {
            Path = @"C:\App\appsettings.json"
        };
        Debug.Assert(source.FileProvider == null);

        source.ResolveFileProvider();
        var fileProvider = (PhysicalFileProvider)source.FileProvider;
        Debug.Assert(fileProvider.Root == @"C:\App\");
        Debug.Assert(source.Path == "appsettings.json");
    }

    private class FakeConfigurationSource : FileConfigurationSource
    {
        public override IConfigurationProvider Build(IConfigurationBuilder builder) => throw new NotImplementedException();
    }
}

EnsureDefaults方法

除了ResolveFileProvider方法,FileConfigurationSource還定義了另一個名為EnsureDefaults的方法,該方法會確保FileConfigurationSource總是具有一個用於載入配置檔案的IFileProvider物件。具體來說,該方法最終會呼叫IConfigurationBuilder介面具有如下定義的擴充套件方法GetFileProvider來獲取預設的IFileProvider物件。

public static class FileConfigurationExtensions
{    
    public static IFileProvider GetFileProvider(this IConfigurationBuilder builder)
    {
        if (builder.Properties.TryGetValue("FileProvider", out object provider))
        {
            return builder.Properties["FileProvider"] as IFileProvider;
        }
        return new PhysicalFileProvider(AppContext.BaseDirectory ?? string.Empty);
    }
}

從上面給出的程式碼片段可以看出,這個擴充套件方法 實際上是將IConfigurationBuilder物件的Properties屬性表示的字典作為了存放IFileProvider物件的容器(對應的Key為“FileProvider”)。如果這個容器中存在一個IFileProvider物件,那麼它將作為方法的返回值。反之,該方法會根據當前應用的基礎目錄(預設為當前應用程式域的基礎目錄,也就是當前執行的.exe檔案所在的目錄)作為根目錄建立一個PhysicalFileProvider物件。

SetFileProvider和SetBasePath方法

既然預設情況下EnsureDefaults方法會從IConfigurationBuilder物件的屬性字典中提取IFileProvider物件,那麼我們可以在這個屬性字典中存放一個預設的IFileProvider物件供所有註冊在它上面的FileConfigurationSource物件共享。實際上IConfigurationBuilder介面提供瞭如下兩個SetFileProvider和SetBasePath擴充套件方法實現了這個功能。

public static class FileConfigurationExtensions
{    
    public static IConfigurationBuilder SetFileProvider( this IConfigurationBuilder builder, IFileProvider fileProvider)
    {
        builder.Properties["FileProvider"] = fileProvider;
        return builder;
    }

    public static IConfigurationBuilder SetBasePath( this IConfigurationBuilder builder, string basePath)
        =>builder.SetFileProvider(new PhysicalFileProvider(basePath));
}

可預設配置檔案

FileConfigurationSource的Optional表示當前配置源是否可以預設。如果該屬性被設定成False,即使指定的配置檔案不存在也不會丟擲異常。可預設的配置檔案在支援多環境的場景中具有廣泛的應用。正如前面例項演示的一樣,我們可以按照如下的方式載入兩個配置檔案,基礎配置檔案appsettings.json一般包含相對全面的配置,針對某個環境的差異化配置則定義在appsettings.{environment}.json檔案中。前者是必需的,後者則是可以預設的,這保證了應用程式在缺少基於當前環境的差異化配置檔案的情況下依然可以使用定義在基礎配置檔案中的預設配置。

var configuration = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile(path: "appsettings.json", optional: false)
    .AddJsonFile(path: $"appsettings.{environment}.json", optional: true)
    .Build();

配置資料的實時同步

FileConfigurationSource藉助IFileProvider物件提供的檔案系統監控功能實現了配置檔案在更新後的自動實時載入功能,這個特性通過ReloadOnChange屬性來開啟或者關閉。預設情況下這個特性是關閉的,我們需要通過將這個屬性設定為True來顯式地開啟該特性。如果開啟了配置檔案的重新載入功能,一旦配置檔案發生變化,IFileProvider物件會在第一時間將通知傳送給對應的FileConfigurationProvider物件,後者會呼叫Load方法重新載入配置檔案。考慮到有可能針對配置檔案的寫入此時尚未結束,FileConfigurationSource採用了 “延時載入” 的方式來解決這個問題,具體的延時通過ReloadDelay屬性來控制。該屬性的單位是毫秒,預設設定的延時為250毫秒。

異常處理

考慮到針對配置檔案的載入不可能百分之百成功,所以FileConfigurationSource提供了相應的異常處理機制。具體來說,我們可以通過FileConfigurationSource物件的OnLoadException屬性註冊一個Action<FileLoadExceptionContext>型別的委託作為異常處理器。作為引數的FileLoadExceptionContext 物件代表FileConfigurationProvider在載入配置檔案出錯的情況下為異常處理器提供的執行上下文。

public class FileLoadExceptionContext
{
    public Exception Exception { get; set; }
    public FileConfigurationProvider Provider { get; set; }
    public bool Ignore { get; set; }
}

如上面的程式碼片段所示,我們可以從FileLoadExceptionContext上下文中獲取丟擲的異常和當前FileConfigurationProvider物件。如果異常處理結束之後上下文物件的Ignore屬性被設定為True,FileConfigurationProvider物件會認為目前的異常(可能是原來丟擲的異常,也可能是異常處理器設定的異常)是可以被忽略的,此時程式會繼續執行,否則異常還是會丟擲來。順便強調一下,最終丟擲來的是原來的異常,所以我們不可以通過修改上下文的Exception屬性來達到丟擲另一個異常的目的。

就像我們可以為註冊到IConfigurationBuilder物件上的所有FileConfigurationSource註冊一個共享的IFileProvider物件一樣,我們也可以呼叫IConfigurationBuilder介面的SetFileLoadExceptionHandler擴充套件方法註冊一個共享的異常處理器,該方法依然是利用IConfiguration
Builder物件的屬性字典來存放這個作為異常處理器的委託物件。註冊的這個異常處理器通過對應的擴充套件方法GetFileLoadExceptionHandler來獲取。

public static class FileConfigurationExtensions
{
    public static IConfigurationBuilder SetFileLoadExceptionHandler(this IConfigurationBuilder builder, Action<FileLoadExceptionContext> handler)
    {
        builder.Properties["FileLoadExceptionHandler"] = handler;
        return builder;
    }

    public static Action<FileLoadExceptionContext> GetFileLoadExceptionHandler(this IConfigurationBuilder builder)
        => builder.Properties.TryGetValue("FileLoadExceptionHandler", out object handler) ? handler as Action<FileLoadExceptionContext> : null;
}

前面我們提到FileConfigurationSource的EnsureDefaults方法,這個方法除了在IFileProvider物件沒有被初始化的情況下呼叫IConfigurationBuilder的GetFileProvider擴充套件方法提供一個預設的IFileProvider物件之外,它還會在異常處理器沒有初始化的情況下呼叫上面這個GetFileLoad
ExceptionHandler擴充套件方法提供一個預設的異常處理器。

二、FileConfigurationProvider

對於配置系統預設提供的針對三種檔案格式化的FileConfigurationSource型別來說,它們提供的IConfigurationProvider實現都派生於如下這個抽象基類FileConfigurationProvider。對於我們自定義的FileConfigurationSource,但我們也傾向於將這個抽象類作為對應IConfiguration
Provider實現型別的基類。

public abstract class FileConfigurationProvider : ConfigurationProvider
{
    public FileConfigurationSource Source { get; }
    public FileConfigurationProvider(FileConfigurationSource source);

    public override void Load();    
    public abstract void Load(Stream stream);    
}

當我們建立一個FileConfigurationProvider物件的時候需要提供對應的FileConfigurationSource物件,它會賦值給Source屬性。如果指定的FileConfigurationSource物件開啟了配置檔案更新監控和自動載入功能(其屬性OnLoadException返回True),FileConfigurationProvider物件會利用FileConfigurationSource物件提供的IFileProvider物件對配置檔案實施監控,並通過註冊回撥的方式在配置檔案更新的時候呼叫Load方法重新載入配置。

由於FileConfigurationSource物件提供了IFileProvider物件,所以FileConfigurationProvider物件可以呼叫其CreateReadStream方法獲取讀取配置檔案內容的流物件,因此我們可以利用這個Stream物件來完成配置的載入。根據基於Stream載入配置的功能體現在抽象方法Load上,所以FileConfigurationProvider物件的派生類都需要重寫這個方法。

三、JsonConfigurationSource

JsonConfigurationSource代表針對通過JSON檔案的配置源,該型別定義在NuGet包“Microsoft.Extensions.Configuration.Json”中。從如下給出的定義可以看出,JsonConfigurationSource重寫的Build方法在提供對應的JsonConfigurationProvider物件之前會呼叫EnsureDefaults方法,這個方法確保用於讀取配置檔案的IFileProvider物件和處理配置檔案載入異常的處理器被初始化。JsonConfigurationProvider物件派生於抽象類FileConfigurationProvider,它利用重寫的Load方法讀取配置檔案的內容並將其轉換成配置字典。

public class JsonConfigurationSource : FileConfigurationSource
{
    public override IConfigurationProvider Build(IConfigurationBuilder builder)
    {
        EnsureDefaults(builder);
        return new JsonConfigurationProvider(this);
    }
}

public class JsonConfigurationProvider : FileConfigurationProvider
{
    public JsonConfigurationProvider(JsonConfigurationSource source);
    public override void Load(Stream stream);
}

IConfigurationBuilder介面具有如下幾個名為AddJsonFile擴充套件方法來註冊JsonConfigurationSource。如果呼叫第一個AddJsonFile方法過載,我們可以利用指定的Action<JsonConfigurationSource>物件對建立的JsonConfigurationSource進行初始化。至於其他AddJsonFile方法過載,實際上就是通過相應的引數初始化JsonConfigurationSource物件的Path、Optional和ReloadOnChange屬性罷了。

public static class JsonConfigurationExtensions
{
    public static IConfigurationBuilder AddJsonFile(this IConfigurationBuilder builder, Action<JsonConfigurationSource> configureSource);
    public static IConfigurationBuilder AddJsonFile(this IConfigurationBuilder builder, string path);
    public static IConfigurationBuilder AddJsonFile(this IConfigurationBuilder builder, string path, bool optional);
    public static IConfigurationBuilder AddJsonFile(this IConfigurationBuilder builder, string path, bool optional, bool reloadOnChange);
    public static IConfigurationBuilder AddJsonFile(this IConfigurationBuilder builder, IFileProvider provider, string path, bool optional, bool reloadOnChange);
}

當使用JSON檔案來定義配置的時候,我們會發現不論對於何種資料結構(複雜物件、集合、陣列和字典),我們都能通過JSON格式以一種簡單而自然的方式來定義它們。同樣以前面定義的Profile型別為例,我們可以利用如下所示的三個JSON檔案分別定義一個完整的Profile物件、一個Profile物件的集合以及一個Key和Value型別分別為字串和Profile的字典。

Profile物件:

{
    "profile": {
        "gender" : "Male",
        "age" : "18",
        "contactInfo"    : {
            "email" : "[email protected]",
            "phoneNo": "123456789"
        }
    }
}

Profile集合或者陣列:

{
  "profiles": [
    {
      "gender": "Male",
      "age": "18",
      "contactInfo": {
        "email": "[email protected]",
        "phoneNo": "123"
      }
    },
    {
      "gender": "Male",
      "age": "25",
      "contactInfo": {
        "email": "[email protected]",
        "phoneNo": "456"
      }
    },
    {
      "gender": "Female",
      "age": "40",
      "contactInfo": {
        "email": "[email protected]",
        "phoneNo": "789"
      }
    }
  ]
}

Profile字典(Dictionary<string, Profile>):

{
  "profiles": {
    "foo": {
      "gender": "Male",
      "age": "18",
      "contactInfo": {
        "email": "[email protected]",
        "phoneNo": "123"
      }
    },
    "bar": {
      "gender": "Male",
      "age": "25",
      "contactInfo": {
        "email": "[email protected]",
        "phoneNo": "456"
      }
    },
    "baz": {
      "gender": "Female",
      "age": "40",
      "contactInfo": {
        "email": "[email protected]",
        "phoneNo": "789"
      }
    }
  }
}

四、XmlConfiguationSource

XML也是一種常用的配置定義形式,它對資料的表達能力甚至強於JSON,幾乎所有型別的資料結構都可以通過XML表示出來。當我們通過一個XML元素表示一個複雜物件的時候,物件的資料成員定義成當前XML元素的子元素。如果資料成員是一個簡單資料型別,我們還可以選擇將其定義成當前XML元素的屬性(Attribute)。針對一個Profile物件,我們可以採用如下兩種不同的形式來定義。

<Profile>
    <Gender>Male</Gender>
    <Age>18</Age>
    <ContactInfo>
        <EmailAddress>[email protected]</EmailAddress>
        <PhoneNo>123456789</PhoneNo>
   </ContactInfo>
</Profile>

或者

<Profile Gender="Male" Age="18">
  <ContactInfo EmailAddress ="[email protected]" PhoneNo="123456789"/>
</Profile>

雖然XML對資料結構的表達能力總體要強於JSON,但是作為配置模型的資料來源卻有自己的侷限性,比如它們對集合的表現形式有點不盡如人意。舉個簡單的例子,對於一個元素型別為Profile的集合,我們可以採用具有如下結構的XML來表現。

<Profiles>
    <Profile Gender="Male" Age="18">
        <ContactInfo EmailAddress ="[email protected]" PhoneNo="123"/>
    </Profile>
    <Profile Gender="Male" Age="25">
        <ContactInfo EmailAddress ="[email protected]" PhoneNo="456"/>
    </Profile>
    <Profile Gender="Male" Age="36">
        <ContactInfo EmailAddress ="[email protected]" PhoneNo="789"/>
    </Profile>
</Profiles>

但是這段XML卻不能正確地轉換成配置字典,原因很簡單,因為字典的Key必須是唯一的,這必然要求最終構成配置樹的每個節點必須具有不同的路徑。上面這段XML很明顯不滿足這個基本的要求,因為表示一個Profile物件的三個XML元素(<Profile>...</Profile>)是“同質”的,對於由它們表示的三個Profile物件來說,分別表示性別、年齡、電子郵箱地址和電話號碼的四個葉子節點的路徑是完全一樣的,所以根本無法作為配置字典的Key。通過前面針對配置繫結的介紹我們知道,如果需要通過配置字典來表示一個Profile物件的集合,我們需要按照如下的方式為每個集合元素加上相應的索引(“foo”、“bar”和“baz”)。

foo:Gender
foo:Age
foo:ContactInfo:EmailAddress
foo:ContactInfo:PhoneNo

bar:Gender
bar:Age
bar:ContactInfo:EmailAddress
bar:ContactInfo:PhoneNo

baz:Gender
baz:Age
baz:ContactInfo:EmailAddress
baz:ContactInfo:PhoneNo

按照這樣的結構,如果我們需要以XML的方式來表示一個Profile物件的集合,就不得不採用如下的結構。但是這樣的定義方式從語義的角度來講是不合理的,因為同一個集合的所有元素就應該是“同質”的,同質的XML元素採用不同的名稱有點說不過去。根據配置繫結的規則,這樣的結構同樣可以表示一個由三個元素組成的Dictionary<string, Profile>物件,Key分別是“Foo”、“Bar”和“Baz”。如果用這樣的XML來表示一個字典物件,語義上就完全沒有問題了。

<Profiles>
  <Foo Gender="Male" Age="18">
    <ContactInfo EmailAddress ="[email protected]" PhoneNo="123"/>
  </Foo>
  <Bar Gender="Male" Age="25">
    <ContactInfo EmailAddress ="[email protected]" PhoneNo="123"/>
  </Bar>
  <Baz Gender="Male" Age="18">
    <ContactInfo EmailAddress ="[email protected]" PhoneNo="789"/>
  </Baz>
</Profiles>

針對XML檔案的配置源型別為XmlConfigurationSource,該型別定義在“Microsoft.Extensions.Configuration.Xml”這個NuGet包中。如下面的程式碼片段所示,XmlConfigurationSource通過重寫的Build方法創建出對應的XmlConfigurationProvider物件。作為抽象型別FileConfigurationProvider的繼承者,XmlConfigurationProvider通過重寫的Load方法完成了針對XML檔案的讀取和配置字典的初始化。

public class XmlConfigurationSource : FileConfigurationSource
{
    public override IConfigurationProvider Build(IConfigurationBuilder builder)
    {
        EnsureDefaults(builder);
        return new XmlConfigurationProvider(this);
    }
}

public class XmlConfigurationProvider : FileConfigurationProvider
{   
    public XmlConfigurationProvider(XmlConfigurationSource source);   
    public override void Load(Stream stream);
}

JsonConfigurationSource的註冊可以通過呼叫針對IConfigurationBuilder物件的擴充套件方法AddJsonFile來完成。與之類似,IConfigurationBuilder介面同樣具有如下一系列名為AddXmlFile的擴充套件方法,這些方法會幫助我們註冊根據指定XML檔案建立的XmlConfigurationSource物件。

public static class XmlConfigurationExtensions
{
    public static IConfigurationBuilder AddXmlFile(this IConfigurationBuilder builder, string path);
    public static IConfigurationBuilder AddXmlFile(this IConfigurationBuilder builder, string path, bool optional);
    public static IConfigurationBuilder AddXmlFile(this IConfigurationBuilder builder, string path, bool optional, bool reloadOnChange);
    public static IConfigurationBuilder AddXmlFile(this IConfigurationBuilder builder, IFileProvider provider, string path, bool optional, bool reloadOnChange);
}

五、IniConfigurationSource

“INI”是“Initialization”的縮寫,INI檔案又被稱為初始化檔案,它是Windows系統普遍使用的配置檔案,同時也被一些Linux和Unix系統所支援。INI檔案直接以鍵值對的形式定義配置項,如下所示的程式碼片段體現了INI檔案的基本格式。總的來說,INI檔案以單純的“{Key}={Value}”的形式定義配置項,{Value}可以定義在可選的雙引號中(如果值的前後包括空白字元,必須使用雙引號,否則會被忽略)。

[Section]
key1=value1
key2 = " value2 "
; comment
# comment
/ comment

除了以“{Key}={Value}”的形式定義的原子配置項外,我們還可以採用“[{SectionName}]”的形式定義配置節對它們進行分組。中括號(“[]”)作為下一個的配置節開始的標誌和上一個配置節結束的標誌,所以採用INI檔案定義的配置節並不存在層次化的結構,即沒有“子配置節”的概念。除此之外,我們可以在INI中定義相應的註釋,註釋行前置的字元可以採用“;”、“#”或者“/”。

由於INI檔案自身就體現為一個數據字典,所以我們可以採用“路徑化”的Key來定義最終繫結為複雜物件、集合或者字典的配置資料。如果採用INI檔案來定義一個Profile物件的基本資訊,我們就可以採用如下的定義形式。

Gender = "Male"
Age  = "18"
ContactInfo:EmailAddress = "[email protected]"
ContactInfo:PhoneNo = "123456789"

由於Profile的配置資訊具有兩個層次(Profile>ContactInfo),我們可以按照如下的形式將EmailAddress和PhoneNo定義在配置節“ContactInfo”中,這個INI檔案在語義表達上和上面是完全等效的。

Gender = "Male"
Age  = "18"

[ContactInfo]
EmailAddress = "[email protected]"
PhoneNo  = "123456789"

針對INI檔案型別的配置源型別通過如下所示的IniConfigurationSource來表示,該型別定義在“Microsoft.Extensions.Configuration.Ini”這個NuGet包中。IniConfigurationSource重寫的Build方法建立的是一個IniConfigurationProvider物件。作為抽象類FileConfigurationProvider的繼承者,IniConfigurationProvider利用重寫的Load方法完成INI檔案內容的讀取和配置字典的初始化。

public class IniConfigurationSource : FileConfigurationSource
{
    public override IConfigurationProvider Build(IConfigurationBuilder builder)
    {
        EnsureDefaults(builder);
        return new IniConfigurationProvider(this);
    }
 }

public class IniConfigurationProvider : FileConfigurationProvider
{
    public IniConfigurationProvider(IniConfigurationSource source);
    public override void Load(Stream stream);
}

既然JsonConfigurationSource和XmlConfigurationSource的註冊可以通過呼叫IConfigurationBuilder介面的擴充套件方法AddJsonFile和AddXmlFile來完成,“Microsoft.Extensions. Configuration.Ini”這個NuGet包會也會為IniConfigurationSource定義如下所示的AddIniFile擴充套件方法。

public static class IniConfigurationExtensions
{
    public static IConfigurationBuilder AddIniFile( this IConfigurationBuilder builder, string path);
    public static IConfigurationBuilder AddIniFile( this IConfigurationBuilder builder, string path, bool optional);
    public static IConfigurationBuilder AddIniFile( this IConfigurationBuilder builder, string path, bool optional,  bool reloadOnChange);
    public static IConfigurationBuilder AddIniFile( this IConfigurationBuilder builder, IFileProvider provider, string path,  bool optional, bool reloadOnChange);
}

[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]:自定義配置源