多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

AspectJ静态编织原理与实战:从字节码织入到Spring AOP差异对比

AspectJ静态编织原理与实战:从字节码织入到Spring AOP差异对比 说实话做了这么多年Java开发绝大部分人一听到AOP第一反应就是Spring AOP和那几个注解。但如果你在面试中把Spring AOP就是AOP全部这个观点抛出去遇到较真的面试官基本就被问住了。今天我想花点篇幅把Java生态里一个常被忽视但极其重要的角色——AspectJ这个名副其实的Java AOP静态编织引擎讲清楚。这篇文章适合这些人看准备Java面试、被Spring AOP和AspectJ有什么区别折磨过的人项目里遇到Spring AOP解决不了的问题比如拦截不了私有方法调用、想在构造器执行时插一脚的人还有纯粹想搞清楚字节码层面织入到底是怎么回事的人。AspectJ不是新东西它在Java生态里活了二十多年Eclipse基金会主导维护大量框架底层像Spring的Configurable功能、部分分布式链路追踪组件都在借助它完成能力不是什么悬空的概念而是实打实能落地的工具。1. 先搞清楚一件事AOP到底解决什么问题1.1 横切逻辑的尴尬处境写业务代码的时候最烦的是什么不是业务本身难而是那些跟业务无关、但你又不得不写的外围代码。假设你写一个转账方法核心代码就三行查余额、扣款、写流水。但真实项目里这个方法里起码还有这些内容日志记录、权限校验、事务控制、埋点上报。这些逻辑在系统中不是只有转账才有而是横跨了所有业务方法。它们有一个专门的名字叫横切关注点cross-cutting concerns。不写这些行不行不行。事务不控制出了异常账就错了日志不打印线上出了问题什么都查不到。但每个方法都抄一份代码就变成了这样public void transfer(Long fromAccount, Long toAccount, BigDecimal amount) { log.info(transfer start, from{}, to{}, fromAccount, toAccount); try { // 权限校验 checkPermission(); // 业务 doTransfer(fromAccount, toAccount, amount); // 事务提交 transactionManager.commit(); } finally { log.info(transfer end, from{}, to{}, fromAccount, toAccount); } }写十个方法就得复制十遍。这不是代码质量问题是架构层面的问题——核心业务逻辑被外围逻辑淹没了。AOP的出现就是解决这个问题的把横切关注点从业务代码里抽取出来在系统运行的关键节点动态插入业务代码只看业务本身。1.2 五个核心概念用装修来类比AOP里那五个术语刚接触的人很容易绕晕切面、连接点、切点、通知、织入。我举个例子你把系统代码当成一套毛坯房现在要在里面砌隔断墙。连接点Join Point所有可以动手施工的位置比如一道完整的墙。程序里每个方法调用位置、方法执行入口、异常抛出位置都是连接点。切点Pointcut在众多连接点里选哪些位置要施工相当于施工图纸上画了红圈的地方。切点就是一套匹配规则比如所有Service接口里以save开头的方法。通知Advice具体的施工动作——是在墙这里开一个门洞还是挂一幅画。对应到程序里就是在方法执行前打日志在方法返回后做统计这类具体逻辑。切面Aspect施工图纸施工动作的容器即哪个地方切点做什么通知的组合。织入Weaving把通知逻辑编入目标方法的过程也就是工人实际动手砌墙的过程。这几个概念搞清楚了AOP后面所有的学习都不是问题。1.3 为什么需要AOP从代码污染看它的生存空间我见过一些坚持不用AOP的团队理由很直接不就几十个方法嘛复制粘贴的成本比引入框架低。这种思路在小项目里是成立的但系统到一定规模后横切关注的种类会越来越多审计日志、防重提交、幂等控制、限流熔断、多租户隔离、操作人填充……一旦需要修改公共逻辑就得把所有涉及的方法翻出来改一遍。AOP的不可替代性就在于两个词解耦和一致性。业务方法里不再写外围模板代码外围逻辑统一收敛到一个切面类里维护改一处全局生效。如果你负责的系统里出现过日志格式不统一事务注解漏加权限校验绕过这类问题那你应该能立刻理解这里面的价值。2. AspectJ是什么站在Spring AOP肩膀上才能对比出它的价值2.1 被误解的Java AOP实现说起Java的AOP很多人第一个想到的就是Spring AOP。但严格来说Spring AOP不是完整的AOP实现它更像一个借用了AspectJ语法的运行时代理方案。当年Spring AOP设计时团队看中了AspectJ的切点表达式语言——那套描述在哪些方法上执行什么逻辑的语法确实好用于是直接拿过来用了。所以你平时写的Aspect、Before、Pointcut(execution(...))本身其实是AspectJ定义的注解和语法Spring只是引入了AspectJ的库来解析这些定义。但这不代表Spring AOP就是AspectJ。两者最大的一条分界线是织入的时机和机制。Spring AOP是在运行时创建代理对象把增强逻辑挂到代理上AspectJ是在编译期或者类加载期直接把增强逻辑写进字节码里。一个是在代码运行阶段套壳一个是在代码出生阶段换骨。2.2 AspectJ的本质和发展简史AspectJ最早由施乐帕罗奥多研究中心搞出来1999年发布首个版本2001年捐给了Eclipse基金会维护至今依然是Java生态里最成熟的AOP方案。它自称是Java语言的无缝扩展——这不是自夸因为它的切入点不是框架层的反射或代理而是语言编译器本身的扩展。AspectJ编译器ajc接受Java代码和AspectJ特有的切面语法直接输出标准的Java字节码。织入完成后目标类的字节码已经是增强之后的版本运行时跟普通Java类没有任何区别不需要什么特殊的运行容器。Java是静态链接的这个说法恰好能解释AspectJ的定位编译阶段就把切面逻辑绑定进类文件里字节码层面的绑定是明确、静态的运行时想动态改也改不了。这跟很多语言把AOP做成运行时动态特性的思路完全相反。2.3 AspectJ与Spring AOP的能力对比两者各有各的适用场景直接列张表看得最清楚对比项Spring AOPAspectJ织入方式运行时动态代理JDK动态代理/CGLIB编译期字节码织入依赖环境需要Spring容器管理Bean不依赖容器普通Java程序即可可拦截范围只能拦截代理对象的方法调用private方法、final方法、构造器、字段访问都无法拦截可以拦截构造器、private方法、字段读写、静态方法等几乎所有连接点性能损耗每次调用都经过代理对象有额外开销编译期织入运行时零代理开销使用门槛低Spring Boot项目加几个注解就行高需要单独配置编译器插件理解字节码概念典型场景日常业务系统的日志、事务、权限拦截框架底层、性能敏感组件、需要改变类结构的场景Spring AOP是够用就好的代表80%的日常业务拦截靠它都能完成。另外那20%的硬骨头——比如给private方法做增强、在对象构造时注入上下文、拦截字段赋值——就必须请出AspectJ。2.4 静态编织和动态代理用装修来理解继续用装修这个类比。动态代理的做法房子已经装修完了你在客厅里买了一个新屏风想改变空间布局就把它往那一放。屏风确实改变了视觉效果但它只是一个代理挡不住墙背后的空间对应Spring AOP拦截不到的构造器和私有方法。静态编织的做法动工之前就在设计稿里画好了隔断工人砌墙的时候直接把隔断砌进去了。以后这堵墙本身就带隔断功能不需要额外的屏风来扮演角色好处是性能好、能力全代价是整个施工过程复杂了。理解了这条分界线你就掌握了AspectJ和Spring AOP对比的所有考点。3. 静态编织到底怎么织编译期与加载期的完整流程3.1 五种织入类型不止编译期一种AspectJ的织入方式并不只有编译期这一种官方文档里明确列出了多种策略按时机排序有以下几类源码织入Source Weaving最直观的方式讲给入门的人听都用这种。切面代码和业务代码都是以源码形式存在编译器在生成最终字节码之前直接把切面逻辑替换进业务方法的源码结构里。这种方式要求在编译时同时传入切面源码和业务源码。编译期织入Compile-time Weaving这是AspectJ最经典的用法。业务代码编译成.class的过程中编译器顺手把切面字节码织入进去。输出的.class文件字节码已经是增强后的完整形态。我后面实操部分用的就是这种方式。后编译织入Post-compile Weaving业务代码已经编译成了.class文件你在不拿回源码重新编译的前提下通过二次编译把这些二进制文件再增强一遍。适合新增切面逻辑、改造已有jar包内部的类。加载期织入Load-time WeavingLTW类文件在进入JVM之前由Java Agent在字节码加载阶段动态改写好再交给类加载器。配置靠META-INF/aop.xml文件完成JVM启动时加一个-javaagent:aspectjweaver.jar参数。运行期织入理论上AspectJ不推荐也不支持真正意义的运行期织入因为它的强项就是编译期和加载期。大家听到AOP想到的运行时代理那是Spring AOP的做法不是AspectJ的主战场。我们平时说AspectJ是静态编织引擎主要是强调第一、三、四种要么在编译阶段完成要么在类加载的最早期完成绝不会拖到对象创建之后再来挂代理。3.2 编译期织入的字节码视角javap一探虚实说了这么多编译期织入的好处口说无凭咱们看字节码。我写一个最简单的类public class Foo { public void sayHi() { System.out.println(hello); } }用ajc编译切面定义为在sayHi()执行前打印一行日志。然后对织入后的Foo.class执行javap -c Foo切面逻辑已经真真切切出现在方法体里public void sayHi(); Code: 0: getstatic #2 // Field System.out:Ljava/io/PrintStream; 3: ldc #3 // String before sayHi 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: getstatic #2 // Field System.out:Ljava/io/PrintStream; 11: ldc #5 // String hello 13: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 16: return注意第6行编译器凭空插入的日志输出指令就出现在原始println(hello)之前。这就是静态编织的实锤——增强逻辑对运行时完全透明没有代理没有反射性能损耗几乎为零。你在Spring AOP里用的代理对象多得一层方法调用栈在这里直接省掉了。3.3 Load-time Weaving与aop.xml配置编译期织入虽然强大但有个现实问题如果你要增强的是一个第三方jar包里的类而且不想改动生产构建流程编译期织入就很尴尬。加载期织入就是为这种情况准备的。配置一个META-INF/aop.xmlJVM启动参数加上-javaagent:aspectjweaver-1.9.20.jar代码逻辑和编译期写法完全一致区别只是织入时机延后到了类加载阶段。aspectj aspects aspect namecom.example.LogAspect/ /aspects weaver options-verbose -showWeaveInfo include withincom.example..*/ /weaver /aspectj加载期织入的好处是不污染磁盘上的class文件切面逻辑只存在于当前JVM实例内缺点是启动参数配置麻烦而且Java Agent会拖慢启动速度生产环境如果没统一管理很容易踩类加载器的坑。我自己的经验是能编译期织入就别折腾加载期非必需场景收益不划算。4. 实操从零接入AspectJ的完整流程4.1 准备工作先确定版本选型。AspectJ目前还在活跃更新1.9.x系列持续跟进新版JDK。如果你用JDK8选1.9.7以上即可如果项目在JDK17或JDK21一定选1.9.20早期版本对模块化系统和支持等级不完整容易出现奇怪的不兼容问题。开发工具方面IDEA有AspectJ插件支持不过日常调试其实不强制只要确认以下两样东西就够Maven或Gradle构建环境aspectjrt依赖运行时库和aspectjweaver如果走加载期织入我下面的示例基于Maven和JDK17这个组合在当前最常见。4.2 Maven中接入AspectJ编译器插件AspectJ要完成编译期织入必须把ajc编译器接入构建流程。最常规的做法是使用AspectJ的Maven插件。看配置properties aspectj.version1.9.20/aspectj.version /properties dependencies dependency groupIdorg.aspectj/groupId artifactIdaspectjrt/artifactId version${aspectj.version}/version /dependency /dependencies build plugins plugin groupIdorg.codehaus.mojo/groupId artifactIdaspectj-maven-plugin/artifactId version1.14.0/version configuration complianceLevel17/complianceLevel source17/source target17/target showWeaveInfotrue/showWeaveInfo verbosetrue/verbose /configuration executions execution goals goalcompile/goal goaltest-compile/goal /goals /execution /executions /plugin /plugins /build几个关键配置说下aspectjrt是运行时库织入后的类在运行时会引用它的部分类型编译期和运行期都需要。complianceLevel要和JDK版本匹配写17代表按Java17字节码级别编译如果项目手滑配成8在JDK17环境会报不支持发行版本。showWeaveInfo打开之后构建日志会打出详细织入信息比如Match #1、advice applied之类排查问题很好用。特别提醒一个坑aspectj-maven-plugin默认会在compile阶段调用ajc但Maven默认还有个maven-compiler-plugin也在编译两者同时跑会冲突。用AspectJ插件时不加maven.compiler.release之类的属性避免两个编译器解析同一个源码目录的时机重叠。更稳妥的做法是把maven-compiler-plugin的编译目标在AspectJ插件之前执行或者干脆不要显式引入它。4.3 一个完整的示例日志耗时切面目标很朴素拦截一个HelloService的所有sayHello方法打印执行耗时。先写业务类package com.example.service; public class HelloService { public void sayHello(String name) { System.out.println(Hello, name); } }再写切面类package com.example.aspect; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; Aspect public class TimeLogAspect { Pointcut(execution(* com.example.service.HelloService.sayHello(..))) public void helloPointcut() { } Around(helloPointcut()) public Object logTime(ProceedingJoinPoint joinPoint) throws Throwable { long start System.currentTimeMillis(); Object result joinPoint.proceed(); long cost System.currentTimeMillis() - start; System.out.println(方法 joinPoint.getSignature().getName() 执行耗时: cost ms); return result; } }注意这里的Aspect注解和切点表达式语法和Spring AOP一模一样因为它们本来就同源。区别在于加载方式Spring靠容器扫描Bean发现它AspectJ靠编译器在编译时把所有匹配的类编织一遍。写一个主方法验证public class Main { public static void main(String[] args) { HelloService helloService new HelloService(); helloService.sayHello(world); helloService.sayHello(aspectj); } }用mvn clean compile exec:java跑起来控制台输出Hello, world 方法 sayHello 执行耗时: 1ms Hello, aspectj 方法 sayHello 执行耗时: 0ms没有配置代理工厂没有Spring容器切面已经生效了。这就是静态编织引擎的直观感受。4.4 织入不生效的排查方向清单运行正常但切面死活不执行这个情况我遇到太多次了。按照下面的思路排查基本能覆盖切点表达式里包名、方法名拼写是否和目标完全匹配..与*的使用是否正确。切面类是否被声明为Aspect且是public。排查插件有没有真的编译你的切面文件打开showWeaveInfo看日志最直接。complianceLevel与JDK目标是否匹配低等级编译能过但不代表切点表达式语义完全一致。target class是否可以被AspectJ解析类名和实际文件路径不一致时切点匹配会静默失败。5. 切点表达式与通知类型面试绕不开的核心考点5.1 execution表达式逐段拆解execution是切点表达式里最核心的指示器几乎每次面试都会考。我们拿一个完整例子来拆execution(public * com.example.controller.UserController.*(..))execution(...)表示这是在匹配方法执行这一时机的连接点。public限定方法的访问权限修饰符。如果省略则匹配所有可见性。*返回值类型通配符代表任意类型。com.example.controller.UserController方法所属的类全限定名。.*类下任意方法。(..)参数列表。..代表零个或多个任意类型参数。完整含义匹配UserController里所有public方法的任意执行。表达式中还可以用throws子句比如execution(* com.example.service.*.*(..) throws java.io.IOException)匹配抛出特定异常的方法不过日常用得少。5.2 其他常用的切点指示器别只会execution除了execution还有一批指示器在实际项目中经常出现每种都有自己的适用场景指示器匹配目标示例within类型内的所有连接点within(com.example.controller..*)this目标对象是当前代理类型实例的连接点this(com.example.service.UserService)target目标对象是原始类型实例的连接点target(com.example.service.UserService)args方法参数匹配指定类型的连接点args(String, ..)annotation方法上标注了指定注解的连接点annotation(org.springframework.web.bind.annotation.GetMapping)within类型上标注了指定注解的所有方法within(org.springframework.web.bind.annotation.RestController)掌握了这几大类日常的切面设计基本就够用了。this和target的区别是面试常客this匹配的是代理对象类型target匹配的是被代理的目标对象类型在Spring AOP中代理对象与目标对象往往不是同一个类型两者匹配的集合就有差异。5.3 五种通知类型的对比和使用姿势AOP通知Advice类型有五种面试主要考察两件事每种通知触发的时机、在Around里如何控制目标方法执行。整理成表格通知类型触发时机典型使用场景备注Before目标方法执行之前权限校验、参数校验此时还不能取到目标方法返回值AfterReturning目标方法正常返回之后数据审计记录可以拿到返回值AfterThrowing目标方法抛出异常之后异常收集、告警不执行则说明方法没抛异常After目标方法执行之后无论成功或异常资源清理、释放锁类似finallyAround可以控制目标方法前后全部流程性能监控、限流、重试可以决定是否调用proceed()Around是最灵活也最危险的灵活在于它就像把整个方法执行包了一层包装前后逻辑尽在掌握危险在于如果你在Around里忘记调用joinPoint.proceed()目标方法就永远不会执行。我踩过一次坑防重复提交的切面里前两次都正常第三次想直接返回提示信息结果少写了一个分支的proceed()线上接口直接返回null查了半天才发现是切面把方法吞了。5.4 一个综合实战示例Controller层防爬拦截结合AspectJ做防爬接口防护是一个能覆盖不少热搜词点的场景。思路是定义一个RateLimit注解在接口方法上标注切面针对这些方法做IP频次检查。下面是简化的核心代码Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface RateLimit { int limit() default 60; // 单位时间最大请求数 long interval() default 60000; // 窗口毫秒数 } Aspect public class RateLimitAspect { private final MapString, DequeLong requestRecords new ConcurrentHashMap(); Around(annotation(rateLimit)) public Object guard(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable { String ip obtainClientIp(pjp); long now System.currentTimeMillis(); DequeLong deque requestRecords.computeIfAbsent(ip, k - new ArrayDeque()); synchronized (deque) { while (!deque.isEmpty() now - deque.peekFirst() rateLimit.interval()) { deque.pollFirst(); } if (deque.size() rateLimit.limit()) { throw new RuntimeException(请求过于频繁); } deque.addLast(now); } return pjp.proceed(); } private String obtainClientIp(ProceedingJoinPoint pjp) { Object[] args pjp.getArgs(); HttpServletRequest request findHttpServletRequest(args); if (request null) { return unknown; } String ip request.getHeader(X-Forwarded-For); return ip null ? request.getRemoteAddr() : ip; } private HttpServletRequest findHttpServletRequest(Object[] args) { for (Object arg : args) { if (arg instanceof HttpServletRequest) { return (HttpServletRequest) arg; } } return null; } }这里用annotation(rateLimit)做切点把自定义注解实例直接绑定到切面方法参数上是AspectJ注解驱动式开发的标准玩法。防爬逻辑本身不算高深但用编译期织入来做对业务代码零侵入比在Controller基类里写公共逻辑优雅太多。6. 常见问题与排查技巧实录6.1 编译期各种报错如何破AspectJInternal错误. 非法的切入点表达式这类错误多半是切点语法写错。比如execution(* com.example.*.*(..))里的*只能匹配一级包名想匹配多级包必须写成com.example..*。排查办法是拆表达式一级一级试。The type java.lang.Object cannot be resolvedJDK17环境容易出这个问题一般是complianceLevel等级与当前JDK不一致把配置里的等级改到17并确保AspectJ版本1.9.20即可。apply to joinpoint... but none applicable警告AspectJ在告诉你切点定义了但没有匹配到任何连接点。原因基本是包名、方法名写错或者目标方法不是public但你写了execution(public ...)。6.2 多个切面的执行顺序控制切面多了执行顺序就乱。AspectJ里原始语法可以用declare precedence声明优先级public aspect SafetyAspect { declare precedence: AuthAspect, LogAspect, *; }意思是AuthAspect优先级最高LogAspect次之其他切面排后面。用注解方式时可以通过Order注解控制数字越小优先级越高。遇到多个切面都有Around时顺序错乱会导致包装逻辑嵌套顺序不对跟Java装饰器模式的顺序问题一模一样。6.3 与Spring AOP混用容易出现双重代理一个项目里同时用Spring AOP和AspectJ理论上是可以的但小心踩坑Spring容器里的Bean先被Spring AOP的代理包装了一层又被AspectJ在编译期织入了一层。如果切面逻辑设计得不好会出现同一方法被通知执行两次或者代理方法上的切点匹配到了被包装后的对象导致this和target判定异常。我的经验是确定一个主线要么全部用Spring AOP要么全部用AspectJ别在同一方法上同时挂两套方案。6.4 构造器织入的注意事项AspectJ能织构造器这是它相比Spring AOP的巨大优势。但有一个细节在构造器里执行的切面通知中操作目标对象字段时可能字段还没初始化完成。比如在execution(com.example.User.new(..))的around通知里先执行了父类或本类的字段初始化逻辑后你的代码才插入如果切面逻辑依赖那个字段一下就会空指针。解决方法是尽量用after类型的通知保证构造流程完整后再动手。6.5 面试高频追问参考答案如果面试官顺着AOP往下问这几个问题几乎是必考点为什么Spring AOP默认用JDK动态代理因为JDK动态代理要求目标类实现接口而CGLIB通过继承生成子类来代理两者各有边界。Spring对这种选择还做了自动切换没接口时用CGLIB。静态编织的静态到底指什么指织入时机发生在编译期或类加载期切面逻辑变成字节码后固定下来运行时无法改变行为依赖的是编译器而非运行时反射或代理机制。AspectJ能拦截private方法吗可以。编译器可以直接访问目标类字节码内部结构不像代理那样受限于对象外部调用。private方法的切点表达式需要写成execution(private * com.example..*(..))。最后再分享一点实操体会说实话日常业务开发我大部分场景仍然优先选用Spring AOP毕竟配置简单容器接管一切。但一旦碰到三个情况——需要拦截构造器或私有方法、需要织入第三方class文件、项目对性能开销敏感——我会毫不犹豫换AspectJ。个人经验上最推荐的使用方式是把它用在独立的基础组件模块里写一个通用的审计日志切面、防刷切面、性能监控切面然后把整个组件打包成jar给业务方引用。不要在每个服务里都配置一遍aspectj-maven-plugin否则升级切面逻辑时得全链路改配置。还有个细节很多人不知道AspectJ切面里可以直接访问被织入方法所在类的私有字段这是编译器权限赋予的能力。这很诱人但用的时候克制一点毕竟切面一旦访问了具体业务类的内部状态它就失去了通用性也会让代码的耦合度变得难以控制。如果面试或者做设计的时候提到AOP我的建议永远是先把AspectJ的原理讲透再去谈Spring里的注解和代理。这会让别人觉得你既理解运行时方案又懂字节码层面的底层机制而不只是会加几个注解。
返回列表