升维打击,设计之道
《三体》让我们了解了什么是“降维打击”,在软件设计领域很多时候需要反其道而行。对于某个问题,如果不能有效的解决,可以考虑是否可以上升一个维度,从高维视角审视问题往往可以找到捷径。软件设计是抽象的艺术,“升维打击”实际上就是“维度”层面的抽象罢了。(本文实例从这里下载)
目录
一、源起:一个接口,多个实现
二、根据当前上下文来过滤目标服务
三、将这个方案做得更加通用一点
四、我们是否走错了方向?
一、源起:一个接口,多个实现
上周在公司做了一个关于.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; } }
《三体》让我们了解了什么是“降维打击”,在软件设计领域则需要反其道而行。对于某个问题,如果不能有效的解决,可以考虑是否可以上升一个维度,从高维视角审视问题往往可以找到捷径。软件设计是抽象的艺术,“升维打击”实际上就是“维度”层面的抽象罢了。