Enjoy software architecture and programming

01May 2022

高性能 AOP 框架 AspectInjector 教程

1953 words - 10 mins

AspectInjector—— 一个免费开源框架,能够在编译时(compile-time,下同)期间,运用切面(Aspect,下同)实现对应用程序静态注入,并且具备简单却不失灵活的接口。 与更常用的运行时(run-time)代理生成框架(例如: UnityCastle.Core.AsyncInterceptor )相比,编译时 AOP 显然提供了更好的性能,这在某些情况下非常重要。

AspectInjector 自其最稳定的测试版发布以来,已经过去了五年。在此期间,开发团队进行了多次改进及重大变化,API 最终稳定了下来。

现在,我从一个简单的例子开始,说明 AspectInjector 可以轻松做到的事情。

假设有这样的场景:需要对所有方法进行简单的跟踪——只需捕获方法的开始和结束。 为了实现此目的,需要定义一个实现所需行为的切面:

[Aspect(Scope.Global)]
[Injection(typeof(TraceAspectAttribute))]
public sealed class TraceAspectAttribute : Attribute
{
    [Advice(Kind.Before, Targets = Target.Method)]
    public void TraceStart(
        [Argument(Source.Type)] Type type,
        [Argument(Source.Name)] string name)
    {
        Console.WriteLine($"[{DateTime.UtcNow}] Method {type.Name}.{name} started");
    }

    [Advice(Kind.After, Targets = Target.Method)]
    public void TraceFinish(
        [Argument(Source.Type)] Type type,
        [Argument(Source.Name)] string name)
    {
        Console.WriteLine($"[{DateTime.UtcNow}] Method {type.Name}.{name} finished");
    }
}

要将切面应用于需要它的任何类,只需使用刚创建的特性(Attribute,下同)标记目标类:

[TraceAspect]
class SampleService
{
    public void Method1()
    {
        //...
    }

    public void Method2()
    {
        //...
    }
}

因此,对 TraceStartTraceFinish 的调用将注入到 SampleService 的所有方法中。

现在,让我们更详细地研究一下用于定义切面的特性。为便于下文理解,上述示例代码中,切面类和特性类属同一个类,即 TraceAspectAttribute

  • Aspect 注解于切面类上。它的必需参数( mandatory parameter)用来指定应如何创建切面实例——整个应用程序的单个实例(Scope.Global),或为每个目标对象的新实例(Scope.PerInstance)创建切面实例。此特性还有一个可选参数 Factory,用于指定用于实例化切面的工厂类,其用法示例可以在本文后面找到。

  • Injection 将指定的切面类,注解在应用于目标( SampleService)的特性上。 仅有一个必需参数,用来接受要应用的切面类类型( typeof(TraceAspectAttribute)),其余可选的优先级参数允许控制切面注入的顺序:其值越高,将执行此切面的越早方法。

  • Advice 注解于切面类的方法,该方法将作为切面的一部分注入到目标。它有一个必需参数 Kind,用于指定带注解的方法将如何应用于目标:Kind.Before —— 指示在调用目标之前注入此(Advice 注解的)方法;Kind.After —— 指示在从目标返回后立即注入此(Advice 注解的)方法; Kind.Around —— 允许通过指定的(Advice 注解的)方法包装原始目标。为了使它成为可能,(Advice 注解的)方法可能有两个参数,对应于目标的委托和目标的调用参数。此特性的非必需参数 Targets 也非常重要——它允许控制应将该切面应用于哪种类成员(getter、setter、常规方法、构造函数、静态成员等)。 可以在 Target 枚举中找到可能值的完整列表,列表中的每个值都是自描述的。 Targets 参数的默认值为 Target.Any,这意味着将为每个类成员注入该 Advice

  • Argument 用于Advice 注解的方法参数,指定应通过此参数传递有关目标的哪些信息。 该特性的 “source” 参数的每个值都需要一个特定的 Advice 参数类型:Source.Instance (object)——此 Advice 已注入的对象实例; Source.Type(Type)——目标类型; Source.Method (MethodBase) —— 目标的元数据; Source.Target (Func<object[], object>) – 目标的委托; Source.Name (string) —— 目标的名称; Source.Arguments (object[]) —— 原始调用参数; Source.ReturnValue(object) —— 原始调用返回的值; Source.ReturnType (Type) —— 目标的返回类型;Source.Injections (Attribute[]) —— 添加此 Advice 的注入特性列表。

上面的日志记录示例可能会引发有关如何传递记录器实例的问题,如果想要使用日志记录框架而不仅仅是写入控制台。有两种方法:

  • 通过静态属性访问记录器或记录器工厂,并直接在切面代码中使用它。显然,它并不理想,因为它不允许使用依赖注入技术。
  • 有一个工厂类,该工厂类处理内部的所有依赖关系问题,并具有一个静态 GetInstance(Type aspectType) 方法,用于创建指定类型的切面。

让我们看一下与上面的日志记录示例相关的第二种方法的可能实现。 除了添加切面工厂之外,我们还将增强日志记录切面,以封装测量调用时间的方式。

[Injection(typeof(TraceAspect))]
public sealed class TraceAttribute : Attribute
{
}

[Aspect(Scope.Global, Factory = typeof(AspectFactory))]
public sealed class TraceAspect
{
    private readonly ILogger _logger;
    public TraceAspect(ILogger logger)
    {
        _logger = logger;
    }

    [Advice(Kind.Around, Targets = Target.Method)]
    public object Trace(
       [Argument(Source.Type)] Type type,
       [Argument(Source.Name)] string name,
       [Argument(Source.Target)] Func<object[], object> methodDelegate,
       [Argument(Source.Arguments)] object[] args)
    {
        _logger.LogInformation($"[{DateTime.UtcNow}] Method {type.Name}.{name} started");
        var sw = Stopwatch.StartNew();
        var result = methodDelegate(args);
        sw.Stop();
        _logger.LogInformation($"[{DateTime.UtcNow}] Method {type.Name}.{name} finished in {sw.ElapsedMilliseconds} ms");
        return result;
    }
}

public class AspectFactory
{
    public static object GetInstance(Type aspectType)
    {
        // 这里,你可以实现任何实例化方法,以下方法仅为简单示例
        if (aspectType == typeof(TraceAspect))
        {
            var logger = new Logger();
            return new TraceAspect(logger);
        }
        throw new ArgumentException($"未知切面类型 '{aspectType.FullName}'");
    }
}

在这里,你可以看到为该特性创建了一个单独的类,因为现在切面( aspect)类需要传入一些外部依赖项,但该特性的构造函数不应有任何参数。

在切面的定义中,指定的工厂类必须具有静态方法 GetInstance,如上面示例中的签名:它接受类型并返回对象。对象创建的所有细节都可隐藏在方法内部,可以使用 DI 容器来实现目标。与"传统" DI 容器使用相比,这种方法的唯一缺点是必须具有这种静态的"服务定位器"。不幸的是,编译时 AOP 没有其他方法可以做到这一点——工厂方法必须在编译时可用。

最后,但并非最不重要的一点是 —— AdviceKind.Around。它允许将调用完全包装在新方法中,并在原始调用之前和之后引入任何其他逻辑。在此示例中,秒表在基础调用之前创建,然后用于计算其持续时间。如果没有 AdviceKind.Around,将某些状态从"调用前"上下文可靠地传递到"调用后"将相当具有挑战性。请注意,任何 AdviceKind.Around 方法必须至少有两个参数——目标方法委托 (Source.Target) 和原始调用参数 (Source.Arguments)。

让我们还看一下 Aspect Injector 的另一个重要特性——接口实现注入。 下面的示例演示如何使任意类实现 INotifyPropertyChanged 接口,就像每个公共属性在每次调用其 setter 时调用 PropertyChaged 事件处理程序一样。

[AttributeUsage(AttributeTargets.Property)]
[Injection(typeof(NotifyAspect))]
public class Notify : Attribute
{
}

[Mixin(typeof(INotifyPropertyChanged))]
[Aspect(Scope.PerInstance)]
internal class NotifyAspect : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged = (s, e) => { };

    [Advice(Kind.After, Targets = Target.Public | Target.Setter)]
    public void AfterSetter(
        [Argument(Source.Instance)] object source,
        [Argument(Source.Name)] string propertyName)
    {
        PropertyChanged(source, new PropertyChangedEventArgs(propertyName));
    }
}

如你所见,要创建注入接口实现的切面,只需在切面中实现所需的接口,并使用 Mixin 特性对其进行注解,指定要注入目标类的接口。上面用 Notify 特性注解的每个类都将在编译期间获得 INotifyPropertyChanged 及其实现。

总体而言,当应用程序框架不提供任何注入操作处理管道的方法时,此库在许多情况下可以提供帮助。并且由于它隐式实现编译时注入,因此它提供了比基于动态代理的 AOP 框架更好的性能。

本文编译自:Aspect-oriented programming in .NET with AspectInjector