AOP框架Dora.Interception 3.0 [1]: 编程体验
.NET Core正式发布之后,我为.NET Core度身定制的AOP框架Dora.Interception也升级到3.0。这个版本除了升级底层类库(.NET Standard 2.1)之外,我还对它进行大范围的重构甚至重新设计。这次重构大部分是在做减法,其目的在于使设计和使用更加简单和灵活,接下来我们就来体验一下在一个ASP.NET Core应用程序下如何使用Dora.Interception。
源代码下载
实例1(Console)
实例2(ASP.NET Core MVC + 注册可拦截服务)
实例3(ASP.NET Core MVC + 注册InterceptableServiceProviderFactory)
实例4(ASP.NET Core MVC + 拦截策略)
实例5(ASP.NET Core MVC + 策略脚本化)
一、演示场景
我们依然沿用“缓存”这个应用场景:我们创建一个缓存拦截器,并将其应用到某个方法上。缓存拦截器会将目标方法的返回值缓存起来。在缓存过期之前,提供相同参数列表的方法调用会直接返回缓存的数据,而无需执行目标方法。如下所示是作为缓存键类型的CacheKey的定义,可以看出缓存时针对”方法+参数列表”实施缓存的。
private class Cachekey { public MethodBase Method { get; } public object[] InputArguments { get; } public Cachekey(MethodBase method, object[] arguments) { Method = method; InputArguments = arguments; } public override bool Equals(object obj) { if (!(obj is Cachekey another)) { return false; } if (!Method.Equals(another.Method)) { return false; } for (int index = 0; index < InputArguments.Length; index++) { var argument1 = InputArguments[index]; var argument2 = another.InputArguments[index]; if (argument1 == null && argument2 == null) { continue; } if (argument1 == null || argument2 == null) { return false; } if (!argument1.Equals(argument2)) { return false; } } return true; } public override int GetHashCode() { int hashCode = Method.GetHashCode(); foreach (var argument in InputArguments) { hashCode = hashCode ^ argument.GetHashCode(); } return hashCode; } }
二、定义拦截器
作为Dora.Interception区别于其他AOP框架的最大特性,我们注册的拦截器类型无需实现某个预定义的接口,因为我们采用基于“约定”的拦截器定义方式。基于约定方式定义的缓存拦截器类型CacheInterceptor定义如下。
public class CacheInterceptor { private readonly IMemoryCache _cache; private readonly MemoryCacheEntryOptions _options; public CacheInterceptor(IMemoryCache cache, IOptionsoptionsAccessor) { _cache = cache; _options = optionsAccessor.Value; } public async Task InvokeAsync(InvocationContext context) { var key = new Cachekey(context.Method, context.Arguments); if (_cache.TryGetValue(key, out object value)) { context.ReturnValue = value; } else { await context.ProceedAsync(); _cache.Set(key, context.ReturnValue, _options); } } }
按照约定,拦截器类型只需要定义成一个普通的“公共、实例”类型即可。拦截操作需要定义在约定的InvokeAsync方法中,该方法的返回类型为Task,并且包含一个InvocationContext类型的参数。InvocationContext类型封装了当前方法的调用上下文,我们可以利用它获取当前的方法和输入参数等信息。InvocationContext的ReturnValue 属性表示方法调用的返回结果,CacheInterceptor正式通过设置该属性从而实现将方法返回值进行缓存的目的。
如上面的代码片段所示,在InvokeAsync方法中,我们先判断针对当前的参数参数列表是否具有缓存的结果,如果有的话我们直接将它作为InvocationContext上下文的ReturnValue属性。如果从缓存中找不到对应的结果,在通过调用InvocationContext上下文的ProceedAsync方法执行目标方法(也可能是后续拦截器),并将新的结果缓存起来。
三、依赖注入
Dora.Interception是为.NET Core度身定制的轻量级AOP框架。由于依赖注入已经成为了.NET Core基本的编程方式,所以Dora.Interception和.NET Core的依赖注入框架进行了无缝整合。正因为如此,当我们在定义拦截器的时候可以将依赖服务直接注入到构造函数中。对于上面定义的CacheInterceptor来说,由于我们直接使用的是.NET Core提供的基于内存的缓存框架,所以我们直接将所需的IMemoryCache 服务和提供配置选项的IOptions
除了构造函数注入,我们还支持针对InvokeAsync方法的“方法注入”。也就是说我们可以将上述的两个依赖服务以如下的方式注入到InvokeAsync方法中。
public class CacheInterceptor { public async Task InvokeAsync(InvocationContext context, IMemoryCache cache, IOptionsoptionsAccessor) { var key = new Cachekey(context.Method, context.Arguments); if (cache.TryGetValue(key, out object value)) { context.ReturnValue = value; } else { await context.ProceedAsync(); cache.Set(key, context.ReturnValue, optionsAccessor.Value); } } }
针对拦截器类型的两种依赖注入方式并不是等效的,它们之间的差异体现在服务实例的生命周期上。由于拦截器对象自身属于一个Singleton服务,所以我们不能在它的构造函数中注入一个Scoped服务,否则依赖服务将不能按照期望的方式被释放。Scoped服务只能注入到InvokeAsync方法中,因为该方法注入的服务实例是根据当前Scope的IServiceProvider提供的(对于ASP.NET Core应用来说,就是当前HttpContext上下文的RequestServices)。
四、注册拦截器
AOP的本质对方法调用进行拦截,并在调用目标方法之前执行应用的拦截器,所以我们定义的拦截器最终需要注册到一个或者多个方法上。Dora.Interception刻意将“拦截器”和“拦截器注册”分离开来,因为拦截器具有不同的注册方式。
在类型或者方法上标注特性是我们常用的拦截器注册方式,为此我们为CacheInterceptor定义了如下这个CacheReturnValueAttribute。CacheReturnValueAttribute继承自抽象类型InterceptorAttribute,在重写的Use方法中,我们只需要调用作为参数的IInterceptorChainBuilder对象的Use
[AttributeUsage(AttributeTargets.Method)] public class CacheReturnValueAttribute : InterceptorAttribute { public override void Use(IInterceptorChainBuilder builder) { builder.Use(Order); } }
Use
如果你觉得将拦截器类型和对应的特性分开定义比较烦,也可以将两者合二为一,我们只需要将InvokeAsync方法按照如下的方式转移到InterceptorAttribute类型中就可以了。由于它自身就是一个拦截器,我们在Use方法中会调用IInterceptorChainBuilder对象非泛型Use方法,并将自身作为第一个参数。
[AttributeUsage(AttributeTargets.Method)] public class CacheReturnValueAttribute : InterceptorAttribute { public async Task InvokeAsync(InvocationContext context, IMemoryCache cache, IOptionsoptionsAccessor) { var key = new Cachekey(context.Method, context.Arguments); if (cache.TryGetValue(key, out object value)) { context.ReturnValue = value; } else { await context.ProceedAsync(); cache.Set(key, context.ReturnValue, optionsAccessor.Value); } } public override void Use(IInterceptorChainBuilder builder) { builder.Use(this, Order); } }
为了能够很直观地看到针对方法返回值的缓存,我们定义了如下这个表示系统时钟的ISystemClock的服务接口。该接口具有唯一的GetCurrentTime方法返回当前的时间,方法参数用于控制行为方法的时间类型(UTC或者Local)。实现类型SystemClock标注了我们定义的InterceptorAttribute特性。
public interface ISystemClock { DateTime GetCurrentTime(DateTimeKind dateTimeKind); } public class SystemClock : ISystemClock { [CacheReturnValue(Order = 1)] public DateTime GetCurrentTime(DateTimeKind dateTimeKind) { return dateTimeKind switch { DateTimeKind.Local => DateTime.UtcNow.ToLocalTime(), DateTimeKind.Unspecified => DateTime.Now, _ => DateTime.UtcNow, }; } }
五、注册可被拦截的服务
接下来我们在一个ASP.NET Core MVC应用中演示针对ISystemClock服务提供时间的缓存。如下所示的是应用承载程序和注册Startup类型的定义。为了让依赖注入框架提供的ISystemClock服务是可以被拦截的,我们调用了IServiceCollection接口的AddSingletonInterceptable
public class Program { public static void Main(string[] args) { Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(buider => buider.UseStartup()) .Build() .Run(); } } public class Startup { public void ConfigureServices(IServiceCollection services) { services .AddMemoryCache() .AddInterception() .AddSingletonInterceptable () .AddRouting() .AddControllers(); } public void Configure(IApplicationBuilder app) { app .UseRouting() .UseEndpoints(endpoints => endpoints.MapControllers()); } }
我们定义了如下这个HomeController,并在其构造函数中注入了ISystemClock服务。在Action方法Index中,我们利用ISystemClock服务在1秒时间间隔内两次提供当前时间,并将这两个时间呈现在浏览器上。调用ISystemClock的GetCurrentTime方法指定的时间类型(UTC或者Local)是利用查询字符串提供的。
public class HomeController : Controller { private readonly ISystemClock _clock; public HomeController(ISystemClock clock) { _clock = clock ?? throw new ArgumentNullException(nameof(clock)); } [HttpGet("/{kind?}")] public async Task Index(string kind="local") { DateTimeKind dateTimeKind = string.Compare(kind, "utc", true) == 0 ? DateTimeKind.Utc : DateTimeKind.Local; Response.ContentType = "text/html"; await Response.WriteAsync(""); for (int i = 0; i < 2; i++) { await Response.WriteAsync($"
{_clock.GetCurrentTime(dateTimeKind)} "); await Task.Delay(1000); } await Response.WriteAsync(""); } }
运行程序后,我们利用浏览器对定义在HomeController中的Action方法Index发起请求。如下图所示,由于缓存的存在,只要指定的时间类型一样,返回的时间就是一样的。
六、保留现有的服务注册方式
在上面的示例演示中,为了让依赖注入框架提供的ISystemClock服务能够被拦截,我们不得不调用自定义的AddSingletonInterceptable
public class Program { public static void Main(string[] args) { Host.CreateDefaultBuilder() .UseInterceptableServiceProvider() .ConfigureWebHostDefaults(buider => buider.UseStartup()) .Build() .Run(); } }
一旦我们按照上面的当时完成了针对InterceptableServiceProviderFactory的注册之后,我们将可以将针对ISystemClock服务的注册还原成我们熟悉的方式。
public class Startup { public void ConfigureServices(IServiceCollection services) { services .AddMemoryCache() .AddInterception() .AddSingleton() .AddRouting() .AddControllers(); } public void Configure(IApplicationBuilder app) { app .UseRouting() .UseEndpoints(endpoints => endpoints.MapControllers()); } }
七、基于策略的拦截器注册方式
Dora.Interception提供了扩展点使我们可以实现任意的拦截器注册方式。除了默认提供的针对“特性标注”的方式之外,我们还提供了一种针对策略的注册方式。这里的策略旨在提供这样的表达:将某种类型的拦截器应用到某个类型的某个方法或者属性上。如果我们没有将CacheReturnValueAttribute特性标注到SystemClock的GetCurrentTime方法上,我们可以将承载程序修改成如下的形式。
public class Program { public static void Main(string[] args) { Host.CreateDefaultBuilder() .UseInterceptableServiceProvider(configure: Configure) .ConfigureWebHostDefaults(buider => buider.UseStartup()) .Build() .Run(); static void Configure(InterceptionBuilder interceptionBuilder) { interceptionBuilder.AddPolicy(policyBuilder => policyBuilder .For<CacheReturnValueAttribute>(order: 1, cache => cache .To<SystemClock>(target => target .IncludeMethod(clock => clock.GetCurrentTime(default))))); } } }
如上面的代码片段所示,我们在调用IHostBuilder的UseInterceptableServiceProvider扩展方法的时候指定了一个Action
八、策略脚本化
如果希望在不修改现有程序代码的前提下自由的修改拦截策略,我们可以将策略脚本化。在这里我们使用的脚本语言就是C#,所以我们可以将上面提供的策略代码放在一个C#脚本中。比如我们在根目录下创建一个interception.dora文件,并在其中定义如下的策略。
policyBuilder .For(1, cache => cache .To (clock => clock .IncludeMethod(it => it.GetCurrentTime(default))));
为了使用这个策略脚本,我们需要对承载程序作相应修改。如下面的代码片段所示,我们同样调用了InterceptionBuilder 的AddPolicy方法,但是这次我们指定的是策略脚本文件名。为了能够识别脚本文件中的类型,我们提供了一个Action
public class Program { public static void Main(string[] args) { Host.CreateDefaultBuilder() .UseInterceptableServiceProvider(configure: Configure) .ConfigureWebHostDefaults(buider => buider.UseStartup()) .Build() .Run(); static void Configure(InterceptionBuilder interceptionBuilder) { interceptionBuilder.AddPolicy("Interception.dora", script => script .AddReferences(Assembly.GetExecutingAssembly()) .AddImports("App")); } } }
AOP框架Dora.Interception 3.0 [1]: 编程体验
AOP框架Dora.Interception 3.0 [2]: 实现原理
AOP框架Dora.Interception 3.0 [3]: 拦截器设计
AOP框架Dora.Interception 3.0 [4]: 基于特性的拦截器注册
AOP框架Dora.Interception 3.0 [5]: 基于策略的拦截器注册
AOP框架Dora.Interception 3.0 [6]: 自定义拦截器注册方式