3

全新升级的AOP框架Dora.Interception[2]: 基于约定的拦截器定义方式

 1 year ago
source link: https://www.cnblogs.com/artech/p/dora-aop-2.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Dora.Interception(github地址,觉得不错不妨给一颗星)有别于其他AOP框架的最大的一个特点就是采用针对“约定”的拦截器定义方式。如果我们为拦截器定义了一个接口或者基类,那么拦截方法将失去任意注册依赖服务的灵活性。除此之外,由于我们采用了动态代码生成的机制,我们可以针对每一个目标方法生成对应的方法调用上下文,所以定义在拦截上下文上针对参数和返回值的提取和设置都是泛型方法,这样可以避免无谓的装箱和拆箱操作,进而将引入拦截带来的性能影响降到最低。(拙著《ASP.NET Core 6框架揭秘》于日前上市,加入读者群享6折优惠)

目录
一、方法调用上下文
二、拦截器类型约定
三、提取调用上下文信息
四、修改输出参数和返回值
五、控制拦截器的执行顺序
六、短路返回
七、构造函数注入
八、方法注入
九、ASP.NET Core应用的适配

一、方法调用上下文

针对同一个方法调用的所有拦截器都是在同一个方法调用上下文中进行的,我们将这个上下文定义成如下这个InvocationContext基类。我们可以利用Target和MethodInfo属性得到当前方法调用的目标对象和目标方法。泛型的GetArgument和SetArgument用于返回和修改传入的参数,针对返回值的提取和设置则通过GetReturnValue和SetReturnValue方法来完成。如果需要利用此上下文传递数据,可以将其置于Properties属性返回的字典中。InvocationServices属性返回针对当前方法调用范围的IServiceProvider。如果在ASP.NET Core应用中,这个属性将返回针对当前请求的IServiceProvider,否则Dora.Interception会为每次方法调用创建一个服务范围,并返回该范围内的IServiceProvider对象。

public abstract class InvocationContext
{
    public object Target { get; }
    public abstract MethodInfo MethodInfo { get; }
    public abstract IServiceProvider InvocationServices { get; }
    public IDictionary<object, object> Properties { get; } 
    public abstract TArgument GetArgument<TArgument>(string name);
    public abstract TArgument GetArgument<TArgument>(int index);
    public abstract InvocationContext SetArgument<TArgument>(string name, TArgument value);
    public abstract InvocationContext SetArgument<TArgument>(int index, TArgument value);
    public abstract TReturnValue GetReturnValue<TReturnValue>();
    public abstract InvocationContext SetReturnValue<TReturnValue>(TReturnValue value);

    protected InvocationContext(object target);

    public ValueTask ProceedAsync() => Next.Invoke(this);
}

和ASP.NET Core的中间件管道类似,应用到同一个方法上的所有拦截器最终也会根据指定的顺序构建成管道。对于某个具体的拦截器来说,是否需要指定后续管道的操作是由它自己决定的。我们知道ASP.NET Core的中间件最终体现为一个Func<RequestDelegate,RequestDelegate>委托,作为输入的RequestDelegate委托代表后续的中间件管道,当前中间件利用它实现针对后续管道的调用。Dora.Interception针对拦截器采用了更为简单的设计,将其表示为如下这个InvokeDelegate(相当于RequestDelegate),因为InvocationContext(相当于HttpContext)的ProceedAsync方法直接可以帮助我们完整针对后续管道的调用。

public delegate ValueTask InvokeDelegate(InvocationContext context);

二、拦截器类型约定

虽然拦截器最终体现为一个InvokeDelegate对象,但是我们倾向于将其定义成一个类型。作为拦截器的类型具有如下的约定:

  • 必须是一个公共的实例类型;
  • 必须包含一个或者多个公共构造函数,针对构造函数的选择由依赖注入框架决定。被选择的构造函数可以包含任意参数,参数在实例化的时候由依赖注入容器提供或者手工指定。
  • 拦截方法被定义在命名为InvokeAsync的公共实例方法中,此方法的返回类型为ValueTask,其中包含一个表示方法调用上下文的InvocationContext类型的参数,能够通过依赖注入容器提供的服务均可以注入在此方法中。

三、提取调用上下文信息

由于拦截器类型的InvokeAsync方法提供了表示调用上下文的InvocationContext参数,我们可以利用它提取基本的调用上下文信息,包括当前调用的目标对象和方法,以及传入的参数和设置的返回值。如下这个FoobarInterceptor类型表示的拦截器会将上述的这些信息输出到控制台上。

public class FoobarInterceptor
{
    public async ValueTask InvokeAsync(InvocationContext invocationContext)
    {
        var method = invocationContext.MethodInfo;
        var parameters = method.GetParameters();
        Console.WriteLine($"Target: {invocationContext.Target}");
        Console.WriteLine($"Method: {method.Name}({string.Join(", ", parameters.Select(it => it.ParameterType.Name))})");

        if (parameters.Length > 0)
        {
            Console.WriteLine("Arguments (by index)");
            for (int index = 0; index < parameters.Length; index++)
            {
                Console.WriteLine($"    {index}:{invocationContext.GetArgument<object>(index)}");
            }

            Console.WriteLine("Arguments (by name)");
            foreach (var parameter in parameters)
            {
                var parameterName = parameter.Name!;
                Console.WriteLine($"    {parameterName}:{invocationContext.GetArgument<object>(parameterName)}");
            }
        }

        await invocationContext.ProceedAsync();
        if (method.ReturnType != typeof(void))
        {
            Console.WriteLine($"Return: {invocationContext.GetReturnValue<object>()}");
        }
    }
}

我们利用InterceptorAttribute特性将这个拦截器应用到如下这个Calculator类型的Add方法中。由于我们没有为它定义接口,只能将它定义成虚方法才能被拦截。

public class Calculator
{
    [Interceptor(typeof(FoobarInterceptor))]
    public virtual int Add(int x, int y) => x + y;
}

在如下这段演示程序中,在将Calculator作为服务注册到创建的ServiceCollection集合后,我们调用BuildInterceptableServiceProvider扩展方法构建一个IServiceCollection对象。在利用它得到Calculator对象之后,我们调用其Add方法。

using App;
using Microsoft.Extensions.DependencyInjection;

var calculator = new ServiceCollection()
    .AddSingleton<Calculator>()
    .BuildInterceptableServiceProvider()
    .GetRequiredService<Calculator>();

Console.WriteLine($"1 + 1 = {calculator.Add(1, 1)}");

针对Add方法的调用会被FoobarInterceptor拦截下来,后者会将方法调用上下文信息以如下的形式输出到控制台上(源代码)。

image

四、修改输出参数和返回值

拦截器可以篡改输出的参数值,比如我们将上述的FoobarInterceptor类型改写成如下的形式,它的InvokeAsync方法会将输入的两个参数设置为0(源代码)。

public class FoobarInterceptor
{
    public ValueTask InvokeAsync(InvocationContext invocationContext)
    {
        invocationContext.SetArgument("x", 0);
        invocationContext.SetArgument("y", 0);
        return invocationContext.ProceedAsync();
    }
}

再次执行上面的程序后就会出现1+1=0的现象。

在完成目标方法的调用后,返回值会存储到上下文中,拦截器也可以将其篡改。如下这个改写的FoobarInterceptor选择将返回值设置为0。程序执行后也会出现上面的输出结果(源代码)。

public class FoobarInterceptor
{
    public async ValueTask InvokeAsync(InvocationContext invocationContext)
    {
        await invocationContext.ProceedAsync();
        invocationContext.SetReturnValue(0);
    }
}

五、控制拦截器的执行顺序

拦截器最终被应用到某个方法上,多个拦截器最终会构成一个由InvokeDelegate委托表示的执行管道,构造管道的拦截器的顺序可以由指定的序号来控制。如下所示的代码片段定义了三个派生于同一个基类的拦截器类型(FooInterceptor、BarInterceptor、BazInterceptor),它们会在目标方法之前后输出当前的类型进而确定它们的执行顺序。

public class InterceptorBase
{
    public async ValueTask InvokeAsync(InvocationContext invocationContext)
    {
        Console.WriteLine($"[{GetType().Name}]: Before invoking");
        await invocationContext.ProceedAsync();
        Console.WriteLine($"[{GetType().Name}]: After invoking");
    }
}

public class FooInterceptor : InterceptorBase { }
public class BarInterceptor : InterceptorBase { }
public class BazInterceptor : InterceptorBase { }

我们利用InterceptorAttribute特性将这三个拦截器应用到如下这个Invoker类型的Invoke方法上。指定的Order属性最终决定了对应的拦截器在构建管道的位置,进而决定了它们的执行顺序。

public class Invoker
{
    [Interceptor(typeof(BarInterceptor), Order = 2)]
    [Interceptor(typeof(BazInterceptor), Order = 3)]
    [Interceptor(typeof(FooInterceptor), Order = 1)]
    public virtual void Invoke() => Console.WriteLine("Invoker.Invoke()");
}

在如下所示的演示程序中,我们按照上述的方式得到Invoker对象,并调用其Invoke方法。

var invoker = new ServiceCollection()
    .AddSingleton<Invoker>()
    .BuildInterceptableServiceProvider()
    .GetRequiredService<Invoker>();

invoker.Invoke();

按照标注InterceptorAttribute特性指定的Order属性,三个拦截器执行顺序依次是:FooInterceptor、BarInterceptor、BazInterceptor,如下所示的输出结果体现了这一点(源代码)。

image

六、短路返回

任何一个拦截器都可以根据需要选择是否继续执行后续的拦截器以及目标方法,比如入门实例中的缓存拦截器将缓存结果直接设置为调用上下文的返回值,并不再执行后续的操作。对上面定义的三个拦截器类型,我们将第二个拦截器BarInterceptor改写成如下的形式。它的InvokeAsync在输出一段指示性文字后,不再调用上下文的ProceedAsync方法,而是直接返回一个ValueTask对象。

public class BarInterceptor
{
    public virtual  ValueTask InvokeAsync(InvocationContext invocationContext)
    {
        Console.WriteLine($"[{GetType().Name}]: InvokeAsync");
        return ValueTask.CompletedTask;
    }
}

再次执行我们的演示程序后会发现FooInterceptor和BarInterceptor会正常执行,但是BazInterceptor目标方法均不会执行(源代码)。

image

七、构造函数注入

由于拦截器是由依赖注入容器创建的,其构造函数中可以注入依赖服务。但是拦截器具有全局生命周期,所以我们不能将生命周期模式为Scoped的服务对象注入到构造函数中。我们可以利用一个简单的实例来演示这一点。我们定义了如下一个拦截器类型FoobarInspector,其构造函数中注入了依赖服务FoobarSerivice。FoobarInspector被采用如下的方式利用InterceptorAttribute特性应用到Invoker类型的Invoke方法上。

public class FoobarInterceptor
{
    public FoobarInterceptor(FoobarService foobarService)=> Debug.Assert(foobarService != null);
    public async  ValueTask InvokeAsync(InvocationContext invocationContext)
    {
        Console.WriteLine($"[{GetType().Name}]: Before invoking");
        await invocationContext.ProceedAsync();
        Console.WriteLine($"[{GetType().Name}]: After invoking");
    }
}

public class FoobarService { }

public class Invoker
{
    [Interceptor(typeof(FoobarInterceptor))]
    public virtual void Invoke() => Console.WriteLine("Invoker.Invoke()");
}

在如下的演示程序中,我们利用命令行参数(0,1,2)来指定依赖服务FoobarService采用的生命周期,然后将其作为参数调用辅助方法Invoke方法完成必要的服务注册,利用构建的依赖注入容器提取Invoker对象,并调用应用了FoobarInspector拦截器的Invoke方法。

var lifetime = (ServiceLifetime)int.Parse(args.FirstOrDefault() ?? "0");
Invoke(lifetime);

static void Invoke(ServiceLifetime lifetime)
{
    Console.WriteLine(lifetime);
    try
    {
        var services = new ServiceCollection().AddSingleton<Invoker>();
        services.Add(ServiceDescriptor.Describe(typeof(FoobarService), typeof(FoobarService), lifetime));
        var invoker = services.BuildInterceptableServiceProvider().GetRequiredService<Invoker>();
        invoker.Invoke();
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.Message);
    }
}

我们以命令行参数的形式启动程序,并指定三种不同的生命周期模式。从输出结果可以看出,如果注册的FoobarService服务采用Scoped生命周期模式会抛出异常(源代码)。

image

八、方法注入

如果FoobarInspector依赖一个Scoped服务,或者依赖的服务采用Transient生命周期模式,但是希望在每次调用的时候创建新的对象(如果将生命周期模式设置为Transient,实际上是希望采用这样的服务消费方式)。此时可以利用InvocationContext的InvocationServices返回的IServiceProvider对象。在如下的实例演示中,我们定义了派生于ServiceBase 的三个将会注册为对应生命周期的服务类型SingletonService 、ScopedService 和TransientService 。为了确定依赖服务实例被创建和释放的时机,ServiceBase实现了IDisposable接口,并在构造函数和Dispose方法中输出相应的文字。在拦截器类型FoobarInterceptor的InvokeAsync方法中,我们利用InvocationContext的InvocationServices返回的IServiceProvider对象两次提取这三个服务实例。FoobarInterceptor依然应用到Invoker类型的Invoke方法中。

public class FoobarInterceptor
{
    public async  ValueTask InvokeAsync(InvocationContext invocationContext)
    {
        var provider = invocationContext.InvocationServices;

        _ = provider.GetRequiredService<SingletonService>();
        _ = provider.GetRequiredService<SingletonService>();

        _ = provider.GetRequiredService<ScopedService>();
        _ = provider.GetRequiredService<ScopedService>();

        _ = provider.GetRequiredService<TransientService>();
        _ = provider.GetRequiredService<TransientService>();

        Console.WriteLine($"[{GetType().Name}]: Before invoking");
        await invocationContext.ProceedAsync();
        Console.WriteLine($"[{GetType().Name}]: After invoking");
    }
}

public class ServiceBase : IDisposable
{
    public ServiceBase()=>Console.WriteLine($"{GetType().Name}.new()");
    public void Dispose() => Console.WriteLine($"{GetType().Name}.Dispose()");
}

public class SingletonService : ServiceBase { }
public class ScopedService : ServiceBase { }
public class TransientService : ServiceBase { }

public class Invoker
{
    [Interceptor(typeof(FoobarInterceptor))]
    public virtual void Invoke() => Console.WriteLine("Invoker.Invoke()");
}

在如下的演示程序中,我们将三个服务按照对应的生命周期模式添加到创建的ServiceCollection集合中。在构建出作为依赖注入容器的IServiceProvider对象后,我们利用它提取出Invoker对象,并先后两次调用应用了拦截器的Invoke方法。为了释放所有由ISerivceProvider对象提供的服务实例,我们调用了它的Dispose方法。

var provider = new ServiceCollection()
    .AddSingleton<SingletonService>()
    .AddScoped<ScopedService>()
    .AddTransient<TransientService>()
    .AddSingleton<Invoker>()
    .BuildInterceptableServiceProvider();
using (provider as IDisposable)
{
   var invoker = provider .GetRequiredService<Invoker>();
    invoker.Invoke();
    Console.WriteLine();
    invoker.Invoke();
}

程序运行后会在控制台上输出如下的结果,可以看出SingletonService 对象只会创建一次,并最终在作为跟容器的ISerivceProvider对象被释放时随之被释放。ScopedSerivce对象每次方法调用都会创建一次,并在调用后自动被释放。每次提取TransientService 都会创建一个新的实例,它们会在方法调用后与ScopedSerivce对象一起被释放(源代码)。

image

其实利用InvocationServices提取所需的依赖服务并不是我们推荐的编程方式,更好的方式是以如下的方式将依赖服务注入拦截器的InvokeAsync方法中。上面演示程序的FoobarInterceptor改写成如下的方式后,执行后依然会输出如上的结果(源代码)。

public class FoobarInterceptor
{
    public async  ValueTask InvokeAsync(InvocationContext invocationContext,
        SingletonService singletonService1, SingletonService singletonService2,
        ScopedService scopedService1, ScopedService scopedService2,
        TransientService transientService1, TransientService transientService2)
    {
        Console.WriteLine($"[{GetType().Name}]: Before invoking");
        await invocationContext.ProceedAsync();
        Console.WriteLine($"[{GetType().Name}]: After invoking");
    }
}

九、ASP.NET Core应用的适配

对于上面演示实例来说,Scoped服务所谓的“服务范围”被绑定为单次方法调用,但是在ASP.NET Core应用应该绑定为当前的请求上下文,Dora.Interception对此做了相应的适配。我们将上面定义的FoobarInterceptor和Invoker对象应用到一个ASP.NET Core MVC程序中。为此我们定义了如下这个HomeController,其Action方法Index中注入了Invoker对象,并先后两次调用了它的Invoke方法。

public class HomeController
{
    [HttpGet("/")]
    public string Index([FromServices] Invoker invoker)
    {
        invoker.Invoke();
        Console.WriteLine();
        invoker.Invoke();
        return "OK";
    }
}

MVC应用的启动程序如下。

var builder = WebApplication.CreateBuilder(args);
builder.Host.UseInterception();
builder.Services
    .AddLogging(logging=>logging.ClearProviders())
    .AddSingleton<Invoker>()
    .AddSingleton<SingletonService>()
    .AddScoped<ScopedService>()
    .AddTransient<TransientService>()
    .AddControllers();
var app = builder.Build();
app
    .UseRouting()
    .UseEndpoints(endpint => endpint.MapControllers());
app.Run();

启动程序后针对根路径“/”(只想HomeController的Index方法)的请求(非初次请求)会在服务端控制台上输出如下的结果,可以看出ScopedSerivce对象针对每次请求只会被创建一次。

image

全新升级的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]: 框架设计和实现原理


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK