理解ASP.NET Core - 配置(Configuration)

xiaoxiaotank發表於2021-10-08

注:本文隸屬於《理解ASP.NET Core》系列文章,請檢視置頂部落格或點選此處檢視全文目錄

配置提供程式

在.NET中,配置是通過多種配置提供程式來提供的,包括以下幾種:

  • 檔案配置提供程式
  • 環境變數配置提供程式
  • 命令列配置提供程式
  • Azure應用配置提供程式
  • Azure Key Vault 配置提供程式
  • Key-per-file配置提供程式
  • 記憶體配置提供程式
  • 應用機密(機密管理器)
  • 自定義配置提供程式

為了方便大家後續瞭解配置,這裡先簡單提一下選項(Options),它是用於以強型別的方式對程式配置資訊進行訪問的一種方式。接下來的示例中,我會新增一個簡單的配置Book,結構如下:

public class BookOptions
{
    public const string Book = "Book";

    public string Name { get; set; }

    public BookmarkOptions Bookmark { get; set; }

    public List<string> Authors { get; set; }
}

public class BookmarkOptions
{
    public string Remarks { get; set; }
}

然後我們在Startup.ConfigureServices中使用IConfiguration進行配置的讀取,並顯示在控制檯中,如下:

public void ConfigureServices(IServiceCollection services)
{
    var book = Configuration.GetSection(BookOptions.Book).Get<BookOptions>();
    Console.WriteLine($"Book Name: {book.Name}" +
        $"{Environment.NewLine}Bookmark Remarks:{book.Bookmark.Remarks}" +
        $"{Environment.NewLine}Book Authors: {string.Join(" & ", book.Authors)}");
}

接下來,就挑幾個常用的配置提供程式來詳細講解一下。

檔案配置提供程式

顧名思義,就是從檔案中載入配置。檔案細分為

  • JSON配置提供程式(JsonConfigurationProvider)
  • XML配置提供程式(XmlConfigurationProvider)
  • INI配置提供程式(IniConfigurationProvider)

以上這些配置提供程式,均繼承於抽象類FileConfigurationProvider

另外,所有檔案配置提供程式都支援提供兩個配置引數:

  • optionalbool型別,指示該檔案是否是可選的。如果該引數為false,但是指定的檔案又不存在,則會報錯。
  • reloadOnChangebool型別,指示該檔案發生更改時,是否要重新載入配置。

JSON配置提供程式

通過JsonConfigurationProvider在執行時從Json檔案中載入配置。

Install-Package Microsoft.Extensions.Configuration.Json

使用方式非常簡單,只需要呼叫AddJsonFile擴充套件方法新增用於儲存配置的Json檔案即可:

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) =>
        {
            // 清空所有配置提供程式
            config.Sources.Clear();

            var env = context.HostingEnvironment;

            // 新增 appsettings.json 和 appsettings.{env.EnvironmentName}.json 兩個json檔案
            config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
                  .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);
        });

你可以在 appsetting.json 中新增如下配置:

{
  "Book": {
    "Name": "appsettings.json book name",
    "Authors": [
      "appsettings.json author name A",
      "appsettings.json author name B"
    ],
    "Bookmark": {
      "Remarks": "appsettings.json bookmark remarks"
    }
  }
}

XML配置提供程式

通過XmlConfigurationProvider在執行時從Xml檔案中載入配置。

Install-Package Microsoft.Extensions.Configuration.Xml

同樣的,只需呼叫AddXmlFile擴充套件方法新增Xml檔案即可:

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) =>
        {
            config.AddXmlFile("appsettings.xml", optional: true, reloadOnChange: true);
        });

你可以在 appsettings.xml 中新增如下配置:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <Book>
    <Name>appsettings.xml book name</Name>
    <Authors name="0">appsettings.xml author name A</Authors>
    <Authors name="1">appsettings.xml author name B</Authors>
     <Bookmark>
      <Remarks>appsettings.xml bookmark remarks</Remarks>
    </Bookmark>
  </Book>
</configuration>

在 .NET 6 中,我們就不用手動新增 name 屬性來指定索引了,它會自動進行索引編號。

INI配置提供程式

通過IniConfigurationProvider在執行時從Ini檔案中載入配置。

Install-Package Microsoft.Extensions.Configuration.Ini

同樣的,只需呼叫AddIniFile擴充套件方法新增Ini檔案即可:

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) =>
        {
            config.AddIniFile("appsettings.ini", optional: true, reloadOnChange: true);
        });

你可以在 appsettings.ini 中新增如下配置

[Book]
Name=appsettings.ini book name
Authors:0=appsettings.ini book author A
Authors:1=appsettings.ini book author B

[Book:Bookmark]
Remarks=appsettings.ini bookmark remarks

環境變數配置提供程式

通過EnvironmentVariablesConfigurationProvider在執行時從環境變數中載入配置。

Install-Package Microsoft.Extensions.Configuration.EnvironmentVariables

同樣的,只需呼叫AddEnvironmentVariables擴充套件方法新增環境變數即可:

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) =>
        {
            // 新增字首為 My_ 的環境變數
            config.AddEnvironmentVariables(prefix: "My_");
        });

在新增環境變數時,通過指定引數prefix,只讀取限定字首的環境變數。不過在讀取環境變數時,會將字首刪除。如果不指定引數prefix,那麼會讀取所有環境變數。

當建立預設通用主機(Host)時,預設就已經新增了字首為DOTNET_的環境變數,載入應用配置時,也新增了未限定字首的環境變數。另外,在 ASP.NET Core 中,配置 Web主機時,預設新增了字首為ASPNETCORE_的環境變數。

需要注意的是,由於環境變數的分層鍵:並不受所有平臺支援,而雙下劃線(__)是全平臺支援的,所以要使用雙下劃線(__)來代替冒號(:)。

在 Windows 平臺下,可以通過setsetx命令進行環境變數配置,不過:

  • set命令設定的環境變數是臨時的,僅在當前程式有效,這個程式就是當前cmd視窗啟動的。也就是說,當你開啟一個cmd視窗時,通過set命令設定了環境變數,然後通過dotnet xxx.dll啟動了你的應用程式,是可以讀取到環境變數的,但是在該cmd視窗之外,例如通過VS啟動應用程式,是無法讀取到該環境變數的。
  • setx命令設定的環境變數是持久化的。可選的新增/M開關,表示將該環境變數配置到系統環境中(需要管理員許可權),否則,將新增到使用者環境中。

我更喜歡通過setx去設定環境變數(記得以管理員身份執行哦):

# 注意,這裡的 My_ 是字首
setx My_Book__Name "Environment variables book name" /M
setx My_Book__Authors__0 "Environment variables book author A" /M
setx My_Book__Authors__1 "Environment variables book author B" /M
setx My_Book__Bookmark__Remarks "Environment variables bookmark remakrs" /M

配置完環境變數後,一定要記得重啟VS或cmd視窗,否則是無法讀取到最新的環境變數值的

連線字串字首的特殊處理

當沒有向AddEnvironmentVariables傳入字首時,預設也會針對含有以下字首的環境變數進行特殊處理:

字首 環境變數Key 配置Key 配置提供程式
MYSQLCONNSTR_ MYSQLCONNSTR_{KEY} ConnectionStrings:{KEY} MySQL
SQLCONNSTR_ SQLCONNSTR_{KEY} ConnectionStrings:{KEY} SQL Server
SQLAZURECONNSTR_ SQLAZURECONNSTR_{KEY} ConnectionStrings:{KEY} Azure SQL
CUSTOMCONNSTR_ CUSTOMCONNSTR_{KEY} ConnectionStrings:{KEY} 自定義配置提供程式

在 launchSettings.json 中配置環境變數

在 ASP.NET Core 模板專案中,會生成一個 launchSettings.json 檔案,我們也可以在該檔案中配置環境變數。

需要注意的是,launchSettings.json 中的配置只用於開發環境,並且在該檔案中設定的環境變數會覆蓋在系統環境中設定的變數。

{
  "WebApplication": {
    "commandName": "Project",
    "dotnetRunMessages": "true",
    "launchBrowser": true,
    "launchUrl": "swagger",
    "applicationUrl": "http://localhost:5000",      // 設定環境變數 ASPNETCORE_URLS
    "environmentVariables": {
      "ASPNETCORE_ENVIRONMENT": "Development",
      "My_Book__Name": "launchSettings.json Environment variables book name",
      "My_Book__Authors__0": "launchSettings.json Environment variables book author A",
      "My_Book__Authors__1": "launchSettings.json Environment variables book author B",
      "My_Book__Bookmark__Remarks": "launchSettings.json Environment variables bookmark remarks"
    }
  }
}

雖然說在 launchSettings.json 中配置環境變數時可以使用冒號(:)作為分層鍵,但是我在測試過程中,發現當同時配置了系統環境變數時,程式讀取到的環境變數值會發生錯亂(一部分是系統環境變數,一部分是該檔案中的環境變數)。所以建議大家還是使用雙下劃線(__)作為分層鍵。

在Linux平臺,當設定的環境變數為URL時,需要設定為轉義後的URL。可以使用systemd-escaple工具:

$ systemd-escape http://localhost:5001
http:--localhost:5001

命令列配置提供程式

通過CommandLineConfigurationProvider在執行時從命令列引數鍵值對中載入配置。

Install-Package Microsoft.Extensions.Configuration.CommandLine

通過呼叫AddCommandLine擴充套件方法,並傳入引數args

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) =>
        {
            config.AddCommandLine(args);
        });

有三種設定命令列引數的方式:

使用=

dotnet run Book:Name="Command line book name" Book:Authors:0="Command line book author A" Book:Authors:1="Command line book author B" Book:Bookmark:Remarks="Command line bookmark remarks"

使用/

dotnet run /Book:Name "Command line book name" /Book:Authors:0 "Command line book author A" /Book:Authors:1 "Command line book author B"  /Book:Bookmark:Remarks "Command line bookmark remarks"

使用--

dotnet WebApplication5.dll --Book:Name "Command line book name" --Book:Authors:0 "Command line book author A" --Book:Authors:1 "Command line book author B" --Book:Bookmark:Remarks "Command line bookmark remarks"

交換對映

該功能是針對命令列配置引數進行key對映的,如你可以將n對映為Name,要求:

  • 交換對映key必須以---開頭。當使用-開頭時,命令列引數書寫時也要以-開頭,當使用--開頭時,命令列引數書寫時可以以--/開頭。
  • 交換對映字典中的key不區分大小寫,不能包含重複key。如不能同時出現-n-N,但可以同時出現-n--n

接下來我們來對映一下:

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) =>
        {
            var switchMappings = new Dictionary<string, string>
            {
                ["--bn"] = "Book:Name",
                ["-ba0"] = "Book:Authors:0",
                ["--ba1"] = "Book:Authors:1",
                ["--bmr"] = "Book:Bookmark:Remarks"
            };
            config.AddCommandLine(args, switchMappings);
        });

然後以命令列命令啟動:

dotnet run --bn "Command line book name" -ba0 "Command line book author A" /ba1 "Command line book author B" --bmr="Command line bookmark remarks"

記憶體配置提供程式

通過MemoryConfigurationProvider在執行時從記憶體中的集合中載入配置。

Install-Package Microsoft.Extensions.Configuration

通過呼叫AddInMemoryCollection新增記憶體配置:

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) =>
        {
            config.AddInMemoryCollection(new Dictionary<string, string>
            {
                ["Book:Name"] = "Memmory book name",
                ["Book:Authors:0"] = "Memory book author A",
                ["Book:Authors:1"] = "Memory book author B",
                ["Book:Bookmark:Remarks"] = "Memory bookmark remarks"
            });
        });

主機(Host)中的預設配置優先順序

約定:越後新增的配置提供程式優先順序越高,優先順序高的配置值會覆蓋優先順序低的配置值

在 主機(Host)中,我們介紹了Host的啟動流程,根據預設的配置提供程式的新增順序,預設的優先順序從低到高為(我順便將WebHost預設配置的也加進來了):

  1. 記憶體配置提供程式 環境變數配置提供程式(prefix: DOTNET_)
  2. 環境變數配置提供程式(prefix: ASPNETCORE_)
  3. JSON配置提供程式(appsettings.json)
  4. JSON配置提供程式(appsettings.{Environment}.json)
  5. 機密管理器(僅Windows)
  6. 環境變數配置提供程式(未限定字首)
  7. 命令列配置提供程式

完整的配置提供程式列表可以通過 IConfigurationRoot.Providers 來檢視。

如果想要新增額外配置檔案,但是仍然想要環境變數或命令列引數優先,則可以類似這樣做:

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) =>
        {
            config.AddJsonFile("my.json", optional: true, reloadOnChange: true);
            
            config.AddEnvironmentVariables();
            config.AddCommandLine(args);
        });

配置體系

上面我們已經瞭解了幾種常用的配置提供程式,這是微軟已經提供的。如果你看過某個配置提供程式的原始碼的話,一定見過IConfigurationSourceIConfigurationProvider等介面。

IConfigurationSource

IConfigurationSource負責建立IConfigurationProvider實現的例項。它的定義很簡單,就一個Build方法,返回IConfigurationProvider例項:

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

IConfigurationProvider

IConfigurationProvider負責實現配置的設定、讀取、過載等功能,並以鍵值對形式提供配置。

所有配置提供程式均建議繼承於抽象類ConfigurationProvider,該類實現了介面IConfigurationProvider

public interface IConfigurationProvider
{
    // 獲取指定父路徑下的直接子節點Key,然後 Concat(earlierKeys) 一同返回
    IEnumerable<string> GetChildKeys(IEnumerable<string> earlierKeys, string parentPath);
    
    // 當該配置提供程式支援更改追蹤(change tracking)時,會返回 change token
    // 否則,返回 null
    IChangeToken GetReloadToken();

    // 載入配置
    void Load();

    // 設定 key:value
    void Set(string key, string value);

    // 嘗試獲取指定 key 的 value
    bool TryGet(string key, out string value);
}

public abstract class ConfigurationProvider : IConfigurationProvider
{
    // 包含了該配置提供程式的所有葉子節點的配置項
    protected IDictionary<string, string> Data { get; set; }

    protected ConfigurationProvider() { }

    // 從 Data 中查詢指定父路徑下的直接子節點Key,然後 Concat(earlierKeys) 一同返回
    public virtual IEnumerable<string> GetChildKeys(IEnumerable<string> earlierKeys, string parentPath) { }

    public IChangeToken GetReloadToken() { }

    // 將配置項賦值到 Data 中
    public virtual void Load() { }

    protected void OnReload() { }

    // 設定 Data key:value
    public virtual void Set(string key, string value) { }

    public override string ToString() { }
    
    // 嘗試從 Data 中獲取指定 key 的 value
    public virtual bool TryGet(string key, out string value) { }
}

Data包含了該配置提供程式的所有葉子節點的配置項。拿上方的Book示例來說,該Data包含“Book:Name”、“Book:Authors:0”、“Book:Authors:1”和“Book:Bookmark:Remarks”這4個Key。

另外,你可能還會見到一個名為ChainedConfigurationProvider的配置提供程式,它可以將一個已存在的IConfiguration例項,作為配置提供程式新增到另一個IConfiguration中。例如HostConfiguration流轉到AppConfiguration就使用了這個。

IConfigurationBuilder

public interface IConfigurationBuilder
{
    // 存放用於該 Builder 的 Sources 列表中各個元素的共享字典
    IDictionary<string, object> Properties { get; }

    // 已註冊的 IConfigurationSource 列表
    IList<IConfigurationSource> Sources { get; }

    // 將 IConfigurationSource 新增到 Sources 中
    IConfigurationBuilder Add(IConfigurationSource source);

    // 通過 Sources 構建配置提供程式例項,並建立 IConfigurationRoot 例項
    IConfigurationRoot Build();
}

ConfigurationBuilder實現了IConfigurationBuilder介面:

public class ConfigurationBuilder : IConfigurationBuilder
{
    public IList<IConfigurationSource> Sources { get; } = new List<IConfigurationSource>();

    public IDictionary<string, object> Properties { get; } = new Dictionary<string, object>();

    public IConfigurationBuilder Add(IConfigurationSource source)
    {
        if (source == null)
        {
            throw new ArgumentNullException(nameof(source));
        }

        Sources.Add(source);
        return this;
    }

    public IConfigurationRoot Build()
    {
        var providers = new List<IConfigurationProvider>();
        foreach (IConfigurationSource source in Sources)
        {
            IConfigurationProvider provider = source.Build(this);
            providers.Add(provider);
        }
        return new ConfigurationRoot(providers);
    }
}

IConfiguration

public interface IConfiguration
{
    // 獲取或設定指定配置 key 的 value
    string this[string key] { get; set; }
    
    // 獲取當前配置節點的 直接 子節點列表
    IEnumerable<IConfigurationSection> GetChildren();

    // 獲取監控配置發生更改的 token
    IChangeToken GetReloadToken();
    
    // 獲取指定Key的配置子節點
    IConfigurationSection GetSection(string key);
}

GetValue

通過IConfiguration的擴充套件方法ConfigurationBinder.GetValue,可以以類似字典的方式,讀取某個Key對應的Value。

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        var bookName = Configuration.GetValue<string>("Book:Name", defaultValue: "Unknown");
        Console.WriteLine(bookName);
    }
}

該擴充套件的實質(預設實現)是在底層通過呼叫IConfigurationProvider.TryGet方法,讀取ConfigurationProvider.Data字典中的鍵值對。所以,只能通過該擴充套件方法讀取葉子節點的配置值。

GetSection

通過IConfiguration.GetSection方法,可以獲取到指定Key的配置子節點:

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // 返回的 section 永遠不會為 null
        IConfigurationSection bookSection = Configuration.GetSection(BookOptions.Book);

        IConfigurationSection bookmarkSection = bookSection.GetSection("Bookmark");
        // or
        //IConfigurationSection bookmarkSection = Configuration.GetSection("Book:Bookmark");

        var remarks = bookmarkSection["Remarks"];
        Console.WriteLine(remarks);
    }
}

GetChildren

通過IConfiguration.GetChildren方法,可以獲取到當前配置節點的直接子節點列表

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        // children 包含了 Name、Bookmark、Authors
        var children = Configuration.GetSection(BookOptions.Book).GetChildren();
        foreach (var child in children)
        {
            Console.WriteLine($"Key: {child.Key}\tValue: {child.Value}");
        }
    }
}

Exists

前面提到了,Configuration.GetSection永遠不會返回null,那麼我們如何判斷該 Section 是否真的存在呢?這就要用到擴充套件方法ConfigurationExtensions.Exists了:

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        IConfigurationSection bookSection = Configuration.GetSection(BookOptions.Book);
        if (bookSection.Exists())
        {
            var notExistSection = bookSection.GetSection("NotExist");
            if (!notExistSection.Exists())
            {
                Console.WriteLine("Book:NotExist");
            }
        }
    }
}

這裡分析一下Exists的原始碼:

public static class ConfigurationExtensions
{
    public static bool Exists(this IConfigurationSection section)
    {
        if (section == null)
        {
            return false;
        }
        return section.Value != null || section.GetChildren().Any();
    }
}

因此,在這裡補充一下:假設存在某個子節點(ConfigurationSection),若該子節點為葉子節點,那麼其Value一定不為null,若該子節點非葉子節點,則該子節點的子節點一定不為空

Get

通過ConfigurationBinder.Get方法,可以將配置以強型別的方式繫結到選項物件上:

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        var book = Configuration.GetSection(BookOptions.Book).Get<BookOptions>();
        Console.WriteLine($"Book Name: {book.Name}" +
        $"{Environment.NewLine}Bookmark Remarks:{book.Bookmark.Remarks}" +
        $"{Environment.NewLine}Book Authors: {string.Join(" & ", book.Authors)}");
    }
}

Bind

與上方Get方法類似,通過ConfigurationBinder.Bind 方法,可以將配置以強型別的方式繫結到已存在的選項物件上:

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        var book = new BookOptions();
        Configuration.GetSection(BookOptions.Book).Bind(book);
        Console.WriteLine($"Book Name: {book.Name}" +
        $"{Environment.NewLine}Bookmark Remarks:{book.Bookmark.Remarks}" +
        $"{Environment.NewLine}Book Authors: {string.Join(" & ", book.Authors)}");
    }
}

IConfigurationRoot

IConfigurationRoot表示配置的,相應的,下面要提到的IConfigurationSection則表示配置的子節點。舉個例子,XML格式的文件都會有一個根節點(如上方示例中的<configuration>),還可以包含多個子節點(如上方示例中的<Book><Name>等)。

public interface IConfigurationRoot : IConfiguration
{
    // 存放了當前應用程式的所有配置提供程式
    IEnumerable<IConfigurationProvider> Providers { get; }

    // 強制從配置提供程式中過載配置
    void Reload();
}

ConfigurationRoot實現了IConfigurationRoot介面,下面就著重看一下Reload方法的實現:

Startup建構函式中注入的IConfiguration其實就是ConfigurationRoot的例項。

public class ConfigurationRoot : IConfigurationRoot, IDisposable
{
    private readonly IList<IConfigurationProvider> _providers;
    
    public ConfigurationRoot(IList<IConfigurationProvider> providers)
    {
        // 該建構函式內程式碼有刪減
    
        _providers = providers;
        foreach (IConfigurationProvider p in providers)
        {
            p.Load();
        }
    }
    
    public void Reload()
    {
        foreach (IConfigurationProvider provider in _providers)
        {
            provider.Load();
        }
        
        // 此處刪減了部分程式碼
    }
}

IConfigurationSection

IConfigurationSection表示配置的子節點。

public interface IConfigurationSection : IConfiguration
{
    // 該子節點在其父節點中所表示的 key
    string Key { get; }

    // 該子節點在配置中的全路徑(從根節點開始,到當前節點的路徑)
    string Path { get; }

    // 該子節點的 value。如果該子節點下存在孩子節點,則其始終為 null
    string Value { get; set; }
}

借用上方的資料舉個例子,假設配置提供程式為記憶體:

  • 當我們通過Configuration.GetSection("Book:Name")獲取到子節點時,Key為“Name”,Path為“Book:Name”,Value則為“Memmory book name”
  • 當我們通過Configuration.GetSection("Book:Bookmark")獲取到子節點時,Key為“Bookmark”,Path為“Book:Name”,Value則為null

實現自定義配置提供程式

既然我們已經理解了.NET中的配置體系,那我們完全可以自己動手實踐一下了,現在就來實現一個自定義的配置提供程式來玩玩。

日常使用的配置中心客戶端,如Apollo等,都是通過實現自定義配置提供程式來提供配置的。

我們們不搞那麼複雜,就基於ORM框架EF Core來實現一個自定義配置提供程式,具體邏輯是這樣的:資料庫中有一個JsonConfiguration資料集,專門用來存放Json格式的配置。該表有KeyValue兩個欄位,Key對應例子中的“Book”,而Value則是“Book”對應值的Json字串。

首先,裝一下Nuget包:

Install-Package Microsoft.EntityFrameworkCore.InMemory

然後定義自己的DbContext——AppDbContext

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions options) 
        : base(options) { }

    public virtual DbSet<JsonConfiguration> JsonConfigurations { get; set; }
}

public class JsonConfiguration
{
    [Key]
    public string Key { get; set; }

    public string Value { get; set; }
}

接下來,通過EFConfigurationSource來構建EFConfigurationProvider例項:

public class EFConfigurationSource : IConfigurationSource
{
    private readonly Action<DbContextOptionsBuilder> _optionsAction;

    public EFConfigurationSource(Action<DbContextOptionsBuilder> optionsAction)
    {
        _optionsAction = optionsAction;
    }

    public IConfigurationProvider Build(IConfigurationBuilder builder)
    {
        return new EFConfigurationProvider(_optionsAction);
    }
}

接著,就是EFConfigurationProvider的實現了,邏輯類似於Json檔案配置提供程式,只不過配置來源於EF而不是Json檔案:

public class EFConfigurationProvider : ConfigurationProvider
{
    public EFConfigurationProvider(Action<DbContextOptionsBuilder> optionsAction)
    {
        OptionsAction = optionsAction;
    }

    Action<DbContextOptionsBuilder> OptionsAction { get; }

    public override void Load()
    {
        var builder = new DbContextOptionsBuilder<AppDbContext>();

        OptionsAction(builder);

        using var dbContext = new AppDbContext(builder.Options);

        dbContext.Database.EnsureCreated();

        // 如果沒有任何配置則新增預設配置
        if (!dbContext.JsonConfigurations.Any())
        {
            CreateAndSaveDefaultValues(dbContext);
        }

        // 將配置項轉換為鍵值對(key和value均為字串型別)
        Data = EFJsonConfigurationParser.Parse(dbContext.JsonConfigurations);
    }

    private static void CreateAndSaveDefaultValues(AppDbContext dbContext)
    {
        dbContext.JsonConfigurations.AddRange(new[]
        {
            new JsonConfiguration
            {
                Key = "Book",
                Value = JsonSerializer.Serialize(
                new BookOptions()
                {
                    Name = "ef configuration book name",
                    Authors = new List<string>
                    {
                        "ef configuration book author A",
                        "ef configuration book author B"
                    },
                    Bookmark = new BookmarkOptions
                    {
                        Remarks = "ef configuration bookmark Remarks"
                    }
                })
            }
        });

        dbContext.SaveChanges();
    }
}

internal class EFJsonConfigurationParser
{
    private EFJsonConfigurationParser() { }

    private readonly IDictionary<string, string> _data = new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
    private readonly Stack<string> _context = new();
    private string _currentPath;

    public static IDictionary<string, string> Parse(DbSet<JsonConfiguration> inputs)
        => new EFJsonConfigurationParser().ParseJsonConfigurations(inputs);

    private IDictionary<string, string> ParseJsonConfigurations(DbSet<JsonConfiguration> inputs)
    {
        _data.Clear();

        if(inputs?.Any() != true)
        {
            return _data;
        }

        var jsonDocumentOptions = new JsonDocumentOptions
        {
            CommentHandling = JsonCommentHandling.Skip,
            AllowTrailingCommas = true,
        };

        foreach (var input in inputs)
        {
            ParseJsonConfiguration(input, jsonDocumentOptions);
        }

        return _data;
    }

    private void ParseJsonConfiguration(JsonConfiguration input, JsonDocumentOptions options)
    {
        if (string.IsNullOrWhiteSpace(input.Key))
            throw new FormatException($"The key {input.Key} is invalid.");

        var jsonValue = $"{{\"{input.Key}\": {input.Value}}}";
        using var doc = JsonDocument.Parse(jsonValue, options);

        if (doc.RootElement.ValueKind != JsonValueKind.Object)
            throw new FormatException($"Unsupported JSON token '{doc.RootElement.ValueKind}' was found.");

        VisitElement(doc.RootElement);
    }

    private void VisitElement(JsonElement element)
    {
        foreach (JsonProperty property in element.EnumerateObject())
        {
            EnterContext(property.Name);
            VisitValue(property.Value);
            ExitContext();
        }
    }

    private void VisitValue(JsonElement value)
    {
        switch (value.ValueKind)
        {
            case JsonValueKind.Object:
                VisitElement(value);
                break;

            case JsonValueKind.Array:
                var index = 0;
                foreach (var arrayElement in value.EnumerateArray())
                {
                    EnterContext(index.ToString());
                    VisitValue(arrayElement);
                    ExitContext();
                    index++;
                }
                break;

            case JsonValueKind.Number:
            case JsonValueKind.String:
            case JsonValueKind.True:
            case JsonValueKind.False:
            case JsonValueKind.Null:
                var key = _currentPath;
                if (_data.ContainsKey(key))
                    throw new FormatException($"A duplicate key '{key}' was found.");

                _data[key] = value.ToString();
                break;

            default:
                throw new FormatException($"Unsupported JSON token '{value.ValueKind}' was found.");
        }
    }

    private void EnterContext(string context)
    {
        _context.Push(context);
        _currentPath = ConfigurationPath.Combine(_context.Reverse());
    }

    private void ExitContext()
    {
        _context.Pop();
        _currentPath = ConfigurationPath.Combine(_context.Reverse());
    }
}

其中,EFJsonConfigurationParser是我借鑑JsonConfigurationFileParser而實現的,這也是學習優秀設計的一種方式!

接著,我們按照AddXXX的格式將該配置提供程式的新增封裝為擴充套件方法:

public static class EntityFrameworkExtensions
{
    public static IConfigurationBuilder AddEFConfiguration(
        this IConfigurationBuilder builder,
        Action<DbContextOptionsBuilder> optionsAction)
    {
        return builder.Add(new EFConfigurationSource(optionsAction));
    }
}

這時,我們就可以使用擴充套件方法新增EFConfigurationProvider了:

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureAppConfiguration((context, config) => 
        {
            config.AddEFConfiguration(options => options.UseInMemoryDatabase("configdb"));
        })
        .ConfigureWebHostDefaults(webBuilder =>
        {
            webBuilder.UseStartup<Startup>();
        });

最後,你可以試著讀取一下Book配置了,看看是不是如我們們所期望的那樣,讀取到EF中的配置呢?這裡,我就不再演示了。

其他

檢視所有配置項

通過擴充套件方法ConfigurationExtensions.AsEnumerable,來檢視所有配置項:

public static void Main(string[] args)
{
    var host = CreateHostBuilder(args).Build();

    var config = host.Services.GetRequiredService<IConfiguration>();

    foreach (var c in config.AsEnumerable())
    {
        Console.WriteLine(c.Key + " = " + c.Value);
    }
    host.Run();
}

通過委託配置選項

除了可以通過配置提供程式來提供配置外,也可以通過委託來提供配置:

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<BookOptions>(book =>
    {
        book.Name = "delegate book name";
        book.Authors = new List<string> { "delegate book author A", "delegate book author A" };
        book.Bookmark = new BookmarkOptions { Remarks = "delegate bookmark reamarks" };
    });
}

關於選項的更多理解,將在後續章節進行詳細講解。

注意事項

配置Key

  • 不區分大小寫。例如Namename被視為等效的。
  • 配置提供程式有很多種,如果在多個提供程式中新增了某個配置項,那麼,只有在最後一個提供程式中配置的才會生效。
  • 分層鍵:
    • 在環境變數中,由於冒號(:)無法適用於所有平臺,所以要使用全平臺均支援的雙下劃線(__),它會在程式中自動轉換為冒號(:
    • 在其他型別的配置中,一般均使用冒號(:)分隔符即可
  • ConfigurationPath類提供了一些輔助方法。

配置Value

  • 均被儲存為字串

相關文章