说起 AOP 的实现方式,大家可能第一时间想到的是 Spring AOP。Spring AOP 通过封装 Cglib 和 JDK 动态代理的相关逻辑,提供给我们方便的途径来生成动态代理对象,从而轻松实现方法执行前后的切面逻辑。很多常见的日志框架、权限校验框架(Apache Shiro)、RPC 调用框架(Apache Dubbo)的切面逻辑都是通过集成 Spring AOP 来实现的。

我们组内也有一个被广泛使用的日志框架:Diagnose,其相关的切面逻辑实现也是通过 Spring AOP 的方式来完成的。简而言之,使用 AOP 达到的效果是:针对那些被 @Diagnosed 注解标注的方法,在执行完之后,会将方法执行的入参,返回值,过程中的日志打印等信息都记录下来,最终将调用堆栈串联起来,展示在前端,方便问题排查和溯源。

如下图所示,当一个 Bean 对象的某个方法被 @Diagnosed 注解标注之后,一旦该方法被执行,就会在前端打印出相关的调用信息。

最终,当越来越多的方法 @Diagnosed 注解所标注,一个业务流程的调用信息就会被串联起 来。

当然,Diagnose 会通过用户、诊断场景等方式来区分每条调用链路。


Spring AOP的三大局限性

Diagnose 可以满足绝大多数场景,但是,使用 Spring AOP 方式实现的 Diagnose 还是存在不可避免的局限性:

  1. @Diagnosed 注解所在的方法,必须是一个 Bean 对象的方法。这个很好理解,因为是通过 BeanPostProcessor 的方式,在创建 Bean 的时候进行切面逻辑的操作。如果不是一个 Bean,就无法委托给 BeanPostProcessor,也就谈不上切面了。这就导致一些非 Bean 类的方法无法被 Diagnose 记录调用信息。

  2. @Diagnosed 注解所在的方法,不能是静态方法。这是因为 Spring AOP 的两种实现方式:Cglib 和 JDK 动态代理,分别是通过生成目标类的子类和实现目标接口的方式来创建动态代理的,而静态方法不能被子类重写,更谈不上接口实现。

  3. @Diagnosed 注解所在的方法,必须从外部被调用才可以使切面逻辑生效,内部的 this.xxx () 无法使 AOP 生效。这个是本文重点要讨论的场景。

前两个局限性很好理解,下面,我们着重针对第三个局限性进行分析。

首先来讲一下何谓 “从外部被调用”。假设有以下 Bean A,他有三个方法,分别是公有方法 foo,bar 和私有方法 wof。其中 foo 方法在 A 类内部对 bar 和 wof 进行了调用。

@Componentpublic class A {    @Diagnosed(name = "foo")    public void foo() {        bar();        wof();    }
    @Diagnosed(name = "wof")    private void wof() {        System.out.println("A.wof");    }
    @Diagnosed(name = "bar")    public void bar() {        System.out.println("A.bar");    }}

再假设有以下 Bean B,他注入了 Bean A,并在 A 类外部对 foo 方法进行调用,如下所示:

@Componentpublic class B {    @Resource    private A a;
    public void invokeA() {        a.foo();    }}

那么,A 中的 foo,wof,bar 三个方法都会被调用,而且它们三个都被 @Diagnose 注解所标注,哪个方法的诊断日志会被打印呢?换言之,哪个方法的 AOP 切面逻辑会生效呢?



答案是,只有 foo 的切面逻辑会生效,wof 和 bar 的都不会生效。

其中,通过反编译,在 A 的动态代理的生成类中,wof 方法压根就没有切面逻辑;而 bar 方法有切面逻辑,但是没有生效。因此,可以抛出两个问题:

  1. 为什么反编译的类中,wof 方法没有被织入 AOP 相关的切面逻辑?

  2. 为什么 bar 中有 AOP 相关的切面逻辑,但是没有生效?



首先分析第一个问题,这个问题是所有的运行时 AOP 方案都不可避免的问题。因为不管是 Cglib,还是 JDK 自带的动态代理,本质上是通过在运行时定义新的 Class 来实现的,而这个 Class 必须是原 Class 的接口实现类或者子类,因为如果不是接口实现类、子类的关系,就无法被注入到代码的引用中。

拿我们最常使用的 HSF 举例来说,在代码中,我们会通过以下方式来引用一个 HSF 远程服务。

@ResourceMyHsfRemoteService myHsfRemoteService;

HSF 会针对 MyHsfRemoteService 接口进行动态代理类的生成,在运行时定义一个新的 Class 对象,同样实现 MyHsfRemoteService 接口,只不过接口方法的调用被拦截,改为远程调用。这个过程其实严格限制了动态代理所定义的新的 Class 对象必须是 MyHsfRemoteService 的实现类,否则就无法被注入给 myHsfRemoteService 这个 bean 引用。Cglib 这种通过继承方式来实现的动态代理也存在同样的局限性。

回到问题本身, 由于 wof 方法是 A 的私有方法,生成的目标 Class 对象作为 A 的子类,无法感知到父类私有方法 wof 的存在,因此也就不会将相关的切面逻辑织入 wof。

解释完 wof 之后,再来看下 bar。bar 作为一个公有方法,通过反编译能证明生成 Class 的 bar 方法中也有 AOP 相关的切面逻辑,那为什么相关的切面逻辑还是没有生效?这个问题需要从动态代理类的生成原理来解释。简而言之,通过动态代理生成的类,会在方法调用前、后执行定义好的织入逻辑,并最终将方法的执行转发给源对象,而源对象是没有相关的切面逻辑的。如下图所示:

因此,第三个局限性可以被进一步扩展,即:所有被 AOP 增强的方法,必须从外部被调用才可以使切面逻辑生效,内部通过 this 的方式进行调用是无效的


Java Agent:治病的良药

Spring AOP 之所以具有上述的三个缺陷,本质上是因为 Spring AOP 是一个 JVM 运行时的技术,此时 class 文件已经被加载完成,Spring AOP 无法对源 class 文件进行修改,只能通过子类继承、接口类实现的方式再重新定义一个类,随后再用这个新生成的类替换掉原有的 bean。

而 Java agent 可以完美的避开这一缺陷。Java agent 并不是什么新技术,早在 jdk 1.5 就已经被推出。简单概括,Java agent 提供给开发者一个 JVM 级别的扩展点,可以在 JVM 启动时,直接对类的字节码做一次修改。使用 Java agent 不需要再新生成一个 Class,而是直接在启动时修改原有的 Class,这样就不必再受继承 / 接口实现的制约以及静态方法,内部方法调用等限制。

Java agent 的使用步骤可以分为以下几步:

  1. 定义一个对象,包含方法名为 premain,方法参数为 String agentArgs, Instrumentation instrumentation 的静态方法;

  2. 在 resources 文件夹里,定义 META-INF/MANIFEST.MF 文件,里面指定具体的 Premain-Class:,指向刚刚定义的对象;

  3. 将上述 MANIFEST.MF 文件和 premain 对象打成一个 jar 包,并在 JVM 启动时通过 - javaagent 参数指定该 jar 文件。

如此一来,JVM 会在启动时执行 jar 包中的 premain 方法,我们可以在 premain 方法中修改特定类,特定方法的字节码文件,来实现在 JVM 启动时的 “AOP” 了。实践中,Java Agent 经常与 Bytebuddy(一个用于创建和修改 Java 类的库,通常应用于字节码操作场景)组合,从而更便捷的实现修改字节码的目的。

下面是我使用 Java Agent + Bytebuddy 对 Diagnose 的改造实践,目的是让 @Diagnose 注解能够对类内部的 this 调用以及外部的静态方法调用生效。

  Premain

Premain 的 agentArgs 参数可以在启动时传入参数。我们可以借助这个特性,传入一些包名前缀,目的是只对我们关心的类执行后续的 transform 操作。

匹配好之后,通过.transform 指定一个 Transformer,我在这里定义了一个 DiagnoseTransformer,完成 Class 的字节码修改操作。

  DiagnoseTransformer

DiagnoseTransformer 需要再对方法进行一次过滤,匹配带有 @Diagnosed 注解的方法,并通过.intercept 进行方法执行的委托。我这里定义了一个 SelfInvokeMethodInterceptor,并将方法的执行委托给它。

SelfInvokeMethodInterceptor 里面可以执行具体的 AOP 逻辑,这里就是每个 AOP 业务相关的操作了。针对 Diagnose,我会从 ApplicationContext 中取出 DiagnosedMethodInterceptor Bean 对象,这个 Bean 对象是由 Diagnose 框架自身定义的方法拦截器,里面是具体的方法执行信息的解析和保存逻辑,这里就不再展示。

最终的包结构如下所示:

  打包过程

在打包时,需要注意,由于 premain 方法是在打出的 jar 包中执行的,不是在业务 jar 包中执行的。因此需要打出的 jar 包中具有相关的依赖。这里使用 “jar-with-dependencies” 的方式,将相关的依赖也打入 jar 包。

Logo

更多推荐