【.NET Core框架】选项模式(Options)

简介

.net core中有两种读取配置的方式:

  • 通过注入IConfiguration读取配置
  • 通过强类型的Options,通过注入来获取到配置对象。
    Options类的命名规则:{Object}Options

建议使用强类型的Options,这样在你想获取某个配置时,只需要注入对应的Options,而不是获取整个配置

选项的优点

  • 强类型配置
  • 提供多种读取方式
  • 支持热加载
  • 支持设置默认值
  • 支持验证配置有效性
  • 支持更改通知
  • 支持命名选项

涉及的nuget包

Microsoft.Extensions.Options: 选项模型本身就这一个包(构建在依赖注入容器之上);
Microsoft.Extensions.Options.ConfigurationExtensions:用于和“配置模块”集成,集成后可以从“配置模块”提取数据;

Options绑定

    public void ConfigureServices(IServiceCollection services)
    {
        // 方式 1:
        var bookOptions1 = new BookOptions();
        Configuration.GetSection(BookOptions.Book).Bind(bookOptions1);

        // 方式 2:
        var bookOptions2 = Configuration.GetSection(BookOptions.Book).Get<BookOptions>();
    }

Options注册

Options框架为我们提供了一些的IServiceCollection的扩展方法,方便我们的使用。

Configure方法

通过IConfiguration进行配置:

// 不指定名称
services.Configure<MyOptions>(Configuration.GetSection("Sign"));
// 指定具体名称
services.Configure<MyOptions>("my", Configuration.GetSection("Sign"));
// 配置所有实例
services.ConfigureAll<MyOptions>(Configuration.GetSection("Sign"));

通过Action配置:

services.Configure<MyOptions>(o => o.DefaultValue = true);
services.Configure<MyOptions>("my", o => o.DefaultValue = true);
services.ConfigureAll<MyOptions>(o => o.DefaultValue = true);

命名选项

上面我们提到了命名选项,命名选项常用于多个配置节点绑定同一属性的情况,举例:

{
  "DateTime": {
    "Beijing": {
      "Year": 2021,
      "Month": 1,
      "Day":1,
      "Hour":12
    },
    "Tokyo": {
      "Year": 2021,
      "Month": 1,
      "Day":1,
      "Hour":13
    },
  }
}
public void ConfigureServices(IServiceCollection services)
{
    services.Configure<BookOptions>(Configuration.GetSection(BookOptions.Book));
    services.Configure<DateTimeOptions>("Beijing", Configuration.GetSection($"DateTime:Beijing"));
    services.Configure<DateTimeOptions>("Tokyo", Configuration.GetSection($"DateTime:Tokyo"));
}

通过IConfigureOptions配置:

更灵活的注册方式,以上两种方式也是通过IConfigureOptions方式注册的

public class ConfigureEmailOptions : IConfigureOptions<EmailOptions>
{
    private readonly IConfiguration _configuration;

    public ConfigureEmailOptions(IConfiguration configuration)
    {
        this._configuration = configuration;
    }

    public void Configure(EmailOptions options)
    {
       options.Url =  _configuration["Url"];
    }
}

services.AddSingleton<IConfigureOptions<EmailOptions>, ConfigureEmailOptions>();

使用 DI 服务配置选项

在某些场景下,选项的配置需要依赖DI中的服务,这时可以借助OptionsBuilder的Configure方法(注意这个Configure不是上面提到的IServiceCollection的扩展方法Configure,这是两个不同的方法),该方法支持最多5个服务来配置选项:

services.AddOptions<BookOptions>()
    .Configure<Service1, Service2, Service3, Service4, Service5>((o, s, s2, s3, s4, s5) => 
    {
        o.Authors = DoSomethingWith(s, s2, s3, s4, s5);
    });

第二种方式:

builder.Services.AddTransient<Order>(p => new Order() { ID = 10000 });

builder.Services.Configure<OrderOptions>(options =>options.Site="cnblog.com");
builder.Services.AddTransient<IConfigureOptions<OrderOptions>>(sp =>
    new ConfigureNamedOptions<OrderOptions, Order>(string.Empty, sp.GetRequiredService<Order>(), (options,order) => {
        options.OrderID = order.ID;
    }));

Options读取

通过Options接口,我们可以读取依赖注入容器中的Options。常用的有三个接口:

  • IOptions
  • IOptionsSnapshot
  • IOptionsMonitor

IOptions

  • 该接口对象实例生命周期为 Singleton,因此能够将该接口注入到任何生命周期的服务中
  • 当该接口被实例化后,其中的选项值将永远保持不变
  • 不支持命名选项(Named Options)

IOptionsSnapshot

  • 该接口被注册为 Scoped,因此该接口无法注入到 Singleton 的服务中,只能注入到 Transient 和 Scoped 的服务中。
  • 在作用域中,创建IOptionsSnapshot对象实例时,会从配置中读取最新选项值作为快照,并在作用域中始终使用该快照。
  • 支持命名选项

IOptionsMonitor

该接口除了可以查看TOptions的值,还可以监控TOptions配置的更改。
该接口被注册为 Singleton,因此能够将该接口注入到任何生命周期的服务中
每次读取选项值时,都是从配置中读取最新选项值(CurrentValue),并当配置发生更改时,进行通知(OnChange)
支持命名选项

Options后期配置

Configure之后执行,可以为Options设置默认值,也可以做数据校验
介绍两个方法,分别是PostConfigure和PostConfigureAll,他们用来对选项进行后期配置。

  • 在所有的OptionsServiceCollectionExtensions.Configure方法运行后执行
  • 与Configure和ConfigureAll类似,PostConfigure仅用于对指定名称的选项进行后期配置(默认名称为string.Empty),PostConfigureAll则用于对所有选项实例进行后期配置
  • 每当选项更改时,均会触发相应的方法
public void ConfigureServices(IServiceCollection services)
{
    services.PostConfigure<DateTimeOptions>(options =>
    {
        Console.WriteLine($"我只对名称为{Options.DefaultName}的{nameof(DateTimeOptions)}实例进行后期配置");
    });

    services.PostConfigure<DateTimeOptions>(DateTimeOptions.Beijing, options =>
    {
        Console.WriteLine($"我只对名称为{DateTimeOptions.Beijing}的{nameof(DateTimeOptions)}实例进行后期配置");
    });

    services.PostConfigureAll<DateTimeOptions>(options =>
    {
        Console.WriteLine($"我对{nameof(DateTimeOptions)}的所有实例进行后期配置");
    });
}

PostConfigure案例:

public static IServiceCollection AddAliyunService(this IServiceCollection serviceCollection, Action<AliyunServiceOption> configAction)
{
    if (configAction != null)
    {
        serviceCollection.Configure(configAction);
    }
    serviceCollection.PostConfigure<AliyunServiceOption>(options =>
    {
        if (string.IsNullOrWhiteSpace(options.AccessKeyId))
        {
            throw new ArgumentNullException(nameof(options.AccessKeyId), $"{nameof(AliyunServiceOption)} {nameof(options.AccessKeyId)} can not be null or empty");
        }
        if (string.IsNullOrWhiteSpace(options.AccessKeySecret))
        {
            throw new ArgumentNullException(nameof(options.AccessKeySecret), $"{nameof(AliyunServiceOption)} {nameof(options.AccessKeySecret)} can not be null or empty");
        }
    });
    serviceCollection.TryAddSingleton<IAliyunService, AliyunService>();
    return serviceCollection;
}

Options验证

配置毕竟是我们手动进行文本输入的,难免会出现错误,这种情况下,就需要使用程序来帮助进行校验了。
Install-Package Microsoft.Extensions.Options.DataAnnotations
我们先升级一下BookOptions,增加一些数据校验:

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

    [Range(1,1000,
        ErrorMessage = "必须 {1} <= {0} <= {2}")]
    public int Id { get; set; }

    [StringLength(10, MinimumLength = 1,
        ErrorMessage = "必须 {2} <= {0} Length <= {1}")]
    public string Name { get; set; }

    public string Author { get; set; }
}

然后我们在添加到DI容器时,增加数据注解验证:

public void ConfigureServices(IServiceCollection services)
{
    services.AddOptions<BookOptions>()
        .Bind(Configuration.GetSection(BookOptions.Book))
        .ValidateDataAnnotations();
        .Validate(options =>
        {
            // 校验通过 return true
            // 校验失败 return false
    
            if (options.Author.Contains("A"))
            {
                return false;
            }
    
            return true;
        });
}

ValidateDataAnnotations会根据你添加的特性进行数据校验,当特性无法实现想要的校验逻辑时,则使用Validate进行较为复杂的校验,如果过于复杂,则就要用到IValidateOptions了(实质上,Validate方法内部也是通过注入一个IValidateOptions实例来实现选项验证的)。

IValidateOptions

通过实现IValidateOptions接口,增加数据校验规则,例如:

public class BookValidation : IValidateOptions<BookOptions>
{
    public ValidateOptionsResult Validate(string name, BookOptions options)
    {
        var failures = new List<string>();
        if(!(options.Id >= 1 && options.Id <= 1000))
        {
            failures.Add($"必须 1 <= {nameof(options.Id)} <= {1000}");
        }
        if(!(options.Name.Length >= 1 && options.Name.Length <= 10))
        {
            failures.Add($"必须 1 <= {nameof(options.Name)} <= 10");
        }

        if (failures.Any())
        {
            return ValidateOptionsResult.Fail(failures);
        }

        return ValidateOptionsResult.Success;
    }
}

然后我们将其注入到DI容器 Singleton,这里使用了TryAddEnumerable扩展方法添加该服务,是因为我们可以注入多个针对同一Options的IValidateOptions,这些IValidateOptions实例都会被执行:

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<BookOptions>(Configuration.GetSection(BookOptions.Book));
    services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<BookOptions>, BookValidation>());
}

Configure方法源码解析

使用Action的Configure方法

Configure方法是为IConfigureOptions<>注册了一个单例ConfigureNamedOptions<>

public static IServiceCollection Configure<TOptions>(this IServiceCollection services, string name, Action<TOptions> configureOptions)
    where TOptions : class
{
    services.AddOptions();
    services.AddSingleton<IConfigureOptions<TOptions>>(new ConfigureNamedOptions<TOptions>(name, configureOptions));
    return services;
}

而不指定nameConfigureConfigureAll方法,都只是一种简写形式,使用默认的DefaultName和null

public static class Options
{
    public static readonly string DefaultName = string.Empty;
}

public static IServiceCollection Configure<TOptions>(this IServiceCollection services, Action<TOptions> configureOptions) where TOptions : class 
=> services.Configure(Options.Options.DefaultName, configureOptions);

public static IServiceCollection ConfigureAll<TOptions>(this IServiceCollection services, Action<TOptions> configureOptions) where TOptions : class
=> services.Configure(name: null, configureOptions: configureOptions);

使用IConfiguration的Configure方法

public static IServiceCollection Configure<TOptions>(this IServiceCollection services, string name, IConfiguration config)
    where TOptions : class
{
    services.AddOptions();
    services.AddSingleton<IOptionsChangeTokenSource<TOptions>>(new ConfigurationChangeTokenSource<TOptions>(name, config));
    return services.AddSingleton<IConfigureOptions<TOptions>>(new NamedConfigureFromConfigurationOptions<TOptions>(name, config));
}

注册的实例变成了NamedConfigureFromConfigurationOptionsNamedConfigureFromConfigurationOptions<TOptions>继承自ConfigureNamedOptions<TOptions>,所以其本质依然是ConfigureNamedOptions,只不过Action的方法体变成了ConfigurationBinder.Bind()

public class NamedConfigureFromConfigurationOptions<TOptions> : ConfigureNamedOptions<TOptions>
    where TOptions : class
{
    public NamedConfigureFromConfigurationOptions(string name, IConfiguration config)
        : base(name, options => ConfigurationBinder.Bind(config, options))
    {
        if (config == null)
        {
            throw new ArgumentNullException(nameof(config));
        }
    }
}

ConfigureNamedOptions

ConfigureNamedOptions实现了IConfigureNamedOptions,而IConfigureNamedOptions则是对IConfigureOptions的一个扩展,添加了Name参数,这样,我们便可以为同一个Options类型注册多个独立的实例,在某些场景下则是非常有用的。
IConfigureOptions接口:

public interface IConfigureOptions<in TOptions> where TOptions : class
    {
        void Configure(TOptions options);
    }

IConfigureNamedOptions接口:

public interface IConfigureNamedOptions<in TOptions> : IConfigureOptions<TOptions> where TOptions : class
{
    void Configure(string name, TOptions options);
}

ConfigureNamedOptions的源码:

public class ConfigureNamedOptions<TOptions> : IConfigureNamedOptions<TOptions>, IConfigureOptions<TOptions> where TOptions : class
{
    public ConfigureNamedOptions(string name, Action<TOptions> action)
    {
        Name = name;
        Action = action;
    }

    public string Name { get; }

    public Action<TOptions> Action { get; }

    public virtual void Configure(string name, TOptions options)
    {
        if (Name == null || name == Name)
        {
            Action?.Invoke(options);
        }
    }

    public void Configure(TOptions options) => Configure(Options.DefaultName, options);
}

ConfigureNamedOptions本质上就是把我们注册的Action包装成统一的Configure方法,以方便后续创建Options实例时,进行初始化。

AddOptions()方法源码

上面的Configure()方法中都调用了AddOptions()方法,它是用来注册Options模式相关的核心对象

public static IServiceCollection AddOptions(this IServiceCollection services)
{
    services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptions<>), typeof(OptionsManager<>)));
    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;
}

IOptions

单例,它会一直缓存选项对象

public interface IOptions<out TOptions> where TOptions : class, new()
{
    TOptions Value { get; }
}

IOptionsSnapshot

Scope,每个请求都重读配置以获得新的选项对象(支持热更新)
先看IOptionsSnapshot接口的定义,IOptionsSnapshot还继承了IOptions,而且只是多了一个Get方法

public interface IOptionsSnapshot<out TOptions> : IOptions<TOptions> where TOptions : class, new()
{
    TOptions Get(string name);
}

再看它们的实现类都是OptionsManager<>

services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptions<>), typeof(OptionsManager<>)));
services.TryAdd(ServiceDescriptor.Scoped(typeof(IOptionsSnapshot<>), typeof(OptionsManager<>)));

区别是生命周期不同,单例的IOptions意味着,只要您注入之后以后获取的都是同一个实例,而IOptionsSnapshot呢,作为Scoped级别,每再一个新的Scoped中获取它一次,它就会请求一个新的实例。
所以来举个例子,在AspNet Core中咱们某个选项的值是根据一个文件的某个值来的。刚开始文本的值是“A”,咱们在运行AspNet Core之后我们获取IOptions<MyOptions>IOptionsSnapshot<MyOptions>,此时得到的MyOptions的该属性的值都是"A"。但是假如我们更改了文本的值,改为“B”。如果在发起一个http请求去获取MyOptions的结果,此时IOptions<MyOptions>依旧是“A”,而IOptionsSnapshot<MyOptions>则更改为了B。

OptionsManager

OptionsManager对options的创建和获取进行管理,OptionsManager使用IOptionsFactory来创建options对象,并使用内部属性OptionsCache<TOptions> _cache进行缓存。
源码:

public class OptionsManager<TOptions> : IOptions<TOptions>, IOptionsSnapshot<TOptions> where TOptions : class, new()
{
    private readonly IOptionsFactory<TOptions> _factory;
    private readonly OptionsCache<TOptions> _cache = new OptionsCache<TOptions>(); // cache

    public OptionsManager(IOptionsFactory<TOptions> factory)
    {
        _factory = factory;
    }

    public TOptions Value => Get(Options.DefaultName);

    public virtual TOptions Get(string name) => _cache.GetOrAdd(name, () => _factory.Create(name ?? Options.DefaultName));
}

OptionsFactory

public class OptionsFactory<TOptions> : IOptionsFactory<TOptions> where TOptions : class, new()
{
    private readonly IEnumerable<IConfigureOptions<TOptions>> _setups;
    private readonly IEnumerable<IPostConfigureOptions<TOptions>> _postConfigures;

    public OptionsFactory(IEnumerable<IConfigureOptions<TOptions>> setups, IEnumerable<IPostConfigureOptions<TOptions>> postConfigures)
    {
        _setups = setups;
        _postConfigures = postConfigures;
    }

    public TOptions Create(string name)
    {
        var options = new TOptions();
        foreach (var setup in _setups)
        {
            if (setup is IConfigureNamedOptions<TOptions> namedSetup)
            {
                namedSetup.Configure(name, options);
            }
            else if (name == Options.DefaultName)
            {
                setup.Configure(options);
            }
        }
        foreach (var post in _postConfigures)
        {
            post.PostConfigure(name, options);
        }
        return options;
    }
}

OptionsCache

OptionsCache 则是对字典的一个简单封装
源码:

public class OptionsCache<TOptions> : IOptionsMonitorCache<TOptions> where TOptions : class
{
    private readonly ConcurrentDictionary<string, Lazy<TOptions>> _cache = new ConcurrentDictionary<string, Lazy<TOptions>>(StringComparer.Ordinal);

    public void Clear() => _cache.Clear();

    public virtual TOptions GetOrAdd(string name, Func<TOptions> createOptions)
    {
        name = name ?? Options.DefaultName;
        return _cache.GetOrAdd(name, new Lazy<TOptions>(createOptions)).Value;
    }

    public virtual bool TryAdd(string name, TOptions options)
    {
        name = name ?? Options.DefaultName;
        return _cache.TryAdd(name, new Lazy<TOptions>(() => options));
    }

    public virtual bool TryRemove(string name)
    {
        name = name ?? Options.DefaultName;
        return _cache.TryRemove(name, out var ignored);
    }
}

IOptionsMonitor

一直缓存选项对象,但当配置源发生更改时自动更新我的选项对象时使用(支持热更新,并缓存,推荐)
IOptionsMonitor<>接口的实现类是OptionsMonitor<>

services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptionsMonitor<>), typeof(OptionsMonitor<>)));

IOptionsMonitor,更强大的Options,它的用法和IOptionsSnapshot没有区别,不同的时,它多了一个配置文件发生改变之后事件处理

public interface IOptionsMonitor<out TOptions>
{
    TOptions CurrentValue { get; }
    TOptions Get(string name);
    IDisposable OnChange(Action<TOptions, string> listener);
}

IOptionsMonitor<>的注入级别虽然是单例,但是因为它具有IChangeToken的实现,所以它能够在选项源改变的时候,“立马对选项做出对应的改变”。而改变依赖于IOptionsChangeTokenSource这个令牌源,目前.net core对很多常用工具都实现了该令牌源,比如Logger,Configuration等。所以当我们某个选项依赖于IConfiguration(appsetting.json)的某一项时,当修改appsetting.json文件,该选项的值就能够立马得到更改。

假如把IOptionsMonitor<MyOptions>添加到上面IOptions<MyOptions>IOptionsSnapshot<MyOptions>的文件变更案例,如果在一次HTTP请求中,文件变更了两次,那么IOptionsSnapshot<MyOptions>不会在第二次更改中同步更改,而IOptionsMonitor<MyOptions>则可以。
那么什么时候来使用什么样的接口呢?当您的选项只是负责一次性处理的时候,应用启动了就不需要更改,那么考虑使用IOptions<MyOptions>,如果是对数据源的变更要求很严格,比如开启了一个“BackgroundJob”在后台运行,该job需要一个选项类型,而该类型依赖于配置文件,需要对配置文件更改时即刻做出改变,那么请考虑使用IOptionsMonitor<MyOptions>
OptionsMonitor源码:

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 IEnumerable<IOptionsChangeTokenSource<TOptions>> _sources;
        private readonly List<IDisposable> _registrations = new List<IDisposable>();
        internal event Action<TOptions, string> _onChange;

        public OptionsMonitor(IOptionsFactory<TOptions> factory, IEnumerable<IOptionsChangeTokenSource<TOptions>> sources, IOptionsMonitorCache<TOptions> cache)
        {
            _factory = factory;
            _sources = sources;
            _cache = cache;

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

                _registrations.Add(registration);
            }
        }

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

        public TOptions CurrentValue
        {
            get => Get(Options.DefaultName);
        }

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

        public IDisposable OnChange(Action<TOptions, string> listener)
        {
            var disposable = new ChangeTrackerDisposable(this, listener);
            _onChange += disposable.OnChange;
            return disposable;
        }

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

            _registrations.Clear();
        }

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

ConfigurationChangeTokenSource

ConfigurationChangeTokenSource是接口IOptionsChangeTokenSource的实现类
源码:

public class ConfigurationChangeTokenSource<TOptions> : IOptionsChangeTokenSource<TOptions>
{
    private IConfiguration _config;

    public ConfigurationChangeTokenSource(IConfiguration config) : this(Options.DefaultName, config) { }

    public ConfigurationChangeTokenSource(string name, IConfiguration config)
    {
        if (config == null)
        {
            throw new ArgumentNullException(nameof(config));
        }
        _config = config;
    }

    public string Name { get; }

    public IChangeToken GetChangeToken()
    {
        return _config.GetReloadToken();
    }
}

参考:
https://www.cnblogs.com/artech/p/inside-asp-net-core-3.html
https://www.cnblogs.com/RainingNight/tag/Options/
https://www.cnblogs.com/uoyo/p/12583149.html
https://www.cnblogs.com/xiaoxiaotank/p/15391905.html

posted @ 2021-01-11 00:17  .Neterr  阅读(1488)  评论(0编辑  收藏  举报