[ASP.NET Core 3框架揭祕] Options[6]: 擴充套件與定製
由於Options模型涉及的核心物件最終都註冊為相應的服務,所以從原則上講這些物件都是可以定製的,下面提供幾個這樣的例項。由於Options模型提供了針對配置系統的整合,所以可以採用配置檔案的形式來提供原始的Options資料,可以直接採用反序列化的方式將配置檔案的內容轉換成Options物件。
一、使用JSON檔案提供Options資料
在介紹IConfigureOptions擴充套件的實現之前,下面先演示如何在應用中使用它。首先在演示例項中定義一個Options型別。簡單起見,我們沿用前面使用的包含兩個成員的FoobarOptions型別,從而實現IEquatable<FoobarOptions>介面。最終繫結生成的是一個FakeOptions物件,為了演示針對複合型別、陣列、集合和字典型別的繫結,可以為其定義相應的屬性成員。
public class FakeOptions { public FoobarOptions Foobar { get; set; } public FoobarOptions[] Array { get; set; } public IList<FoobarOptions> List { get; set; } public IDictionary<string, FoobarOptions> Dictionary { get; set; } } public class FoobarOptions : IEquatable<FoobarOptions> { public int Foo { get; set; } public int Bar { get; set; } public FoobarOptions() { } public FoobarOptions(int foo, int bar) { Foo = foo; Bar = bar; } public override string ToString() => $"Foo:{Foo}, Bar:{Bar}"; public bool Equals(FoobarOptions other) => this.Foo == other?.Foo && this.Bar == other?.Bar; }
可以在專案根目錄新增一個JSON檔案(命名為fakeoptions.json),如下所示的程式碼片段表示該檔案的內容,可以看出檔案的格式與FakeOptions型別的資料成員是相容的,也就是說,這個檔案的內容能夠被反序列化成一個FakeOptions物件。
{ "Foobar": { "Foo": 1, "Bar": 1 }, "Array": [{ "Foo": 1, "Bar": 1 }, { "Foo": 2, "Bar": 2 }, { "Foo": 3, "Bar": 3 }], "List": [{ "Foo": 1, "Bar": 1 }, { "Foo": 2, "Bar": 2 }, { "Foo": 3, "Bar": 3 }], "Dictionary": { "1": { "Foo": 1, "Bar": 1 }, "2": { "Foo": 2, "Bar": 2 }, "3": { "Foo": 3, "Bar": 3 } } }
下面按照Options模式直接讀取該配置檔案,並將檔案內容繫結為一個FakeOptions物件。如下面的程式碼片段所示,在呼叫IServiceCollection介面的AddOptions擴充套件方法之後,我們呼叫了另一個自定義的Configure<FakeOptions>擴充套件方法,該方法的引數表示承載原始Options資料的JSON檔案的路徑。這個演示程式提供的一系列除錯斷言表明:最終獲取的FakeOptions物件與原始的JSON檔案具有一致的內容。(S710)
class Program { static void Main() { var foobar1 = new FoobarOptions(1, 1); var foobar2 = new FoobarOptions(2, 2); var foobar3 = new FoobarOptions(3, 3); var options = new ServiceCollection() .AddOptions() .Configure<FakeOptions>("fakeoptions.json") .BuildServiceProvider() .GetRequiredService<IOptions<FakeOptions>>() .Value; Debug.Assert(options.Foobar.Equals(foobar1)); Debug.Assert(options.Array[0].Equals(foobar1)); Debug.Assert(options.Array[1].Equals(foobar2)); Debug.Assert(options.Array[2].Equals(foobar3)); Debug.Assert(options.List[0].Equals(foobar1)); Debug.Assert(options.List[1].Equals(foobar2)); Debug.Assert(options.List[2].Equals(foobar3)); Debug.Assert(options.Dictionary["1"].Equals(foobar1)); Debug.Assert(options.Dictionary["2"].Equals(foobar2)); Debug.Assert(options.Dictionary["3"].Equals(foobar3)); } }
二、JsonFileConfigureOptions<TOptions>
Options模型中針對Options物件的初始化是通過IConfigureOptions<TOptions>物件實現的,演示程式中呼叫的Configure<TOptions>方法實際上就是註冊了這樣一個服務。我們採用Newtonsoft.Json來完成針對JSON的序列化,並且使用基於物理檔案系統的IFileProvider來讀取檔案。Configure<TOptions>方法註冊的實際上就是如下這個JsonFileConfigureOptions<TOptions>型別。JsonFileConfigureOptions<TOptions>實現了IConfigureNamedOptions<TOptions>介面,在呼叫建構函式建立一個JsonFileConfigureOptions<TOptions>物件的時候,我們指定了Options名稱、JSON檔案的路徑以及用於讀取該檔案的IFileProvider物件。
public class JsonFileConfigureOptions<TOptions> : IConfigureNamedOptions<TOptions> where TOptions : class, new() { private readonly IFileProvider _fileProvider; private readonly string _path; private readonly string _name; public JsonFileConfigureOptions(string name, string path, IFileProvider fileProvider) { _fileProvider = fileProvider; _path = path; _name = name; } public void Configure(string name, TOptions options) { if (name != null && _name != name) { return; } byte[] bytes; using (var stream = _fileProvider.GetFileInfo(_path).CreateReadStream()) { bytes = new byte[stream.Length]; stream.Read(bytes, 0, bytes.Length); } var contents = Encoding.Default.GetString(bytes); contents = contents.Substring(contents.IndexOf('{')); var newOptions = JsonConvert.DeserializeObject<TOptions>(contents); Bind(newOptions, options); } public void Configure(TOptions options) => Configure(Options.DefaultName, options); private void Bind(object from, object to) { var type = from.GetType(); if (type.IsDictionary()) { var dest = (IDictionary)to; var src = (IDictionary)from; foreach (var key in src.Keys) { dest.Add(key, src[key]); } return; } if (type.IsCollection()) { var dest = (IList)to; var src = (IList)from; foreach (var item in src) { dest.Add(item); } } foreach (var property in type.GetProperties()) { if (property.IsSpecialName || property.GetMethod == null || property.Name == "Item" || property.DeclaringType != type) { continue; } var src = property.GetValue(from); var propertyType = src?.GetType() ?? property.PropertyType; if ((propertyType.IsValueType || src is string || src == null) && property.SetMethod != null) { property.SetValue(to, src); continue; } var dest = property.GetValue(to); if (null != dest && !propertyType.IsArray()) { Bind(src, dest); continue; } if (property.SetMethod != null) { var destType = propertyType.IsDictionary() ? typeof(Dictionary<,>).MakeGenericType(propertyType.GetGenericArguments()) : propertyType.IsArray() ? typeof(List<>).MakeGenericType(propertyType.GetElementType()) : propertyType.IsCollection() ? typeof(List<>).MakeGenericType(propertyType.GetGenericArguments()) : propertyType; dest = Activator.CreateInstance(destType); Bind(src, dest); if (propertyType.IsArray()) { IList list = (IList)dest; dest = Array.CreateInstance(propertyType.GetElementType(), list.Count); list.CopyTo((Array)dest, 0); } property.SetValue(to, src); } } } } internal static class Extensions { public static bool IsDictionary(this Type type) => type.IsGenericType && typeof(IDictionary).IsAssignableFrom(type) && type.GetGenericArguments().Length == 2; public static bool IsCollection(this Type type) => typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string); public static bool IsArray(this Type type) => typeof(Array).IsAssignableFrom(type); }
在實現的Configure方法中,JsonFileConfigureOptions<TOptions>利用提供的IFileProvider物件讀取了指定JSON檔案的內容,並將其反序列化成一個新的Options物件。由於Options模型最終提供的總是IOptionsFactory<TOptions>物件最初建立的那個Options物件,所以針對Options的初始化只能針對這個Options物件。因此,不能使用新的Options物件替換現有的Options物件,只能將新Options物件承載的資料繫結到現有的這個Options物件上,針對Options物件的繫結實現在上面提供的Bind方法中。如下所示的程式碼片段是註冊JsonFileConfigureOptions<TOptions>物件的Configure<TOptions>擴充套件方法的定義。
public static class ServiceCollectionExtensions { public static IServiceCollection Configure<TOptions>(this IServiceCollection services, string filePath, string basePath = null) where TOptions : class, new() => services.Configure<TOptions>(Options.DefaultName, filePath, basePath); public static IServiceCollection Configure<TOptions>(this IServiceCollection services, string name, string filePath, string basePath = null) where TOptions : class, new() { var fileProvider = string.IsNullOrEmpty(basePath) ? new PhysicalFileProvider(Directory.GetCurrentDirectory()) : new PhysicalFileProvider(basePath); return services.AddSingleton<IConfigureOptions<TOptions>>( new JsonFileConfigureOptions<TOptions>(name, filePath, fileProvider)); } }
三、定時重新整理Options資料
通過對IOptionsMonitor<Options>的介紹,可知它通過IOptionsChangeTokenSource<TOptions>物件來感知Options資料的變化。到目前為止,我們尚未涉及針對這個服務的註冊,下面演示如何通過註冊該服務來實現定時重新整理Options資料。對於如何同步Options資料,最理想的場景是在資料來源發生變化的時候及時將通知“推送”給應用程式。如果採用本地檔案,採用這種方案是很容易實現的。但是在很多情況下,實時監控資料變化的成本很高,訊息推送在技術上也不一定可行,此時需要退而求其次,使應用定時獲取並更新Options資料。這樣的應用場景可以通過註冊一個自定義的IOptionsChangeTokenSource<TOptions>實現型別來完成。
在講述自定義IOptionsChangeTokenSource<TOptions>型別的具體實現之前,先演示針對Options資料的定時重新整理。我們依然沿用前面定義的FoobarOptions作為繫結的目標Options型別,而具體的演示程式則體現在如下所示的程式碼片段中。
class Program { static void Main() { var random = new Random(); var optionsMonitor = new ServiceCollection() .AddOptions() .Configure<FoobarOptions>(TimeSpan.FromSeconds(1)) .Configure<FoobarOptions>(foobar => { foobar.Foo = random.Next(10, 100); foobar.Bar = random.Next(10, 100); }) .BuildServiceProvider() .GetRequiredService<IOptionsMonitor<FoobarOptions>>(); optionsMonitor.OnChange(foobar => Console.WriteLine($"[{DateTime.Now}]{foobar}")); Console.Read(); } }
如上面的程式碼片段所示,針對自定義IOptionsChangeTokenSource<TOptions>物件的註冊實現在我們為IServiceCollection介面定義的Configure<FoobarOptions>擴充套件方法中,該方法具有一個TimeSpan型別的引數表示定時重新整理Options資料的時間間隔。在演示程式中,我們將這個時間間隔設定為1秒。為了模擬資料的實時變化,可以呼叫Configure<FoobarOptions>擴充套件方法註冊一個Action<FoobarOptions>物件來更新Options物件的兩個屬性值。
利用IServiceProvider物件得到IOptionsMonitor<FoobarOptions>物件,並呼叫其OnChange方法註冊了一個Action<FoobarOptions>物件,從而將FoobarOptions承載的資料和當前時間打印出來。由於我們設定的自動重新整理時間為1秒,所以程式會以這個頻率定時將新的Options資料以下圖所示的形式列印在控制檯上。
四、TimedRefreshTokenSource<TOptions>
前面演示程式中的Configure<TOptions>擴充套件方法註冊了一個TimedRefreshTokenSource<TOptions>物件,下面的程式碼片段給出了該型別的完整定義。從給出的程式碼片段可以看出,實現的OptionsChangeToken方法返回的IChangeToken物件是通過欄位_changeToken表示的OptionsChangeToken物件,它與第6章介紹的ConfigurationReloadToken型別具有完全一致的實現。
public class TimedRefreshTokenSource<TOptions> : IOptionsChangeTokenSource<TOptions> { private OptionsChangeToken _changeToken; public string Name { get; } public TimedRefreshTokenSource(TimeSpan interval, string name) { this.Name = name ?? Options.DefaultName; _changeToken = new OptionsChangeToken(); ChangeToken.OnChange(() => new CancellationChangeToken(new CancellationTokenSource(interval).Token), () => { var previous = Interlocked.Exchange(ref _changeToken, new OptionsChangeToken()); previous.OnChange(); }); } public IChangeToken GetChangeToken() => _changeToken; private class OptionsChangeToken : IChangeToken { private readonly CancellationTokenSource _tokenSource; public OptionsChangeToken() => _tokenSource = new CancellationTokenSource(); public bool HasChanged => _tokenSource.Token.IsCancellationRequested; public bool ActiveChangeCallbacks => true; public IDisposable RegisterChangeCallback(Action<object> callback, object state) => _tokenSource.Token.Register(callback, state); public void OnChange() => _tokenSource.Cancel(); } }
通過呼叫建構函式建立一個TimedRefreshTokenSource<TOptions>物件時,除了需要指定Options的名稱,還需要提供一個TimeSpan物件來控制Options自動重新整理的時間間隔。在建構函式中,可以通過呼叫ChangeToken的OnChange方法以這個間隔定期地建立新的OptionsChangeToken物件並賦值給_changeToken。與此同時,我們通過呼叫前一個OptionsChange
Token物件的OnChange方法對外通知Options已經發生變化。
public static class ServiceCollectionExtensions { public static IServiceCollection Configure<TOptions>( this IServiceCollection services, string name, TimeSpan refreshInterval) => services.AddSingleton<IOptionsChangeTokenSource<TOptions>>( new TimedRefreshTokenSource<TOptions>(refreshInterval, name)); public static IServiceCollection Configure<TOptions>( this IServiceCollection services, TimeSpan refreshInterval) => services.Configure<TOptions>(Options.DefaultName, refreshInterval); }
[ASP.NET Core 3框架揭祕] Options[1]: 配置選項的正確使用方式[上篇]
[ASP.NET Core 3框架揭祕] Options[2]: 配置選項的正確使用方式[下篇]
[ASP.NET Core 3框架揭祕] Options[3]: Options模型[上篇]
[ASP.NET Core 3框架揭祕] Options[4]: Options模型[下篇]
[ASP.NET Core 3框架揭祕] Options[5]: 依賴注入
[ASP.NET Core 3框架揭祕] Options[6]: 擴充套件與定製
[ASP.NET Core 3框架揭祕] Options[7]: 與配置系統的