作者:以奇
在软件开发领域,面向切面编程(AOP)作为一种强大的技术手段,极大地促进了代码的模块化与可维护性,尤其在处理横切关注点方面表现出色。本文将深入探讨 Java 平台上的 AOP 实现,聚焦于 Spring AOP 框架及其在实际项目中的应用限制,以团队内部广泛应用的日志框架 Diagnose 为例,揭示了 Spring AOP 在处理非 Bean 类方法、静态方法及内部调用时的局限性。
一、AOP 概述:以 Diagnose 为例
说起 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 进行了调用。
@Component
public 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 方法进行调用,如下所示:
@Component
public class B {
@Resource
private A a;
public void invokeA() {
a.foo();
}
}
那么,A 中的 foo,wof,bar 三个方法都会被调用,而且它们三个都被 @Diagnose 注解所标注,哪个方法的诊断日志会被打印呢?换言之,哪个方法的 AOP 切面逻辑会生效呢?
答案是,只有 foo 的切面逻辑会生效,wof 和 bar 的都不会生效。
其中,通过反编译,在 A 的动态代理的生成类中,wof 方法压根就没有切面逻辑;而 bar 方法有切面逻辑,但是没有生效。因此,可以抛出两个问题:
-
为什么反编译的类中,wof 方法没有被织入 AOP 相关的切面逻辑?
-
为什么 bar 中有 AOP 相关的切面逻辑,但是没有生效?
首先分析第一个问题,这个问题是所有的运行时 AOP 方案都不可避免的问题。因为不管是 Cglib,还是 JDK 自带的动态代理,本质上是通过在运行时定义新的 Class 来实现的,而这个 Class 必须是原 Class 的接口实现类或者子类,因为如果不是接口实现类、子类的关系,就无法被注入到代码的引用中。
拿我们最常使用的 HSF 举例来说,在代码中,我们会通过以下方式来引用一个 HSF 远程服务。
@Resource
MyHsfRemoteService myHsfRemoteService;
HSF 会针对 MyHsfRemoteService 接口进行动态代理类的生成,在运行时定义一个新的 Class 对象,同样实现 MyHsfRemoteService 接口,只不过接口方法的调用被拦截,改为远程调用。这个过程其实严格限制了动态代理所定义的新的 Class 对象必须是 MyHsfRemoteService 的实现类,否则就无法被注入给 myHsfRemoteService 这个 bean 引用。Cglib 这种通过继承方式来实现的动态代理也存在同样的局限性。
回到问题本身, 由于 wof 方法是 A 的私有方法,生成的目标 Class 对象作为 A 的子类,无法感知到父类私有方法 wof 的存在,因此也就不会将相关的切面逻辑织入 wof。
解释完 wof 之后,再来看下 bar。bar 作为一个公有方法,通过反编译能证明生成 Class 的 bar 方法中也有 AOP 相关的切面逻辑,那为什么相关的切面逻辑还是没有生效?这个问题需要从动态代理类的生成原理来解释。简而言之,通过动态代理生成的类,会在方法调用前、后执行定义好的织入逻辑,并最终将方法的执行转发给源对象,而源对象是没有相关的切面逻辑的。如下图所示:
因此,第三个局限性可以被进一步扩展,即:所有被 AOP 增强的方法,必须从外部被调用才可以使切面逻辑生效,内部通过 this 的方式进行调用是无效的。
三、ava 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 调用以及外部的静态方法调用生效。
3.1 Premain
Premain 的 agentArgs 参数可以在启动时传入参数。我们可以借助这个特性,传入一些包名前缀,目的是只对我们关心的类执行后续的 transform 操作。
匹配好之后,通过.transform 指定一个 Transformer,我在这里定义了一个 DiagnoseTransformer,完成 Class 的字节码修改操作。
3.2 DiagnoseTransformer
DiagnoseTransformer 需要再对方法进行一次过滤,匹配带有 @Diagnosed 注解的方法,并通过.intercept 进行方法执行的委托。我这里定义了一个 SelfInvokeMethodInterceptor,并将方法的执行委托给它。
SelfInvokeMethodInterceptor 里面可以执行具体的 AOP 逻辑,这里就是每个 AOP 业务相关的操作了。针对 Diagnose,我会从 ApplicationContext 中取出 DiagnosedMethodInterceptor Bean 对象,这个 Bean 对象是由 Diagnose 框架自身定义的方法拦截器,里面是具体的方法执行信息的解析和保存逻辑,这里就不再展示。
最终的包结构如下所示:
3.3 打包过程
在打包时,需要注意,由于 premain 方法是在打出的 jar 包中执行的,不是在业务 jar 包中执行的。因此需要打出的 jar 包中具有相关的依赖。这里使用“jar-with-dependencies”的方式,将相关的依赖也打入 jar 包。
3.4 指定 JVM 参数
在需要使用 Java Agent 的应用的 APP-META/docker-config/environment/common/bin/setenv.sh 文件中,添加一行:
SERVICE_OPTS="${SERVICE_OPTS} -javaagent:/home/admin/${APP_NAME}/target/${APP_NAME}/BOOT-INF/lib/diagnose-agent-1.3.0-SNAPSHOT-jar-with-dependencies.jar=com.taobao.gearfactory,com.taobao.message"
其中,com.taobao.gearfactory,com.taobao.message 是指定需要进行字节码 transform 的包路径,每个应用需自行定义。
3.5 类加载性陷阱分析
经过上述操作之后,会发现应用启动过程中报错:ClassNotFoundException
这是因为 premain 方法是在 AppClassLoader 中执行的,打出的 Java agent jar 包也会被加载入 AppClassloader,而我们的应用都是 SpringBoot 应用,SpringBoot 为了实现 一个 jar 文件包含全部依赖 的效果,特别定义了 AppClassloader 的子类加载器 LaunchedURLClassLoader,用于解析 jar 中的 jar。也就是说我们的业务代码实际上是运行在 LaunchedURLClassLoader 中的。
而一旦我们在 AppClassloader 中引入了与业务相关的依赖,就会导致本应由 LaunchedURLClassLoader 加载的类被双亲委派给 AppClassloader 加载。比如,在 Java Agent jar 的 DiagnoseTransformer 类中,定义的 Diagonse 类、log4j、ApplicationContext 等类都会被 AppClassloader 加载,而由于我们的 AppClassloader 仅仅有类定义,却没有足够多的依赖去加载这些类(因为相关依赖都在 LaunchedURLClassLoader 中),所以会报错 ClassNotFoundException
那怎么解决这个问题呢?分为两个步骤:
1)字节码操作过程中,但凡涉及到与业务相关的依赖,如 Diagonse 类、log4j、ApplicationContext 等,都将相关依赖和逻辑定义在业务 jar 包中,即由 LaunchedURLClassLoader 加载。
2)在 agent 的 jar 中,通过反射的方式获取这部分与业务有关的类。
如下图,将涉及到其他依赖的 SelfInvokeMethodInterceptor 类从 diagnose-agent 包中分离出来,放入到 diagnose-client 包,并让应用依赖这个 jar 包(应用目前已经依赖了 diagnose-client)
diagnose-agent 包只定义了与业务无关的依赖,如 ByteBuddy。diagnose-client 中定义了 Spring,log 相关的依赖。
业务应用依赖如下图所示:
在 diagnose-agent 中,调用 SelfInvokeMethodInterceptor,以及 Diagnose 相关类时,通过反射的方式获取。transform 方法的 classLoader 参数就是 LaunchedURLClassLoader
如此一来,diagnose-agent 中不会对任何其他业务相关的类产生依赖,业务相关的类交给 LaunchedURLClassLoader 进行加载。
四、效果展示:实现对私有方法及静态方法的拦截
如下图,对两个私有方法 decryptBuyerId 和 getAndCheckOrder 加上 @Diagnosed 注解。
对静态方法 ResultDTO.fail 也加上 @Diagnosed 注解。
最终相关的 AOP 逻辑都可以生效。
五、结语
本文深入探讨了在 Java 平台上利用 AOP 进行方法监控的挑战与解决方案,特别是聚焦于 Spring AOP 的局限性及其在处理内部方法调用与静态方法时的不足。通过一个实际案例——日志框架 Diagnose 的使用,文章揭示了 Spring AOP 在非 Bean 对象方法、静态方法以及内部调用场景下的应用局限,并详细分析了这些局限性的技术原因。
总之,本文不仅是一次技术探索之旅,更是对如何克服现有技术框架限制、持续优化和创新的一次生动示范,展现了 Java 平台下 AOP 技术深度与广度的无限可能。