一個接口,多個實現


一個接口,多個實現

目錄
一、源起:一個接口,多個實現
二、根據當前上下文來過濾目標服務
三、將這個方案做得更加通用一點
四、我們是否走錯了方向?

一、源起:一個接口,多個實現

上周在公司做了一個關於.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)的形式來指定應用類型,可以得到我們希望的結果。

image

三、將這個方案做得更加通用一點

我們可以將上述這個方案做得更加通用一點。由於“服務過濾”的目的就是確定目標服務類型是否與當前請求上下文是否匹配,所以我們可以定義如下這個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;
    }
}

《三體》讓我們了解了什么是“降維打擊”,在軟件設計領域則需要反其道而行。對於某個問題,如果不能有效的解決,可以考慮是否可以上升一個維度,從高維視角審視問題往往可以找到捷徑。軟件設計是抽象的藝術,“升維打擊”實際上就是“維度”層面的抽象罷了。

image

作者:蔣金楠 
微信公眾賬號:大內老A


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM