ASP.NET Core —选项系统

ASP.NET Core — 选项系统

  • 1. 选项
  • 2. 选项配置方式
    • 2.1 手动绑定
    • 2.2 依赖注入配置
      • 2.2.1 配置文件节点转换选项
      • 2.2.1 硬编码配置选项
      • 2.2.3 使用DI服务配置选项
      • 2.2.4 命名选项
      • 2.2.4 后期配置
  • 3. 使用
    • 3.1 IOptions
    • 3.2 IOptionsSnapshot
    • 3.3 IOptionsMonitor
    • 3.4 三个接口的选项读取机制演示
  • 4. 选项验证
    • 4.1 注解验证
    • 4.2 自定义验证逻辑
    • 4.3 IValidateOptions 验证接口
  • 5. 选项系统源码介绍
  • 6. 总结

1. 选项

上一篇讲完了.NET Core 下的配置系统,我们可以通过 IConfiguration 服务从各种来源的配置中读取到配置信息,但是每次要用的时候都通过 IConfiguration 读取配置文件会比较不方便,而且效率低。.NET Core 体系下提供了一个选项系统,该功能用于实现以强类型的方式对程序配置信息进行访问,并且可以将选项类注入到依赖注入容器中进行管理和使用。

在进行配置信息的强类型选项绑定的时候,需要一个相应的选项类,该类推荐按{Object}Options命名规则进行命名,有以下特点:

  • 必须非抽象类
  • 必须包含公共无参的构造函数
  • 类中需要与配置项进行绑定的属性必须拥有public 的 get、set 访问器,并且属性的命名必须与配置键一直,不区分大小写
  • 要确保配置项能够转换到其绑定的属性类型
  • 该类的字段不会被绑定

2. 选项配置方式

2.1 手动绑定

上一篇讲到了 IConfiguration 服务通过 ConfigurationBinder 类扩展了 Get 和 Bind 两个方法,这两个方法可以将配置信息绑定到选项类上。这种方式其实在上一篇配置系统中已经有提过了,这里再做一下演示:

首先在配置文件中添加以下节点:

"Blog": {
    "Title": "ASP.NET Core Options",
    "Content": "This is a blog about Options System in ASP.NET Core Framework.",
    "CreateTime": "2022-12-06"
  }

之后定义一个选项类:

public class BlogOptions
{
    public const string Blog = "Blog";

    public string Title { get; set; }

    public string Content { get; set; }

    public DateTime CreateTime { get; set; }
}

然后,在任何可以获取到 IConfiguration 服务的地方都可以通过 IConfiguration 服务进行绑定:

using OptionsSample.Options;
using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

var blog1 = new BlogOptions();
app.Configuration.GetSection(BlogOptions.Blog).Bind(blog1);
var blog2 = app.Configuration.GetSection(BlogOptions.Blog).Get<BlogOptions>();

Console.WriteLine(JsonSerializer.Serialize(blog1));
Console.WriteLine(JsonSerializer.Serialize(blog2));

app.Run();

在这里插入图片描述

这种方式依旧有些不方便,虽然也有好处,能够监测到配置的修改,在应用运行中同步改变(如果相应的配置处理程序支持变更重载的化),但每次都要指定相应的节点,每次都要实时构建新的选项对象,并且选项系统也能做到配置更改时更新选项。

2.2 依赖注入配置

2.2.1 配置文件节点转换选项

除了手动绑定的方式外,我们还可以在应用启动的时候读取相应的配置节点,配置成Options并同时注册到依赖注入容器中,由依赖注入容器管理其生命周期,并且多次使用。我们可以像注册其他的依赖注入关系一样,在入口文件中通过 IServiceCollection 的 Configure 扩展方法进行配置。

// 通过配置文件读取某一配置节点
builder.Services.Configure<BlogOptions>(builder.Configuration.GetSection(BlogOptions.Blog));

这里通过获取特定的配置节点,并将其配置为选项,之后就可以在任何能够进行依赖注入的地方使用注入的 BlogOptions 选项类了。

var blogOption = app.Services.GetRequiredService<IOptions<BlogOptions>>().Value;
Console.WriteLine(JsonSerializer.Serialize(blogOption));

这里使用到的 Configure 方法是 OptionsConfigurationServiceCollectionExtensions 类中的扩展方法,选项系统中有好几个同名的 Configure 方法,但是这些是并不是同一个方法的重载,下面会详细讲到。

2.2.1 硬编码配置选项

除了从配置文件中读取配置节点转换为选项之外,我们也可以直接在代码中硬编码指定选项内容,并注入到容器之中。

//硬编码的方式设置配置信息,也可以在这里读取数据库信息
builder.Services.Configure<BlogOptions>(option =>
{
    option.Title = "test";
    option.Content = "test hard code option";
    option.CreateTime = DateTime.Now;
});

这种情况用得不多,在这种情况下我们可以进行额外的一些逻辑,例如从数据库中获取一些信息。这里的 Configure 方法是 OptionsServiceCollectionExtensions 扩展类中的方法。值得注意的是,在同时使用上面两个方法配置同一个选项类的情况下,硬编码配置的方式优先。

2.2.3 使用DI服务配置选项

在某些场景下,选项的配置需要比较复杂的逻辑,会依赖容器中的其他服务,我们还可以使用以下方式:

builder.Services.AddOptions<BlogOptions>()
    // 这里可以通过 Configure 方法指定需要的服务, IServiceProvider 只是一个示例
    .Configure<IServiceProvider>((option, service) => // 接收的的第一个参数选项类对象,后面依次是所注入的服务
    {
        // 通过注入的服务执行相应的逻辑

        option.Title = "test DI Configure";
        option.Content = "test DI Configure";
        option.CreateTime = DateTime.Now;
    });

这里的 Configure 方法,与上面的不一样,不再是 IServiceCollection 的扩展方法,而是 OptionsBuilder 类中的方法,AddOptions 扩展方法在 OptionsServiceCollectionExtensions 中,返回一个 OptionsBuilder 对象。该方法有多个重载,最多支持5个服务来配置选项:
ASP.NET Core —选项系统_第1张图片
当使用这种方式和上面的硬编码的方式同时配置同一个选项类的情况下,哪部分代码后执行,最后选项类的内容就以哪部分为准。

2.2.4 命名选项

在一些情况下,应用中是存在多份配置结构相同,但具体配置值不同的配置信息的,例如以下的情况:

"FirstBlog": {
   "Title": "ASP.NET Core Options",
   "Content": "This is a blog about Options System in ASP.NET Core Framework.",
   "CreateTime": "2022-12-06"
 },
 "SecondBlog": {
   "Title": "ASP.NET Core Configuration",
   "Content": "This is a blog about Configuration System in ASP.NET Core Framework.",
   "CreateTime": "2022-12-08"
 }

这种情况下,两个配置节点其实可以用同一个选项类接收,它们的结构是一样的。而命名选项就是为了应对这种情况,选项系统中允许为当前配置的选项命名,而选项系统区分同一个类型的不同选项也是根据名字进行区分的。事实上,.NET Core 中所有 Options 都是命名选项,当没有显式指定名字时(如上面讲到的默认配置方式),使用的名字默认是Options.DefaultName,即string.Empty。

builder.Services.Configure<BlogOptions>("First", builder.Configuration.GetSection("FirstBlog"));
builder.Services.Configure<BlogOptions>("Second", builder.Configuration.GetSection("SecondBlog"));

这里要说明的是,上面我们从依赖注入容器中解析 IOptions 接口,从而获取我们需要的选项值,但是使用命名选项的情况下是无法通过 IOptions 接口解析相应的选项的,必须通过 IOptionsMonitor 或者 IOptionsSnapshot 接口来解析。这三个接口的区别下面会重点讲。

var blog = app.Services.GetRequiredService<IOptions<BlogOptions>>().Value;
Console.WriteLine(JsonSerializer.Serialize(blog));
var firstBlog = app.Services.GetRequiredService<IOptionsMonitor<BlogOptions>>().Get("First");
Console.WriteLine(JsonSerializer.Serialize(firstBlog));
var secondBlog = app.Services.GetRequiredService<IOptionsMonitor<BlogOptions>>().Get("Second");
Console.WriteLine(JsonSerializer.Serialize(secondBlog));

在这里插入图片描述

2.2.4 后期配置

后期配置是指当我们通过前面的方法对一个选项类进行配置之后,可能还会因为其他业务逻辑需要对其中的配置信息进行修改,这时候我们可以通过后期配置对选项系统中已配置的选项内容进行替换。后期配置在所有的OptionsServiceCollectionExtensions.Configure 执行完成之后执行,即配置系统不管代码顺序,会先完成所有选项的配置,再执行后期配置。

builder.Services.Configure<BlogOptions>("First", builder.Configuration.GetSection("FirstBlog"));
builder.Services.PostConfigure<BlogOptions>("First", options =>
{
    options.Title = "Post Config";
});

var firstBlog = app.Services.GetRequiredService<IOptionsMonitor<BlogOptions>>().Get("First");
Console.WriteLine(JsonSerializer.Serialize(firstBlog));

在这里插入图片描述
通过 IOptionsMonitor 或者 IOptionsSnapshot 接口解析选项,在配置来源更改的情况下,相应的选项内容也会随之变化(两者的适用场景不同), 后期配置逻辑在选项的每一次更改之后都会执行。

3. 使用

上面提到 IOptions、IOptionsMonitor 和 IOptionsSnapshot 三个接口,通过这三个接口都可以从依赖注入容器中解析出已经配置的选项类,在我们通过 Configure 方法配置选项时,这三个接口会被同时注册,但三个接口是有区别的,适用场景也有所不同。

3.1 IOptions

  • IOptions 对象的生命周期是 Singleton (单例),它可以在任意地方进行注入使用
  • 该接口对象在第一次使用的时候被实例化,并且选项类中的内容会一直保持不变,前面也提过选项类内容可以在配置来源修改之后更新,但是通过 IOption 解析的选项类不会随着更新而改变
  • IOptions 接口不支持命名选项模式,它是没有 get 方法的,也并不会默认读取第一个,它只能读取 String.Empty 默认命名的选项,如果没有配置默认选项的话,虽然也能解析出Options选项类对象,但是对象的属性都是相应类型的默认值(引用类型是null,值类型是0,其他的也都是相应类型的默认值)
public class OptionController : ControllerBase
{
    private readonly BlogOptions _blogOptions;
    public OptionController(IOptions<BlogOptions> options)
    {
        // 通过 IOptions 接口的 Value 属性读取选项类
        // 选项类始终是程序启动时加载的值,不会改变
        _blogOptions = options.Value;
    }
}

3.2 IOptionsSnapshot

  • IOptionsSnapshot 对象的生命周期是 Scoped(作用域),Scoped 生命周期的特点是不能注入到 Singleton 服务中
  • 在作用域中(最常见的一次Http请求),创建IOptionsSnapshot对象实例时,会从配置中读取最新选项值作为快照,并在当前作用域中始终使用该快照。也就是说一次请求中选项类内容保持不变,但是不同请求中可能因为配置来源的修改而不同
  • IOptionsSnapshot 支持命名选项
public class OptionController : ControllerBase
{
    private readonly BlogOptions _blogOptions;
    public OptionController(IOptionsSnapshot<BlogOptions> optionsSnapshot)
    {
        // IOptionsSnapshot 可以通过 Value 属性读取默认的命名的选项类, Options 对象实例创建时读取的配置快照
        _blogOptions = optionsSnapshot.Value;
        // 也可以通过 Get 方法获取某一个命名选项,没有指定命名时,默认命名为 string.Empty
        //_blogOptions = optionsSnapshot.Get(string.Empty);
    }
}

3.3 IOptionsMonitor

  • IOptionsMonitor 对象的生命周期也是 Singleton (单例)
  • 通过 IOptionsMonitor 接口注入的对象每次读取选项值时,都是从配置中读取最新选项值,能够实时获取配置来源的更改
  • 该接口支持命名选项模式
  • 除了可以查看TOptions的值,还可以监控TOptions配置的更改,支持重新加载配置(CurrentValue),并当配置发生更改时,进行通知(OnChange),支持缓存与缓存失效 (IOptionsMonitorCache),每次调用实例的 CurrentValue 时,会先检查缓存(IOptionsMonitorCache)是否有值,如果有值,则直接用,如果没有,则从配置中读取最新选项值,并记入缓存。当配置发生更改时,会将缓存清空。
public class OptionController : ControllerBase
{
    private readonly BlogOptions _blogOptions;
    public OptionController(IOptionsMonitor<BlogOptions> optionsMonitor)
    {
        // IOptionsMonitor 接口没有 Value 属性,通过 CurrentValue 获取选项类对象,
        // 每次调用 CurrentValue都会实时读取配置源,始终是最新配置的值
        _blogOptions = optionsMonitor.CurrentValue;
        // 该接口也支持通过 Get 方法获取命名选项
        _blogOptions = optionsMonitor.Get(string.Empty);
        // 可以通过 OnChange 注册事件,当配置被加载时会触发事件
        optionsMonitor.OnChange(OnOptionsChange);
    }

    [HttpGet]
    public Task<BlogOptions> Get()
    {
        return Task.FromResult(_blogOptions);
    }

    private void OnOptionsChange(BlogOptions options)
    {
        Console.WriteLine(JsonSerializer.Serialize(options));
    }
}

启动应用,调用一次 Get 接口,在 Api 控制器构造函数中注册了配置加载触发事件,之后修改appsettings.json配置文件中选项类对于的配置节点内容,可以看到事件触发,控制台中输出了改变之后的选项类内容。
在这里插入图片描述

3.4 三个接口的选项读取机制演示

三个接口解析的选项类的差别,可以通过以下测试清楚得看出:

配置文件中初始选项节点如下:

"Blog": {
  "Title": "ASP.NET Core Options11",
  "Content": "This is a blog about Options System in ASP.NET Core Framework.",
  "CreateTime": "2022-12-06"
}

这里为了方便看出 Scoped 生命周期 IOptionSnapeshoot 接口的变化,所有通过 Web Api 接口来测试

public class OptionController : ControllerBase
{
    private readonly IOptions<BlogOptions> _blogOptions;
    private readonly IOptionsSnapshot<BlogOptions> _blogSnapshotOptions;
    private readonly IOptionsMonitor<BlogOptions> _blogMonitorOptions;
    public OptionController(
        IOptions<BlogOptions> options,
        IOptionsSnapshot<BlogOptions> optionsSnapshot,
        IOptionsMonitor<BlogOptions> optionsMonitor
        )
    {
// 注意这里不能再把选项类对象先读取出来,否则选项类对象也不会再改变了
        _blogOptions = options;
        _blogSnapshotOptions = optionsSnapshot;
        _blogMonitorOptions = optionsMonitor;
    }

    [HttpGet]
    public Task Get()
    {
        Console.WriteLine("第一次读取配置:");
        Console.WriteLine("IOptions:" + JsonSerializer.Serialize(_blogOptions.Value));
        Console.WriteLine("IOptionsSnapshot:" + JsonSerializer.Serialize(_blogSnapshotOptions.Value));
        Console.WriteLine("IOptionsMonitor:" + JsonSerializer.Serialize(_blogMonitorOptions.CurrentValue));

        Console.WriteLine("请修改配置文件!");
        Console.ReadKey();

        Console.WriteLine("第二次读取配置:");
        Console.WriteLine("IOptions:" + JsonSerializer.Serialize(_blogOptions.Value));
        Console.WriteLine("IOptionsSnapshot:" + JsonSerializer.Serialize(_blogSnapshotOptions.Value));
        Console.WriteLine("IOptionsMonitor:" + JsonSerializer.Serialize(_blogMonitorOptions.CurrentValue));

        return Task.CompletedTask;
    }
}

之后启动应用调用 Get 接口,并在过程中将配置文件内容修改为以下:

	"Blog": {
	    "Title": "ASP.NET Core Options222",
	    "Content": "This is a blog about Options System in ASP.NET Core Framework.",
	    "CreateTime": "2022-12-06"
	  }

可以看到控制台的输出中,第二次读取配置的时候,IOptionsMonitor 接口读取到的内容已经改变

ASP.NET Core —选项系统_第2张图片
之后不要关闭应用,再调一次 Get 接口,并再次修改配置如下:

ASP.NET Core —选项系统_第3张图片
可以看到 IOptionsMonitor 接口每次都能获取到配置文件的实时值,IOptionsSnapshot 接口相较于第一次调用 Get 接口的时候已经改变,获取到了之前修改的值,但是之后的修改它又获取不到了,因为它是 Scoped 生命周期,在一次请求内是保持一致的,而 IOptions 接口获取到的选项类对象是一致不变的。

4. 选项验证

就像 Web Api 接口可以对入参进行验证,避免用户传入非法的或者不符合我们预期的参数一样,选项也可以对配置源的内容进行验证,避免配置中的值与选项类中的属性不对应或者不满足预期,毕竟大部分配置都是通过字符串的方式,验证是很有必要的。

4.1 注解验证

像入参验证一样,选项验证也可以通过特性注解方便地对选项类中的某个属性进行验证,这种是最简单便捷的方式。使用选项标签注解验证,需要引入 Microsoft.Extensions.Options.DataAnnotations Nuget 包。

在选项类中通过以下方式添加数据验证规则:

public class BlogOptions
{
    public const string Blog = "Blog";

    [StringLength(10, ErrorMessage = "Title is too long. {0} Length <= {1}")]
    public string Title { get; set; }

    public string Content { get; set; }

    public DateTime CreateTime { get; set; }
}

之后在进行选项类配置的时候就不能直接使用 Configure 方法了,而是要用以下方式:

builder.Services.AddOptions<BlogOptions>()
    .Bind(builder.Configuration.GetSection(BlogOptions.Blog))
    .ValidateDataAnnotations();

4.2 自定义验证逻辑

预定义的数据注解毕竟有限,在某些验证逻辑比较复杂的情况下,数据注解可能并不能完全满足我们的需求,我们可以通过 OptionsBuilder 类中的 Validate 方法传入一个委托来实现自己的验证逻辑。

builder.Services.AddOptions<BlogOptions>()
    .Bind(builder.Configuration.GetSection(BlogOptions.Blog))
    .Validate(options =>
    {
        // 标题中不能包含特殊字符
        if (options.Title.Contains("eval"))
        {
            // 验证失败
            return false;
        }
        // 验证通过
        return true;
    });

4.3 IValidateOptions 验证接口

如果逻辑更加复杂,通过 Validate 方法会导致代码臃肿,不好管理和维护,这时候我们可以通过 IValidateOptions 接口实现相应的选项验证类。

public class BlogValidation : IValidateOptions<BlogOptions>
{
    public ValidateOptionsResult Validate(string name, BlogOptions options)
    {
        var failures = new List<string>();
        if(options.Title.Length > 100)
        {
            failures.Add($"博客标题长度不能超过100个字符。");
        }
        if(options.Content.Length > 10000)
        {
            failures.Add($"博客内容太长,不能超过10000字。");
        }
        if (failures.Any())
        {
            return ValidateOptionsResult.Fail(failures);
        }
        return ValidateOptionsResult.Success;
    }
}

然后将其注入到依赖注入容器中,可以同时注入针对同一个选项类的验证逻辑类,这些验证类都会被调用,只有全部验证逻辑通过才能正常配置。

builder.Services.Configure<BlogOptions>(builder.Configuration.GetSection(BlogOptions.Blog));
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<BlogOptions>, BlogValidation>());

5. 选项系统源码介绍

.NET Core 选项系统的主要实现在 Microsoft.Extensions.Options 和 Microsoft.Extensions.Options.ConfigurationExtensions 两个 Nuget 包。对于一个框架的源码进行解读,我们可以从我们常用的框架中的类或方法入手,这些类或方法就是我们解读的入口。

从上面对选项系统的介绍中,大家也可以看出,日常对选项系统的使用涉及到的主要有 Configure 方法,有 IOptions、IOptionsSnapshot、IOptionMonitor 等接口。

Configure

首先看选项注册,也就是 Configure 方法,注册相关的方法都是扩展方法,上面也讲到 Configure 方法有多个扩展来源,其中最常用的是 OptionsConfigurationServiceCollectionExtensions 中的 Configure 方法,该方法用于从配置信息中读取配置并绑定为选项,如下,这里将相应的方法单独摘出来了。

public static class OptionsConfigurationServiceCollectionExtensions
{
    /// 
    /// Registers a configuration instance which TOptions will bind against.
    /// 
    /// The type of options being configured.
    /// The  to add the services to.
    /// The configuration being bound.
    /// The  so that additional calls can be chained.
    [RequiresUnreferencedCode(OptionsBuilderConfigurationExtensions.TrimmingRequiredUnreferencedCodeMessage)]
    public static IServiceCollection Configure<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TOptions>(this IServiceCollection services, IConfiguration config) where TOptions : class
        => services.Configure<TOptions>(Options.Options.DefaultName, config);

    /// 
    /// Registers a configuration instance which TOptions will bind against.
    /// 
    /// The type of options being configured.
    /// The  to add the services to.
    /// The name of the options instance.
    /// The configuration being bound.
    /// The  so that additional calls can be chained.
    [RequiresUnreferencedCode(OptionsBuilderConfigurationExtensions.TrimmingRequiredUnreferencedCodeMessage)]
    public static IServiceCollection Configure<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TOptions>(this IServiceCollection services, string name, IConfiguration config) where TOptions : class
        => services.Configure<TOptions>(name, config, _ => { });

    /// 
    /// Registers a configuration instance which TOptions will bind against.
    /// 
    /// The type of options being configured.
    /// The  to add the services to.
    /// The configuration being bound.
    /// Used to configure the .
    /// The  so that additional calls can be chained.
    [RequiresUnreferencedCode(OptionsBuilderConfigurationExtensions.TrimmingRequiredUnreferencedCodeMessage)]
    public static IServiceCollection Configure<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TOptions>(this IServiceCollection services, IConfiguration config, Action<BinderOptions> configureBinder)
        where TOptions : class
        => services.Configure<TOptions>(Options.Options.DefaultName, config, configureBinder);

    /// 
    /// Registers a configuration instance which TOptions will bind against.
    /// 
    /// The type of options being configured.
    /// The  to add the services to.
    /// The name of the options instance.
    /// The configuration being bound.
    /// Used to configure the .
    /// The  so that additional calls can be chained.
    [RequiresUnreferencedCode(OptionsBuilderConfigurationExtensions.TrimmingRequiredUnreferencedCodeMessage)]
    public static IServiceCollection Configure<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TOptions>(this IServiceCollection services, string name, IConfiguration config, Action<BinderOptions> configureBinder)
        where TOptions : class
    {
        if (services == null)
        {
            throw new ArgumentNullException(nameof(services));
        }

        if (config == null)
        {
            throw new ArgumentNullException(nameof(config));
        }

        services.AddOptions();
        services.AddSingleton<IOptionsChangeTokenSource<TOptions>>(new ConfigurationChangeTokenSource<TOptions>(name, config));
        return services.AddSingleton<IConfigureOptions<TOptions>>(new NamedConfigureFromConfigurationOptions<TOptions>(name, config, configureBinder));
    }
}

其中 IOptionsChangeTokenSource 接口是用来监听配置变化的服务。

另外还有 OptionsServiceCollectionExtensions 中的 Configure 方法,用于直接通过委托对选项类进行配置。

public static class OptionsServiceCollectionExtensions
{

	public static IServiceCollection Configure<TOptions>(this IServiceCollection services, Action<TOptions> configureOptions) where TOptions : class
	            => services.Configure(Options.Options.DefaultName, configureOptions);
	
	public static IServiceCollection Configure<TOptions>(this IServiceCollection services, string name, Action<TOptions> configureOptions)
        where TOptions : class
    {
        if (services == null)
        {
            throw new ArgumentNullException(nameof(services));
        }

        if (configureOptions == null)
        {
            throw new ArgumentNullException(nameof(configureOptions));
        }

        services.AddOptions();
        services.AddSingleton<IConfigureOptions<TOptions>>(new ConfigureNamedOptions<TOptions>(name, configureOptions));
        return services;
   }
}

可以看出,其实选项系统中的选项都是命名模式的,默认名称为 Options.DefaultName,实际就是 string.Empty。当我们调用 Configure 方法对选项进行配置的时候,实际上时调用了 AddOptions 方法,并且往容器中添加了一个单例的实现了 IConfigureOptions 接口的实现。

IConfigureOptions、IConfigureNamedOptions、IPostConfigureOptions

其中 IConfigureOptions 是选项配置行为服务接口,ConfigureOptions 是它的默认实现,该类的内容很简单,它的内部主要就是保存了一个委托,用于记录使用者对选项的配置操作,。

public class ConfigureOptions<TOptions> : IConfigureOptions<TOptions> where TOptions : class
{
    /// 
    /// Constructor.
    /// 
    /// The action to register.
    public ConfigureOptions(Action<TOptions> action)
    {
        Action = action;
    }

    /// 
    /// The configuration action.
    /// 
    public Action<TOptions> Action { get; }

    /// 
    /// Invokes the registered configure .
    /// 
    /// The options instance to configure.
    public virtual void Configure(TOptions options)
    {
        if (options == null)
        {
            throw new ArgumentNullException(nameof(options));
        }

        Action?.Invoke(options);
    }
}

IConfigureNamedOptions 继承了 IConfigureNamedOptions 接口,默认实现是 ConfigureNamedOptions ,作用一样,只不过多了一个方法用于应对命名选项模式。它有多个重载泛型重载,也是前面讲到的“使用DI服务配置选项”的具体实现。

public class ConfigureNamedOptions<TOptions> : IConfigureNamedOptions<TOptions> where TOptions : class
{
    /// 
    /// Constructor.
    /// 
    /// The name of the options.
    /// The action to register.
    public ConfigureNamedOptions(string name, Action<TOptions> action)
    {
        Name = name;
        Action = action;
    }

    /// 
    /// The options name.
    /// 
    public string Name { get; }

    /// 
    /// The configuration action.
    /// 
    public Action<TOptions> Action { get; }

    /// 
    /// Invokes the registered configure  if the  matches.
    /// 
    /// The name of the options instance being configured.
    /// The options instance to configure.
    public virtual void Configure(string name, TOptions options)
    {
        if (options == null)
        {
            throw new ArgumentNullException(nameof(options));
        }

        // Null name is used to configure all named options.
        if (Name == null || name == Name)
        {
            Action?.Invoke(options);
        }
    }

    /// 
    /// Invoked to configure a  instance with the .
    /// 
    /// The options instance to configure.
    public void Configure(TOptions options) => Configure(Options.DefaultName, options);
}

而 NamedConfigureFromConfigurationOptions 类是 IConfigureNamedOptions 的另一个实现,继承了ConfigureNamedOptions 类,重写了一些行为,最终是通过之前讲到的 ConfigurationBuilder的 Bind 方法将配置绑定到选项类而已。

public class NamedConfigureFromConfigurationOptions<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TOptions> : ConfigureNamedOptions<TOptions>
    where TOptions : class
{
    /// 
    /// Constructor that takes the  instance to bind against.
    /// 
    /// The name of the options instance.
    /// The  instance.
    [RequiresUnreferencedCode(OptionsBuilderConfigurationExtensions.TrimmingRequiredUnreferencedCodeMessage)]
    public NamedConfigureFromConfigurationOptions(string name, IConfiguration config)
        : this(name, config, _ => { })
    { }

    /// 
    /// Constructor that takes the  instance to bind against.
    /// 
    /// The name of the options instance.
    /// The  instance.
    /// Used to configure the .
    [RequiresUnreferencedCode(OptionsBuilderConfigurationExtensions.TrimmingRequiredUnreferencedCodeMessage)]
    public NamedConfigureFromConfigurationOptions(string name, IConfiguration config, Action<BinderOptions> configureBinder)
        : base(name, options => BindFromOptions(options, config, configureBinder))
    {
        if (config == null)
        {
            throw new ArgumentNullException(nameof(config));
        }
    }

    [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode",
        Justification = "The only call to this method is the constructor which is already annotated as RequiresUnreferencedCode.")]
    private static void BindFromOptions(TOptions options, IConfiguration config, Action<BinderOptions> configureBinder) => config.Bind(options, configureBinder);
}

其他的 IPostConfigureOptions 接口也是一样套路,当我们通过相应的方法传入委托对选项类进行配置的时候,会向容器中注入一个单例服务,将配置行为保存起来。

AddOptions

接着往下看 AddOptions 方法,AddOptions 方法有两个重载:

public static class OptionsServiceCollectionExtensions
{
	public static IServiceCollection AddOptions(this IServiceCollection services)
    {
        if (services == null)
        {
            throw new ArgumentNullException(nameof(services));
        }

        services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptions<>), typeof(UnnamedOptionsManager<>)));
        services.TryAdd(ServiceDescriptor.Scoped(typeof(IOptionsSnapshot<>), typeof(OptionsManager<>)));
        services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptionsMonitor<>), typeof(OptionsMonitor<>)));
        services.TryAdd(ServiceDescriptor.Transient(typeof(IOptionsFactory<>), typeof(OptionsFactory<>)));
        services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptionsMonitorCache<>), typeof(OptionsCache<>)));
        return services;
    }
	public static OptionsBuilder<TOptions> AddOptions<TOptions>(this IServiceCollection services, string name)
        where TOptions : class
    {
        if (services == null)
        {
            throw new ArgumentNullException(nameof(services));
        }

        services.AddOptions();
        return new OptionsBuilder<TOptions>(services, name);
    }
}

这里可以看出两者的返回值不同,而且第二个方法也调用了第一个方法,第一个方法中主要就是向容器中添加我们常用的 IOptions、IOptionsSnapshot、IOptionsMonitor 服务接口,这里也可以看到不同服务接口对于的生命周期。除此之外还有工厂服务 IOptionsFactory<>和缓存服务 IOptionsMonitorCache<>,这两个就是选项体系的关键。每个选项进行配置的时候都会同时注入这些服务,所以每一个选项我们都能使用三个不同接口去解析。

OptionsBuilder

上面第二个 AddOptions 方法返回 OptionsBuilder 对象。之前讲过 OptionsBuilder 类中也有 Configure 方法,其实不止 Configure 方法,其他的 PostConfigure 方法等也有,它其实就是最终的选项系统配置类,我们所有的选项配置其实都可以通过调用第二个 AddOptions 方法,再通过 OptionsBuilder 对象中的方法来完成配置。其他各个扩展方法的配置方式不过是进行了使用简化而已。

public class OptionsBuilder<TOptions> where TOptions : class
{
    private const string DefaultValidationFailureMessage = "A validation error has occurred.";
    
    public string Name { get; }
    
    public IServiceCollection Services { get; }
    
    public OptionsBuilder(IServiceCollection services, string name)
    {
        Services = services;
        Name = name ?? Options.DefaultName;
    }
    
    public virtual OptionsBuilder<TOptions> Configure(Action<TOptions> configureOptions)
    {
        Services.AddSingleton<IConfigureOptions<TOptions>>(new ConfigureNamedOptions<TOptions>(Name, configureOptions));
        return this;
    }
    
    public virtual OptionsBuilder<TOptions> PostConfigure(Action<TOptions> configureOptions)
    {
        Services.AddSingleton<IPostConfigureOptions<TOptions>>(new PostConfigureOptions<TOptions>(Name, configureOptions));
        return this;
    }
    
    public virtual OptionsBuilder<TOptions> Validate(Func<TOptions, bool> validation)
        => Validate(validation: validation, failureMessage: DefaultValidationFailureMessage);
        
    public virtual OptionsBuilder<TOptions> Validate(Func<TOptions, bool> validation, string failureMessage)
    {
        Services.AddSingleton<IValidateOptions<TOptions>>(new ValidateOptions<TOptions>(Name, validation, failureMessage));
        return this;
    }
}

IValidateOptions

我们除了可以对选项进行配置绑定之外,还可以对选项进行验证。验证规则是通过上面的第二个 AddOptions 方法返回的 OptionsBuilder 方法进行添加的。

验证规则配置有三种方式,最后其实都是通过 IValidateOptions 的实现类来完成。我们自己实现的自定义验证类就不用说了,最后我们会将其注入到容器中,而从上面的代码中可以看到,当我们通过委托的方式自定义验证规则的时候,它会被构建成一个 ValidateOptions 类对象,并注入到容器中作为一个服务。

ValidateOptions 是 IValidateOptions 的一个实现类,构造函数中接收委托,通过委托返回的 bool 结构判断验证是否通过。

public class ValidateOptions<TOptions> : IValidateOptions<TOptions> where TOptions : class
{
    /// 
    /// Constructor.
    /// 
    /// Options name.
    /// Validation function.
    /// Validation failure message.
    public ValidateOptions(string name, Func<TOptions, bool> validation, string failureMessage)
    {
        Name = name;
        Validation = validation;
        FailureMessage = failureMessage;
    }

    /// 
    /// The options name.
    /// 
    public string Name { get; }

    /// 
    /// The validation function.
    /// 
    public Func<TOptions, bool> Validation { get; }

    /// 
    /// The error to return when validation fails.
    /// 
    public string FailureMessage { get; }

    /// 
    /// Validates a specific named options instance (or all when  is null).
    /// 
    /// The name of the options instance being validated.
    /// The options instance.
    /// The  result.
    public ValidateOptionsResult Validate(string name, TOptions options)
    {
        // null name is used to configure all named options
        if (Name == null || name == Name)
        {
            if ((Validation?.Invoke(options)).Value)
            {
                return ValidateOptionsResult.Success;
            }
            return ValidateOptionsResult.Fail(FailureMessage);
        }

        // ignored if not validating this instance
        return ValidateOptionsResult.Skip;
    }
}

我们可以通过重载方法传入相应的验证失败提醒文本。

IOptions、UnnamedOptionsManager

接下来看选项使用相关的内容,其中 IOptions 中的选项类一经创建一直保持不变,默认实现类 UnnamedOptionsManager。

internal sealed class UnnamedOptionsManager<[DynamicallyAccessedMembers(Options.DynamicallyAccessedMembers)] TOptions> :
    IOptions<TOptions>
    where TOptions : class
{
    private readonly IOptionsFactory<TOptions> _factory;
    private volatile object _syncObj;
    private volatile TOptions _value;

    public UnnamedOptionsManager(IOptionsFactory<TOptions> factory) => _factory = factory;

    public TOptions Value
    {
        get
        {
            if (_value is TOptions value)
            {
                return value;
            }

            lock (_syncObj ?? Interlocked.CompareExchange(ref _syncObj, new object(), null) ?? _syncObj)
            {
                return _value ??= _factory.Create(Options.DefaultName);
            }
        }
    }
}

IOptions 接口只有一个 Value 属性,实现类中通过锁确保创建的 Value 值不会因为线程问题导致不同,且该服务被注册为单例生命周期,所以对象不销毁,后续一直会读取内存中的 Value 值。具体选项类对象的创建由工厂服务负责。

IOptionsSnapshot、OptionsManager

IOptionsSnapshot 的实现类是 OptionsManager,该类中有一个私有的 OptionsCache 属性,每次对选项类进行读取的时候,都是先尝试从缓存读取,如果没有才创建。而由于 IOptionsSnapshot 被注册为请求域生命周期,所以单次请求内相应对象不会销毁,缓存不会清空,会一直保持一个。

public class OptionsManager<[DynamicallyAccessedMembers(Options.DynamicallyAccessedMembers)] TOptions> :
    IOptions<TOptions>,
    IOptionsSnapshot<TOptions>
    where TOptions : class
{
    private readonly IOptionsFactory<TOptions> _factory;
    private readonly OptionsCache<TOptions> _cache = new OptionsCache<TOptions>(); // Note: this is a private cache

    /// 
    /// Initializes a new instance with the specified options configurations.
    /// 
    /// The factory to use to create options.
    public OptionsManager(IOptionsFactory<TOptions> factory)
    {
        _factory = factory;
    }

    /// 
    /// The default configured  instance, equivalent to Get(Options.DefaultName).
    /// 
    public TOptions Value => Get(Options.DefaultName);

    /// 
    /// Returns a configured  instance with the given .
    /// 
    public virtual TOptions Get(string name)
    {
        name = name ?? Options.DefaultName;

        if (!_cache.TryGetValue(name, out TOptions options))
        {
            // Store the options in our instance cache. Avoid closure on fast path by storing state into scoped locals.
            IOptionsFactory<TOptions> localFactory = _factory;
            string localName = name;
            options = _cache.GetOrAdd(name, () => localFactory.Create(localName));
        }

        return options;
    }
}

IOptionsMonitor、OptionsMonitor

IOptionsMonitor 每次获取选项类都是最新的值,它实现类是 OptionsMonitor,实现类中使用了从容器中注入的单例缓存 IOptionsMonitorCache 来保存选项类,并且通过相应的 IOptionsChangeTokenSource 注册了选项类绑定内容的监听,例如上面讲到的 ConfigurationChangeTokenSource,在选项类配置内容改变的时候会触发事件,而在事件中会将缓存先清除并重新获取创建类,并且执行注册进来的额外的监听事件,可以看看下面的 InvokeChanged 方法。

public class OptionsMonitor<[DynamicallyAccessedMembers(Options.DynamicallyAccessedMembers)] TOptions> :
    IOptionsMonitor<TOptions>,
    IDisposable
    where TOptions : class
{
    private readonly IOptionsMonitorCache<TOptions> _cache;
    private readonly IOptionsFactory<TOptions> _factory;
    private readonly List<IDisposable> _registrations = new List<IDisposable>();
    internal event Action<TOptions, string> _onChange;

    /// 
    /// Constructor.
    /// 
    /// The factory to use to create options.
    /// The sources used to listen for changes to the options instance.
    /// The cache used to store options.
    public OptionsMonitor(IOptionsFactory<TOptions> factory, IEnumerable<IOptionsChangeTokenSource<TOptions>> sources, IOptionsMonitorCache<TOptions> cache)
    {
        _factory = factory;
        _cache = cache;

        void RegisterSource(IOptionsChangeTokenSource<TOptions> source)
        {
            IDisposable registration = ChangeToken.OnChange(
                      () => source.GetChangeToken(),
                      (name) => InvokeChanged(name),
                      source.Name);

            _registrations.Add(registration);
        }

        // The default DI container uses arrays under the covers. Take advantage of this knowledge
        // by checking for an array and enumerate over that, so we don't need to allocate an enumerator.
        if (sources is IOptionsChangeTokenSource<TOptions>[] sourcesArray)
        {
            foreach (IOptionsChangeTokenSource<TOptions> source in sourcesArray)
            {
                RegisterSource(source);
            }
        }
        else
        {
            foreach (IOptionsChangeTokenSource<TOptions> source in sources)
            {
                RegisterSource(source);
            }
        }
    }

    private void InvokeChanged(string name)
    {
        name = name ?? Options.DefaultName;
        _cache.TryRemove(name);
        TOptions options = Get(name);
        if (_onChange != null)
        {
            _onChange.Invoke(options, name);
        }
    }

    /// 
    /// The present value of the options.
    /// 
    public TOptions CurrentValue
    {
        get => Get(Options.DefaultName);
    }

    /// 
    /// Returns a configured  instance with the given .
    /// 
    public virtual TOptions Get(string name)
    {
        name = name ?? Options.DefaultName;
        return _cache.GetOrAdd(name, () => _factory.Create(name));
    }

    /// 
    /// Registers a listener to be called whenever  changes.
    /// 
    /// The action to be invoked when  has changed.
    /// An  which should be disposed to stop listening for changes.
    public IDisposable OnChange(Action<TOptions, string> listener)
    {
        var disposable = new ChangeTrackerDisposable(this, listener);
        _onChange += disposable.OnChange;
        return disposable;
    }

    /// 
    /// Removes all change registration subscriptions.
    /// 
    public void Dispose()
    {
        // Remove all subscriptions to the change tokens
        foreach (IDisposable registration in _registrations)
        {
            registration.Dispose();
        }

        _registrations.Clear();
    }

    internal sealed class ChangeTrackerDisposable : IDisposable
    {
        private readonly Action<TOptions, string> _listener;
        private readonly OptionsMonitor<TOptions> _monitor;

        public ChangeTrackerDisposable(OptionsMonitor<TOptions> monitor, Action<TOptions, string> listener)
        {
            _listener = listener;
            _monitor = monitor;
        }

        public void OnChange(TOptions options, string name) => _listener.Invoke(options, name);

        public void Dispose() => _monitor._onChange -= OnChange;
    }
}

OnChange 方法中传入的委托本来可以可以直接追加到事件中的,这里将其再包装多一层,是为了 OptionsMonitor 对象销毁的时候能够将相应的事件释放,如果不包装多一层的话,委托只在方法作用域中,对象释放的时候是获取不到的。

IOptionsMonitorCache、OptionsCache

OptionsCache 是 IOptionsMonitorCache 接口的的实现类,从上面可以看到 OptionsMonitor 和 OptionsSnapshot 都使用到了这个,OptionsSnapshot 通过内部创建的私有的缓存属性实现了请求域内选项类不变,而 OptionsMonitor 则通过它减少了每次都直接读取配置来源(如文件、数据库、配置中心api)的性能消耗,而是通过变更事件的方式进行更新。其实我们还可以在需要的时候注入 IOptionsMonitorCache 服务自行对选项类进行更新。

OptionsCache 的具体实现比较简单,主要就是通过 ConcurrentDictionary 对象作为内存缓存,其中为了性能还再使用了 Lazy 方式。

IOptionsFactory、OptionsFactory

OptionsFactory 类实现 IOptionsFactory 接口,是选项类的实际创建配置之处,其实就是将之前注册到容器中与当前相关的各种配置、验证的行为配置类注入进来,再通过放射创建对象之后,将选项类对象传进去,逐一对相应的行为进行调用,最后得到一个成型的选项类。这里选项类的创建方式很简单,这也是要求选项类要有无参构造函数的原因。

public class OptionsFactory<[DynamicallyAccessedMembers(Options.DynamicallyAccessedMembers)] TOptions> :
    IOptionsFactory<TOptions>
    where TOptions : class
{
    private readonly IConfigureOptions<TOptions>[] _setups;
    private readonly IPostConfigureOptions<TOptions>[] _postConfigures;
    private readonly IValidateOptions<TOptions>[] _validations;

    /// 
    /// Initializes a new instance with the specified options configurations.
    /// 
    /// The configuration actions to run.
    /// The initialization actions to run.
    public OptionsFactory(IEnumerable<IConfigureOptions<TOptions>> setups, IEnumerable<IPostConfigureOptions<TOptions>> postConfigures) : this(setups, postConfigures, validations: Array.Empty<IValidateOptions<TOptions>>())
    { }

    /// 
    /// Initializes a new instance with the specified options configurations.
    /// 
    /// The configuration actions to run.
    /// The initialization actions to run.
    /// The validations to run.
    public OptionsFactory(IEnumerable<IConfigureOptions<TOptions>> setups, IEnumerable<IPostConfigureOptions<TOptions>> postConfigures, IEnumerable<IValidateOptions<TOptions>> validations)
    {
        // The default DI container uses arrays under the covers. Take advantage of this knowledge
        // by checking for an array and enumerate over that, so we don't need to allocate an enumerator.
        // When it isn't already an array, convert it to one, but don't use System.Linq to avoid pulling Linq in to
        // small trimmed applications.

        _setups = setups as IConfigureOptions<TOptions>[] ?? new List<IConfigureOptions<TOptions>>(setups).ToArray();
        _postConfigures = postConfigures as IPostConfigureOptions<TOptions>[] ?? new List<IPostConfigureOptions<TOptions>>(postConfigures).ToArray();
        _validations = validations as IValidateOptions<TOptions>[] ?? new List<IValidateOptions<TOptions>>(validations).ToArray();
    }

    /// 
    /// Returns a configured  instance with the given .
    /// 
    public TOptions Create(string name)
    {
        TOptions options = CreateInstance(name);
        foreach (IConfigureOptions<TOptions> setup in _setups)
        {
            if (setup is IConfigureNamedOptions<TOptions> namedSetup)
            {
                namedSetup.Configure(name, options);
            }
            else if (name == Options.DefaultName)
            {
                setup.Configure(options);
            }
        }
        foreach (IPostConfigureOptions<TOptions> post in _postConfigures)
        {
            post.PostConfigure(name, options);
        }

        if (_validations != null)
        {
            var failures = new List<string>();
            foreach (IValidateOptions<TOptions> validate in _validations)
            {
                ValidateOptionsResult result = validate.Validate(name, options);
                if (result is not null && result.Failed)
                {
                    failures.AddRange(result.Failures);
                }
            }
            if (failures.Count > 0)
            {
                throw new OptionsValidationException(name, typeof(TOptions), failures);
            }
        }

        return options;
    }

    /// 
    /// Creates a new instance of options type
    /// 
    protected virtual TOptions CreateInstance(string name)
    {
        return Activator.CreateInstance<TOptions>();
    }
}

以上就是 .NET Core 下的选项系统,由于选项系统的源码不多,这里也就将大部分类都拿出来讲了一下,相当于把这个框架的流程思路都讲了一遍,不知不觉写得字数又很多了,希望有童鞋能够耐心地看到这里。

以下是一个总结,引用自 理解ASP.NET Core - 选项(Options) ,作者概括得很好,这里就直接引用了,文章中的一些内容也是借鉴了它。

6. 总结

  • 所有选项均为命名选项,默认名称为Options.DefaultName,即string.Empty。
  • 通过ConfigurationBinder.Get或ConfigurationBinder.Bind手动获取选项实例。
  • 通过Configure方法进行选项配置:
    • OptionsBuilder.Configure:通过包含DI服务的委托来进行选项配置
    • OptionsServiceCollectionExtensions.Configure:通过简单委托来进行选项配置
    • OptionsConfigurationServiceCollectionExtensions.Configure:直接将IConfiguration实例绑定到选项上
  • 通过OptionsServiceCollectionExtensions.ConfigureAll方法针对某个选项类型的所有实例(不同名称)统一进行配置。
  • 通过PostConfigure方法进行选项后期配置:
    • OptionsBuilder.PostConfigure:通过包含DI服务的委托来进行选项后期配置
    • OptionsServiceCollectionExtensions.PostConfigure:通过简单委托来进行选项后期配置
  • 通过PostConfigureAll方法针对某个选项类型的所有实例(不同名称)统一进行配置。
  • 通过Validate进行选项验证:
    • OptionsBuilderDataAnnotationsExtensions.ValidateDataAnnotations:通过数据注解进行选项验证
    • OptionsBuilder.Validate:通过委托进行选项验证
    • IValidateOptions:通过实现该接口并注入实现来进行选项验证
  • 通过依赖注入读取选项:
    • IOptions:Singleton,值永远是该接口被实例化时的选项配置初始值
    • IOptionsSnapshot:Scoped,每一次Http请求开始时会读取选项配置的最新值,并在当前请求中保持不变
    • IOptionsMonitor:Singleton,每次读取都是选项配置的最新值


参考文章:

ASP.NET Core 中的选项模式 | Microsoft Learn
选项模式 - .NET | Microsoft Learn
面向 .NET 库创建者的选项模式指南 - .NET | Microsoft Learn
理解ASP.NET Core - 选项(Options)


ASP.NET Core 系列总结:
上一篇:ASP.NET Core — 配置系统

你可能感兴趣的:(ASP.NET,CORE,系列总结,asp.net,后端,.netcore,c#)