全新升级的AOP框架Dora.Interception[1]: 编程体验
多年之前利用IL Emit写了一个名为Dora.Interception(github地址,觉得不错不妨给一颗星)的AOP框架。前几天利用Roslyn的Source Generator对自己为公司写的一个GraphQL框架进行改造,性能得到显著的提高,觉得类似的机制同样可以用在AOP框架上,实验证明这样的实现方式不仅仅极大地改善性能(包括执行耗时和GC内存分配),而且让很多的功能特性变得简单了很多。这并不是说IL Emit性能不好(其实恰好相反),而是因为这样的实现太复杂,面向IL编程比写汇编差不多。由于AOP拦截机制涉及的场景很多(比如异步等待、泛型类型和泛型方法、按地址传递参数等等),希望完全利用IL Emit高效地实现所有的功能特性确实很难,但是从C#代码的层面去考虑就简单多了。((拙著《ASP.NET Core 6框架揭秘》6折优惠,首印送签名专属书签)
目录
一、Dora.Interception的设计特点
二、基于约定的拦截器定义
三、基于特性的拦截器注册方式
四、基于表达式的拦截器注册方式
五、更好的拦截器定义方式
六、方法注入
七、拦截的屏蔽
八、在ASP.NET Core程序中的应用
一、Dora.Interception的设计特点
彻底改造升级后的Dora.Interception直接根据.NET 6开发,不再支持之前.NET (Core)版本。和之前一样,Dora.Interception的定位是一款轻量级的AOP框架,同样建立在.NET的依赖注入框架上,可拦截的对象必需由依赖注入容器来提供。
除了性能的提升和保持低侵入性,Dora.Interception在编程方式上于其他所有的AOP框架都不太相同。在拦截器的定义上,我们并没有提供接口和基类来约束拦截方法的实现,而是采用“基于约定”的编程模式将拦截器定义成一个普通的类,拦截方法上可以任意注入依赖的对象。
在如何应用定义的拦截器方面,我们提供了常见的“特性标注”的编程方式将拦截器与目标类型、方法和属性建立关联,我们还提供了一种基于“表达式”的拦截器应用方式。Dora.Interception主张将拦截器“精准”地应用到具体的目标方法上,所以提供的这两种方式针对拦截器的应用都是很“明确的”。如果希望更加灵活的拦截器应用方式,通过提供的扩展可以自由发挥。
接下来我们通过一个简单实例来演示一下Dora.Interception如何使用。在这个实例中,我们利用AOP的方式来缓存某个方法的结果,我们希望达到的效果很简单:目标方法将返回值根据参数列表进行缓存,以避免针对方法的重复执行。
二、基于约定的拦截器定义
我们创建一个普通的控制台程序,并添加如下两个NuGet包的引用。前者正是提供Dora.Interception框架的NuGet包,后者提供的基于内存缓存帮助我们缓存方法返回值。
- Dora.Interception
- Microsoft.Extensions.Caching.Memory
由于方法的返回值必须针对输入参数进行缓存,所以我们定义了如下这个类型Key作为缓存的键。作为缓存键的Key对象是对作为目标方法的MethodInfo对象和作为参数列表的对象数组的封装。
internal class Key : IEquatable<Key> { public Key(MethodInfo method, IEnumerable<object> arguments) { Method = method; Arguments = arguments.ToArray(); } public MethodInfo Method { get; } public object[] Arguments { get; } public bool Equals(Key? other) { if (other is null) return false; if (Method != other.Method) return false; if (Arguments.Length != other.Arguments.Length) return false; for (int index = 0; index < Arguments.Length; index++) { if (!Arguments[index].Equals(other.Arguments[index])) { return false; } } return true; } public override int GetHashCode() { var hashCode = new HashCode(); hashCode.Add(Method); for (int index = 0; index < Arguments.Length; index++) { hashCode.Add(Arguments[index]); } return hashCode.ToHashCode(); } public override bool Equals(object? obj) => obj is Key key && key.Equals(this); }
如下所示的就是用来缓存目标方法返回值的拦截器类型CachingInterceptor的定义。正如上面所示,Dora.Interception提供的是“基于约定”的编程方式。这意味着作为拦截器的类型不需要实现既定的接口或者继承既定的基类,它仅仅是一个普通的公共实例类型。由于Dora.Interception建立在依赖注入框架之上,所以我们可以在构造函数中注入依赖的对象,在这里我们就注入了用来缓存返回值的IMemoryCache 对象。
public class CachingInterceptor { private readonly IMemoryCache _cache; public CachingInterceptor(IMemoryCache cache) => _cache = cache; public async ValueTask InvokeAsync(InvocationContext invocationContext) { var method = invocationContext.MethodInfo; var arguments = Enumerable.Range(0, method.GetParameters().Length).Select(index => invocationContext.GetArgument<object>(index)); var key = new Key(method, arguments); if (_cache.TryGetValue<object>(key, out var value)) { invocationContext.SetReturnValue(value); return; } await invocationContext.ProceedAsync(); _cache.Set(key, invocationContext.GetReturnValue<object>()); } }
具体的“切面(Aspect)”逻辑实现在一个面向约定的InvokeAsync方法中,该方法只需要定义成返回类型为ValueTask的公共实例方法即可。InvokeAsync方法提供的InvocationContext 对象是针对当前方法调用的上下文,我们利用其MethodInfo属性得到代表目标方法的MethodInfo对象,调用泛型方法GetArgument<TArgument>根据序号得到传入的参数。在利用它们生成代码缓存键的Key对象之后,我们利用构造函数中注入的IMemoryCache 对象确定是否存在缓存的返回值。如果存在,我们直接调用InvocationContext 对象的SetReturnValue<TReturnValue>方法将它设置为方法返回值,并直接“短路”返回,目标方法将不再执行。
如果返回值尚未被缓存,我们调用InvocationContext 对象的ProceedAsync方法,该方法会帮助我们调用后续的拦截器或者目标方法。在此之后我们利用上下文的SetReturnValue<TReturnValue>方法将返回值提取出来进行缓存就可以了。
三、基于特性的拦截器注册方式
拦截器最终需要应用到某个具体的方法上。为了能够看到上面定义的CachingInterceptor针对方法返回值缓存功能,我们定义了如下这个用来提供系统时间戳的SystemTimeProvider服务类型和对应的接口ISystemTimeProvider,定义的GetCurrentTime方法根据作为参数的DateTimeKind枚举返回当前时间。实现在SystemTimeProvider中的GetCurrentTime方法上利用预定义的InterceptorAttribute特性将上面定义的CachingInterceptor拦截器应用到目标方法上,该特性提供的Order属性用来控制应用的多个拦截器的执行顺序。
public interface ISystemTimeProvider { DateTime GetCurrentTime(DateTimeKind kind); }
public class SystemTimeProvider : ISystemTimeProvider { [Interceptor(typeof(CachingInterceptor),Order = 1)] public virtual DateTime GetCurrentTime(DateTimeKind kind) => kind switch { DateTimeKind.Utc => DateTime.UtcNow, _ => DateTime.Now }; }
虽然大部分AOP框架都支持将拦截器应用到接口上,但是Dora.Interception倾向于避免这样做,因为接口是服务消费的契约,面向切面的横切(Crosscutting)功能体现的是服务实现的内部行为,所以拦截器应该应用到实现类型上。如果你一定要做么做,只能利用提供的扩展点来实现,实现方式其实也很简单。
Dora.Interception直接利用依赖注入容器来提供可被拦截的实例。如下面的代码片段所示,我们创建了一个ServiceCollection对象并完成必要的服务注册,最终调用BuildInterceptableServiceProvider扩展方法得到作为依赖注入容器的IServiceProvider对象。
var timeProvider = new ServiceCollection() .AddMemoryCache() .AddSingleton<ISystemTimeProvider, SystemTimeProvider>() .AddSingleton<SystemTimeProvider>() .BuildInterceptableServiceProvider() .GetRequiredService<SystemTimeProvider>(); Console.WriteLine("Utc time:"); for (int index = 0; index < 5; index++) { Console.WriteLine($"{timeProvider.GetCurrentTime(DateTimeKind.Utc)}[{DateTime.UtcNow}]"); await Task.Delay(1000); } Console.WriteLine("Utc time:"); for (int index = 0; index < 5; index++) { Console.WriteLine($"{timeProvider.GetCurrentTime(DateTimeKind.Local)}[{DateTime.Now}]"); await Task.Delay(1000); }
在利用BuildInterceptableServiceProvider对象得到用于提供当前时间戳的ISystemTimeProvider服务实例,并在控制上以UTC和本地时间的形式输出时间戳。由于输出的间隔被设置为1秒,如果方法的返回值被缓存,那么输出的时间是相同的,下图所示的输出结果体现了这一点(源代码)。
四、基于Lambda表达式的拦截器注册方式
如果拦截器应用的目标类型是由自己定义的,我们可以在其类型或成员上标注InterceptorAttribute特性来应用对应的拦截器。如果对那个的程序集是由第三方提供的呢?此时我们可以采用提供的第二种基于表达式的拦截器应用方式。这里的拦截器是一个调用目标类型某个方法或者提取某个属性的Lambda表达式,我们采用这种强类型的编程方式得到目标方法,并提升编程体验。对于我们演示的实例来说,拦截器最终应用到SystemTimeProvider的GetCurrentTime方法上,所以我们可以按照如下的形式来代替标注在该方法上的InterceptorAttribute特性(源代码)。
var timeProvider = new ServiceCollection() .AddMemoryCache() .AddSingleton<ISystemTimeProvider, SystemTimeProvider>() .AddSingleton<SystemTimeProvider>() .BuildInterceptableServiceProvider(interception => interception.RegisterInterceptors(RegisterInterceptors)) .GetRequiredService<SystemTimeProvider>(); static void RegisterInterceptors(IInterceptorRegistry registry) { registry.For<CachingInterceptor>().ToMethod<SystemTimeProvider>(1, it => it.GetCurrentTime(default)); }
五、更好的拦截器定义方式
全新的Dora.Interception在提升性能上做了很多考量。从上面定义的CachingInterceptor可以看出,作为方法调用上下文的InvocationContext类型提供的大部分方法都是泛型方法,其目的就是避免装箱带来的内存分配。但是CachingInterceptor为了适应所有方法,只能将参数和返回值转换成object对象,所以这样会代码一些性能损失。为了解决这个问题,我们可以针对参数的个数相应的泛型拦截器。比如针对单一参数方法的拦截器就可以定义成如下的形式,我们不仅可以直接使用 Tuple<MethodInfo, TArgument>元组作为缓存的Key,还可以直接调用泛型的GetArgument<TArgument>方法和SetReturnValue<TReturnValue>提起参数和设置返回值。
public class CachingInterceptor<TArgument, TReturnValue> { private readonly IMemoryCache _cache; public CachingInterceptor(IMemoryCache cache) => _cache = cache; public async ValueTask InvokeAsync(InvocationContext invocationContext) { var key = new Tuple<MethodInfo, TArgument>(invocationContext.MethodInfo, invocationContext.GetArgument<TArgument>(0)); if (_cache.TryGetValue<TReturnValue>(key, out var value)) { invocationContext.SetReturnValue(value); return; } await invocationContext.ProceedAsync(); _cache.Set(key, invocationContext.GetReturnValue<TReturnValue>()); } }
具体的参数类型只需要按照如下的方式在应用拦截器的时候指定就可以了(源代码)。
public class SystemTimeProvider : ISystemTimeProvider { [Interceptor(typeof(CachingInterceptor<DateTimeKind,DateTime>), Order = 1)] public virtual DateTime GetCurrentTime(DateTimeKind kind) => kind switch { DateTimeKind.Utc => DateTime.UtcNow, _ => DateTime.Now }; }
六、方法注入
拦截器定义的时候可以在构造函数中注入依赖对象,其实更方便不是采用构造函数注入,而是采用方法注入,也就是直接将对象注入到InvokeAsync方法中。由于拦截器对象具有全局生命周期(从创建到应用关闭),所以Scoped服务不能注入到构造函数中,此时只能采用方法注入,因为方法中注入的对象是在方法调用时实时提供的。上面定义的拦截器类型改写成如下的形式(源代码)。
public class CachingInterceptor<TArgument, TReturnValue> { public async ValueTask InvokeAsync(InvocationContext invocationContext, IMemoryCache cache) { var key = new Tuple<MethodInfo, TArgument>(invocationContext.MethodInfo, invocationContext.GetArgument<TArgument>(0)); if (cache.TryGetValue<TReturnValue>(key, out var value)) { invocationContext.SetReturnValue(value); return; } await invocationContext.ProceedAsync(); cache.Set(key, invocationContext.GetReturnValue<TReturnValue>()); } }
七、拦截的屏蔽
除了“精准地”将某个拦截器应用到目标方法上,我们也可以采用“排除法”先将拦截器批量应用到一组候选的方法上(比如应用到某个类型设置是程序集上),然后将某些不需要甚至不能被拦截的方法排除掉。此外我们使用这种机制避免某些不能被拦截(比如在一个循环中重复调用)的方法被错误地与某些拦截器进行映射。针对拦截的屏蔽也提供了两种编程方式,一种方式就是在类型、方法或者属性上直接标注NonInterceptableAttribute特性。由于针对拦截的屏蔽具有最高优先级,如果我们按照如下的方式在SystemTimeProvider类型上标注NonInterceptableAttribute特性,针对该类型的所有方法的调用将不会被拦截(源代码)。
[NonInterceptable] public class SystemTimeProvider : ISystemTimeProvider { [Interceptor(typeof(CachingInterceptor<DateTimeKind, DateTime>), Order = 1)] public virtual DateTime GetCurrentTime(DateTimeKind kind) => kind switch { DateTimeKind.Utc => DateTime.UtcNow, _ => DateTime.Now }; }
我们也可以采用如下的方式调用SuppressType<TTarget>方法以表达式的方式提供需要屏蔽的方式。除了这个方法,IInterceptorRegistry接口还提供了其他方法,我们会在后续的内容进行系统介绍。
var timeProvider = new ServiceCollection() .AddMemoryCache() .AddSingleton<ISystemTimeProvider, SystemTimeProvider>() .AddSingleton<SystemTimeProvider>() .BuildInterceptableServiceProvider(interception => interception.RegisterInterceptors(RegisterInterceptors)) .GetRequiredService<SystemTimeProvider>(); ... static void RegisterInterceptors(IInterceptorRegistry registry) => registry.SupressType<SystemTimeProvider>();
八、在ASP.NET Core程序中的应用
由于ASP.NET Core框架建立在依赖注入框架之上,Dora.Interception针对方法的拦截也是通过动态改变服务注册的方式实现的,所以Dora.Interception在ASP.NET Core的应用更加自然。现在我们将上面定义的ISystemTimeProvider/SystemTimeProvider服务应用到如下这个HomeController中。两个采用路由路径“/local”和“utc”的Action方法会利用注入的ISystemTimeProvider对象返回当前时间。为了检验返回的时间是否被缓存,方法还会返回当前的真实时间戳
public class HomeController { [HttpGet("/local")] public string GetLocalTime([FromServices] ISystemTimeProvider provider) => $"{provider.GetCurrentTime(DateTimeKind.Local)}[{DateTime.Now}]"; [HttpGet("/utc")] public string GetUtcTime([FromServices] ISystemTimeProvider provider) => $"{provider.GetCurrentTime(DateTimeKind.Utc)}[{DateTime.UtcNow}]"; }
ASP.NET Core针对Dora.Interception的整合是通过调用IHostBuilder的UseInterception扩展方法实现的,该扩展方法由“Dora.Interception.AspNetCore”提供(源代码)。
using App;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseInterception();
builder.Services
.AddHttpContextAccessor()
.AddMemoryCache()
.AddSingleton<ISystemTimeProvider, SystemTimeProvider>()
.AddControllers();
var app = builder.Build();
app
.UseRouting()
.UseEndpoints(endpint => endpint.MapControllers());
app.Run();
程序启动后,我们请求路径“local”和“utc”得到的时间戳都将被缓存起来,如下的输出结果体现了这一点(源代码)。
全新升级的AOP框架Dora.Interception[1]: 编程体验
全新升级的AOP框架Dora.Interception[2]: 基于约定的拦截器定义方式
全新升级的AOP框架Dora.Interception[3]: 基于“特性标注”的拦截器注册方式
全新升级的AOP框架Dora.Interception[4]: 基于“Lambda表达式”的拦截器注册方式
全新升级的AOP框架Dora.Interception[5]: 实现任意的拦截器注册方式
全新升级的AOP框架Dora.Interception[6]: 框架设计和实现原理