8

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

 2 years ago
source link: https://www.cnblogs.com/xiaoxiaotank/p/15367747.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

注:本文隶属于《理解ASP.NET Core》系列文章,请查看置顶博客或点击此处查看全文目录

配置提供程序

在.NET中,配置是通过多种配置提供程序来提供的,包括以下几种:

  • 文件配置提供程序
  • 环境变量配置提供程序
  • 命令行配置提供程序
  • Azure应用配置提供程序
  • Azure Key Vault 配置提供程序
  • Key-per-file配置提供程序
  • 内存配置提供程序
  • 应用机密(机密管理器)
  • 自定义配置提供程序

为了方便大家后续了解配置,这里先简单提一下选项(Options),它是用于以强类型的方式对程序配置信息进行访问的一种方式。接下来的示例中,我会添加一个简单的配置Book,结构如下:

csharp
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进行配置的读取,并显示在控制台中,如下:

csharp
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文件即可:

csharp
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 中添加如下配置:

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文件即可:

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

你可以在 appsettings.xml 中添加如下配置:

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文件即可:

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

你可以在 appsettings.ini 中添加如下配置

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扩展方法添加环境变量即可:

csharp
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去设置环境变量(记得以管理员身份运行哦):

bash
# 注意,这里的 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 中的配置只用于开发环境,并且在该文件中设置的环境变量会覆盖在系统环境中设置的变量。

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工具:

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

命令行配置提供程序

通过CommandLineConfigurationProvider在运行时从命令行参数键值对中加载配置。

Install-Package Microsoft.Extensions.Configuration.CommandLine

通过调用AddCommandLine扩展方法,并传入参数args

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

有三种设置命令行参数的方式:

使用=

bash
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"

使用/

bash
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"

使用--

bash
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

接下来我们来映射一下:

csharp
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);
        });

然后以命令行命令启动:

bash
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添加内存配置:

csharp
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 来查看。

如果想要添加额外配置文件,但是仍然想要环境变量或命令行参数优先,则可以类似这样做:

csharp
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实例:

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

IConfigurationProvider

IConfigurationProvider负责实现配置的设置、读取、重载等功能,并以键值对形式提供配置。

所有配置提供程序均建议继承于抽象类ConfigurationProvider,该类实现了接口IConfigurationProvider

csharp
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

csharp
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接口:

csharp
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

csharp
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。

csharp
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的配置子节点:

csharp
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方法,可以获取到当前配置节点的直接子节点列表

csharp
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了:

csharp
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的源码:

csharp
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,若该子节点非叶子节点,则该子节点的子节点一定不为空

通过ConfigurationBinder.Get方法,可以将配置以强类型的方式绑定到选项对象上:

csharp
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)}");
    }
}

与上方Get方法类似,通过ConfigurationBinder.Bind 方法,可以将配置以强类型的方式绑定到已存在的选项对象上:

csharp
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>等)。

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

    // 强制从配置提供程序中重载配置
    void Reload();
}

ConfigurationRoot实现了IConfigurationRoot接口,下面就着重看一下Reload方法的实现:

Startup构造函数中注入的IConfiguration其实就是ConfigurationRoot的实例。

csharp
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表示配置的子节点。

csharp
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

csharp
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实例:

csharp
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文件:

csharp
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的格式将该配置提供程序的添加封装为扩展方法:

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

这时,我们就可以使用扩展方法添加EFConfigurationProvider了:

csharp
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,来查看所有配置项:

csharp
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();
}

通过委托配置选项

除了可以通过配置提供程序来提供配置外,也可以通过委托来提供配置:

csharp
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

  • 均被保存为字符串

__EOF__


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK