在软件开发领域,面向切面编程(AOP)作为一种强大的技术手段,极大地促进了代码的模块化与可维护性,尤其在处理横切关注点方面表现出色。本文将深入探讨Java平台上的AOP实现,聚焦于Spring AOP框架及其在实际项目中的应用限制,以团队内部广泛应用的日志框架Diagnose为例,揭示了Spring AOP在处理非Bean类方法、静态方法及内部调用时的局限性。
说起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还是存在不可避免的局限性:
@Diagnosed注解所在的方法,必须是一个Bean对象的方法。这个很好理解,因为是通过BeanPostProcessor的方式,在创建Bean的时候进行切面逻辑的操作。如果不是一个Bean,就无法委托给BeanPostProcessor,也就谈不上切面了。这就导致一些非Bean类的方法无法被Diagnose记录调用信息。
@Diagnosed注解所在的方法,不能是静态方法。这是因为Spring AOP的两种实现方式:Cglib和JDK动态代理,分别是通过生成目标类的子类和实现目标接口的方式来创建动态代理的,而静态方法不能被子类重写,更谈不上接口实现。
@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的方式进行调用是无效的。
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的使用步骤可以分为以下几步:
定义一个对象,包含方法名为premain,方法参数为String agentArgs, Instrumentation instrumentation的静态方法;
在resources文件夹里,定义META-INF/MANIFEST.MF文件,里面指定具体的Premain-Class:,指向刚刚定义的对象;
将上述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包。
▐ 指定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的包路径,每个应用需自行定义。
▐ 类加载器陷阱分析
经过上述操作之后,会发现应用启动过程中报错: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
那怎么解决这个问题呢?分为两个步骤:
字节码操作过程中,但凡涉及到与业务相关的依赖,如Diagonse类、log4j、ApplicationContext等,都将相关依赖和逻辑定义在业务jar包中,即由LaunchedURLClassLoader加载。
在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技术深度与广度的无限可能。
团队介绍
我们是大淘宝技术用户消息与社交团队,负责淘宝消息、客服、Push、分享、我淘、关系、社交互动等业务,涵盖淘宝APP中两个一级Tab,第三个消息tab和第五个我的淘宝tab,这里有一流的产品技术,为消费者提供更好的消息与社交服务;丰富的业务场景,为淘系业务增加助力;几十万QPS的高并发流量,可以与淘系各位技术大牛合作,思想激荡碰撞,共同提升。