【.NET源码解读】Configuration组件及自动更新

Configuration组件是.NET中一个核心的、非常重要的组件。它提供了一种方便的机制,用于从配置文件、环境变量、命令行参数等各种数据源中读取和配置应用程序,以满足不同环境下应用程序的需求。

在本篇文章中,将会介绍Configuration的基本用法,并通过源码探究.NET中Configuration的实现及热加载的原理。同时,还将提供标准组件扩展封装的示例,帮助深入理解如何自定义配置提供程序,以适应不同的业务需求。

阅读本篇文章,您将会获得以下收获:

  1. 熟练运用Configuration组件
  2. 掌握Configuration的实现原理,并了解热加载的实现方法
  3. 实现自定义配置提供程序

一、Configuration的基本用法

在本章节中,重点介绍了如何读取和运用配置文件。如果您已经熟练掌握了这些内容,可以直接跳过本章节。

1. 默认配置源的优先级(由高到低)

如果在配置源中有两个或更多具有相同键的配置项,除非您显式指定使用哪个配置源,否则后添加的配置项将覆盖先前添加的配置项。

  1. 命令行参数提供
  2. 非前缀环境变量提供(不以ASPNETCORE_ 或 DOTNET_ 为前缀的环境变量)
  3. 运行时的用户机密
  4. 通过 appsettings.{Environment}.json 提供
  5. 通过 appsettings.json 提供

2. 添加数据文件

创建MyConfig.json文件

{
  "Student": {
    "Name": "Broder",
    "Age": "26"
  }
  "section0": {
    "key0": "value00",
    "key1": "value01"
  },
  "section1": {
    "key0": "value10",
    "key1": "value11"
  },
  "array": {
    "entries": {
      "0": "value00",
      "1": "value10",
      "2": "value20",
    }
  }
}

2.1 添加json配置文件

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddJsonFile("MyConfig.json",
        optional: true, // 文件是否可选
        reloadOnChange: true ); // 如果文件更改,是否重载配置

var app = builder.Build();

2.2 添加xml配置文件

builder.Configuration
    .AddXmlFile("MyXMLFile.xml", optional: true, reloadOnChange: true)
    .AddXmlFile($"MyXMLFile.{builder.Environment.EnvironmentName}.xml",
                optional: true, reloadOnChange: true);

2.3 命令行参数

以下命令使用 = 设置键和值:

dotnet run MyKey="Using =" Position:Title=Cmd Position:Name=Cmd_Rick

以下命令使用 / 设置键和值:

dotnet run /MyKey "Using /" /Position:Title=Cmd /Position:Name=Cmd_Rick

以下命令使用 -- 设置键和值:

dotnet run --MyKey "Using --" --Position:Title=Cmd --Position:Name=Cmd_Rick

键值:

  • 必须后跟 =,或者当值后跟一个空格时,键必须具有一个 -- 或 / 的前缀。
  • 如果使用 =,则不是必需的。 例如 MySetting=。

在同一命令中,请勿将使用 = 的命令行参数键值对与使用空格的键值对混合使用。

3. 多种读取方式

3.1 获取指定键的值

  • 索引器

使用 IConfiguration 接口的索引器来获取指定键的值(字符串类型),可以使用冒号分隔的键序列作为索引。例如,可以使用 config["Student:Name"] 来获取嵌套在 "Student" 属性下的 "Name" 属性的值。

请注意,在使用此方法读取配置数据时,确保配置提供程序中存在相应的键/值对。如果配置数据中没有指定的键,则此方法将返回 null。

// requires using Microsoft.Extensions.Configuration;
private readonly IConfiguration _configuration;

public WeatherForecastController(IConfiguration configuration)
{
    _configuration = configuration;
}

public void Test()
{
    string? city = _configuration["City"]; // Shanghai
    string? student = _configuration["Student"]; // null
    string? name = _configuration["Student:Name"]; // Broder
    string? age = _configuration["Student:Age"]; // 26
}
  • GetValue

从配置中提取一个具有指定键的值,并将它转换为指定的类型

// 找不到,使用默认值 0
var number = _configuration.GetValue<int>("NumberKey");

// 配置中找不到 NumberKey,则使用默认值 99
var number = _configuration.GetValue<int>("NumberKey", 99);

3.2 获取多级嵌套配置

  • GetSection()

返回具有指定子节键的配置子节,GetSection 永远不会返回 null。 如果找不到匹配的节,则返回空 IConfigurationSection

IConfigurationSection? section = _configuration.GetSection("section1");
string? a = section["key0"];
  • GetChildren()
IEnumerable<IConfigurationSection>? children = _configuration.GetSection("section2").GetChildren();
foreach (IConfigurationSection item in children)
{
   // 处理数据
}

3.3 绑定配置值到对象

Get<T>() 方法适用于将一组相关的配置值聚合到一个自定义对象中,而 Bind() 方法适用于将配置值绑定到已实例化的自定义对象的属性上。选择使用哪种方法取决于您的需求和偏好

// 定义类
public class MyOptions
{
    public string Key = "Student";
    public string Name { get; set; }
    public string Age { get; set; } 
}
  • Get<T>()

方法将配置值绑定到指定类型的对象上,适用于需要将一组相关的配置值聚合到一个对象中的情况

MyOptions? myOptions= _configuration.GetSection("Student").Get<MyOptions>();
  • Bind()

方法将配置绑定到已实例化的自定义对象上,适用于将配置值直接绑定到现有对象的情况

var myOptions = new MyOptions();
_configuration.GetSection(myOptions.Key).Bind(myOptions);

3.4 添加到IOC容器

注入容器

builder.Services.Configure<MyOptions>(
builder.Configuration.GetSection("Student"));

使用配置

private readonly MyOptions _options;

public WeatherForecastController(IOptions<MyOptions> options)
{
    _options = options.Value;
}

public void Test()
{
    string? name = _options.Name;
}

更详细的描述,可以直接阅读官方文档

二、Configuration源码解析

核心接口

  • IConfigurationBuilder 负责构建配置体系
  • IConfigurationRoot 提供了访问配置数据的方法
  • IConfigurationProvider 实现了具体的配置数据获取和解析逻辑
  • IConfigurationSource 定义了配置数据的来源和构建方式。

核心执行流程是使用IConfigurationBuilder的Build方法,遍历配置的IConfigurationSource集合,创建IConfigurationProvider实例,将不同的IConfigurationProvider集合存储到IConfigurationRoot中,用户读取时依次遍历IConfigurationProvider集合,获取Value。

具体功能及类图

根据我的理解,这个过程可以分为三个部分,以IConfigurationBuilder为核心。第一部分是对用户的IConfiguration,第二部分是确定配置数据来源的IConfigurationSource,第三部分则是IConfigurationProvider。

  • IConfiguration:接口表示应用程序的配置数据,提供了读取和访问配置值的功能
  • IConfigurationRoot:接口继承自 IConfiguration 接口,表示应用程序配置的根节点。它提供了额外的功能,如热加载配置和监听配置变更
  • IConfigurationSection:接口表示配置数据的一个特定部分或节点。它继承自 IConfiguration 接口,因此可以使用 IConfiguration 接口中的方法来读取和访问配置值,通过 GetSection() 方法,IConfiguration 接口可以获取一个 IConfigurationSection 对象,用于处理嵌套和分层的配置结构
  • IConfigurationBuilder:接口用于构建应用程序的配置体系,负责管理配置提供程序和配置源,并构建最终的 IConfigurationRoot 对象。通过调用 Build() 方法,IConfigurationBuilder 可以创建一个 IConfigurationRoot 对象,即应用程序的配置根节点
  • ConfigurationManager: 表示可变配置对象。 它继承了IConfigurationBuilder和IConfigurationRoot。添加源时,它会更新其配置的当前视图。 调用 后 IConfigurationBuilder.Build() ,配置将冻结。(注意这个在Microsoft.Extensions.Configuration下,不是之前的System.Configuration空间下)

这几个对象的关系:IConfigurationBuilder 用于构建和配置应用程序的配置体系,生成一个 IConfigurationRoot 对象作为配置的根节点,而 IConfiguration 和 IConfigurationSection 则用于读取和访问配置值,处理嵌套和分层的配置结构

在上文介绍的IConfigurationBuilder接口中,Add()方法会将IConfigurationSource到Sources配置源集合中。

  • IConfigurationSource:用于定义配置数据的来源(文件、内存、数据库等)和通过Build()方法构建配置提供程序(Configuration Provider)。ps:我认为这就是个工厂
  • FileConfigurationSource:是 IConfigurationSource 接口的一个具体实现类,通过指定文件路径和可选的配置源选项来从文件中加载配置数据(JSON、XML等文件)
  • StreamConfigurationSource:通过指定一个输入流和可选的配置源选项来确定配置数据的来源,可以是内存中的流或其他类型的流。
  • JsonConfigurationSource: 并专门用于处理 JSON 格式的配置文件

FileConfigurationSource 和 StreamConfigurationSource 都继承自 IConfigurationSource,而 JsonConfigurationSource 又继承自 FileConfigurationSource,意味着它们拥有各自的实现方式,但都遵循了 IConfigurationSource 的接口规范。这样,开发人员就可以基于实际需求选择不同的 IConfigurationSource 实现方式来读取与处理配置数据

  • IConfigurationProvider:定义了从 IConfigurationSource 加载数据并提供对其访问的接口
  • ConfigurationProvider:它为 IConfigurationProvider 接口提供了许多通用的方法,例如管理键值对集合、更改和保存配置数据等
  • FileConfigurationProvider:使用了 FileConfigurationSource 提供的数据源,并将数据读取到内存中的键值对集合中,供应用程序使用。
  • JsonConfigurationProvider: 使用System.Text.Json 库来解析 JSON 数据,并将数据转换成键值对的形式。

可以看出 ConfigurationProvider 是 IConfigurationProvider 的基础实现,而 FileConfigurationProvider 和 JsonConfigurationProvider 则是针对特定类型的数据源进行的实现,它们都继承自 ConfigurationProvider 并且遵循了 IConfigurationProvider 接口的规范

源码流程

以下是源代码的部分删减和修改,以便于更好地理解

在程序启动时,创建一个新的 HostBuilder 实例。调用 ConfigureDefaults() 方法向 HostBuilder 实例中添加一些默认的配置和服务

public static IHostBuilder CreateDefaultBuilder(string[]? args)
{
    HostBuilder builder = new();
    return builder.ConfigureDefaults(args);
}

在ConfigureDefaults()方法中,会调用静态方法ApplyDefaultAppConfiguration()。用于向应用程序的配置对象中添加默认的配置信息。

internal static void ApplyDefaultAppConfiguration(HostBuilderContext hostingContext, IConfigurationBuilder appConfigBuilder, string[]? args)
{
    // 首先获取主机环境和一些配置参数
    IHostEnvironment env = hostingContext.HostingEnvironment;
    bool reloadOnChange = GetReloadConfigOnChangeValue(hostingContext);
    
    // 加载 appsettings.json 和 appsettings.{环境名称}.json 文件中的配置信息(如果存在)。同时,还要每当文件改变时重新加载配置信息。
    appConfigBuilder.AddJsonFile("appsettings.json", optional: true, reloadOnChange: reloadOnChange)
            .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: reloadOnChange);
    
    // AddUserSecrets方法加载用户机密
    if (env.IsDevelopment() && env.ApplicationName is { Length: > 0 })
    {
        try
        {
            var appAssembly = Assembly.Load(new AssemblyName(env.ApplicationName));
            appConfigBuilder.AddUserSecrets(appAssembly, optional: true, reloadOnChange: reloadOnChange);
        }
        catch (FileNotFoundException)
        {
            // The assembly cannot be found, so just skip it.
        }
    }
    // 将操作系统环境变量中的配置信息添加到配置对象中
    appConfigBuilder.AddEnvironmentVariables();
    
    // AddCommandLineConfig方法将命令行参数中的配置信息添加到配置对象中
    AddCommandLineConfig(appConfigBuilder, args);

    [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode", Justification = "Calling IConfiguration.GetValue is safe when the T is bool.")]
    static bool GetReloadConfigOnChangeValue(HostBuilderContext hostingContext) => hostingContext.Configuration.GetValue("hostBuilder:reloadConfigOnChange", defaultValue: true);
}

我们继续跟进AddEnvironmentVariables方法,就会发现在主机初始时,就已经将默认的ConfigurationSource添加到configurationBuilder中了。

public static IConfigurationBuilder AddCommandLine(
    this IConfigurationBuilder configurationBuilder,
    string[] args,
    IDictionary<string, string>? switchMappings)
{
   configurationBuilder.Add(new CommandLineConfigurationSource { Args = args, SwitchMappings = switchMappings });
   return configurationBuilder;
}

接下来就开始构建和返回 IHost 实例

public IHost Build()
{
    _hostBuilt = true;
    
    // 诊断监听器
    using DiagnosticListener diagnosticListener = LogHostBuilding(this);
    
    // 初始化主机配置,包括默认配置和应用程序附加的配置
    InitializeHostConfiguration();
    // 初始化主机环境,设置应用程序名称、内容根路径和环境名称
    InitializeHostingEnvironment();
    // 初始化 HostBuilderContext 对象,将主机环境和主机配置设置为成员变量
    InitializeHostBuilderContext();
    // 初始化应用程序配置,包括从 appsettings.json 文件加载配置信息和应用程序自定义的配置
    InitializeAppConfiguration();
    // 初始化服务提供程序,包括向 DI 容器中添加所需的服务并编译容器以生成 IServiceProvider 实例
    InitializeServiceProvider();

    return ResolveHost(_appServices, diagnosticListener);
}

在InitializeHostConfiguration()方法中,我们创建了ConfigurationBuilder对象,并通过调用Build()方法生成了一个IConfiguration实例。然而,在下文的InitializeAppConfiguration()方法中,我们又重新创建了一次ConfigurationBuilder并进行了配置。因此,我们可以直接跳过InitializeHostConfiguration()方法,直接来看InitializeAppConfiguration()方法的实现。

private void InitializeAppConfiguration()
{
    IConfigurationBuilder configBuilder = new ConfigurationBuilder()
        .SetBasePath(_hostingEnvironment!.ContentRootPath)
        .AddConfiguration(_hostConfiguration!, shouldDisposeConfiguration: true);

    foreach (Action<HostBuilderContext, IConfigurationBuilder> buildAction in _configureAppConfigActions)
    {
        buildAction(_hostBuilderContext!, configBuilder);
    }

    // Build() 方法从 ConfigurationBuilder 实例中创建 IConfiguration 实例
    _appConfiguration = configBuilder.Build();
    _hostBuilderContext!.Configuration = _appConfiguration;
}

经过前面的铺垫,我们终于来到了IConfigurationBuilder对象中。在该对象中,Build()方法的实现非常简单,它遍历Sources集合中的每个IConfigurationSource对象,并调用其Build()方法生成对应的IConfigurationProvider实例。然后,将所有的IConfigurationProvider合并到一个单独的IConfigurationRoot实例中,最终将该对象返回。

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

我们看下IConfigurationSource的Build()方法的实现,分了图片左侧这么多。我们挑选命令行的深入看一下。

CommandLineConfigurationProvider的构造方法:

public CommandLineConfigurationProvider(IEnumerable<string> args, IDictionary<string, string>? switchMappings = null)
{
    Args = args;

    if (switchMappings != null)
    {
        // 确保命令行参数映射到配置键的字典是有效的、无重复的,并且所有键都是大小写不敏感的
        _switchMappings = GetValidatedSwitchMappingsCopy(switchMappings);
    }
}

private static Dictionary<string, string> GetValidatedSwitchMappingsCopy(IDictionary<string, string> switchMappings)
{
    // 使用不区分大小写的比较器来确保字典中的所有键都是大小写不敏感的
    var switchMappingsCopy = new Dictionary<string, string>(switchMappings.Count, StringComparer.OrdinalIgnoreCase);
    foreach (KeyValuePair<string, string> mapping in switchMappings)
    {
        // Only keys start with "--" or "-" are acceptable
        if (!mapping.Key.StartsWith("-") && !mapping.Key.StartsWith("--"))
        {
            throw new ArgumentException(
                SR.Format(SR.Error_InvalidSwitchMapping, mapping.Key),
                nameof(switchMappings));
        }

        if (switchMappingsCopy.ContainsKey(mapping.Key))
        {
            throw new ArgumentException(
                SR.Format(SR.Error_DuplicatedKeyInSwitchMappings, mapping.Key),
                nameof(switchMappings));
        }

        switchMappingsCopy.Add(mapping.Key, mapping.Value);
    }

    return switchMappingsCopy;
}

在上文的IConfigurationBuilder的Build()方法中,我们将所有的IConfigurationProvider对象添加到ConfigurationRoot并返回配置根对象。其中,我们需要重点关注的是p.Load()方法用于加载配置信息。该方法涉及的热加载,在下文中介绍。

public ConfigurationRoot(IList<IConfigurationProvider> providers)
{
    _providers = providers;
    // 用于存储所有的更改通知委托对象
    _changeTokenRegistrations = new List<IDisposable>(providers.Count);
    foreach (IConfigurationProvider p in providers)
    {
        p.Load();
        // ChangeToken.OnChange() 方法注册一个更改通知委托,监听该提供程序的更改通知,并在收到通知时调用 RaiseChanged() 方法
        _changeTokenRegistrations.Add(ChangeToken.OnChange(p.GetReloadToken, RaiseChanged));
    }
}

该方法是解析命令行参数的Load方法。如有兴趣,您可以继续查看该方法的实现代码,来深入了解其中的实现逻辑。(该方法代码全贴)

public override void Load()
{
    var data = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
    string key, value;

    using (IEnumerator<string> enumerator = Args.GetEnumerator())
    {
        while (enumerator.MoveNext())
        {
            string currentArg = enumerator.Current;
            int keyStartIndex = 0;

            if (currentArg.StartsWith("--"))
            {
                keyStartIndex = 2;
            }
            else if (currentArg.StartsWith("-"))
            {
                keyStartIndex = 1;
            }
            else if (currentArg.StartsWith("/"))
            {
                // "/SomeSwitch" is equivalent to "--SomeSwitch" when interpreting switch mappings
                // So we do a conversion to simplify later processing
                currentArg = $"--{currentArg.Substring(1)}";
                keyStartIndex = 2;
            }

            int separator = currentArg.IndexOf('=');

            if (separator < 0)
            {
                // If there is neither equal sign nor prefix in current argument, it is an invalid format
                if (keyStartIndex == 0)
                {
                    // Ignore invalid formats
                    continue;
                }

                // If the switch is a key in given switch mappings, interpret it
                if (_switchMappings != null && _switchMappings.TryGetValue(currentArg, out string? mappedKey))
                {
                    key = mappedKey;
                }
                // If the switch starts with a single "-" and it isn't in given mappings , it is an invalid usage so ignore it
                else if (keyStartIndex == 1)
                {
                    continue;
                }
                // Otherwise, use the switch name directly as a key
                else
                {
                    key = currentArg.Substring(keyStartIndex);
                }

                if (!enumerator.MoveNext())
                {
                    // ignore missing values
                    continue;
                }

                value = enumerator.Current;
            }
            else
            {
                string keySegment = currentArg.Substring(0, separator);

                // If the switch is a key in given switch mappings, interpret it
                if (_switchMappings != null && _switchMappings.TryGetValue(keySegment, out string? mappedKeySegment))
                {
                    key = mappedKeySegment;
                }
                // If the switch starts with a single "-" and it isn't in given mappings , it is an invalid usage
                else if (keyStartIndex == 1)
                {
                    throw new FormatException(SR.Format(SR.Error_ShortSwitchNotDefined, currentArg));
                }
                // Otherwise, use the switch name directly as a key
                else
                {
                    key = currentArg.Substring(keyStartIndex, separator - keyStartIndex);
                }

                value = currentArg.Substring(separator + 1);
            }

            // Override value when key is duplicated. So we always have the last argument win.
            data[key] = value;
        }
    }

    Data = data;
}

通过上文我们已经了解了如何添加和解析配置文件。关于配置键的读取和设置也非常简单。在ConfigurationRoot类中,我们看下基于索引的方法进行操作

public string? this[string key]
{
    get => GetConfiguration(_providers, key);
    set => SetConfiguration(_providers, key, value);
}

GetConfiguration() 方法会倒序依次遍历所有的配置提供程序,当获取到key,就会返回结果。(所以后添加的配置文件会覆盖之前的key)

internal static string? GetConfiguration(IList<IConfigurationProvider> providers, string key)
{
    for (int i = providers.Count - 1; i >= 0; i--)
    {
        IConfigurationProvider provider = providers[i];

        if (provider.TryGet(key, out string? value))
        {
            return value;
        }
    }

    return null;
}

SetConfiguration()方法会将每个IConfigurationProvider 中的key,进行修改

internal static void SetConfiguration(IList<IConfigurationProvider> providers, string key, string? value)
{
    foreach (IConfigurationProvider provider in providers)
    {
        provider.Set(key, value);
    }
}

AddJson角度解析

我们现在将从程序中添加 JSON 配置文件并解析配置文件的源码流程进行说明。

builder.Configuration.AddJsonFile("MyConfig.json",
        optional: true, // 文件是否可选
        reloadOnChange: true ); // 如果文件更改,是否重载配置

我们来看 AddJsonFile() 方法,它实际上是使用参数构建了一个 JsonConfigurationSource,然后将其传递给 IConfigurationBuilder。

public static IConfigurationBuilder AddJsonFile(this IConfigurationBuilder builder, IFileProvider? provider, string path, bool optional, bool reloadOnChange)
{
    return builder.AddJsonFile(s =>
    {
        s.FileProvider = provider;// 这个为自动更新提供文件变动监听方法
        s.Path = path;
        s.Optional = optional;
        s.ReloadOnChange = reloadOnChange;
        s.ResolveFileProvider();
    });
}

public static IConfigurationBuilder AddJsonFile(this IConfigurationBuilder builder, Action<JsonConfigurationSource>? configureSource)
    => builder.Add(configureSource);

JsonConfigurationSource 类的构建方法,用于创建和返回一个新的IConfigurationProvider 对象。EnsureDefaults()确保提供了默认值,返回一个具体的解析实例JsonConfigurationProvider

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

FileConfigurationProvider是JsonConfigurationProvider的父类,在构造方法中ChangeToken.OnChange方法来持续监听文件更新。

public FileConfigurationProvider(FileConfigurationSource source)
{
    ThrowHelper.ThrowIfNull(source);

    Source = source;

    if (Source.ReloadOnChange && Source.FileProvider != null)
    {
        _changeTokenRegistration = ChangeToken.OnChange(
            () => Source.FileProvider.Watch(Source.Path!),
            () =>
            {
                Thread.Sleep(Source.ReloadDelay);
                Load(reload: true);
            });
    }
}

JsonConfigurationProvider 方法和上文命令行一样,到了具体的实现。

public class JsonConfigurationProvider : FileConfigurationProvider
{
    public JsonConfigurationProvider(JsonConfigurationSource source) : base(source) { }

    public override void Load(Stream stream)
    {
        try
        {
            Data = JsonConfigurationFileParser.Parse(stream);
        }
        catch (JsonException e)
        {
            throw new FormatException(SR.Error_JSONParseError, e);
        }
    }
}

三、自动更新

简要概述自动更新(热加载)的实现过程基于生产者和消费者的关系。它利用FileProvider(内部使用操作系统的文件变更API,并可选择性地开启轮询机制)来生成文件的变更信息,并通过Load/ReLoad方法来消费和重新加载文件。

IFileProvider的作用

IFileProvider 是一个用于抽象文件系统访问的接口。它允许您使用文件和文件夹的基本操作,例如读取、写入和删除文件。

IFileProvider 接口还支持监视文件系统上的更改。您可以注册一个事件,当文件或目录上发生更改时,将调用回调方法。这对于及时更新应用程序配置非常有用。

.NET Core 内置了许多实现了 IFileProvider 接口的文件访问器,例如 PhysicalFileProvider、EmbeddedFileProvider和CompositeFileProvider。在配置文件中默认的是PhysicalFileProvider。

下边是一个使用PhysicalFileProvider来监听文件更改的Demo:

public static void Main(string[] args)
{
    string path = @"C:\Users\";

    // 生效一次
    PhysicalFileProvider phyFileProvider = new PhysicalFileProvider(path);
    // 订阅文件更改事件
    IChangeToken watcher = phyFileProvider.Watch("*.*");
    watcher.RegisterChangeCallback((state) =>
    {
        Console.WriteLine($"文件发生改变: {state}");
    }, null);


    // 持续生效         
    ChangeToken.OnChange(
        changeTokenProducer: () => phyFileProvider.Watch("*.*"),
        changeTokenConsumer: () => Console.WriteLine($"文件发生改变")
    );

    Console.ReadLine();
}

ChangeToken和IChangeToken的作用

在我们.NET中ChangeToken和IChangeToken是用于实现配置变更通知的机制。它们是用于监视配置更改并触发相应操作的重要组件。

ChangeToken是一个抽象类,用于表示一个令牌,用于检测配置更改。它定义了一种模式,允许订阅者注册在配置更改时接收通知。

public static class ChangeToken
{
  public static IDisposable OnChange<TState>(Func<IChangeToken?> changeTokenProducer, Action<TState> changeTokenConsumer, TState state)
  {
      return new ChangeTokenRegistration<TState>(changeTokenProducer, changeTokenConsumer, state);
  }
}

注册ChangeToken的回调函数

public ChangeTokenRegistration(Func<IChangeToken?> changeTokenProducer, Action<TState> changeTokenConsumer, TState state)
{
    _changeTokenProducer = changeTokenProducer;
    _changeTokenConsumer = changeTokenConsumer;
    _state = state;

    IChangeToken? token = changeTokenProducer();

    RegisterChangeTokenCallback(token);
}

private void RegisterChangeTokenCallback(IChangeToken? token)
{
    if (token is null)
    {
        return;
    }
    IDisposable registraton = token.RegisterChangeCallback(s => ((ChangeTokenRegistration<TState>?)s)!.OnChangeTokenFired(), this);
    if (token.HasChanged && token.ActiveChangeCallbacks)
    {
        registraton?.Dispose();
        return;
    }
    SetDisposable(registraton);
}

IChangeToken是ChangeToken的接口,定义了ChangeToken的行为和功能。它包含一个属性HasChanged,指示令牌是否已更改,以及一个事件RegisterChangeCallback,用于注册当令牌更改时触发的回调函数。

public interface IChangeToken
{
    bool HasChanged { get; }

    bool ActiveChangeCallbacks { get; }

    IDisposable RegisterChangeCallback(Action<object?> callback, object? state);
}

PhysicalFileProvider 的监听方法

PhysicalFileProvider 是 .NET中实现 IFileProvider 接口的一个类,用于访问物理文件系统上的文件和文件夹。它可以在应用程序启动时,自动注册到 DI 容器中。

PhysicalFileProvider 的 Watch 方法用于监视指定路径下的文件和文件夹,以便在更改时自动更新应用程序。它返回了一个 IChangeToken 对象,用于触发更改通知。

public IChangeToken Watch(string filter)
{
    if (filter == null || PathUtils.HasInvalidFilterChars(filter))
    {
        return NullChangeToken.Singleton;
    }

    // Relative paths starting with leading slashes are okay
    filter = filter.TrimStart(_pathSeparators);

    return FileWatcher.CreateFileChangeToken(filter);
}

CreateFileChangeToken方法接收一个字符串参数 filter,表示要监视的文件或文件夹的相对路径。它将返回一个实现了 IChangeToken 接口的对象作为文件更改的通知。

通过调用 GetOrAddChangeToken(filter) 方法,查找与 filter 参数对应的更改令牌 IChangeToken 对象。如果找到一个 IChangeToken 对象,则返回它,否则会创建一个新的更改令牌并添加到缓存中,然后返回它。

当一个文件或文件夹被更改时,FileSystemWatcher(这个阅读有点难度,暂时没有深入研究) 会通知更改令牌 IChangeToken 对象,PollForChanges 属性为 true时,在默认时间间隔内检查文件或文件夹的变化,进而引发更改通知。然后,应用程序可以处理这个更改通知,例如重新加载配置或更新数据源。

public IChangeToken CreateFileChangeToken(string filter)
{
    IChangeToken changeToken = GetOrAddChangeToken(filter);
    return changeToken;
}

GetOrAddWildcardChangeToken 是 PhysicalFileProvider 用于创建通配符监视的 IChangeToken 对象的方法,可在文件或文件夹更改时通知应用程序,并进行必要的更新。

internal IChangeToken GetOrAddWildcardChangeToken(string pattern)
{
    if (!_wildcardTokenLookup.TryGetValue(pattern, out ChangeTokenInfo tokenInfo))
    {
        var cancellationTokenSource = new CancellationTokenSource();
        var cancellationChangeToken = new CancellationChangeToken(cancellationTokenSource.Token);
        var matcher = new Matcher(StringComparison.OrdinalIgnoreCase);
        matcher.AddInclude(pattern);
        tokenInfo = new ChangeTokenInfo(cancellationTokenSource, cancellationChangeToken, matcher);
        tokenInfo = _wildcardTokenLookup.GetOrAdd(pattern, tokenInfo);
    }

    IChangeToken changeToken = tokenInfo.ChangeToken;
    if (PollForChanges)
    {
        // The expiry of CancellationChangeToken is controlled by this type and consequently we can cache it.
        // PollingFileChangeToken on the other hand manages its own lifetime and consequently we cannot cache it.
        var pollingChangeToken = new PollingWildCardChangeToken(_root, pattern);

        if (UseActivePolling)
        {
            pollingChangeToken.ActiveChangeCallbacks = true;
            pollingChangeToken.CancellationTokenSource = new CancellationTokenSource();
            PollingChangeTokens.TryAdd(pollingChangeToken, pollingChangeToken);
        }

        changeToken = new CompositeChangeToken(
            new[]
            {
                changeToken,
                pollingChangeToken,
            });
    }

    return changeToken;
}

现在回顾一下配置文件的自动更新,相信您应该能够理解它了。

四、封装自定义数据源

通过上文对源码的解析,我们已经了解到了 Configuration 组件的工作流程。在封装自定义数据源时,我们不需要关注热加载部分,只需要按照以下步骤进行:

  • 创建实现 IConfigurationSource 接口的类,该类用于定义从数据源读取配置信息的方法。
  • 创建实现 IConfigurationProvider 接口的类,该类用于将配置信息加载到 Data 字典中,并提供获取配置信息的方法。
  • 封装扩展方法,将配置源添加到 ConfigurationBuilder。

如果需要支持热加载功能,还需要相应地实现 IChangeToken 接口和 IOptionsChangeTokenSource 接口。这些接口的实现可以参考官方文档或其他资料。

具体示例请看官方Demo

五、常见问题和解决方案

  • 如何从 JSON 文件中读取数组?

在 JSON 文件中,可以使用 [ ] 符号表示一个数组。如果要将这个数组作为配置项读取,可以使用 GetSection() 方法获取该数组所在的子节点,然后通过 AsEnumerable() 方法将其转换为 IEnumerable<KeyValuePair<string, string>> 类型,并对其中的每个元素进行处理。

  • 如何从环境变量中读取特殊字符?

如果环境变量中包含特殊字符(如 $、:、/ 等),可能会导致解析错误。为了正确地从环境变量中读取特殊字符,可以使用双引号 " 对变量进行引用,例如:"my$envVar"。

  • 如何从命令行参数中读取配置信息?

可以使用 AddCommandLine() 方法将命令行参数添加到 IConfigurationBuilder 中。在使用该方法时,需要指定一个字典,用于将命令行参数映射到配置键。

  • 如何更改已注册的配置源的优先级?

可以使用 Add() 方法按顺序向 IConfigurationBuilder 中添加配置源。越先添加的配置源优先级越高。此外,还可以使用 AddJsonFile().AddEnvironmentVariables() 等方法来指定默认的配置源,并在需要时使用 AddFirst()、AddLast() 等方法将其他配置源添加到特定位置。

  • 如何自定义 IConfigurationProvider 实现?

可以继承 ConfigurationProvider 抽象类,并实现其抽象成员来创建一个新的 IConfigurationProvider。然后,可以通过 Add() 方法将其添加到 IConfigurationBuilder 中,并在需要时进行配置。

六、总结

这篇文章耗时近两周。阅读源码的过程并不算困难,但是将整个过程通过文字进行讲解时,却遇到了如何设计文章结构和顺序的困扰。幸好有GPT的帮助,可以提供语言表达上的支持。接下来,我将努力提升自己的技术水平和写作能力,为我们.NET社区贡献绵薄之力。

如果您觉得这篇文章有所收获,还请点个赞并关注。如果您有宝贵建议,欢迎在评论区留言,非常感谢您的支持!

(也可以关注我的公众号噢:Broder,万分感谢_)

posted @ 2023-05-30 22:57  Broder  阅读(1941)  评论(4编辑  收藏  举报