依賴注入使我們可以將依賴的功能定義成服務,最終以一種松耦合的形式注入消費該功能的組件或者服務中。除了可以采用依賴注入的形式消費承載某種功能的服務,還可以采用相同的方式消費承載配置數據的Options對象,這篇文章演示幾種典型的編程模式。(本篇提供的實例已經匯總到《ASP.NET Core 6框架揭秘-實例演示版》)
[601]將配置綁定為Options對象(源代碼)
[602]具名Options的注冊和提取(源代碼)
[603]Options與配置源的實時同步(匿名Options)(源代碼)
[604]Options與配置源的實時同步(具名Options)(源代碼)
[605]用代碼方式初始化Options(匿名Options)(源代碼)
[606]用代碼方式初始化Options(具名Options)(源代碼)
[607]針對依賴服務的Options設置(源代碼)
[608]驗證Options的有效性(源代碼)
[601]將配置綁定為Options對象
Options模式采用依賴注入的方式提供Options對象,但是由依賴注入容提供的是一個IOptions<TOptions>對象,后者為我們提供承載配置選項的Options對象。Options模式的核心接口和類型定義在“Microsoft.Extensions.Options”這個NuGet包。在為創建的控制台項目添加了該NuGet包的引用后,我們定義了如下這個Profile類型。
public class Profile { public Gender Gender { get; set; } public int Age { get; set; } public ContactInfo? ContactInfo { get; set; } } public class ContactInfo { public string? EmailAddress { get; set; } public string? PhoneNo { get; set; } } public enum Gender { Male, Female }
我們在項目根目錄下創建一個名為profile.json的配置文件,並在啟動定義了如下的內容。為了使該文件能夠在編譯后自動復制到輸出目錄,我們需要將“Copy to Output Directory”屬性設置為“Copy Always”。
{ "gender": "Male", "age": "18", "contactInfo": { "emailAddress" : "foobar@outlook.com", "phoneNo": "123456789" } }
在如下演示的程序中。我們調用AddJsonFile擴展方法將針對JSON配置文件(profile.json)的配置源注冊到創建的ConfigurationBuilder對象上,並最終將IConfiguration對象構建出來。我們接下來創建了一個ServiceCollection對象,通過調用它的AddOptions擴展方法注冊Options模式的核心服務。我們然后將創建的IConfiguration對象作為參數調用了ServiceCollection對象Configure<Profile>擴展方法,其目的在於利用這個IConfiguration對象來綁定作為Options的Profile對象。擴展方法Configure<TOptions>定義在“Microsoft.Extensions.Options.ConfigurationExtensions”這個NuGet包中,所以我們還得為演示程序添加該包的引用。
using App; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; var configuration = new ConfigurationManager(); configuration.AddJsonFile("profile.json"); var profile = new ServiceCollection() .AddOptions() .Configure<Profile>(configuration) .BuildServiceProvider() .GetRequiredService<IOptions<Profile>>().Value; Console.WriteLine($"Gender: {profile.Gender}"); Console.WriteLine($"Age: {profile.Age}"); Console.WriteLine($"Email Address: {profile.ContactInfo?.EmailAddress}"); Console.WriteLine($"Phone No: {profile.ContactInfo?.PhoneNo}");
在成功構建出作為依賴注入容器的IServiceProvider對象后,我們調用其GetRequiredService<T>擴展方法得到一個IOptions<Profile>對象,后者利用其Value屬性提供所需的Profile對象。我們將這個Profile承載的相關信息輸出到控制台上。程序運行后將在控制台上輸出如圖1所示結果。
[602]具名Options的注冊和提取
IOptions<TOptions>對象在整個應用范圍內只能提供一個單一的Options對象,但是在很多情況下我們需要利用多個同類型的Options對象來承載不同的配置。就拿演示實例中用來表示個人信息的Profile類型來說,應用程序中可能會使用它來表示不同用戶的信息,如張三、李四和王五。為了解決這個問題,我們可以在調用Configure<TOptions>方法對配置選項進行設置的時候指定一個具體的名稱,然后使用IOptionsSnapshot<TOptions>來代替IOptions<TOptions>以提供指定名稱的Options對象。為了演示提供針對不同用戶的Profile對象,我們通過修改profile.json文件使之包含兩個用戶(“foo”和“bar”)的信息,具體內容如下所示。
{ "foo": { "gender": "Male", "age": "18", "contactInfo": { "emailAddress": "foo@outlook.com", "phoneNo": "123" } }, "bar": { "gender": "Female", "age": "25", "contactInfo": { "emailAddress": "bar@outlook.com", "phoneNo": "456" } } }
具名Options的注冊和提取體現在如下的演示程序中。如代碼片段所示,在調用IServiceCollection接口的Configure<TOptions>擴展方法時,我們將注冊的映射關系分別命名為“foo”和“bar”,提供原始配置數據的IConfiguration對象也由原來的ConfigurationRoot對象變成它的兩個子配置節。
using App; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; var configuration = new ConfigurationManager(); configuration.AddJsonFile("profile.json"); var serviceProvider = new ServiceCollection() .AddOptions() .Configure<Profile>("foo", configuration.GetSection("foo")) .Configure<Profile>("bar", configuration.GetSection("bar")) .BuildServiceProvider(); var optionsAccessor = serviceProvider.GetRequiredService<IOptionsSnapshot<Profile>>(); Print(optionsAccessor.Get("foo")); Print(optionsAccessor.Get("bar")); static void Print(Profile profile) { Console.WriteLine($"Gender: {profile.Gender}"); Console.WriteLine($"Age: {profile.Age}"); Console.WriteLine($"Email Address: {profile.ContactInfo?.EmailAddress}"); Console.WriteLine($"Phone No: {profile.ContactInfo?.PhoneNo}\n"); }
我們調用IServiceProvider對象的GetRequiredService<TService>擴展方法得到一個IOptionsSnapshot<TOptions>服務,並將用戶名作為參數調用其Get方法得到對應的Profile對象。程序運行后,針對兩個用戶的基本信息將以圖2所示的形式輸出到控制台上。
[603]Options與配置源的實時同步(匿名Options)
前面演示的第一個實例利用JSON文件定義了一個單一Profile對象的信息,我們現在對它做相應的修改來演示如何監控這個JSON文件,並在文件更新之后加載新的內容來生成對Profile對象進行綁定的IConfiuration對象。如下面的代碼片段所示,我們在調用AddJsonFile擴展方法注冊對應配置源時應將該方法的參數reloadOnChange設置為True,從而開啟對對應配置文件的監控功能。
using App; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; var configuration = new ConfigurationManager(); configuration.AddJsonFile( path : "profile.json", optional : false, reloadOnChange : true); new ServiceCollection() .AddOptions() .Configure<Profile>(configuration) .BuildServiceProvider() .GetRequiredService<IOptionsMonitor<Profile>>() .OnChange(profile => { Console.WriteLine($"Gender: {profile.Gender}"); Console.WriteLine($"Age: {profile.Age}"); Console.WriteLine($"Email Address: {profile.ContactInfo?.EmailAddress}"); Console.WriteLine($"Phone No: {profile.ContactInfo?.PhoneNo}\n"); }); Console.Read();
我們利用作為依賴注入容器得到IOptionsMonitor<TOptions>對象,並調用它的OnChange方法注冊了一個類型為Action<TOptions>的委托作為回調。該回調會在Options內容發生變化時自動執行,而作為輸入的正是重新生成的Options對象。程序啟動后針對配置文件的任何修改都會導致新的數據被打印在控制台上。比如我們先后修改了年齡(25)和性別(Female),新的數據將按照圖3所示的形式反映在控制台上。
圖3 及時提取新的Profile對象並應用到程序中(匿名Options)
[604]Options與配置源的實時同步(具名Options)
具名Options同樣可以采用類似的編程模式來。我們在前面演示程序的基礎上做了如下修改。如代碼片段所示,在得到IOptionsMonitor<TOptions>服務之后,我們調用另一個OnChange方法重載注冊了類型為Action<TOptions, String>的委托作為回調,該委托的第二個參數表示的正是在注冊Configure<TOptions>指定的Options名稱。
using App; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; var configuration = new ConfigurationManager(); configuration.AddJsonFile( path : "profile.json", optional : false, reloadOnChange : true); new ServiceCollection() .AddOptions() .Configure<Profile>("foo", configuration.GetSection("foo")) .Configure<Profile>("bar", configuration.GetSection("bar")) .BuildServiceProvider() .GetRequiredService<IOptionsMonitor<Profile>>() .OnChange((profile, name) => { Console.WriteLine($"Name: {name}"); Console.WriteLine($"Gender: {profile.Gender}"); Console.WriteLine($"Age: {profile.Age}"); Console.WriteLine($"Email Address: {profile.ContactInfo?.EmailAddress}"); Console.WriteLine($"Phone No: {profile.ContactInfo?.PhoneNo}\n"); }); Console.Read();
改動后的程序啟動之后,針對配置文件所作的任何更新都會體現在控制台上。比如我們分別修改了用戶foo的年齡(25)和用戶bar的性別(Male),新的內容將以圖4所示的形式及時呈現在控制台上。
圖4 及時提取新的Profile對象並應用到程序中(具名Options)
[605]用代碼方式初始化Options(匿名Options)
前面演示的幾個實例具有一個共同的特征,那就是都采用承載配置的IConfiguration對象來綁定Options對象。實際上Options是一個完全獨立於配置系統的框架,利用配置綁定的形式來對Options對象進行初始化僅僅是該框架提供的一個小小的擴展而已。我們現在摒棄配置文件,轉而采用編程的方式直接對Options進行初始化。如下面的代碼片段所示,在調用IServiceCollection接口的Configure<Profile>擴展方法時,我們指定一個Action<Profile>委托來對作為Options的Profile對象進行初始化。修改后的程序運行后會同樣在控制台上產生圖1所示的輸出結果。
using App; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; var profile = new ServiceCollection() .AddOptions() .Configure<Profile>(it => { it.Gender = Gender.Male; it.Age = 18; it.ContactInfo = new ContactInfo { PhoneNo = "123456789", EmailAddress = "foobar@outlook.com" }; }) .BuildServiceProvider() .GetRequiredService<IOptions<Profile>>() .Value; Console.WriteLine($"Gender: {profile.Gender}"); Console.WriteLine($"Age: {profile.Age}"); Console.WriteLine($"Email Address: {profile.ContactInfo?.EmailAddress}"); Console.WriteLine($"Phone No: {profile.ContactInfo?.PhoneNo}\n");
[606]用代碼方式初始化Options(具名Options)
具名Options同樣可以采用類似的編程方式。如果需要根據指定的名稱對Options進行初始化,那么調用方法時就需要指定一個Action<TOptions,String>類型的委托對象,該委托對象的第二個參數表示Options的名稱。在如下所示的代碼片段中,我們通過類似的方式設置了兩個用戶(“foo”和“bar”)的信息,然后利用IOptionsSnapshot<Profile>服務將它們分別提取出來。該程序運行后會在控制台上產生圖6-2所示的輸出結果。(S606)
using App; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; var optionsAccessor = new ServiceCollection() .AddOptions() .Configure<Profile>("foo", it => { it.Gender = Gender.Male; it.Age = 18; it.ContactInfo = new ContactInfo { PhoneNo = "123", EmailAddress = "foo@outlook.com" }; }) .Configure<Profile>("bar", it => { it.Gender = Gender.Female; it.Age = 25; it.ContactInfo = new ContactInfo { PhoneNo = "456", EmailAddress = "bar@outlook.com" }; }) .BuildServiceProvider() .GetRequiredService<IOptionsSnapshot<Profile>>(); Print(optionsAccessor.Get("foo")); Print(optionsAccessor.Get("bar")); static void Print(Profile profile) { Console.WriteLine($"Gender: {profile.Gender}"); Console.WriteLine($"Age: {profile.Age}"); Console.WriteLine($"Email Address: {profile.ContactInfo?.EmailAddress}"); Console.WriteLine($"Phone No: {profile.ContactInfo?.PhoneNo}\n"); };
[607]針對依賴服務的Options設置
在很多情況下我們需要針對某個依賴的服務動態地初始化Options的設置,比較典型的就是根據當前的承載環境(開發、預發和產品)對Options做動態設置。我們在第5章“配置選項(上)”中演示了一系列針對日期/時間輸出格式的配置,下面沿用這個場景演示如何根據當前的承載環境設置對應的Options。我們將DateTimeFormatOptions的定義進行簡化,只保留如下所示的表示日期和時間格式的兩個屬性。
public class DateTimeFormatOptions { public string DatePattern { get; set; } public string TimePattern { get; set; } public override string ToString() => $"Date: {DatePattern}; Time: {TimePattern}"; }
我們利用配置來提供當前的承載環境,具體采用的是基於命令行參數的配置源。 .NET的服務承載系統通過IHostEnvironment接口表示承載環境,具體實現類型為HostingEnvironment(該類型定義在“Microsoft.Extensions.Hosting”NuGet包中,我們需要添加針對這個包的引用)。如下面的演示程序所示,我們創建了一個ServiceCollection對象,並添加了針對IHostEnvironment接口的服務注冊,具體提供的是一個根據環境名稱創建的HostingEnvironment對象。
using App; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting.Internal; using Microsoft.Extensions.Options; var environment = new ConfigurationBuilder() .AddCommandLine(args) .Build()["env"]; var services = new ServiceCollection(); services .AddSingleton<IHostEnvironment>( new HostingEnvironment { EnvironmentName = environment }) .AddOptions<DateTimeFormatOptions>().Configure<IHostEnvironment>( (options, env) => { if (env.IsDevelopment()) { options.DatePattern = "dddd, MMMM d, yyyy"; options.TimePattern = "M/d/yyyy"; } else { options.DatePattern = "M/d/yyyy"; options.TimePattern = "h:mm tt"; } }); var options = services .BuildServiceProvider() .GetRequiredService<IOptions<DateTimeFormatOptions>>() .Value; Console.WriteLine(options);
我們調用了ServiceCollection對象的AddOptions<DateTimeFormatOptions>擴展方法完成了針對Options框架核心服務的注冊,並利用返回的OptionsBuilder<DateTimeFormatOptions>對象對作為配置選項的DateTimeFormatOptions作相應設置。具體來說,我們調用了它的Configure<IHostEnvironment>方法利用提供的Action<DateTimeFormatOptions, IHostEnvironment>委托針對開發環境和非開發環境設置了不同的日期與時間格式。我們采用命令行的方式啟動這個應用程序,並利用命令行參數設置不同的環境名稱,就可以在控制台上看到圖5所示的針對DateTimeFormatOptions的不同設置。
[608]驗證Options的有效性
配置選項是整個應用的全局設置,如果對它進行了錯誤的設置可能會造成很嚴重的后果,所以最好能夠在使用之前進行有效性驗證。接下來我們將上面的程序做了如下改動,從而演示如何對設置的日期和時間格式進行驗證。
using App; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using System.Globalization; var config = new ConfigurationBuilder() .AddCommandLine(args) .Build(); var datePattern = config["date"]; var timePattern = config["time"]; var services = new ServiceCollection(); services.AddOptions<DateTimeFormatOptions>() .Configure(options => { options.DatePattern = datePattern; options.TimePattern = timePattern; }) .Validate(options => Validate(options.DatePattern) && Validate(options.TimePattern), "Invalid Date or Time pattern."); try { var options = services .BuildServiceProvider() .GetRequiredService<IOptions<DateTimeFormatOptions>>().Value; Console.WriteLine(options); } catch (OptionsValidationException ex) { Console.WriteLine(ex.Message); } static bool Validate(string format) { var time = new DateTime(1981, 8, 24, 2, 2, 2); var formatted = time.ToString(format); return DateTimeOffset.TryParseExact(formatted, format,null, DateTimeStyles.None, out var value) && (value.Date == time.Date || value.TimeOfDay == time.TimeOfDay); }
上述演示實例借助配置系統以命令行的形式提供了日期和時間格式化字符串。在創建了OptionsBuilder<DateTimeFormatOptions>對象並對DateTimeFormatOptions做了相應設置之后,我們調用Validate<DateTimeFormatOptions>方法利用提供的Func<DateTimeFormatOptions,bool>委托對最終的設置進行驗證。運行該程序並按照圖6所示的方式指定不同的格式化字符串,系統會根據我們指定的規則來驗證其有效性。