【ASP.NET Core】選項模式的相關介面

2022-07-23 21:03:06

在 .NET 中,設定與選項模式其實有聯絡的(這些功能現在不僅限於 ASP.NET Core,而是作為平臺擴充套件來提供,在其他.NET 專案中都能用)。設定一般從多個來源(上一篇水文中的例子,記得否?)來讀取資料,最後以 Key - Value 的方式載入到應用程式中,然後應用程式可以讀取設定。這些來源有 JSON檔案、XML檔案等。上次老周還演示了 CSV 檔案。

而選項模式呢,說直白些就是一些簡單的類,多數情況下只定義些公共屬性,可以稱為選項類(從泛型約束而言,選項類的要求一般就是 class,也就是型別是類的就行)。這些類其根本作用也是用來設定應用程式的,只是它們以物件導向的方式把設定資訊封裝起來。也就是說,選項類可以與設定資訊做繫結。

咱們在定義選項類的時候,習慣讓類名以「Options」結尾。這也不是什麼硬規則,只不過易於理解罷了。你看到某個類以「Options」結尾,你就可以猜到它的用處——設定選項引數用的,其實也就是設定。

假設有這麼個類。

    public class TestOptions
    {
        public int Key1 { get; set; }
        public string? Key2 { get; set; }
    }

看著很是簡單,就兩個屬性。

在 appsettings.json 檔案中,我們可以加上這樣一個節點:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "myConfig": {
    "key1": 5055,
    "key2": "Speaking Chinglish"
  }
}

如你所見,「myConfig」 節點對應的那個 JObject 的屬性結構和 TestOptions 類相同。

appsettings.json 在應用程式初始化時是自動新增的,我們不需要再設定,直接改檔案就行,不用再添程式碼。IConfiguration 介面有個擴充套件方法 Bind,可以將設定資訊與某個型別物件直接繫結。

app.MapGet("/", () =>
{
    IConfigurationRoot config = (IConfigurationRoot)app.Configuration;
    // 找出我們要的節
    IConfigurationSection myconfig = config.GetSection("myConfig");

    TestOptions options = new();
    // 直接繫結
    myconfig.Bind(options);
    // 看看結果
    return $"Key1 = {options.Key1}\nKey2 = {options.Key2}";
});

從 JSON 檔案讀出的設定資訊可以直接與 TestOptions 範例繫結。這樣,在程式碼執行後,能看到咱們剛剛在 JSON 檔案中設定的內容。

 

上面這種方法雖然能做到了繫結,但用起來還費勁。為啥不直接弄進服務容器中,來個依賴注入,豈不美哉?

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.Configure<TestOptions>(builder.Configuration.GetSection("myConfig"));
var app = builder.Build();

Configure<TOptions> 是個擴充套件方法,它用來對選項類進行設定——就是為選項類的屬性賦值。選項模式不僅限於把設定資訊強型別化,它通用於應用程式內各種功能設定。如紀錄檔怎麼記錄、驗證策略、Cookie 策略等。

這裡注意在依賴注入時,我們不是直接用 TestOptions 型別,而是 IOptions<TOptions> 介面。比如,我們可以在 MVC 控制器中這樣玩:

    public class HomeController : Controller
    {
        readonly TestOptions _options;

        // 建構函式,獲取注入的物件
        public HomeController(IOptions<TestOptions> wrapper)
        {
            _options = wrapper.Value;
        }

        public IActionResult Index()
        {
            // 這裡存取選項
            string s = $"Key1: {_options.Key1}\n";
            s += $"Key2: {_options.Key2}";
            return Content(s);
        }
    }

 

其實,咱們可以用的介面型別並不只有 IOptions<>,不妨看看 AddOptions 擴充套件方法的原始碼(OptionsServiceCollectionExtensions.cs)

   public static IServiceCollection AddOptions(this IServiceCollection services)
   {
       ThrowHelper.ThrowIfNull(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;
   }

以上原始碼解釋了為什麼依賴注入時用的是 IOptions<> 介面,在容器中註冊時用的就是這個介面嘛。其實,你可以用 IOptionsMonitor<TestOptions>。至於這幾個介面有啥區別,我們先不管,下一個世紀再告訴你。

AddOptions 方法我們一般不需要呼叫,ASP.NET Core 應用程式初始化時自動呼叫了。

為了使選項模式有更好的適用性,我們也可以通過委託來設定選項類。

builder.Services.Configure<TestOptions>(opt =>
{
    opt.Key1 = 999;
    opt.Key2 = "Hi, baby";
});

至此,Configure 方法的兩種用法就出來了:

1、用 IConfigure 物件,從設定資訊源中載入,並填充選項類的屬性值(這個挺像反序列化操作);

2、直接用委託。

不管是設定資訊還是委託,Configure 方法只是向服務容器新增了一組 IConfigureOptions<TOptions> 物件。它的實現型別有 ConfigureOptions<TOptions>、ConfigureNamedOptions<TOptions> 等。

我們以 ConfigureOptions<TOptions> 類為例分析一下,因為這個類比較簡單

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

        public Action<TOptions>? Action { get; }

        public virtual void Configure(TOptions options)
        {
             ……
            Action?.Invoke(options);
        }
    }

看到建構函式時,你有沒有發現點什麼?看,它是不是有個委託 Action<TOptions>,我們再回頭看看剛剛我們在服務容器呼叫的 Configure 擴充套件方法,它是不是有一個過載版本也有個 Action<TOptions> 的引數。

builder.Services.Configure<TestOptions>(opt =>
{
    opt.Key1 = 999;
    opt.Key2 = "Hi, baby";
});

對的,我們傳給它的委託就是傳到了 ConfigureOptions 的建構函式裡,ConfigureOptions 實現的就是 IConfigureOptions<TOptions> 介面。哦,原來是籍樣子滴。我們每呼叫一次 Configure 方法,它就向容器註冊一個 IConfigureOptions。

這只是放進了容器中罷了,並沒有馬上執行我們寫的委託。

 

那,它們在哪裡被呼叫呢?在工廠裡面——IOptionsFactory<TOptions>。

    public interface IOptionsFactory<TOptions>
        where TOptions : class
    {
        TOptions Create(string name);
    }

別小看這貨,它可是核心角色。選項類的範例就是由它來建立的(實現 Create 方法)。你會注意到,這裡有個 name 引數,幹嗎的?這個是為選項的命名分組準備。一般可以不理會它,除非你要實現不同分組產生不太一樣的選項類範例,這樣就用得上了。

其實,如果需要,我們不妨為 TestOptions 類寫個小工廠(家庭小作坊)。

    public class TestOptionsFactory : IOptionsFactory<TestOptions>
    {
        public TestOptions Create(string name)
        {
            return new TestOptions();
        }
    }

簡單吧,然後咱用呢,不用管它咋用,你只要註冊到服務容器中就行了。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddTransient<IOptionsFactory<TestOptions>, TestOptionsFactory>();

這裡我註冊為暫時性服務,你也可以考慮註冊為其他的,不衝突就行。

你要是有疑問,我自定義的工廠它會執行嗎?會的,不信打個斷點看看。

 

 然後執行程式,瞧,這不,停下來了。

 

 

可,可,可是,這TNND,為什麼是預設值,我剛剛不是 Configure 用委託設定了嗎?

 

 

builder.Services.Configure<TestOptions>(opt =>
{
    opt.Key1 = 999;
    opt.Key2 = "Hi, baby";
});

無效?出啥故障了?等等,剛剛是不是說了,Configure 方法每呼叫一次就會註冊一個 IConfigureOptions<TOptions> 嗎?

想起來了,所以我們的工廠要改造一下,通過依賴注入把這一堆 IConfigureOptions 弄進來,然後逐個呼叫,這樣就可以為選項類的屬性賦值了。

    public class TestOptionsFactory : IOptionsFactory<TestOptions>
    {
        readonly IEnumerable<IConfigureOptions<TestOptions>> _configs;
        public TestOptionsFactory(IEnumerable<IConfigureOptions<TestOptions>> configs)
        {
            _configs = configs;
        }

        public TestOptions Create(string name)
        {
            TestOptions opt = new();
            foreach(var cfg in _configs)
            {
                cfg.Configure(opt);
            }
            return opt;
        }
    }

看,這就成了。

 

 其實,工廠所需要的依賴物件並不只有 IConfigureOptions,還有兩個介面,需要了解一下:

 1、IPostConfigureOptions:這貨史稱「後期設定」。其用法和 IConfigureOptions 一樣的。對應的服務容器擴充套件方法是 PostConfigure<TOptions>,用法和 Configure<TOptions> 擴充套件方法完全一樣的。它的用途是進行選項設定後做一些「修修補補」。比如,看看你還有哪些屬性沒賦值的,給它安排個預設值。有人會說,那我在建立選項類範例時就為每個屬性分配預設值不就行了嗎?還要啥後期設定?舉個例子:

    public class ProgressOptions
    {
        public int Max { get; set; }
        public int Min { get; set; }
        public int Current { get; set; }
    }

就算我在工廠類中範例化時分配 Max = 100, Min = 0, Current = 50。那,如果我設定的時候是這樣的呢:

builder.Services.Configure<ProgressOptions>(o =>
{
    o.Max = 60;
    o.Min = 0;
});

假如我沒設定 Current 屬性,預設規則是它取中值,那這時候取中值 50 顯然就不行了。而是得根據 Max 和 Min 的值來決定。這時候用後期設定就很必要了。

builder.Services.PostConfigure<ProgressOptions>(o =>
{
    o.Current = (o.Max - o.Min) / 2;
});

2、IValidateOptions:這貨用來做驗證用的。這個和上面的「修修補補」不同。驗證是檢查選項類的屬性值是否符合要求。如果不符合,直接給你來個異常——回爐重造。

 

我們不妨看看預設的工作是怎麼實現的。

    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;

        public OptionsFactory(IEnumerable<IConfigureOptions<TOptions>> setups, IEnumerable<IPostConfigureOptions<TOptions>> postConfigures) : this(setups, postConfigures, validations: Array.Empty<IValidateOptions<TOptions>>())
        { }
      
        public OptionsFactory(IEnumerable<IConfigureOptions<TOptions>> setups, IEnumerable<IPostConfigureOptions<TOptions>> postConfigures, IEnumerable<IValidateOptions<TOptions>> validations)
        {
            _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();
        }

        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.Length > 0)
            {
                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;
        }

        /// <summary>
        /// 這裡是建立選項類範例的地方,為了通用化,它用了 Activator
        /// </summary>
        protected virtual TOptions CreateInstance(string name)
        {
            return Activator.CreateInstance<TOptions>();
        }
    }

從其原始碼我們知道它們的執行順序:

N多個IConfigureOptions ---> N多個IPostConfigureOptions ---> N多個IValidateOptions

 

好了,下面咱們解決最後一個疑問:選項類的範例怎麼跑到 IOptions<TOptions> 裡面的?通過前面的例子,咱們都知道,通過依賴注入存取選項類時,是通過 IOptions<> 等介面的。咋關聯起來的?還是跟 Factory 有關,不然就不會說它是核心角色。

這裡老周只說一對服務型別,其他的實現類似。在 AddOptions 擴充套件方法中,通過註冊的服務型別得知,IOptions<> 對應的是 UnnamedOptionsManager。這個類沒有公開,它的原始碼如下:

    internal sealed class UnnamedOptionsManager<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);
                }
            }
        }
    }

現在知道它們怎麼關聯起來了吧——還是一樣的套路,用依賴注入。

 

經過老週上文一系列胡說八道,這些與選項模式有關的介面之間的關係就清晰了。