1. 程式人生 > >升維打擊,設計之道

升維打擊,設計之道

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

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

一、源起:一個介面,多個實現

上週在公司做了一個關於.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;
    }
}

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