理解ASP.NET Core - 選項(Options)

xiaoxiaotank發表於2021-10-11

注:本文隸屬於《理解ASP.NET Core》系列文章,請檢視置頂部落格或點選此處檢視全文目錄

Options繫結

上期我們已經聊過了配置(IConfiguration),今天我們來聊一聊Options,中文譯為“選項”,該功能用於實現以強型別的方式對程式配置資訊進行訪問。

既然是強型別的方式,那麼就需要定義一個Options類,該類:

  • 推薦命名規則:{Object}Options
  • 特點:
    • 非抽象類
    • 必須包含公共無參的建構函式
    • 類中的所有公共讀寫屬性都會與配置項進行繫結
    • 欄位不會被繫結

接下來,為了便於理解,先舉個例子:

首先在 appsetting.json 中新增如下配置:

{
  "Book": {
    "Id": 1,
    "Name": "三國演義",
    "Author": "羅貫中"
  }
}

然後定義Options類:

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

    public int Id { get; set; }

    public string Name { get; set; }

    public string Author { get; set; }
}

最後進行繫結(有BindGet兩種方式):

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

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

其中,屬性IdTitleAuthor均會與配置進行繫結,但是欄位Book並不會被繫結,該欄位只是用來讓我們避免在程式中使用“魔數”。另外,一定要確保配置項能夠轉換到其繫結的屬性型別(你該不會想把string繫結到int型別上吧)。

如果中文讀取出來是亂碼,那麼你可以按照.L.net core 讀取appsettings.json 檔案中文亂碼的問題來配置一下。

當然,這樣寫程式碼還不夠完美,還是要將Options新增到依賴注入服務容器中,例如通過IServiceCollection的擴充套件方法Configure

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.Configure<BookOptions>(Configuration.GetSection(BookOptions.Book));
    }
}

Options讀取

通過Options介面,我們可以讀取依賴注入容器中的Options。常用的有三個介面:

  • IOptions<TOptions>
  • IOptionsSnapshot<TOptions>
  • IOptionsMonitor<TOptions>

接下來,我們看看它們的區別。

IOptions

  • 該介面物件例項生命週期為 Singleton,因此能夠將該介面注入到任何生命週期的服務中
  • 當該介面被例項化後,其中的選項值將永遠保持不變,即使後續修改了與選項進行繫結的配置,也永遠讀取不到修改後的配置值
  • 不支援命名選項(Named Options),這個下面會說
public class ValuesController : ControllerBase
{
    private readonly BookOptions _bookOptions;

    public ValuesController(IOptions<BookOptions> bookOptions)
    {
        // bookOptions.Value 始終是程式啟動時載入的配置,永遠不會改變
        _bookOptions = bookOptions.Value;
    }
}

IOptionsSnapshot

  • 該介面被註冊為 Scoped,因此該介面無法注入到 Singleton 的服務中,只能注入到 Transient 和 Scoped 的服務中。
  • 在作用域中,建立IOptionsSnapshot<TOptions>物件例項時,會從配置中讀取最新選項值作為快照,並在作用域中始終使用該快照。
  • 支援命名選項
public class ValuesController : ControllerBase
{
    private readonly BookOptions _bookOptions;

    public ValuesController(IOptionsSnapshot<BookOptions> bookOptionsSnapshot)
    {
        // bookOptions.Value 是 Options 物件例項建立時讀取的配置快照
        _bookOptions = bookOptionsSnapshot.Value;
    }
}

IOptionsMonitor

  • 該介面除了可以檢視TOptions的值,還可以監控TOptions配置的更改。
  • 該介面被註冊為 Singleton,因此能夠將該介面注入到任何生命週期的服務中
  • 每次讀取選項值時,都是從配置中讀取最新選項值(具體讀取邏輯檢視下方三種介面對比測試)。
  • 支援:
    • 命名選項
    • 重新載入配置(CurrentValue),並當配置發生更改時,進行通知(OnChange
    • 快取與快取失效 (IOptionsMonitorCache<TOptions>)
public class ValuesController : ControllerBase
{
    private readonly IOptionsMonitor<BookOptions> _bookOptionsMonitor;

    public ValuesController(IOptionsMonitor<BookOptions> bookOptionsMonitor)
    {
        // _bookOptionsMonitor.CurrentValue 的值始終是最新配置的值
        _bookOptionsMonitor = bookOptionsMonitor;
    }
}

三種介面對比測試

IOptions<TOptions>就不說了,主要說一下IOptionsSnapshot<TOptions>IOptionsMonitor<TOptions>的不同:

  • IOptionsSnapshot<TOptions> 註冊為 Scoped,在建立其例項時,會從配置中讀取最新選項值作為快照,並在作用域中使用該快照
  • IOptionsMonitor<TOptions> 註冊為 Singleton,每次呼叫例項的 CurrentValue 時,會先檢查快取(IOptionsMonitorCache<TOptions>)是否有值,如果有值,則直接用,如果沒有,則從配置中讀取最新選項值,並記入快取。當配置發生更改時,會將快取清空。

搞個測試小程式:

[ApiController]
[Route("[controller]")]
public class ValuesController : ControllerBase
{
    private readonly IOptions<BookOptions> _bookOptions;
    private readonly IOptionsSnapshot<BookOptions> _bookOptionsSnapshot;
    private readonly IOptionsMonitor<BookOptions> _bookOptionsMonitor;

    public ValuesController(
        IOptions<BookOptions> bookOptions,
        IOptionsSnapshot<BookOptions> bookOptionsSnapshot,
        IOptionsMonitor<BookOptions> bookOptionsMonitor)
    {
        _bookOptions = bookOptions;
        _bookOptionsSnapshot = bookOptionsSnapshot;
        _bookOptionsMonitor = bookOptionsMonitor;

    }

    [HttpGet]
    public dynamic Get()
    {
        var bookOptionsValue1 = _bookOptions.Value;
        var bookOptionsSnapshotValue1 = _bookOptionsSnapshot.Value;
        var bookOptionsMonitorValue1 = _bookOptionsMonitor.CurrentValue;

        Console.WriteLine("請修改配置檔案 appsettings.json");
        Task.Delay(TimeSpan.FromSeconds(10)).Wait();

        var bookOptionsValue2 = _bookOptions.Value;
        var bookOptionsSnapshotValue2 = _bookOptionsSnapshot.Value;
        var bookOptionsMonitorValue2 = _bookOptionsMonitor.CurrentValue;


        return new
        {
            bookOptionsValue1,
            bookOptionsSnapshotValue1,
            bookOptionsMonitorValue1,
            bookOptionsValue2,
            bookOptionsSnapshotValue2,
            bookOptionsMonitorValue2
        };
    }
}

執行2次,並按照指示修改兩次配置檔案(初始是“三國演義”,第一次修改為“水滸傳”,第二次修改為“紅樓夢”)

  • 第1次輸出:
{
  "bookOptionsValue1": {
    "id": 1,
    "name": "三國演義",
    "author": "羅貫中"
  },
  "bookOptionsSnapshotValue1": {
    "id": 1,
    "name": "三國演義",
    "author": "羅貫中"
  },
  "bookOptionsMonitorValue1": {
    "id": 1,
    "name": "三國演義",
    "author": "羅貫中"
  },
  "bookOptionsValue2": {
    "id": 1,
    "name": "三國演義",
    "author": "羅貫中"
  },
  // 注意 OptionsSnapshot 的值在當前作用域內沒有進行更新
  "bookOptionsSnapshotValue2": {
    "id": 1,
    "name": "三國演義",
    "author": "羅貫中"
  },
  
  // 注意 OptionsMonitor 的值變成最新的
  "bookOptionsMonitorValue2": {
    "id": 1,
    "name": "水滸傳",
    "author": "施耐庵"
  }
}
  • 第2次輸出:
{
  // Options 的值始終沒有變化
  "bookOptionsValue1": {
    "id": 1,
    "name": "三國演義",
    "author": "羅貫中"
  },
  
  // 注意 OptionsSnapshot 的值變成當前最新值了
  "bookOptionsSnapshotValue1": {
    "id": 1,
    "name": "水滸傳",
    "author": "施耐庵"
  },
  // 注意 OptionsMonitor 的值始終是最新的
  "bookOptionsMonitorValue1": {
    "id": 1,
    "name": "水滸傳",
    "author": "施耐庵"
  },
  
  // Options 的值始終沒有變化
  "bookOptionsValue2": {
    "id": 1,
    "name": "三國演義",
    "author": "羅貫中"
  },
  // 注意 OptionsSnapshot 的值在當前作用域內沒有進行更新
  "bookOptionsSnapshotValue2": {
    "id": 1,
    "name": "水滸傳",
    "author": "施耐庵"
  },
  
  // 注意 OptionsMonitor 的值始終是最新的
  "bookOptionsMonitorValue2": {
    "id": 1,
    "name": "紅樓夢",
    "author": "曹雪芹"
  }
}

通過測試我相信你應該能深刻理解它們之間的區別了。

命名選項(Named Options)

上面我們提到了命名選項,命名選項常用於多個配置節點繫結同一屬性的情況,舉個例子你就明白了:

在 appsettings.json 中新增如下配置

{
  "DateTime": {
    "Beijing": {
      "Year": 2021,
      "Month": 1,
      "Day":1,
      "Hour":12,
      "Minute":0,
      "Second":0
    },
    "Tokyo": {
      "Year": 2021,
      "Month": 1,
      "Day":1,
      "Hour":13,
      "Minute":0,
      "Second":0
    },
  }
}

很顯然,雖然“Beijing”和“Tokyo”是兩個配置項,但是屬性都是一樣的,我們沒必要建立兩個Options類,只需要建立一個就好了:

public class DateTimeOptions
{
    public const string Beijing = "Beijing";
    public const string Tokyo = "Tokyo";

    public int Year { get; set; }
    public int Month { get; set; }
    public int Day { get; set; }
    public int Hour { get; set; }
    public int Minute { get; set; }
    public int Second { get; set; }
}

然後,通過對選項進行指定命名的方式,一個叫做“Beijing”,一個叫做“Tokyo”,將選項新增到DI容器中:

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.Configure<BookOptions>(Configuration.GetSection(BookOptions.Book));
        services.Configure<DateTimeOptions>(DateTimeOptions.Beijing, Configuration.GetSection($"DateTime:{DateTimeOptions.Beijing}"));
        services.Configure<DateTimeOptions>(DateTimeOptions.Tokyo, Configuration.GetSection($"DateTime:{DateTimeOptions.Tokyo}"));
    }
}

最後,通過建構函式的方式將選項注入到Controller中。需要注意的是,因為DateTimeOptions類繫結了兩個選項類,所以當我們獲取時選項值時,需要指定選項的名字。

public class ValuesController : ControllerBase
{
    private readonly DateTimeOptions _beijingDateTimeOptions;
    private readonly DateTimeOptions _tockyoDateTimeOptions;

    public ValuesController(IOptionsSnapshot<DateTimeOptions> dateTimeOptions)
    {
        _beijingDateTimeOptions = dateTimeOptions.Get(DateTimeOptions.Beijing);
        _tockyoDateTimeOptions = dateTimeOptions.Get(DateTimeOptions.Tokyo);
    }
}

程式執行後,你會發現變數 _beijingDateTimeOptions 繫結的配置是“Beijing”配置節點,變數 _tockyoDateTimeOptions 繫結的配置是“Tokyo” 配置節點,但它們繫結的都是同一個類DateTimeOptions

事實上,.NET Core 中所有 Options 都是命名選項,當沒有顯式指定名字時,使用的名字預設是Options.DefaultName,即string.Empty

使用 DI 服務配置選項

在某些場景下,選項的配置需要依賴DI中的服務,這時可以藉助OptionsBuilderConfigure方法(注意這個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);
    });

Options 驗證

配置畢竟是我們手動進行文字輸入的,難免會出現錯誤,這種情況下,就需要使用程式來幫助進行校驗了。

DataAnnotations

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<TOptions>介面,增加資料校驗規則,例如:

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

Options後期配置

介紹兩個方法,分別是PostConfigurePostConfigureAll,他們用來對選項進行後期配置。

  • 在所有的OptionsServiceCollectionExtensions.Configure方法執行後執行
  • ConfigureConfigureAll類似,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)}的所有例項進行後期配置");
    });
}

Options 體系

IConfigureOptions

該介面用於包裝對選項的配置。預設實現為ConfigureOptions<TOptions>

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

ConfigureOptions

public class ConfigureOptions<TOptions> : IConfigureOptions<TOptions> where TOptions : class
{
    public ConfigureOptions(Action<TOptions> action)
    {
        Action = action;
    }

    public Action<TOptions> Action { get; }

    // 配置 TOptions 例項
    public virtual void Configure(TOptions options)
    {
        Action?.Invoke(options);
    }
}

ConfigureFromConfigurationOptions

該類通過繼承類ConfigureOptions<TOptions>,對選項的配置進行了擴充套件,允許通過ConfigurationBinder.Bind擴充套件方法將IConfiguration例項繫結到選項上:

public class ConfigureFromConfigurationOptions<TOptions> : ConfigureOptions<TOptions>
    where TOptions : class
{
    public ConfigureFromConfigurationOptions(IConfiguration config)
        : base(options => ConfigurationBinder.Bind(config, options))
    { }
}

IConfigureNamedOptions

該介面用於包裝對命名選項的配置,該介面同時繼承了介面IConfigureOptions<TOptions>的行為,預設實現為ConfigureNamedOptions<TOptions>,另外為了實現“使用 DI 服務配置選項”的功能,還提供了一些泛型類過載。

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

ConfigureNamedOptions

public class ConfigureNamedOptions<TOptions> : IConfigureNamedOptions<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)
    {
        // Name == null 表示針對 TOptions 的所有例項進行配置
        if (Name == null || name == Name)
        {
            Action?.Invoke(options);
        }
    }

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

NamedConfigureFromConfigurationOptions

該類通過繼承類ConfigureNamedOptions<TOptions>,對命名選項的配置進行了擴充套件,允許通過ConfigurationBinder.Bind擴充套件方法將IConfiguration例項繫結到命名選項上:

public class NamedConfigureFromConfigurationOptions<TOptions> : ConfigureNamedOptions<TOptions>
    where TOptions : class
{
    public NamedConfigureFromConfigurationOptions(string name, IConfiguration config)
        : this(name, config, _ => { })
    { }

    public NamedConfigureFromConfigurationOptions(string name, IConfiguration config, Action<BinderOptions> configureBinder)
        : base(name, options => config.Bind(options, configureBinder))
    { }
}

IPostConfigureOptions

該介面用於包裝對命名選項的後期配置,將在所有IConfigureOptions<TOptions>執行完畢後才會執行,預設實現為PostConfigureOptions<TOptions>,同樣的,為了實現“使用 DI 服務對選項進行後期配置”的功能,也提供了一些泛型類過載:

public interface IPostConfigureOptions<in TOptions> where TOptions : class
{
    void PostConfigure(string name, TOptions options);
}

public class PostConfigureOptions<TOptions> : IPostConfigureOptions<TOptions> where TOptions : class
{
    public PostConfigureOptions(string name, Action<TOptions> action)
    {
        Name = name;
        Action = action;
    }

    public string Name { get; }

    public Action<TOptions> Action { get; }

    public virtual void PostConfigure(string name, TOptions options)
    {
        // Name == null 表示針對 TOptions 的所有例項進行後期配置
        if (Name == null || name == Name)
        {
            Action?.Invoke(options);
        }
    }
}

AddOptions & AddOptions & OptionsBuilder

public static class OptionsServiceCollectionExtensions
{
    // 該方法幫我們把一些常用的與 Options 相關的服務注入到 DI 容器
    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;
    }
    
    // 沒有指定 Options 名稱時,預設使用 Options.DefaultName
    public static OptionsBuilder<TOptions> AddOptions<TOptions>(this IServiceCollection services) where TOptions : class
        => services.AddOptions<TOptions>(Options.Options.DefaultName);
    
    // 由於後續還要對 TOptions 進行配置,所以返回一個 OptionsBuilder 出去
    public static OptionsBuilder<TOptions> AddOptions<TOptions>(this IServiceCollection services, string name)
        where TOptions : class
    {
        services.AddOptions();
        return new OptionsBuilder<TOptions>(services, name);
    }
}

那我們看看OptionsBuilder<TOptions>可以配置哪些東西,由於該類中有大量過載方法,我只挑選最基礎的方法來看一看:

public class OptionsBuilder<TOptions> where TOptions : class
{
    private const string DefaultValidationFailureMessage = "A validation error has occurred.";
    
    // TOptions 例項的名字
    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;
    }
}

OptionsServiceCollectionExtensions.Configure

OptionsServiceCollectionExtensions.Configure<TOptions>實際上就是對選項的一般配置方式進行了封裝,免去了OptionsBuilder<TOptions>

public static class OptionsServiceCollectionExtensions
{
    // 沒有指定 Options 名稱時,預設使用 Options.DefaultName
    public static IServiceCollection Configure<TOptions>(this IServiceCollection services, Action<TOptions> configureOptions) where TOptions : class
        => services.Configure(Options.Options.DefaultName, configureOptions);
        
    // 等同於做了 AddOptions<TOptions> 和 OptionsBuilder<TOptions>.Configure 兩件事
    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;
    }
    
    // 由於 ConfigureAll 是針對 TOptions 的所有例項進行配置,所以不需要指定名字
    public static IServiceCollection ConfigureAll<TOptions>(this IServiceCollection services, Action<TOptions> configureOptions) where TOptions : class
        => services.Configure(name: null, configureOptions: configureOptions);
}

OptionsConfigurationServiceCollectionExtensions.Configure

請注意,該Configure<TOptions>方法與上方提及的Configure<TOptions>不是同一個。該擴充套件方法針對配置(IConfiguration)繫結到選項(Options)上進行了擴充套件

Install-Package Microsoft.Extensions.Options.ConfigurationExtensions

public static class OptionsConfigurationServiceCollectionExtensions
{
    public static IServiceCollection Configure<TOptions>(this IServiceCollection services, IConfiguration config) where TOptions : class
        => services.Configure<TOptions>(Options.Options.DefaultName, config);

    public static IServiceCollection Configure<TOptions>(this IServiceCollection services, string name, IConfiguration config) where TOptions : class
        => services.Configure<TOptions>(name, config, _ => { });

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

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

IOptionsFactory

IOptionsFactory<TOptions>負責建立命名選項例項,預設實現為OptionsFactory<TOptions>

public interface IOptionsFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions> where TOptions : class
{
    TOptions Create(string name);
}

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

    // 這裡通過依賴注入的的方式將與 TOptions 相關的配置、驗證服務列表解析出來
    public OptionsFactory(IEnumerable<IConfigureOptions<TOptions>> setups, IEnumerable<IPostConfigureOptions<TOptions>> postConfigures) 
    : this(setups, postConfigures, validations: null)
    { }

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

    public TOptions Create(string name)
    {
        // 1. 建立並配置 Options
        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);
            }
        }
        
        // 2. 對 Options 進行後期配置
        foreach (IPostConfigureOptions<TOptions> post in _postConfigures)
        {
            post.PostConfigure(name, options);
        }

        // 3. 執行 Options 校驗
        if (_validations != null)
        {
            var failures = new List<string>();
            foreach (IValidateOptions<TOptions> validate in _validations)
            {
                ValidateOptionsResult result = validate.Validate(name, options);
                if (result.Failed)
                {
                    failures.AddRange(result.Failures);
                }
            }
            if (failures.Count > 0)
            {
                throw new OptionsValidationException(name, typeof(TOptions), failures);
            }
        }

        return options;
    }

    protected virtual TOptions CreateInstance(string name)
    {
        return Activator.CreateInstance<TOptions>();
    }
}

OptionsManager

通過AddOptions擴充套件方法的實現,可以看到,IOptions<TOptions>IOptionsSnapshot<TOptions>的實現都是OptionsManager<TOptions>,只不過一個是 Singleton,一個是 Scoped。我們通過前面的分析也知道了,當源中的配置改變時,IOptions<TOptions>始終維持初始值,IOptionsSnapshot<TOptions>在每次請求時會讀取最新配置值,並在同一個請求中是不變的。接下來就來看看OptionsManager<TOptions>是如何實現的:

public class OptionsManager<[DynamicallyAccessedMembers(Options.DynamicallyAccessedMembers)] TOptions> :
    IOptions<TOptions>,
    IOptionsSnapshot<TOptions>
    where TOptions : class
{
    private readonly IOptionsFactory<TOptions> _factory;
    // 將已建立的 TOptions 例項快取到該私有變數中
    private readonly OptionsCache<TOptions> _cache = new OptionsCache<TOptions>(); 

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

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

    public virtual TOptions Get(string name)
    {
        name = name ?? Options.DefaultName;

        // 若快取不存在,則通過工廠新建 Options 例項,否則直接讀取快取
        return _cache.GetOrAdd(name, () => _factory.Create(name));
    }
}

OptionsMonitor

同樣,通過前面的分析,我們知道OptionsMonitor<TOptions>讀取的始終是配置的最新值,它的實現在OptionsManager<TOptions>的基礎上,除了使用快取將建立的 Options 例項快取起來外,還增添了監聽機制,當配置發生更改時,會將快取移除。

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(Options.DefaultName);

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

    // 通過該方法繫結 OnChange 事件
    public IDisposable OnChange(Action<TOptions, string> listener)
    {
        var disposable = new ChangeTrackerDisposable(this, listener);
        _onChange += disposable.OnChange;
        return disposable;
    }

    public void Dispose()
    {
        // 移除所有 change token 的訂閱
        foreach (IDisposable registration in _registrations)
        {
            registration.Dispose();
        }

        _registrations.Clear();
    }
}

總結

  • 所有選項均為命名選項,預設名稱為Options.DefaultName,即string.Empty
  • 通過ConfigurationBinder.GetConfigurationBinder.Bind手動獲取選項例項。
  • 通過Configure方法進行選項配置:
    • OptionsBuilder<TOptions>.Configure:通過包含DI服務的委託來進行選項配置
    • OptionsServiceCollectionExtensions.Configure<TOptions>:通過簡單委託來進行選項配置
    • OptionsConfigurationServiceCollectionExtensions.Configure<TOptions>:直接將IConfiguration例項繫結到選項上
  • 通過OptionsServiceCollectionExtensions.ConfigureAll<TOptions>方法針對某個選項型別的所有例項(不同名稱)統一進行配置。
  • 通過PostConfigure方法進行選項後期配置:
    • OptionsBuilder<TOptions>.PostConfigure:通過包含DI服務的委託來進行選項後期配置
    • OptionsServiceCollectionExtensions.PostConfigure<TOptions>:通過簡單委託來進行選項後期配置
  • 通過PostConfigureAll<TOptions>方法針對某個選項型別的所有例項(不同名稱)統一進行配置。
  • 通過Validate進行選項驗證:
    • OptionsBuilderDataAnnotationsExtensions.ValidateDataAnnotations:通過資料註解進行選項驗證
    • OptionsBuilder<TOptions>.Validate:通過委託進行選項驗證
    • IValidateOptions<TOptions>:通過實現該介面並注入實現來進行選項驗證
  • 通過依賴注入讀取選項:
    • IOptions<TOptions>:Singleton,值永遠是該介面被例項化時的選項配置初始值
    • IOptionsSnapshot<TOptions>:Scoped,每一次Http請求開始時會讀取選項配置的最新值,並在當前請求中保持不變
    • IOptionsMonitor<TOptions>:Singleton,每次讀取都是選項配置的最新值

相關文章