一個接口,多個實現
目錄
一、源起:一個接口,多個實現
二、根據當前上下文來過濾目標服務
三、將這個方案做得更加通用一點
四、我們是否走錯了方向?
一、源起:一個接口,多個實現
上周在公司做了一個關於.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對象的InvokeAsync方法之前,我們調用了擴展方法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)??Task.CompletedTask;
}
}
我們運行這個程序,並利用查詢字符串(?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;
}
}
《三體》讓我們了解了什么是“降維打擊”,在軟件設計領域則需要反其道而行。對於某個問題,如果不能有效的解決,可以考慮是否可以上升一個維度,從高維視角審視問題往往可以找到捷徑。軟件設計是抽象的藝術,“升維打擊”實際上就是“維度”層面的抽象罷了。
微信公眾賬號:大內老A