Loading

探索 .NET 6 中的 ConfigurationManager

这是该系列的第一篇文章:探索 .NET 6

  • Part 1 - 探索 .NET 6 中的 ConfigurationManager(当前文章)
  • Part 2 - 比较 WebApplicationBuilder 和 Generic Host
  • Part 3 - 探索 WebApplicationBuilder 背后的代码
  • Part 4 - 使用 WebApplication 构建中间件管道
  • Part 5 - 使用 WebApplicationBuilder 支持 EF Core 迁移
  • Part 6 - 在 .NET 6 中使用 WebApplicationFactory 支持集成测试
  • Part 7 - 用于 .NET 6 中 ASP.NET Core的分析器
  • Part 8 - 使用源代码生成器提高日志记录性能
  • Part 9 - 源代码生成器更新:增量生成器
  • Part 10 - .NET 6 中新的依赖关系注入功能
  • Part 11 - [CallerArgumentExpression] and throw helpers
  • Part 12 - 将基于 .NET 5 启动版本的应用升级到 .NET 6

在本系列中,我将介绍 .NET 6 中推出的一些新功能。在.NET 6上已经写了很多内容,包括来自.NET和 ASP.NET 团队本身的大量帖子。在本系列中,我将介绍其中一些功能背后的一些代码。

在系列文章的第一篇中,我们将探索ConfigurationManager类,为什么添加它,以及用于实现它的一些代码。

稍等,什么是ConfigurationManager

如果你的第一反应是"什么是配置管理器",那么别担心,你没有错过一个大公告!

新增的 ConfigurationManager 是用来支持 ASP.NET Core 的新 WebApplication 模型,用于简化 ASP.NET Core 启动代码。但是,ConfigurationManager在很大程度上是一个实现细节。引入它是为了优化特定方案(我稍后会介绍),但在大多数情况下,你不需要(也不会)知道你正在使用它。

在介绍ConfigurationManager本身之前,我们将了解它要替换的内容及其原因。

.NET 5 中的配置

.NET 5 公开了有关配置的多个类型,但直接在应用中使用的两个主要类型是:

  • IConfigurationBuilder - 用于添加配置源。在 builder 调用 Build() 将读取每个配置源,并生成最终配置。
  • IConfigurationRoot - 表示最终的"构建"配置。

IConfigurationBuilder 接口主要是围绕配置源列表的包装器。

配置提供程序通常包括将配置源添加到Sources列表的扩展方法(如 AddJsonFile()AddAzureKeyVault())。

public interface IConfigurationBuilder
{
    IDictionary<string, object> Properties { get; }
    IList<IConfigurationSource> Sources { get; }
    IConfigurationBuilder Add(IConfigurationSource source);
    IConfigurationRoot Build();
}

同时,IConfigurationRoot 表示最终的"分层"配置值,将来自每个配置源的所有值组合在一起,以提供所有配置值的最终"平面"视图。


后来的配置提供程序(环境变量)将覆盖早期配置提供程序(appsettings.json、sharedsettings.json)配置的值。

在 .NET 5 及更早版本中,IConfigurationBuilderIConfigurationRoot 接口分别由 ConfigurationBuilderConfigurationRoot 实现。如果您直接使用这些类型,则可以执行以下操作:

var builder = new ConfigurationBuilder();

// 添加静态值
builder.AddInMemoryCollection(new Dictionary<string, string>
{
    { "MyKey", "MyValue" },
});

// 从 json 文件中添加值
builder.AddJsonFile("appsettings.json");

// 创建 IConfigurationRoot 实例
IConfigurationRoot config = builder.Build();

string value = config["MyKey"]; // 获取值
IConfigurationSection section = config.GetSection("SubSection"); //获取节

在典型的 ASP.NET Core应用程序中,您不会自己创建 ConfigurationBuilder 或调用 Build(),否则这就是幕后发生的事情。这两种类型之间有明显的区别,并且在大多数情况下,配置系统运行良好,那么为什么我们需要在.NET 6中使用新类型呢?

.NET 5 中的"部分配置构建"问题

此设计的主要问题是何时需要"部分"构建配置。将配置存储在云服务(如 Azure Key Vault)中,甚至存储在数据库中时,这是一个常见问题。

例如,以下是在 ASP.NET Core中的 ConfigureAppConfiguration() 中从 Azure Key Vault读取机密的建议方法

.ConfigureAppConfiguration((context, config) =>
{
    // "标准" 配置等等
    config.AddJsonFile("appsettings.json");
    config.AddEnvironmentVariables();

    if (context.HostingEnvironment.IsProduction())
    {
        IConfigurationRoot partialConfig = config.Build(); // 构建部分配置
        string keyVaultName = partialConfig["KeyVaultName"]; // 从配置中读取值
        var secretClient = new SecretClient(
            new Uri($"https://{keyVaultName}.vault.azure.net/"),
            new DefaultAzureCredential());
        config.AddAzureKeyVault(secretClient, new KeyVaultSecretManager()); // 添加额外的配置源
        // 框架再次调用配置 config.Build() 构建最终的 IConfigurationRoot
    }
})

配置 Azure Key Vault 提供程序需要配置值,因此你会遇到先有鸡还是先有蛋的问题 - 在生成配置之前,无法添加配置源!

解决方案是:

  • 添加"初始"配置值
  • 通过调用 IConfigurationBuilder.Build() 来构建"部分"配置结果
  • 从生成的 IConfigurationRoot 中检索所需的配置值
  • 使用这些值添加剩余的配置源
  • 框架隐式调用 IConfigurationBuilder.Build(),生成最终的 IConfigurationRoot 并将其用于最终的应用配置。

这整个舞蹈有点乱,但它本身没有错,那么缺点是什么?

缺点是,我们必须调用 Build() 两次:一次是仅使用第一个源生成 IConfigurationRoot,然后再次使用所有源(包括 Azure Key Vault 源)生成 IConfiguartionRoot

在默认的 ConfigurationBuilder 实现中,将循环访问所有源调用 Build() ,加载提供程序,然后将这些提供程序传递到 ConfigurationRoot 的新实例:

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

然后,ConfigurationRoot 依次循环遍历其中每个提供程序并加载配置值。

public class ConfigurationRoot : IConfigurationRoot, IDisposable
{
    private readonly IList<IConfigurationProvider> _providers;
    private readonly IList<IDisposable> _changeTokenRegistrations;

    public ConfigurationRoot(IList<IConfigurationProvider> providers)
    {
        _providers = providers;
        _changeTokenRegistrations = new List<IDisposable>(providers.Count);

        foreach (IConfigurationProvider p in providers)
        {
            p.Load();
            _changeTokenRegistrations.Add(ChangeToken.OnChange(() => p.GetReloadToken(), () => RaiseChanged()));
        }
    }
    // ... 其余实现部分
}

如果在应用启动期间调用 Build() 两次,则所有这些操作都会发生两次。

一般来说,多次从配置源获取数据没有坏处,但这是不必要的工作,并且通常涉及(相对缓慢的)文件读取等。

这是一种非常常见的模式,以至于在 .NET 6 中引入了一种新类型来避免这种"重新构建",即 ConfigurationManager

.NET 6 中的配置管理器

作为 .NET 6 中"简化"应用程序模型的一部分,.NET 团队添加了一个新的配置类型 ConfigurationManager。此类型同时实现 IConfigurationBuilderIConfigurationRoot。通过将这两种实现组合到一个类型中,.NET 6 可以优化上一节中显示的通用模式。

使用 ConfigurationManager,当添加 IConfigurationSource(例如,当您调用 AddJsonFile() 时),将立即加载提供程序并更新配置。这可以避免在部分生成方案中多次加载配置源。

由于 IConfigurationBuilder 接口将源公开为 IList<IConfigurationSource> 类型,因此实现这一点比听起来要困难一些:

public interface IConfigurationBuilder
{
    IList<IConfigurationSource> Sources { get; }
    // ... 其它成员
}

ConfigurationManager 的角度来看,这样做的问题在于 IList<> 公开了 Add()Remove() 方法。如果使用简单的 List<>,则使用者可以在 ConfigurationManager 不知情的情况下添加和删除配置提供程序。

为了解决此问题,ConfigurationManager 使用自定义 IList<>实现。它包含对 ConfigurationManager 实例的引用,以便任何更改都可以反映在配置中:

private class ConfigurationSources : IList<IConfigurationSource>
{
    private readonly List<IConfigurationSource> _sources = new();
    private readonly ConfigurationManager _config;

    public ConfigurationSources(ConfigurationManager config)
    {
        _config = config;
    }

    public void Add(IConfigurationSource source)
    {
        _sources.Add(source);
        _config.AddSource(source); // 将源添加到 ConfigurationManager
    }

    public bool Remove(IConfigurationSource source)
    {
        var removed = _sources.Remove(source);
        _config.ReloadSources(); // 在 ConfigurationManager 中重置源
        return removed;
    }

    // ... 其它实现
}

通过使用自定义 IList<> 实现,ConfigurationManager 可确保在添加新源时调用 AddSource()。这就是 ConfigurationManager 的优势所在:调用 AddSource() 会立即加载源:

public class ConfigurationManager
{
    private void AddSource(IConfigurationSource source)
    {
        lock (_providerLock)
        {
            IConfigurationProvider provider = source.Build(this);
            _providers.Add(provider);

            provider.Load();
            _changeTokenRegistrations.Add(ChangeToken.OnChange(() => provider.GetReloadToken(), () => RaiseChanged()));
        }

        RaiseChanged();
    }
}

此方法立即调用 IConfigurationSourceBuild 方法以创建 IConfigurationProvider,并将其添加到提供程序列表中。

接下来,该方法调用 IConfigurationProvider.Load()。这会将数据加载到提供程序中(例如,从环境变量、JSON 文件或 Azure Key Vault),这是"昂贵"的步骤!在"正常"情况下,您只需将源添加到IConfigurationBuilder,并且可能需要多次构建它,这给出了"最佳"方法;源加载一次,并且只加载一次。

ConfigurationManager 中实现 Build() 现在是一个回路 ,只是返回自身。

IConfigurationRoot IConfigurationBuilder.Build() => this;

当然,软件开发都是关于权衡取舍的。如果只添加源,则在添加源时以增量方式构建源效果很好。但是,如果您调用任何IList<>的其他函数(如 Clear()Remove() 或索引器,则 ConfigurationManager 必须调用 ReloadSources()

private void ReloadSources()
{
    lock (_providerLock)
    {
        DisposeRegistrationsAndProvidersUnsynchronized();

        _changeTokenRegistrations.Clear();
        _providers.Clear();

        foreach (var source in _sources)
        {
            _providers.Add(source.Build(this));
        }

        foreach (var p in _providers)
        {
            p.Load();
            _changeTokenRegistrations.Add(ChangeToken.OnChange(() => p.GetReloadToken(), () => RaiseChanged()));
        }
    }

    RaiseChanged();
}

如您所见,如果任何源发生更改,ConfigurationManager 必须删除所有内容并重新开始,遍历每个源,然后重新加载它们。如果您要对配置源进行大量操作,这可能会很快变得昂贵,并且会完全否定 ConfigurationManager 的原始优势。

当然,删除源是非常不寻常的 - 除了添加提供程序之外,通常没有理由做任何事情 - 因此 ConfigurationManager 针对最常见的情况进行了非常优化。谁会猜到呢?😉

下表给出了使用 ConfigurationBuilderConfigurationManager 的各种操作的相对成本的最终摘要。

Operation ConfigurationBuilder ConfigurationManager
Add source Cheap Moderately Expensive
Partially Build IConfigurationRoot Expensive Very cheap (noop)
Fully Build IConfigurationRoot Expensive Very cheap (noop)
Remove source Cheap Expensive
Change source Cheap Expensive

那么,我应该关心ConfigurationManager吗?

因此,在阅读了所有这些内容之后,您应该关心您使用的是 ConfigurationManager 还是 ConfigurationBuilder

可能不需要

.NET 6 中引入的新 WebApplicationBuilder 使用 ConfigurationManager,它针对我上面描述的需要部分构建配置的用例进行了优化。

但是,在早期版本的 ASP.NET Core中引入的 WebHostBuilderHostBuilder 在.NET 6中仍然非常受支持,并且它们继续在幕后使用 ConfigurationBuilderConfigurationRoot 类型。

我能想到的唯一需要小心的情况是,如果你在某个地方依赖于 IConfigurationBuilderIConfigurationRoot 作为具体类型ConfigurationBuilderConfigurationRoot。这对我来说似乎不太可能,如果你依赖这一点,我很想知道为什么!

但除了这个例外,没有"旧"类型不会消失,所以没有必要担心。只要知道,如果你需要做一个"部分构建",并且你正在使用新的 WebApplicationBuilder,你的应用程序将会提高一点点性能!

总结

在这篇文章中,我描述了.NET 6中引入的新 ConfigurationManager 类型,该类型由最小的API示例中使用的新 WebApplicationBuilder 使用。引入 ConfigurationManager 是为了优化需要"部分构建"配置的常见情况。这通常是因为配置提供程序本身需要一些配置,例如,从 Azure Key Vault 加载机密需要配置以指示要使用的保管库。

ConfigurationManager 通过在添加源时立即加载源来优化此方案,而不是等到调用 Build()。这样就无需在"部分生成"方案中"重新生成"配置。权衡是其他操作(例如删除源)成本高昂。




Looking inside ConfigurationManager in .NET 6

posted @ 2022-03-06 11:29  GerryGe  阅读(1282)  评论(0编辑  收藏  举报