升維打擊,設計之道
《三體》讓我們瞭解了什麼是“降維打擊”,在軟體設計領域很多時候需要反其道而行。對於某個問題,如果不能有效的解決,可以考慮是否可以上升一個維度,從高維視角審視問題往往可以找到捷徑。軟體設計是抽象的藝術,“升維打擊”實際上就是“維度”層面的抽象罷了。
目錄
一、源起:一個介面,多個實現
二、根據當前上下文來過濾目標服務
三、將這個方案做得更加通用一點
四、我們是否走錯了方向?
一、源起:一個介面,多個實現
上週在公司做了一個關於.NET Core依賴注入的培訓,有人提到一個問題:如果同一個服務介面,需要註冊多個服務實現型別,在消費該服務會根據當前上下文動態對選擇對應的實現。這個問題我會被經常問到,我們不妨使用一個簡單的例子來描述一下這個問題。假設我們需要採用ASP.NET Core MVC開發一個供前端應用消費的微服務,其中某個功能比較特殊,它需要針對消費者應用型別而採用不同的處理邏輯。我們將這個功能抽象成介面IFoobar,具體的功能實現在InvokeAsync方法中。
public interface IFoobar { Task InvokeAsync(HttpContext httpContext); }
假設對於來源於App和小程式的請求,這個功能具有不同的處理邏輯,為此將它們實現在對應的實現型別Foo和Bar中。
public class Foo : IFoobar { public Task InvokeAsync(HttpContext httpContext) => httpContext.Response.WriteAsync("Process for App"); } public class Bar : IFoobar { public Task InvokeAsync(HttpContext httpContext) => httpContext.Response.WriteAsync("Process for MiniApp"); }
二、根據當前上下文來過濾目標服務
服務呼叫的請求會攜帶應用型別(App或者MiniApp)的資訊,現在我們需要解決的是:如何根據提供的應用型別選擇出對應的服務(Foo或者Bar)。為了讓服務型別和應用型別之間實現對映,我們選擇在Foo和Bar型別上應用如下這個InvocationSourceAttribute,它的Source屬性表示呼叫源的應用型別。
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public class InvocationSourceAttribute : Attribute { public string Source { get; } public InvocationSourceAttribute(string source) => Source = source; } [InvocationSource("App")] public class Foo : IFoobar { public Task InvokeAsync(HttpContext httpContext) => httpContext.Response.WriteAsync("Process for App"); } [InvocationSource("MiniApp")] public class Bar : IFoobar { public Task InvokeAsync(HttpContext httpContext) => httpContext.Response.WriteAsync("Process for MiniApp"); }
那麼如何針對當前請求上下文設定和獲取應用型別呢?這可以在表示當前請求的HttpContext物件上附加一個對應的Feature來實現。為此我們定義瞭如下這個IInvocationSourceFeature介面,InvocationSourceFeature為預設的實現型別。IInvocationSourceFeature的屬性成員Source代表呼叫源的應用型別。針對HttpContext的擴充套件方法GetInvocationSource和SetInvocationSource利用這個Feature獲取和設定應用型別。
public interface IInvocationSourceFeature { string Source { get; } } public class InvocationSourceFeature : IInvocationSourceFeature { public string Source { get; } public InvocationSourceFeature(string source) => Source = source; } public static class HttpContextExtensions { public static string GetInvocationSource(this HttpContext httpContext) => httpContext.Features.Get<IInvocationSourceFeature>()?.Source; public static void SetInvocationSource(this HttpContext httpContext, string source) => httpContext.Features.Set<IInvocationSourceFeature>(new InvocationSourceFeature(source)); }
現在我們將“服務選擇”實現在如下一個同樣實現了IFoobar介面的FoobarSelector 型別上。如下面的程式碼片段所示,FoobarSelector 實現的InvokeAsync方法會先呼叫上面定義的GetInvocationSource擴充套件方法獲取應用型別,然後利用作為DI容器的IServiceProvider得到所有實現了IFoobar介面的服務例項。接下來的任務就是通過分析應用在服務型別上的InvocationSourceAttribute特性來選擇目標服務了。
public class FoobarSelector : IFoobar { private static ConcurrentDictionary<Type, string> _sources = new ConcurrentDictionary<Type, string>(); public Task InvokeAsync(HttpContext httpContext) { return httpContext.RequestServices.GetServices<IFoobar>() .FirstOrDefault(it => it != this && GetInvocationSource(it) == httpContext.GetInvocationSource()) ?.InvokeAsync(httpContext); string GetInvocationSource(object service) { var type = service.GetType(); return _sources.GetOrAdd(type, _ => type.GetCustomAttribute<InvocationSourceAttribute>()?.Source); } } }
我們按照如下的方式對針對IFoobar的三個實現型別進行了註冊。由於FoobarSelector作為最後註冊的服務,按照“後來居上”的原則,如果我們利用DI容器獲取針對IFoobar介面的服務例項,返回的將會是一個FoobarSelector物件。我們在HomeController的建構函式中直接注入IFoobar物件。在Action方法Index中,我們將引數source繫結為應用型別,在呼叫IFoobar物件的InvokeAsyncfan方法之前,我們呼叫了擴充套件方法SetInvocationSource將它應用到當前HttpContext上。
public class Program { public static void Main(string[] args) { new WebHostBuilder() .UseKestrel() .ConfigureServices(svcs => svcs .AddHttpContextAccessor() .AddSingleton<IFoobar, Foo>() .AddSingleton<IFoobar, Bar>() .AddSingleton<IFoobar, FoobarSelector>() .AddMvc()) .Configure(app => app.UseMvc()) .Build() .Run(); } } public class HomeController: Controller { private readonly IFoobar _foobar; public HomeController(IFoobar foobar) => _foobar = foobar; [HttpGet("/")] public Task Index(string source) { HttpContext.SetInvocationSource(source); return _foobar.InvokeAsync(HttpContext); } }
我們執行這個程式,並利用查詢字串(?source=App)的形式來指定應用型別,可以得到我們希望的結果。
三、將這個方案做得更加通用一點
我們可以將上述這個方案做得更加通用一點。由於“服務過濾”的目的就是確定目標服務型別是否與當前請求上下文是否匹配,所以我們可以定義如下這個ServiceFilterAttribute特性。具體的過濾實現在ServiceFilterAttribute的Match方法上。派生於這個抽象類的InvocationSourceAttribute 特性幫助我們完成針對應用型別的服務過濾。如果需要針對其他元素的過濾邏輯,定義相應的派生類即可。
public abstract class ServiceFilterAttribute: Attribute { public abstract bool Match(HttpContext httpContext); } [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public sealed class InvocationSourceAttribute : ServiceFilterAttribute { public string Source { get; } public InvocationSourceAttribute(string source) => Source = source; public override bool Match(HttpContext httpContext)=> httpContext.GetInvocationSource() == Source; }
我們依然採用註冊一個額外的“選擇服務”的方式來完成針對匹配服務例項的呼叫,併為這樣的服務定義瞭如下這個基類ServiceSelector<T>。這個基類提供的GetService方法會幫助我們根據當前HttpContext選擇出匹配的服務例項。
public abstract class ServiceSelector<T> where T:class { private static ConcurrentDictionary<Type, ServiceFilterAttribute> _filters = new ConcurrentDictionary<Type, ServiceFilterAttribute>(); private readonly IHttpContextAccessor _httpContextAccessor; protected ServiceSelector(IHttpContextAccessor httpContextAccessor) => _httpContextAccessor = httpContextAccessor; protected T GetService() { var httpContext = _httpContextAccessor.HttpContext; return httpContext.RequestServices.GetServices<T>() .FirstOrDefault(it => it != this && GetFilter(it)?.Match(httpContext) == true); ServiceFilterAttribute GetFilter(object service) { var type = service.GetType(); return _filters.GetOrAdd(type, _ => type.GetCustomAttribute<ServiceFilterAttribute>()); } } }
針對IFoobar的“服務選擇器”則需要作相應的改寫。如下面的程式碼片段所示,FoobarSelector 繼承自基類ServiceSelector<IFoobar>,在實現的InvokeAsync方法中,在呼叫基類的GetService方法得到篩選出來的服務例項後,它只需要呼叫同名的InvokeAsync方法即可。
public class FoobarSelector : ServiceSelector<IFoobar>, IFoobar { public FoobarSelector(IHttpContextAccessor httpContextAccessor) : base(httpContextAccessor) { } public Task InvokeAsync(HttpContext httpContext) => GetService()?.InvokeAsync(httpContext); }
四、我們是否走錯了方向?
我們甚至可以將上面解決方案做到極致:比如我們可以採用如下的形式在實現型別上應用的InvocationSourceAttribute加上服務註冊的資訊(服務型別和生命週期),那麼就可以批量完成針對這些型別的服務註冊。我們還可以採用IL Emit的方式動態生成對應的服務選擇器型別(比如上面的FoobarSelector),並將它註冊到依賴注入框架,這樣應用程式就不需要編寫任何服務註冊的程式碼了。
[InvocationSource("App", ServiceLifetime.Singleton, typeof(IFoobar))] public class Foo : IFoobar { public Task InvokeAsync(HttpContext httpContext) => httpContext.Response.WriteAsync("Process for App"); } [InvocationSource("MiniApp", ServiceLifetime.Singleton, typeof(IFoobar))] public class Bar : IFoobar { public Task InvokeAsync(HttpContext httpContext) => httpContext.Response.WriteAsync("Process for MiniApp"); }
到目前為止,我們的解決方案貌似還不錯(除了需要建立所有服務例項之外),擴充套件靈活,程式設計優雅,但是我覺得我們走錯了方向。由於我們自始自終關注的維度只有IFoobar代表的目標服務,所以我們腦子裡想的始終是:如何利用DI容器提供目標服務例項。但是我們面臨的核心問題其實是:如何根據當前上下文提供與之匹配的服務例項,這是一個關於“服務例項的提供”維度的問題。“維度提升”之後,對應的解決思路就很清晰了:既然要解決的是針對IFoobar例項的提供問題,我們只需要定義如下IFoobarProvider,並利用它的GetService方法提供我們希望的服務例項就可以了。FoobarProvider表示對該介面的預設實現。
public interface IFoobarProvider { IFoobar GetService(); } public sealed class FoobarProvider : IFoobarProvider { private readonly IHttpContextAccessor _httpContextAccessor; public FoobarProvider(IHttpContextAccessor httpContextAccessor) => _httpContextAccessor = httpContextAccessor; public IFoobar GetService() { switch (_httpContextAccessor.HttpContext.GetInvocationSource()) { case "App": return new Foo(); case "MiniApp": return new Bar(); default: return null; } } }
採用用來提供所需服務例項的IFoobarProvider,我們的程式同樣會很簡單。
public class Program { public static void Main(string[] args) { new WebHostBuilder() .UseKestrel() .ConfigureServices(svcs => svcs .AddHttpContextAccessor() .AddSingleton<IFoobarProvider, FoobarProvider>() .AddMvc()) .Configure(app => app.UseMvc()) .Build() .Run(); } } public class HomeController: Controller { private readonly IFoobarProvider _foobarProvider; public HomeController(IFoobarProvider foobarProvider)=> _foobarProvider = foobarProvider; [HttpGet("/")] public Task Index(string source) { HttpContext.SetInvocationSource(source); return _foobarProvider.GetService()?.InvokeAsync(HttpContext)??Task.CompletedTask; } }
《三體》讓我們瞭解了什麼是“降維打擊”,在軟體設計領域則需要反其道而行。對於某個問題,如果不能有效的解決,可以考慮是否可以上升一個維度,從高維視角審視問題往往可以找到捷徑。軟體設計是抽象的藝術,“升維打擊”實際上就是“維度”層面的抽象罷了。