Asp.NetCore源碼學習[1-1]:配置[Configuration]

Asp.NetCore源碼學習[1-1]:配置[Configuration]

在Asp. NetCore中,配置系統支持不一樣的配置源(文件、環境變量等),雖然有多種的配置源,可是最終提供給系統使用的只有一個對象,那就是ConfigurationRoot。其內部維護了一個集合,用於保存各類配置源的IConfigurationProviderIConfigurationProvider提供了對配置源的實際訪問。當經過key去ConfigurationRoot查找對應的Value時,實際上會經過遍歷IConfigurationProvider去查找對應的鍵值。 本篇文章主要描述ConfigurationRoot對象的構建過程。git

本系列源碼地址

1. Asp.NetCore 入口點代碼

CreateWebHostBuilder(args).Build().Run();

2. Asp.NetCore 部分源碼

WebHostBuilder內部維護了_configureAppConfigurationBuilder字段,其類型是 Action<WebHostBuilderContext, IConfigurationBuilder>,該委託用於對ConfigurationBuilder進行配置。首先在構造函數中先將環境變量的配置加載到 _config 字段中,用於設置默認監聽目錄爲程序執行目錄。CreateDefaultBuilder方法中經過調用ConfigureAppConfiguration方法保存委託,而後在Build方法中構建配置系統目標類ConfigurationRoot,最後經過單例模式注入到依賴系統中。github

public class WebHostBuilder
{
    private Action<WebHostBuilderContext, IConfigurationBuilder> _configureAppConfigurationBuilder;
    
    private IConfiguration _config;
    
    public WebHostBuilder()
    {
        _hostingEnvironment = new HostingEnvironment();
        /// 
        _config = new ConfigurationBuilder()
            .AddEnvironmentVariables(prefix: "ASPNETCORE_")
            .Build();
    }
    
    public IWebHostBuilder ConfigureAppConfiguration(Action<WebHostBuilderContext, IConfigurationBuilder> configureDelegate)
    {
        _configureAppConfigurationBuilder += configureDelegate;
        return this;
    }
    
    public IWebHost Build()
    {
        var builder = new ConfigurationBuilder();
        //經過委託配置IConfigurationBuilder
        _configureAppConfigurationBuilder?.Invoke(_context, builder);
        //構建ConfigurationRoot
        var configuration = builder.Build();
        // register configuration as factory to make it dispose with the service provider
        services.AddSingleton<IConfiguration>(_ => configuration);
    }
}
public static IWebHostBuilder CreateDefaultBuilder(string[] args)
{
    var builder = new WebHostBuilder();
    builder.ConfigureAppConfiguration((hostingContext, config) =>
    {
        //爲 IConfigurationBuilder 註冊配置源(JsonConfigurationSource)
        config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
    });
    return builder;
}

3. 參照以上 Asp.NetCore 代碼,寫靜態測試方法

public class ConfigurationTest
    {
        public static void Run()
        {
            //1.實例化ConfigurationBuilder
            var builder = new ConfigurationBuilder();
            //2.增長配置源
            builder.AddJsonFile(null, "appsettings.json", true,true);
            //3.構建ConfigurationRoot對象
            var configuration = builder.Build();
            //觀察ConfigurationRoot是否發生更改
            Task.Run(() => {
                ChangeToken.OnChange(() => configuration.GetReloadToken(), () => {
                    Console.WriteLine("Configuration has changed");
                });
            });
            Thread.Sleep(60000);
        }
    }

4. 經過ConfigurationBuilder類構建目標類ConfigurationRoot

ConfigurationBuilder是配置系統的構建類,經過Build方法構建配置系統的目標類ConfigurationRoot。其維護了一個用於保存IConfigurationSource的集合,IConfigurationSource用於提供IConfigurationProvider。在Build方法中,遍歷IList 構建IConfigurationProvider對象,而後將IConfigurationProvider集合傳到ConfigurationRoot的構造函數中。代碼以下:json

/// <summary>
    /// 配置系統構建類
    /// </summary>
    public class ConfigurationBuilder : IConfigurationBuilder
    {
        /// 配置源集合
        public IList<IConfigurationSource> Sources { get; } = new List<IConfigurationSource>();

        /// 增長一個新的配置源
        public IConfigurationBuilder Add(IConfigurationSource source)
        {
            if (source == null)
            {
                throw new ArgumentNullException(nameof(source));
            }
            Sources.Add(source);
            return this;
        }

        /// 經過配置源中提供的IConfigurationProvider構建配置根對象ConfigurationRoot
        public IConfigurationRoot Build()
        {
            var providers = new List<IConfigurationProvider>();
            foreach (var source in Sources)
            {
                var provider = source.Build(this);
                providers.Add(provider);
            }
            return new ConfigurationRoot(providers);
        }
    }

IConfigurationSource對象不單單用於建立IConfigurationProvider,還保存了構建IConfigurationProvider須要的依賴和配置選項。併發


4.1 ConfigurationRoot 類實現

該類經過IList 進行初始化。其內部維護了類型爲ConfigurationReloadToken的字段,該字段提供給外部,來進行全部配置源的監聽。每一個IConfigurationProvider對象一樣維護了類型爲ConfigurationReloadToken的字段。當IConfigurationProvider監測到配置源發生更改時,更改IConfigurationProvider.IChangeToken的狀態
在構造函數中執行如下操做:app

  • 1 調用IConfigurationProvider.Load()從配置源(文件、環境變量等)加載配置項
  • 2 經過ChangeToken.OnChange()方法 監聽每一個IConfigurationProvider.IChangeToken的狀態改變,當其狀態發生改變時更改ConfigurationRoot.IChangeToken的狀態。(在ConfigurationRoot外部能夠經過監聽IChangeToken狀態的改變,得知配置源發生了改變)
/// <summary>
    /// 配置系統的根節點
    /// </summary>
    public class ConfigurationRoot : IConfigurationRoot, IDisposable
    {
        private readonly IList<IConfigurationProvider> _providers;
        private readonly IList<IDisposable> _changeTokenRegistrations;
        private ConfigurationReloadToken _changeToken = new ConfigurationReloadToken();

        /// <summary>
        /// 使用IConfigurationProvider集合初始化ConfigurationRoot
        /// </summary>
        /// <param name="providers">The <see cref="IConfigurationProvider"/>s for this configuration.</param>
        public ConfigurationRoot(IList<IConfigurationProvider> providers)
        {
            _providers = providers ?? throw new ArgumentNullException(nameof(providers));

            _changeTokenRegistrations = new List<IDisposable>(providers.Count);
            foreach (var p in providers)
            {
                p.Load();
                //將每一個IConfigurationProvider的change token與ConfigurationRoot 的change token綁定
                //當IConfigurationProvider._cts.Cancel()觸發時,觸發當ConfigurationRoot._cts.Cancel()
                _changeTokenRegistrations.Add(ChangeToken.OnChange(() => p.GetReloadToken(), () => RaiseChanged()));
            }
        }

        public IEnumerable<IConfigurationProvider> Providers => _providers;

        /// 遍歷_providers來設置、獲取配置項的鍵值對
        public string this[string key]
        {
            get
            {
                for (var i = _providers.Count - 1; i >= 0; i--)
                {
                    var provider = _providers[i];

                    if (provider.TryGet(key, out var value))
                    {
                        return value;
                    }
                }

                return null;
            }
            set
            {
                if (!_providers.Any())
                {
                    throw new InvalidOperationException("Can't find any IConfigurationProvider");
                }

                foreach (var provider in _providers)
                {
                    provider.Set(key, value);
                }
            }
        }

        /// 獲取IChangeToken,用於供外部使用者收到配置改變的消息通知 
        public IChangeToken GetReloadToken() => _changeToken;

        public void Reload()
        {
            foreach (var provider in _providers)
            {
                provider.Load();
            }
            RaiseChanged();
        }

        /// 生成一個新的change token,並觸發ConfigurationRoot的change token(舊)狀態改變
        private void RaiseChanged()
        {
            var previousToken = Interlocked.Exchange(ref _changeToken, new ConfigurationReloadToken());
            previousToken.OnReload();
        }

        /// <inheritdoc />
        public void Dispose()
        {
            // dispose change token registrations
            foreach (var registration in _changeTokenRegistrations)
            {
                registration.Dispose();
            }

            // dispose providers
            foreach (var provider in _providers)
            {
                (provider as IDisposable)?.Dispose();
            }
        }
    }

4.2 ConfigurationReloadToken 的實現

其使用適配器模式,經過CancellationTokenSource實現IChangeToken接口。代碼以下:asp.net

/// <summary>
    /// 用於發送更改通知
    /// </summary>
    public interface IChangeToken
    {
        /// 指示是否發生更改
        bool HasChanged { get; }

        /// 指示token是否會主動調用callbacks,false的狀況下:token的消費者須要輪詢 HasChanged 屬性檢測是否發生更改
        bool ActiveChangeCallbacks { get; }

        /// 註冊回調函數, 更改發生時(HasChanged爲true),會被調用(只會被調用一次)
        IDisposable RegisterChangeCallback(Action<object> callback, object state);
    }
/// 基於CancellationTokenSource實現IChangeToken接口(適配器模式)
    public class ConfigurationReloadToken:IChangeToken
    {
        private CancellationTokenSource _cts = new CancellationTokenSource();

        /// CancellationTokenSource會主動調用callbacks,因此爲true
        public bool ActiveChangeCallbacks => true;

        public bool HasChanged => _cts.IsCancellationRequested;

        public IDisposable RegisterChangeCallback(Action<object> callback, object state) => _cts.Token.Register(callback, state);

        public void OnReload() => _cts.Cancel();
    }

4.3 簡述CancellationTokenSource 對象

基於協做取消模式設計的對象,用於取消異步操做或者長時間同步操做。( .NET指南/取消託管線程)異步

image

CancellationTokenSource對象的特色:ide

  • 1 CancellationTokenSource.Token是值類型,傳遞副本
  • 2 調用 CancellationTokenSource.Cancel 方法提供取消通知後,CancellationTokenSource.Token的狀態發生改變,調用callbacks,並改變全部Token副本的狀態
  • 3 須要調用dispose釋放CancellationTokenSource
  • 4 屢次調用CancellationTokenSource.Cancel,callbacks也只會執行一次
  • 5 再CancellationTokenSource.Cancel以後,新註冊的callback一樣也會被執行

4.4 經過ChangeToken.OnChange 靜態方法實現更改通知的持續消費

因爲CancellationTokenSource.Cancel只會觸發一次callbacks,須要ChangeToken.OnChange來實現持續監聽取消通知。
實現原理:每次須要發生更改通知時,首先生成一個新的cts,而後改變舊的cts狀態,觸發回調函數,最後將新的cts與回調函數綁定。函數

/// <summary>
    /// 將changeToken消費者註冊到IChangeToken的回調函數中,並實現IChangeToken狀態改變的持續消費
    /// </summary>
    public static class ChangeToken
    {
        /// 爲changetoken生產者綁定消費者. 
        /// 1.在IChangeToken的狀態未改變的狀況下,生產者每次返回相同的IChangeToken
        /// 2.狀態改變時,生產者生成新的IChangeToken,消費者執行響應動做,爲新的IChangeToken綁定消費者,釋放舊的IChangeToken
        public static IDisposable OnChange(Func<IChangeToken> changeTokenProducer, Action changeTokenConsumer)
        {
            if (changeTokenProducer == null)
            {
                throw new ArgumentNullException(nameof(changeTokenProducer));
            }
            if (changeTokenConsumer == null)
            {
                throw new ArgumentNullException(nameof(changeTokenConsumer));
            }
            return new ChangeTokenRegistration<Action>(changeTokenProducer, callback => callback(), changeTokenConsumer);
        }

        private class ChangeTokenRegistration<TState> : IDisposable
        {
            private readonly Func<IChangeToken> _changeTokenProducer;
            private readonly Action<TState> _changeTokenConsumer;
            private readonly TState _state;
            private IDisposable _disposable;//用於保存當前正在使用的 IChangeToken

            private static readonly NoopDisposable _disposedSentinel = new NoopDisposable();

            public ChangeTokenRegistration(Func<IChangeToken> changeTokenProducer, Action<TState> changeTokenConsumer, TState state)
            {
                _changeTokenProducer = changeTokenProducer;
                _changeTokenConsumer = changeTokenConsumer;
                _state = state;

                var token = changeTokenProducer();

                RegisterChangeTokenCallback(token);
            }

            /// 1.先執行消費者動做,再綁定新的token,防止消費者執行並發動做
            /// 2.不然的話可能出現如下狀況:若是在綁定新token的回調方法後,而且在執行callback以前,新token的狀態發生了改變,此時第二次callback也會執行,這樣會形成callback的併發執行。
            private void OnChangeTokenFired()
            {
                var token = _changeTokenProducer();

                try
                {
                    _changeTokenConsumer(_state);
                }
                finally
                {
                    RegisterChangeTokenCallback(token);
                }
            }

            private void RegisterChangeTokenCallback(IChangeToken token)
            {
                var registraton = token.RegisterChangeCallback(s => ((ChangeTokenRegistration<TState>)s).OnChangeTokenFired(), this);
                SetDisposable(registraton);
            }

            /// 1.將當前使用的IChangeToken保存到_disposable字段中
            /// 2.若是本對象已經釋放,馬上釋放新產生的 IChangeToken
            /// 3.已經失效的 IChangeToken 因爲已經不被引用,等待GC自動釋放(爲何不手動釋放?)
            private void SetDisposable(IDisposable disposable)
            {
                // 讀取當前保存的 IChangeToken
                var current = Volatile.Read(ref _disposable);

                // 若是本對象已經釋放,馬上釋放新產生的IChangeToken
                if (current == _disposedSentinel)
                {
                    disposable.Dispose();
                    return;
                }

                // 不然更新_disposable字段,返回原值
                var previous = Interlocked.CompareExchange(ref _disposable, disposable, current);

                // current = 以前的 IChangeToken
                
                if (previous == _disposedSentinel)
                {
                    // 更新失敗 說明對象已釋放 previous = _disposedSentinel
                    // 本對象已經釋放,馬上釋放新產生的IChangeToken
                    disposable.Dispose();
                }
                else if (previous == current)
                {
                    // 更新成功 previous 是以前的 IChangeToken
                }
                else
                {
                    // 若是其餘人爲 _disposable賦值,且值不爲 _disposedSentinel
                    // 會形成對象未釋放、更新失敗的狀況
                    throw new InvalidOperationException("Somebody else set the _disposable field");
                }
            }

            // 釋放當前保存的 IChangeToken,將字段賦值爲_disposedSentinel
            public void Dispose()
            {
                Interlocked.Exchange(ref _disposable, _disposedSentinel).Dispose();
            }

            private class NoopDisposable : IDisposable
            {
                public void Dispose()
                {
                }
            }
        }
    }

4.6 ChangeToken.OnChange 測試方法

該測試方法經過一個ChangeTokenProducer類來模擬內部的狀態改變。經過內部維護一個ConfigurationReloadToken,能夠向外部發出更改通知(一次性)。爲了實現向外部持續發出更改通知,能夠在更改ConfigurationReloadToken狀態以前,從新實例化有一個新的IChangeToken,供外部從新綁定回調方法。oop

class ChangeTokenTest
    {
        public static void Run() {
            var ctsProducer = new ChangeTokenProducer();
            var subscriber = ChangeToken.OnChange(() => ctsProducer.GetReloadToken(), () =>
            {
                Console.WriteLine("消費者觀察到改變事件");
            });
            Console.ReadLine();
        }

        /// <summary>
        /// 假設該類須要在內部狀態發生改變時向外界發送更改通知
        /// </summary>
        private class ChangeTokenProducer
        {
            // cts只能執行一次相應動做
            private ConfigurationReloadToken _changetoken = new ConfigurationReloadToken();

            /// <summary>
            /// 模擬狀態改變
            /// </summary>
            public ChangeTokenProducer()
            {
                Task.Run(()=> {
                    while (true)
                    {
                        Thread.Sleep(3000);//模擬耗時
                        //內部狀態發生改變,通知外部
                        RaiseChanged();
                    }
                });
            }

            public IChangeToken GetReloadToken () => _changetoken;

            private void RaiseChanged() {
                //產生新的cts
                var previousToken = Interlocked.Exchange(ref _changetoken, new ConfigurationReloadToken());
                //觸發老的cts動做
                //外界執行響應動做時,經過GetReloadToken()獲取新的cts,執行相應動做,並從新綁定回調函數
                previousToken.OnReload();
            }
        }
    }

5. IConfigurationSource 的實現

IConfigurationSource擁有一個實現IFileProvider接口的類屬性。默認實現爲PhysicalFileProvider類,文件監控目錄默認爲程序集根目錄。該類提供文件的訪問和監控功能。在Build方法中實例化JsonFileConfigurationProvider,並將自身傳遞進去。
在.NetCore源碼中JsonConfigurationSource 是繼承 抽象類FileConfigurationSource 的。此處合併了兩個類的代碼。

public class JsonFileConfigurationSource : IConfigurationSource
    {
        public IFileProvider FileProvider { get; set; }

        public IConfigurationProvider Build(IConfigurationBuilder builder) {
            EnsureDefaults(builder);
            return new JsonFileConfigurationProvider(this);
        }

        public void EnsureDefaults(IConfigurationBuilder builder)
        {
            FileProvider = FileProvider ?? builder.GetFileProvider();
        }
    }
    
    public static class FileConfigurationExtensions 
    {
        /// 獲取默認的IFileProvider,root目錄默認爲程序集根目錄(AppContext.BaseDirectory)
        public static IFileProvider GetFileProvider(this IConfigurationBuilder builder)
        {
            if (builder == null)
            {
                throw new ArgumentNullException(nameof(builder));
            }
            return new PhysicalFileProvider(AppContext.BaseDirectory ?? string.Empty);
        }
    }

6. IConfigurationProvider 的實現

在Core的源碼中繼承關係爲JsonConfigurationProvider: FileConfigurationProvider:ConfigurationProvider:IConfigurationProvider
。本項目代碼合併了JsonConfigurationProvider FileConfigurationProvider這兩個類

6.1 ConfigurationProvider 的實現

該類使用一個字典用於保存配置項的字符串鍵值對。並擁有一個類型爲 ConfigurationReloadToken 的字段。在配置文件發生更改時,_reloadToken的狀態發生改變,外部能夠經過觀察該字段的狀態來得知配置文件發生更改。

/// <summary>
    /// 配置提供者抽象類
    /// </summary>
    public abstract class ConfigurationProvider : IConfigurationProvider
    {
        private ConfigurationReloadToken _reloadToken = new ConfigurationReloadToken();

        /// 初始化存儲配置的字典,鍵值忽略大小寫
        protected ConfigurationProvider()
        {
            Data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        }
        
        /// 存儲配置的鍵值對,protected只能在子類中訪問
        protected IDictionary<string, string> Data { get; set; }

        /// 讀取鍵值
        public virtual bool TryGet(string key, out string value) => Data.TryGetValue(key, out value);

        /// 設置鍵值
        public virtual void Set(string key, string value) => Data[key] = value;

        /// 加載配置數據源,使用virtual修飾符,在子類中實現重寫
        public virtual void Load()
        { }

        public IChangeToken GetReloadToken()
        {
            return _reloadToken;
        }

        /// <summary>
        /// 觸發change token,並生成一個新的change token
        /// </summary>
        protected void OnReload()
        {
            //原子操做:賦值並返回原始值
            var previousToken = Interlocked.Exchange(ref _reloadToken, new ConfigurationReloadToken());
            previousToken.OnReload();
        }
    }

6.2 JsonFileConfigurationProvider 的實現

該類繼承於抽象類ConfigurationProvider
在構造函數中監聽 FileProvider.Watch() 方法返回的IChangeToken,收到更改通知時執行如下兩個動做,一個是從新讀取文件流,加載到字典中;另外一個是改變_reloadToken 的狀態,用於通知外部:已經從新加載配置文件。因爲在本項目中直接引用了MS的PhysicalFileProvider,而該類監聽文件返回的是微軟的IChangeToken。爲了兼容項目代碼,經過一個適配類來轉換接口。

public class JsonFileConfigurationProvider : ConfigurationProvider, IDisposable
    {
        private readonly IDisposable _changeTokenRegistration;
        
        public JsonFileConfigurationSource Source { get; }

        public JsonFileConfigurationProvider(JsonFileConfigurationSource source)
        {
            if (source == null)
            {
                throw new ArgumentNullException(nameof(source));
            }
            Source = source;

            if (Source.ReloadOnChange && Source.FileProvider != null)
            {
                //1.IFileProvider.Watch(string filter) 返回IChangeToken
                //2.綁定IChangeToken的回調函數(1。生成新的IChangeToken 2.讀取配置文件、向ConfigurationRoot傳遞消息)
                //3.檢測到文件更改時,觸發回調
                //4.爲新的IChangeToken綁定回調函數
                _changeTokenRegistration = ChangeToken.OnChange(
                    () => new IChangeTokenAdapter(Source.FileProvider.Watch(Source.Path)),
                    () => {
                        Thread.Sleep(Source.ReloadDelay);
                        Load(reload: true);
                    });
            }
        }

        //從新加載文件並向IConfigurationRoot傳遞更改通知
        private void Load(bool reload)
        {
            var file = Source.FileProvider?.GetFileInfo(Source.Path);
            if (file == null || !file.Exists)
            {
                if (Source.Optional || reload) // Always optional on reload
                {
                    Data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
                }
                else
                {
                    //處理異常
                }
            }
            else
            {
                // Always create new Data on reload to drop old keys
                if (reload)
                {
                    Data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
                }
                using (var stream = file.CreateReadStream())
                {
                    try
                    {
                        Load(stream);
                    }
                    catch (Exception e)
                    {
                        HandleException(new FileLoadExceptionContext() { Exception = e, Provider = this, Ignore = true });
                    }
                }
            }
            //觸發IConfigurationProvider._cts.Cancel(),向IConfigurationRoot傳遞更改通知
            OnReload();
        }

        public override void Load()
        {
            Load(reload: false);
        }

        /// 從文件流中加載數據到IConfigurationProvider的Data中
        public void Load(Stream stream) {
            try
            {
                //.NetCore3.0使用JsonDocument讀取json文件,生成結構化文檔:
                /// [key] 節點1-1:節點2-1:節點3-1 [value] Value1
                /// [key] 節點1-1:節點2-2:節點3-2 [value] Value2
                /// Data = JsonConfigurationFileParser.Parse(stream);
                //此處使用Newtonsoft.Json,簡單的序列化爲普通鍵值對
                using (StreamReader sr = new StreamReader(stream))
                {
                    String jsonStr = sr.ReadToEnd();
                    Data = Newtonsoft.Json.JsonConvert.DeserializeObject<Dictionary<string, string>>(jsonStr);
                }
            }
            catch (Exception e)
            {
                throw new FormatException("讀取文件流失敗", e);
            }
        }

        public void Dispose() => Dispose(true);

        /// 釋放_changeTokenRegistration
        protected virtual void Dispose(bool disposing)
        {
            _changeTokenRegistration?.Dispose();
        }
    }

    /// 適配器類 
    /// 將Microsoft.Extensions.Primitives.IChangeToken轉換爲CoreWebApp.Primitives.IChangeToken
    public class IChangeTokenAdapter : IChangeToken
    {
        public IChangeTokenAdapter(IChangeTokenMS msToken)
        {
            MsToken = msToken ?? throw new ArgumentNullException(nameof(msToken));
        }

        private IChangeTokenMS MsToken { get; set; }

        public bool HasChanged => MsToken.HasChanged;

        public bool ActiveChangeCallbacks => MsToken.ActiveChangeCallbacks;

        public IDisposable RegisterChangeCallback(Action<object> callback, object state)
        {
            return MsToken.RegisterChangeCallback(callback, state);
        }
    }

7. ConfigurationSection 類的實現

{
  "OptionV1": {
    "OptionV21": "ValueV21",
    "OptionV22": {
      "OptionV31": "ValueV31",
      "OptionV32": "ValueV32"
    }
  }
}

對於如上的配置文件會保存爲key "OptionV1:OptionV22:OptionV31" value "ValueV31"的格式,這樣同時將節點間的層級關係也保存了下來。經過ConfigurationRoot訪問鍵值須要提供鍵的全路徑。ConfigurationSection 類至關於定位了某個節點,經過ConfigurationSection訪問鍵值只須要經過相對路徑。


結語

到此爲止,ConfigurationRoot已經構建完成,而後經過DI模塊以單例模式注入到系統中。在控制器中能夠經過IConfiguration訪問到全部配置源的鍵值對,而且當配置文件發生改變時從新加載IConfigurationProvider。下篇文章將會講述從如何經過強類型IOptions 訪問配置項。

相關文章
相關標籤/搜索