免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

动态代理之外:详解AOP的编译期织入、字节码增强与类加载织入

动态代理之外:详解AOP的编译期织入、字节码增强与类加载织入 只要聊到AOP十个人里有九个第一反应就是动态代理剩下那个也许会补一句CGLIB。面试这么答确实不会扣分但如果你真接过这样的需求——在不改任何源码的情况下给一个已经打包好的老服务加上方法耗时统计或者要去拦截某个类的私有方法和字段读写又或者你压根不想引入Spring只想在几个POJO上做切面——你会发现动态代理这套方案根本架不住。这篇文章想把动态代理之外的AOP实现方式系统讲一遍AspectJ编译期织入、类加载期织入、ASM与Javassist手工改字节码、注解处理器生成代码。每条路线我都会给出原理、关键代码和实际踩过的坑适合刚把Spring AOP背熟的初中级工程师也适合想自己动手做中间件、监控组件的人。1. 先掰扯清楚AOP的本质是织入动态代理只是织入的一种姿势1.1 面向切面编程到底解决的是什么问题AOP全称Aspect-Oriented Programming面向切面编程。它解决的是横切关注点cross-cutting concerns的模块化问题。什么是横切关注点就是那些每个业务方法都得做但又不属于业务本身的逻辑日志记录、事务管理、权限校验、异常兜底、性能监控、接口限流。你不可能在每个方法里手动写一遍于是希望把这些逻辑从业务代码里抽出来统一在一处声明再注入到需要的地方去。Spring全家桶里大多数聪明的自动行为都是这样来的Transactional切了事务Cacheable切了缓存PreAuthorize切了权限Retryable切了重试。你用的时候感觉像魔法其实底层全是AOP这一套机制在干活。面试里常问的IoC和AOP的原理IoC管对象创建和依赖管理AOP管横切逻辑的注入这两个东西撑起了Spring容器化开发的大半体验。1.2 几个必须分清的术语切面、连接点、切点、通知、织入先快速过一遍AOP的基础概念后面讲实现方式时都会用到切面Aspect横切逻辑的模块化单元比如一个日志切面、一个事务切面。连接点Join Point程序执行过程中可以被拦截的位置比如方法调用、方法执行、字段赋值、异常抛出。切点Pointcut用表达式匹配到底拦截哪些连接点常见的就是execution表达式。通知Advice在匹配到的连接点上执行的逻辑有Before、After、Around、AfterReturning、AfterThrowing五种。织入Weaving把切面代码绑定到目标对象上的过程本质上是把切面缝进执行流程里。注意最后一条织入这个词才是AOP实现的核心。动态代理做的无非是选择了一种织入方式在运行时生成一个代理对象把通知逻辑挂在代理对象的回调方法里。换句话说动态代理是织入的一种姿势不是AOP本身。想搞清楚有没有别的实现办法其实就是问除了运行时生成代理对象这条路还有没有别的织入时机和织入手段。答案很明显有而且历史比想象中长得多。1.3 一个容易被忽略的事实Spring AOP不等于AOP很多人的认知是AOP Spring AOP 动态代理这个等式在工程上够用但概念上是不对的。AOP作为一种编程思想最早由AspectJ项目实现——那是Xerox PARC在2001年左右搞出来的东西。AspectJ提供了完整的连接点模型和独立的编译器ajc完全可以脱离Spring工作。Spring AOP只是遵循AOP思想、采用动态代理实现的简化版AOP的一个具体产品。面试如果被问IoC和AOP的原理一个能加分的回答是IoC解决对象创建和依赖管理问题容器控制生命周期AOP解决横切逻辑模块化问题关键看织入时机。Spring AOP在运行时通过JDK动态代理或CGLIB代理织入AspectJ支持编译期和类加载期织入连接点模型更完整。这句话把两个问题都答透了而且明显比AOP就是动态代理高一个层次。2. 动态代理为什么成了默认答案又卡在天花板的哪几层2.1 两条主流动态代理技术JDK动态代理和CGLIBSpring AOP默认的实现方式就是动态代理。目标对象实现了接口用JDK动态代理没实现接口用CGLIB生成子类代理。JDK动态代理的核心是Proxy.newProxyInstance加InvocationHandler接口上每个public方法被调用时都会进入invoke方法你在里面做增强逻辑再通过Method反射调用目标。CGLIB则不走反射路线它利用ASM直接在运行时生成目标类的子类字节码通过MethodInterceptor拦截父类方法。因为生成的是子类所以CGLIB能代理没有实现接口的类。但也正因为要生成子类final类无法代理、final方法无法代理、static方法也拦截不了。Spring Boot 2.x开始默认把proxy-target-class设为true所以现在大多数Spring应用实际跑的都是CGLIB代理这一支。很多人天天用着Spring AOP却不知道自己底层用的到底是哪种代理。2.2 动态代理这条路的四个天花板先说结论在Spring容器内的public方法这个场景里动态代理是最划算的方案。跨出这个场景它处处受制主要有四层第一连接点模型太窄。动态代理只能拦截方法执行字段读写、构造器调用、异常处理器、static初始化块统统没法碰。想实现一个对象被序列化前自动加密某字段这种需求动态代理完全没招。第二自调用失效问题。经典的Transactional失效场景service里一个方法调用同类另一个方法因为this调用没有经过代理对象切面根本不生效。网上有各种绕法——注入自身、加中间委托类、用AopContext.currentProxy()——但本质上都是在和代理模式的天然缺陷做斗争。第三依赖Spring容器。动态代理织入的前提是对象要经过容器创建并被代理包装。一个纯Java SE项目、一个自己new出来的对象想用Spring AOP就非常别扭你得手动走ProxyFactory。脱离容器之后整套机制的使用体验断崖式下跌。第四final、sealed这些现代Java语法带来的限制越来越多。JDK 17的sealed class出来之后CGLIB想给这种类生成子类都生成不了JDK Proxy只能代理接口面对没有接口的sealed实现类也很尴尬。Java语言越往后收紧继承和扩展权限越说明运行时生成子类这条路的根基在变薄。2.3 动态代理的隐性开销启动期和调用期都要算账还有容易被忽略的是性能账。JDK动态代理的调用链方法进代理要经过InvocationHandler再走反射调用虽然JDK近几个版本对这条链路做过优化但它本质上仍多了一层间接跳转无法和直接方法调用比。CGLIB没有反射损耗但有生成子类的启动开销它用ASM织出的字节码复杂在业务类特别多时对类加载有压力。业务系统里这些开销通常可以忽略但做高吞吐的中间件、做用户量极大的网关时差别就出来了——这也是为什么一些性能敏感的框架宁愿选择编译期织入。3. AspectJ编译期织入把切面直接缝进字节码3.1 AspectJ的连接点模型到底强在哪AspectJ是真正意义上的完整AOP实现。和动态代理只能拦方法执行不同AspectJ的连接点模型包含方法调用、方法执行、字段读、字段写、构造器调用、构造器执行、异常处理器执行、static初始化、对象初始化等。这意味着什么意味着你可以写一个某个字段被修改时打印日志的切面可以写一个进入catch块时发告警的切面甚至可以给final类、给私有方法织入逻辑。代码执行流程里凡是你想插手的地方AspectJ基本都能插手。动态代理做不到的这些它统统可以做到。打个比方动态代理像给衣服套了件外套编译期织入则像把补丁直接缝进布料本身。外套本质上是衣服的一部分吗不是它是另一件东西但缝进布料的补丁和衣服就是一体了。3.2 编译期织入CTW的核心流程编译期织入是最经典的一条路用AspectJ编译器ajc在编译阶段直接改写目标类字节码把切面逻辑的字节码缝进原方法里。整个过程里没有代理对象、没有反射、没有运行时包装输出出来的.class文件就是增强后的成品。AspectJ支持两种写法。一种是AspectJ语言风格用专门的语法文件定义切面public aspect LogAspect { pointcut businessOps(): execution(* com.example.service..*.*(..)); before(): businessOps() { System.out.println([AspectJ] before: thisJoinPoint.getSignature()); } after(): businessOps() { System.out.println([AspectJ] after: thisJoinPoint.getTarget()); } }另一种是AspectJ注解风格在Java类上打注解语法和Spring里写的Aspect基本一样只是编译时交给ajc处理而非运行时交给Spring代理处理Aspect public class LogAspect { Around(execution(* com.example.service..*.*(..))) public Object around(ProceedingJoinPoint pjp) throws Throwable { long start System.nanoTime(); try { return pjp.proceed(); } finally { System.out.println(cost ms: (System.nanoTime() - start) / 1_000_000); } } }这里要提醒一句Aspect这个注解本身不代表强AOP。同样一段Aspect代码丢给Spring运行时是代理织入丢给ajc是编译期织入两者语义和拦截能力完全不同。判断标准就一条谁在织入代理还是编译器。3.3 Maven工程接入CTW的配置和坑Maven工程接入编译期织入主要靠aspectj-maven-plugin配置大致这样plugin groupIdorg.codehaus.mojo/groupId artifactIdaspectj-maven-plugin/artifactId version1.14.0/version configuration source17/source target17/target complianceLevel17/complianceLevel showWeaveInfotrue/showWeaveInfo /configuration executions execution goals goalcompile/goal /goals /execution /executions /pluginshowWeaveInfo设为true后编译日志里会输出类似Type com.example.service.OrderService weaved的信息方便确认切面真的织入成功。接入之后有四个坑值得记住IDEA下如果只用自带的javac构建没有走ajc切面不会生效必须在Build配置里选AspectJ编译器。ajc和javac的告警行为不同一些加了严格告警开关的项目会因为切面代码导致编译失败需要单独处理切面模块。织入后的字节码和源码行号对应关系会变得复杂远程调试和链路追踪时看到的栈信息有时会跳行。一旦用了编译期织入CI流程必须保证每台构建机器都有稳定的JDK版本和插件依赖否则切面悄悄失效还很难察觉。3.4 编译期织入最大的收益自调用也被拦截为什么很多人从Spring AOP切到AspectJ最典型的痛点是自调用。Spring AOP里this.open()不经过代理切面失灵AspectJ编译期织入直接改写目标类自己的字节码方法内部的this调用同样被改写了所以private方法可以进切面、final类可以进切面、同类互调也能进切面。这种无死角的拦截能力正是缝进布料和套一件外套的本质差异。4. 类加载期织入LTW对历史Jar包偷梁换柱4.1 LTW的工作机制class文件被JVM使用前的最后一关类加载期织入英文Load-Time Weaving核心思路是在class文件被JVM正式加载为Class对象之前用一个agent拦截字节流改写完毕再放行。对业务代码来说源码不用动、构建不用动、甚至jar包都不用换只要你启动命令里加一个javaagent参数剩下的交给JVM。具体到AspectJ启动命令长这样java -javaagent:/path/to/aspectjweaver.jar -jar app.jaragent启动时会注册一个ClassFileTransformerJVM每加载一个类都会先让transformer看一眼符合配置里切点范围的类就织入不符合就直接放行。整个过程对业务代码完全透明。4.2 aop.xml配置要点LTW的切面声明和切点范围放在META-INF/aop.xml里位于classpath的META-INF目录下根节点是aspectjaspectj aspects aspect namecom.demo.monitor.PerformanceAspect/ /aspects weaver options-verbose include withincom.demo.service..*/ exclude withincom.demo.service.internal..*/ /weaver /aspectjinclude和exclude用within表达式控制扫描范围。这里有个细节容易被忽略agent在类加载时是全量拦截的如果不加范围限制织入过程中的类和JDK内部类都会被扫描类加载开销明显上升所以一定要用include把范围缩到业务代码上。4.3 LTW最适合的场景LTW能派上大用场的基本是三类场景已经打包好、没有源码、不能重新发版的老jar包想给里面的服务类加上监控逻辑。要给第三方依赖里的方法做增强但不想把它们引入编译期织入流程。团队迁移成本敏感改一个启动脚本、扔一个agent jar不动任何编译配置。但LTW的代价也要讲清楚类加载阶段的织入有启动开销agent必须在main方法执行前挂载JDK 9模块化之后如果增强动到了JDK内部类还要处理模块的open/export限制另外调试时看到的行号和堆栈和静态编译不完全一致。可以说它是不动刀的手术好用但得留意排异反应。4.4 顺带一提Instrumentation是这类工具的地基LTW能成立底层依赖的是java.lang.instrument的Instrumentation机制。理解这个机制非常重要因为市面上常见的APM和诊断类工具——比如Arthas、SkyWalking、各种字节码agent——本质都是写了一个agent在premain方法里拿到Instrumentation实例注册transformer再利用字节码库在transform阶段修改class字节流。如果你只是想快速验证一下这条链路不需要引入任何第三方框架。写一个MANIFEST.MF声明Premain-Class的jar再写一个premain方法注册transformer即可。门槛没有想象中高但生产环境我建议直接用成熟方案别从零造轮子后面第五节会细说原因。5. 自己动手改字节码ASM、Javassist和Byte Buddy5.1 为什么要下探到字节码层面动态代理和AspectJ都是拿着现成工具干活但如果你要做一个自己的APM、自研网关、或者特殊的字节码增强需求就得考虑在最底层操作class。字节码操作有三种主流工具ASM、Javassist、Byte Buddy。先说清楚这三个都不是AOP框架本身它们相当于织入手术的器械但想实现非代理式AOP器械是绕不开的。CGLIB内部就是拿ASM生成子类字节码Java动态代理内部也会生成代理类字节码。可以说几乎所有运行时字节码生成底层都离不开ASM这一层。5.2 ASM性能最强写起来最疼ASM用访问者模式遍历class字节码核心是ClassReader读取字节数组、ClassVisitor提供访问回调、ClassWriter输出修改后的字节数组。你要在某个方法开头加一行打印得重写visitMethod返回的MethodVisitor在visitCode阶段干预ClassReader cr new ClassReader(originBytes); ClassWriter cw new ClassWriter(cr, ClassWriter.COMPUTE_MAXS); ClassVisitor cv new ClassVisitor(Opcodes.ASM9, cw) { Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor delegate super.visitMethod(access, name, desc, signature, exceptions); if (pay.equals(name)) { return new MethodVisitor(Opcodes.ASM9, delegate) { Override public void visitCode() { delegate.visitFieldInsn(Opcodes.GETSTATIC, java/lang/System, out, Ljava/io/PrintStream;); delegate.visitLdcInsn([weave] pay start); delegate.visitMethodInsn(Opcodes.INVOKEVIRTUAL, java/io/PrintStream, println, (Ljava/lang/String;)V, false); super.visitCode(); } }; } return delegate; } }; cr.accept(cv, 0); byte[] wovenBytes cw.toByteArray();这段代码看起来已经很抽象了但它只是给一个方法开头插了句打印。真要写一个完整的切面织入器还得维护局部变量槽、栈帧、异常表、标签跳转复杂度指数级上升。所以我对团队的要求很明确没有极强理由不让大家裸写ASM。5.3 Javassist用源码字符串改字节码适合快速验证如果觉得ASM的痛苦没必要受Javassist是个很好的折中。它允许你用类似代码字符串的方式修改方法体不用手写字节码指令ClassPool pool ClassPool.getDefault(); CtClass cc pool.get(com.example.OrderService); CtMethod m cc.getDeclaredMethod(pay); m.insertBefore(System.out.println(\[weave] pay start\);); m.insertAfter(System.out.println(\[weave] pay end\);); byte[] wovenBytes cc.toBytecode(); cc.detach();insertBefore和insertAfter接受的是Java代码片段框架会在编译时把它翻译成字节码插进去。对原型验证、Demo、内部小工具来说足够用了。代价是Javassist处理速度比ASM慢方法体越复杂翻译开销越大做生产级中间件不太合适。5.4 Byte Buddy现代库的默认选择Byte Buddy是近几年最值得推荐的字节码操作库Mockito、Hibernate、Jackson、Netty很多框架都在拿它做底层增强。它的API设计比ASM友好得多同时性能损失和ASM几乎在一个量级。写一个给方法加日志的transformer大概长这样ClassFileTransformer transformer (loader, className, classBeingRedefined, protectionDomain, classfileBuffer) - { if (!com/example/OrderService.equals(className)) { return null; } Class? target Class.forName(com.example.OrderService, false, loader); return new ByteBuddy() .redefine(target) .visit(Advice.to(PayAdvice.class).on(named(pay))) .make() .getBytes(); };Advice类里用Advice.OnMethodEnter和Advice.OnMethodExit标注嵌入逻辑思路接近AOP但又不需要引入AOP框架灵活度极高是做自研监控增强的最佳工具。我前阵子做一个线程池监控增强就是用Byte Buddy实现的从想法到跑通不到一个下午。5.5 自己写织入器的经验心得先跑通ClassLoader链路再谈织入。自定义ClassLoader里defineClass、加上transformer的链路一定要先打通再深入改字节码否则问题根本没法定位。一定要有开关和降级。任何字节码增强都必须能一键关掉上线前想清楚weave失败时是fail-fast还是ignore没有明确策略别上生产。靠测试用例锁行为。字节码改动会影响方法签名、异常行为、反射序列化结果每个方法级增强都配上单测织入前后对比一遍才敢放出去。6. 注解处理器APT编译期生成代码实现AOP的平替6.1 APT的工作方式还有一条经常被忽略的路利用javac的注解处理器Annotation Processing Tool在编译期扫描源代码中的注解自动生成新的源码文件或辅助类。很多知名库就是靠它做的Lombok的Getter/Slf4j、MapStruct的mapper实现都是在编译期生成的。严格讲它和传统AOP的边界有点模糊。它没有在运行时织入原方法而是生成配套的类但实际效果上能解决一类横切问题比如你想给一批资源类自动生成带日志包装的工厂想给接口自动生成带熔断逻辑的代理实现。在不想引入Spring、又想在编译期消除重复逻辑的场景里它比任何运行时方案都轻。6.2 一个简单的TraceProcessor示例声明一个Trace注解再写一个Processor处理它SupportedAnnotationTypes(com.demo.Trace) public class TraceProcessor extends AbstractProcessor { Override public boolean process(Set? extends TypeElement annotations, RoundEnvironment roundEnv) { for (Element element : roundEnv.getElementsAnnotatedWith(Trace.class)) { // 这里可以校验方法签名、生成一个同包下的包装类 // 或者直接生成一个记录所有Trace方法元数据的清单类 } return true; } }处理器通过META-INF/services/javax.annotation.processing.Processor注册javac编译时自动发现并执行。生成源码用JavaPoet之类的库会省很多事手写StringBuilder拼代码很容易拼出缩进错误和引号Bug。6.3 APT的边界和适用判断APT最大的局限是它不能直接修改已有类的行为只能生成新文件。用它做AOP实质上是生成新的代理类然后让业务侧改用这个新类。这个约束决定了它的适用面很窄事务、安全检查、日志这些原方法不动、额外织入的需求APT给不了。它真正适合的是生成样板代码消除重复的场景把拦截逻辑、DTO转换、工厂代码一次性生成完。7. 六条路对比完实际项目里到底怎么选7.1 一张表看清六种方案实现路线织入时机是否需要框架/容器可拦截的连接点上手成本典型场景Spring AOPJDK Proxy/CGLIB运行期生成代理需要Spring容器方法执行低业务系统日志、事务、权限AspectJ CTW编译期不需要方法、字段、构造器、异常处理器中对拦截能力要求完整的核心系统AspectJ LTW类加载期不需要需agent方法、字段、构造器、异常处理器中高给老jar、第三方jar织入ASM/Javassist/Byte Buddy运行期/类加载期不需要完全自由高自研APM、监控、诊断工具注解处理器编译期不需要仅限于生成新类中编译期代码生成、样板消除手工代理/装饰器编码期不需要取决于你自己低少量类、不想引依赖的简单场景7.2 我个人的选型逻辑结合这些年做中间件和业务系统的经验我的判断顺序是这样的目标是普通业务应用Spring容器里跑切面落在public方法上直接Spring AOP别折腾。需要拦截private方法、字段、构造器或者自调用必须生效优先AspectJ编译期织入。只要构建和CI能把ajc稳定跑起来这个方案在后端系统里非常成熟。不能重编译、不能换jar包只能改启动脚本动刀用AspectJ LTW或者准备一个Java agent。想做一个自己的监控、诊断、APM类组件目标进程是任意Java应用就去学Instrumentation加Byte Buddy这条组合路线这是最通用、也最值得投入时间学的一条。只是想消除样板代码、加快团队开发速度用注解处理器生成代码。7.3 如果这是面试题怎么答才显功底最后回到面试被问AOP实现这个场景。标准答法是AOP是面向切面编程核心是切面、切点、通知和织入Spring AOP通过JDK动态代理或CGLIB实现运行时织入切点表达式匹配方法执行。这条答完算及格。要拿高分补一句真相动态代理只是运行时织入的一种具体手段连接点模型窄、自调用会失效、强依赖容器AOP更完整的实现是AspectJ它可以在编译期和类加载期直接改写字节码能处理字段、构造器、私有方法和final类。再加上一句我选方案时会根据是否可重编译、目标类是否final、是否在容器内来权衡这一整段回答就是既有深度又有工程判断力的完整版本。我自己的体会是把AOP怎么实现这个问题从动态代理升级到织入时机连接点模型之后很多想不明白的问题一下子就通透了。下次你遇到Transactional自调用失效或者要给遗留jar包做增强脑子里能立刻跳出第二条、第三条路径这篇文章的目的就达到了。
返回列表