.NET Core 選項系統的主要實現在 Microsoft.Extensions.Options 和 Microsoft.Extensions.Options.ConfigurationExtensions 兩個 Nuget 包。對於一個框架的原始碼進行解讀,我們可以從我們常用的框架中的類或方法入手,這些類或方法就是我們解讀的入口。
從上面對選項系統的介紹中,大家也可以看出,日常對選項系統的使用涉及到的主要有 Configure 方法,有 IOptions
首先看選項註冊,也就是 Configure 方法,註冊相關的方法都是擴充套件方法,上面也講到 Configure 方法有多個擴充套件來源,其中最常用的是 OptionsConfigurationServiceCollectionExtensions 中的 Configure 方法,該方法用於從設定資訊中讀取設定並繫結為選項,如下,這裡將相應的方法單獨摘出來了。
public static class OptionsConfigurationServiceCollectionExtensions
{
/// <summary>
/// Registers a configuration instance which TOptions will bind against.
/// </summary>
/// <typeparam name="TOptions">The type of options being configured.</typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <param name="config">The configuration being bound.</param>
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
[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);
/// <summary>
/// Registers a configuration instance which TOptions will bind against.
/// </summary>
/// <typeparam name="TOptions">The type of options being configured.</typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <param name="name">The name of the options instance.</param>
/// <param name="config">The configuration being bound.</param>
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
[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, _ => { });
/// <summary>
/// Registers a configuration instance which TOptions will bind against.
/// </summary>
/// <typeparam name="TOptions">The type of options being configured.</typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <param name="config">The configuration being bound.</param>
/// <param name="configureBinder">Used to configure the <see cref="BinderOptions"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
[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);
/// <summary>
/// Registers a configuration instance which TOptions will bind against.
/// </summary>
/// <typeparam name="TOptions">The type of options being configured.</typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <param name="name">The name of the options instance.</param>
/// <param name="config">The configuration being bound.</param>
/// <param name="configureBinder">Used to configure the <see cref="BinderOptions"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
[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.Options.DefaultName,實際就是 string.Empty。當我們呼叫 Configure 方法對選項進行設定的時候,實際上時呼叫了 AddOptions 方法,並且往容器中新增了一個單例的實現了 IConfigureOptions
其中 IConfigureOptions
public class ConfigureOptions<TOptions> : IConfigureOptions<TOptions> where TOptions : class
{
/// <summary>
/// Constructor.
/// </summary>
/// <param name="action">The action to register.</param>
public ConfigureOptions(Action<TOptions> action)
{
Action = action;
}
/// <summary>
/// The configuration action.
/// </summary>
public Action<TOptions> Action { get; }
/// <summary>
/// Invokes the registered configure <see cref="Action"/>.
/// </summary>
/// <param name="options">The options instance to configure.</param>
public virtual void Configure(TOptions options)
{
if (options == null)
{
throw new ArgumentNullException(nameof(options));
}
Action?.Invoke(options);
}
}
IConfigureNamedOptions
/// <summary>
/// The options name.
/// </summary>
public string Name { get; }
/// <summary>
/// The configuration action.
/// </summary>
public Action<TOptions> Action { get; }
/// <summary>
/// Invokes the registered configure <see cref="Action"/> if the <paramref name="name"/> matches.
/// </summary>
/// <param name="name">The name of the options instance being configured.</param>
/// <param name="options">The options instance to configure.</param>
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);
}
}
/// <summary>
/// Invoked to configure a <typeparamref name="TOptions"/> instance with the <see cref="Options.DefaultName"/>.
/// </summary>
/// <param name="options">The options instance to configure.</param>
public void Configure(TOptions options) => Configure(Options.DefaultName, options);
}
</details>
而 NamedConfigureFromConfigurationOptions<TOptions> 類是 IConfigureNamedOptions<TOptions> 的另一個實現,繼承了ConfigureNamedOptions<TOptions> 類,重寫了一些行為,最終是通過之前講到的 ConfigurationBuilder的 Bind 方法將設定繫結到選項類而已。
<details>
<summary>點選檢視程式碼 NamedConfigureFromConfigurationOptions<TOptions></summary>
```csharp
public class NamedConfigureFromConfigurationOptions<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TOptions> : ConfigureNamedOptions<TOptions>
where TOptions : class
{
/// <summary>
/// Constructor that takes the <see cref="IConfiguration"/> instance to bind against.
/// </summary>
/// <param name="name">The name of the options instance.</param>
/// <param name="config">The <see cref="IConfiguration"/> instance.</param>
[RequiresUnreferencedCode(OptionsBuilderConfigurationExtensions.TrimmingRequiredUnreferencedCodeMessage)]
public NamedConfigureFromConfigurationOptions(string name, IConfiguration config)
: this(name, config, _ => { })
{ }
/// <summary>
/// Constructor that takes the <see cref="IConfiguration"/> instance to bind against.
/// </summary>
/// <param name="name">The name of the options instance.</param>
/// <param name="config">The <see cref="IConfiguration"/> instance.</param>
/// <param name="configureBinder">Used to configure the <see cref="BinderOptions"/>.</param>
[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 方法有兩個過載:
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);
}
}
</details>
這裡可以看出兩者的返回值不同,而且第二個方法也呼叫了第一個方法,第一個方法中主要就是向容器中新增我們常用的IOptions<TOptions>、IOptionsSnapshot<TOptions>、IOptionsMonitor<TOptions> 服務介面,這裡也可以看到不同服務介面對於的生命週期。除此之外還有工廠服務IOptionsFactory<>和快取服務IOptionsMonitorCache<>,這兩個就是選項體系的關鍵。每個選項進行設定的時候都會同時注入這些服務,所以每一個選項我們都能使用三個不同介面去解析。
# OptionsBuilder
上面第二個 AddOptions 方法返回 OptionsBuilder<TOptions> 物件。之前講過 OptionsBuilder<TOptions> 類中也有 Configure 方法,其實不止 Configure 方法,其他的 PostConfigure 方法等也有,它其實就是最終的選項系統設定類,我們所有的選項設定其實都可以通過呼叫第二個 AddOptions 方法,再通過 OptionsBuilder<TOptions> 物件中的方法來完成設定。其他各個擴充套件方法的設定方式不過是進行了使用簡化而已。
<details>
<summary>點選檢視程式碼 OptionsBuilder<TOptions></summary>
```csharp
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;
}
}
我們除了可以對選項進行設定繫結之外,還可以對選項進行驗證。驗證規則是通過上面的第二個 AddOptions 方法返回的 OptionsBuilder
驗證規則設定有三種方式,最後其實都是通過 IValidateOptions
ValidateOptions
public class ValidateOptions<TOptions> : IValidateOptions<TOptions> where TOptions : class
{
/// <summary>
/// Constructor.
/// </summary>
/// <param name="name">Options name.</param>
/// <param name="validation">Validation function.</param>
/// <param name="failureMessage">Validation failure message.</param>
public ValidateOptions(string name, Func<TOptions, bool> validation, string failureMessage)
{
Name = name;
Validation = validation;
FailureMessage = failureMessage;
}
/// <summary>
/// The options name.
/// </summary>
public string Name { get; }
/// <summary>
/// The validation function.
/// </summary>
public Func<TOptions, bool> Validation { get; }
/// <summary>
/// The error to return when validation fails.
/// </summary>
public string FailureMessage { get; }
/// <summary>
/// Validates a specific named options instance (or all when <paramref name="name"/> is null).
/// </summary>
/// <param name="name">The name of the options instance being validated.</param>
/// <param name="options">The options instance.</param>
/// <returns>The <see cref="ValidateOptionsResult"/> result.</returns>
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
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
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
/// <summary>
/// Initializes a new instance with the specified options configurations.
/// </summary>
/// <param name="factory">The factory to use to create options.</param>
public OptionsManager(IOptionsFactory<TOptions> factory)
{
_factory = factory;
}
/// <summary>
/// The default configured <typeparamref name="TOptions"/> instance, equivalent to Get(Options.DefaultName).
/// </summary>
public TOptions Value => 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;
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
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;
/// <summary>
/// Constructor.
/// </summary>
/// <param name="factory">The factory to use to create options.</param>
/// <param name="sources">The sources used to listen for changes to the options instance.</param>
/// <param name="cache">The cache used to store options.</param>
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);
}
}
/// <summary>
/// The present value of the options.
/// </summary>
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));
}
/// <summary>
/// Registers a listener to be called whenever <typeparamref name="TOptions"/> changes.
/// </summary>
/// <param name="listener">The action to be invoked when <typeparamref name="TOptions"/> has changed.</param>
/// <returns>An <see cref="IDisposable"/> which should be disposed to stop listening for changes.</returns>
public IDisposable OnChange(Action<TOptions, string> listener)
{
var disposable = new ChangeTrackerDisposable(this, listener);
_onChange += disposable.OnChange;
return disposable;
}
/// <summary>
/// Removes all change registration subscriptions.
/// </summary>
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 物件銷燬的時候能夠將相應的事件釋放,如果不包裝多一層的話,委託只在方法作用域中,物件釋放的時候是獲取不到的。
OptionsCache 是 IOptionsMonitorCache 介面的的實現類,從上面可以看到 OptionsMonitor
OptionsCache 的具體實現比較簡單,主要就是通過 ConcurrentDictionary<string, Lazy
OptionsFactory
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;
/// <summary>
/// Initializes a new instance with the specified options configurations.
/// </summary>
/// <param name="setups">The configuration actions to run.</param>
/// <param name="postConfigures">The initialization actions to run.</param>
public OptionsFactory(IEnumerable<IConfigureOptions<TOptions>> setups, IEnumerable<IPostConfigureOptions<TOptions>> postConfigures) : this(setups, postConfigures, validations: Array.Empty<IValidateOptions<TOptions>>())
{ }
/// <summary>
/// Initializes a new instance with the specified options configurations.
/// </summary>
/// <param name="setups">The configuration actions to run.</param>
/// <param name="postConfigures">The initialization actions to run.</param>
/// <param name="validations">The validations to run.</param>
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();
}
/// <summary>
/// Returns a configured <typeparamref name="TOptions"/> instance with the given <paramref name="name"/>.
/// </summary>
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;
}
/// <summary>
/// Creates a new instance of options type
/// </summary>
protected virtual TOptions CreateInstance(string name)
{
return Activator.CreateInstance<TOptions>();
}
}
以上就是 .NET Core 下的選項系統,由於選項系統的原始碼不多,這裡也就將大部分類都拿出來講了一下,相當於把這個框架的流程思路都講了一遍,不知不覺寫得字數又很多了,希望有童鞋能夠耐心地看到這裡。
參考文章:
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 - 選項系統之選項驗證