这一篇Spring之旅,是被一个老项目逼出来的。原本我的计划是直接开讲注解式AOP,毕竟现在新项目里谁还会打开beans.xml写一大堆<aop:config>呢?可前阵子接手了一个七年前的老系统,核心服务的操作日志、权限校验、参数加密,全部是通过XML AOP织进去的。我盯着那几百行的beans.xml,加上各种<aop:aspect的ref引用,整整理了一下午才把切面关系网搞清楚。后面我花了一周时间,把Spring基于XML的AOP开发从配置文件到代理生成链路认真捋了一遍。这篇就当是整理出来的学习笔记,不管你是刚接触Spring的新手,还是被派去维护老项目的“接盘侠”,应该都能少走一些弯路。
1. 为什么现在还要学XML方式的AOP:老项目维护带来的真实教训
1.1 接手老项目:几百行beans.xml给我的冲击
我去看那个老系统的时候,光打开applicationContext.xml就有点懵。<bean>节点倒还好说,真正让人头皮发麻的是后半段那一大坨<aop:config>。一个服务模块配一个<aop:aspect>,里面挂了前置通知、环绕通知、异常通知,业务日志和权限切面还拆成了两个独立文件引入。
这种配置方式最大的问题是:切面的逻辑散落在AOP相关的事实,在代码里根本看不见。你翻开UserService的源码,方法干干净净,但实际上每次调用都在被外层代理拦截。如果你不知道beans.xml里配了哪些切点表达式,排查问题时压根不会往那个方向想。我当时就吃过这个亏,一个接口响应莫名多出几毫秒,查了半天数据库慢查询,最后才发现是一个环绕通知在每次方法执行前做了一次远程调用。
所以我的第一个建议是:接手任何老项目,第一件事先把所有XML配置里的<aop:config全部扫一遍,把所有切点表达式列成清单。这比先看业务代码重要得多。
1.2 AOP原理:动态代理是理解一切配置的钥匙
不管用XML还是注解,Spring AOP的底层原理都是同一个:动态代理。理解不了动态代理,学AOP配置就只是在背标签。
Spring AOP在运行时会为目标对象生成一个代理对象。调用方拿着的是代理对象,真正执行业务方法之前,代理对象会先按“通知链”的顺序把各个切面逻辑执行一遍,再决定是否调用真实目标方法。
有两种代理方式:
- JDK动态代理:目标类实现了接口。Spring基于
java.lang.reflect.Proxy生成一个实现同一接口的代理类,调用时通过InvocationHandler拦截。 - CGLIB代理:目标类没有实现接口。Spring用CGLIB生成目标类的子类,通过继承来覆盖方法,从而植入切面逻辑。
Spring在这两者之间是自动选择的。默认规则是:目标类实现了任何一个接口,就用JDK动态代理;一个接口都没有,才退回到CGLIB。如果想让Spring无条件使用CGLIB,可以在<aop:config>上加proxy-target-class="true"。
理解了这一点,后面好多坑就能看明白了。比如没有接口的类,Spring生成的代理对象和原类不是同一个对象,如果你在代码里用instanceof去判断或者强转成某个具体类,有时候会得到一些匪夷所思的结果,本质上就是代理对象和原始对象的类结构不一样。
1.3 横切关注点:AOP到底解决了什么问题
在没有AOP之前,解决日志、事务、权限这类“横切关注点”问题的标准姿势是在每个方法里重复写同样的代码。
比如你要给100个service方法打印操作日志,老老实实写的话,每个方法开头两三行日志代码,结尾又两三行,出了异常再写一段。项目刚做出来没问题,等需求改成“日志里必须带上操作人ID”的时候,你就得改100个地方的200多行代码,漏改一个就会出线上事故。
AOP的思路是把这些逻辑抽出来,放到一个独立的模块里,然后通过“切点”声明哪些方法要套上这些逻辑。改需求的时候,只改切面类,业务方法一行不动。
这样做的好处有三个:
- 业务代码更干净,关注点单一。
- 横切逻辑复用,同一套日志、事务逻辑可以挂到任何业务方法上。
- 逻辑统一收口,需求变更时只改一个地方。
AOP的典型使用场景我列一下:操作日志记录、事务管理、权限校验、接口限流、参数校验与脱敏、性能监控、缓存处理。我这些年在实际项目里基本就用这几个场景,老系统里那个操作日志切面,就是最典型的XML AOP应用。
2. XML里的AOP配置和AOP核心概念怎么对应
2.1 六大术语对照表:切面、切点、通知、连接点、目标、织入
很多初学者看AOP术语容易昏头,我建议直接把这几个概念和XML标签一一对应起来记。
| 概念 | 含义 | XML中的对应 |
|---|---|---|
| Aspect(切面) | 一组“横切逻辑+要织入的位置”的集合 | <aop:aspect ref="切面Bean"> |
| Pointcut(切入点) | 一个表达式,定义“哪些方法会被拦截” | <aop:pointcut expression="execution(...)"> |
| Advice(通知) | 具体的横切逻辑,比如记录日志、开启事务 | <aop:before>、<aop:around>等子标签 |
| JoinPoint(连接点) | 被拦截到的某一次具体方法调用 | 运行时概念,在切面方法参数里体现 |
| Target(目标对象) | 被切面横切的原业务对象 | <bean id="accountService"> |
| Weaving(织入) | 把切面逻辑应用到目标方法并生成代理的过程 | <aop:config>整体做的事情 |
记忆方法很简单:切面是一个“盒子”,盒子里面装着“通知”,通知负责干活;“切点”决定了盒子上的哪几根“针”会扎到目标方法上。目标是针扎的对象,织入是扎针这个动作,连接点则是针尖落下去的那一个具体位置。
2.2 一个最小可用的aop:config骨架
先看一个最精简的XML AOP配置,后面所有内容都围绕这个骨架展开:
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:aop="http://www.springframework.org/schema/aop" xsi:schemaLocation=" http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd"> <bean id="accountService" class="com.example.service.AccountService"/> <bean id="logAspect" class="com.example.aspect.LogAspect"/> <aop:config> <aop:aspect ref="logAspect"> <aop:pointcut id="servicePointcut" expression="execution(* com.example.service.*.*(..))"/> <aop:before method="logBefore" pointcut-ref="servicePointcut"/> </aop:aspect> </aop:config> </beans><aop:config>是根节点,里面直接子节点有三种:<aop:pointcut>、<aop:advisor>、<aop:aspect>。这三者在实际项目中的使用频率,<aop:aspect>用来定义基于普通POJO的切面,<aop:advisor>适配那些已经实现了Spring通知接口的类(常见于事务配置),而<aop:pointcut>既可以在<aop:aspect>内部定义,也可以定义在<aop:config>顶层,然后被多个aspect引用。
2.3 公共切点的两种定义方式与作用域
切点定义的位置不同,能被谁引用就不一样。
在<aop:config>顶层定义的切点属于“全局切点”,能被当前<aop:config>下所有<aop:aspect>引用。如果切点表达式被多个切面复用,建议放顶层。
在<aop:aspect>内部定义的切点,作用域只在当前切面内可见,其他切面引用不到。如果你两个切面对同一批方法织入不同逻辑,就得在每个aspect里单独定义一份切点,或者干脆在顶层统一定义。
我见过别人把这两个场景混在一起用,结果在某个aspect里引用了另一个aspect的私有点cutpoint,启动直接报错。所以记一条规则:切点定义在哪里,就在哪里作用域可见,跨aspect引用必须用顶层切点。
另外提醒一句,<aop:config>可以有多个,一个配置文件里写多段<aop:config也是允许的,但同一段<aop:config下的aspect会按声明顺序参与代理构建。切面优先级这事儿放到后面第7节讲。
3. 从零搭一个基于XML的AOP案例
3.1 工程依赖怎么配:pom.xml里少了这个包一定会后悔
搭建一个最小的Spring XML AOP项目,Maven依赖只需要两个核心包:
<dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.30</version> </dependency> <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> <version>1.9.19</version> </dependency> </dependencies>spring-context会传递引入spring-core、spring-beans、spring-aop这些基础模块。而aspectjweaver是用来解析AspectJ切点表达式的,没有它,<aop:config>里的expression="execution(...)"在运行时就会因为找不到表达式解析器而异常。
另外提醒一下,如果你图省事直接加spring-boot-starter-aop,它会把aspectjweaver带进来,但那种方式默认走自动配置,跟今天我们纯XML的方式不完全是一回事。做纯XML项目,老老实实把这两个依赖写清楚就行。
3.2 写业务类和切面类
先创建业务类,这里用一个简单的账户服务。注意我故意没有让它实现接口,这样在Spring 5.x下默认走CGLIB代理,后续在测试里能看到更明显的行为:
package com.example.service; public class AccountService { public void transfer(String from, String to, Double amount) { System.out.println("执行转账:" + from + " -> " + to + ",金额:" + amount); } public Double queryBalance(String accountNo) { System.out.println("查询余额,账户:" + accountNo); return 8888.00; } public void freezeAccount(String accountNo) { System.out.println("冻结账户:" + accountNo); throw new RuntimeException("账户余额不足,冻结失败"); } }再写切面类。这个类本身就是一个普通POJO,里面每个方法对应一种通知逻辑,方法签名有约定,具体约定在XML配置里通过method属性指定:
package com.example.aspect; import org.aspectj.lang.ProceedingJoinPoint; public class LogAspect { public void beforeLog() { System.out.println("[前置通知] 开启事务,记录操作日志..."); } public void afterReturningLog(Object result) { System.out.println("[后置返回通知] 方法正常返回,结果:" + result); } public void afterThrowingLog(Throwable ex) { System.out.println("[异常通知] 方法抛出异常:" + ex.getMessage()); } public void afterLog() { System.out.println("[最终通知] 清理资源,关闭连接..."); } public Object aroundLog(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); System.out.println("[环绕通知] 方法开始:" + joinPoint.getSignature().getName()); Object result = joinPoint.proceed(); System.out.println("[环绕通知] 方法结束,耗时:" + (System.currentTimeMillis() - start) + "ms"); return result; } }这里先说两个很容易出错的方法签名细节:
afterReturningLog(Object result)的参数名必须和XML里returning属性指定的一致,否则参数值注入不进去。afterThrowingLog(Throwable ex)的参数名必须和XML里throwing属性的一致,否则异常信息拿不到。aroundLog的参数必须是ProceedingJoinPoint,并且方法要声明throws Throwable,因为在环绕通知里joinPoint.proceed()执行目标方法时,什么异常都可能抛出来,不能随意吞掉。
3.3 关键来了:beans.xml里的AOP配置
把业务类和切面类都定义成Bean,然后用<aop:config>把它们“锚”在一起:
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:aop="http://www.springframework.org/schema/aop" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd"> <bean id="accountService" class="com.example.service.AccountService"/> <bean id="logAspect" class="com.example.aspect.LogAspect"/> <aop:config> <aop:pointcut id="servicePointcut" expression="execution(* com.example.service.*.*(..))"/> <aop:aspect ref="logAspect"> <aop:before method="beforeLog" pointcut-ref="servicePointcut"/> <aop:after-returning method="afterReturningLog" pointcut-ref="servicePointcut" returning="result"/> <aop:after-throwing method="afterThrowingLog" pointcut-ref="servicePointcut" throwing="ex"/> <aop:after method="afterLog" pointcut-ref="servicePointcut"/> <aop:around method="aroundLog" pointcut-ref="servicePointcut"/> </aop:aspect> </aop:config> </beans>注意<aop:aspect ref="logAspect">里面的ref引用的,就是上面定义的那个切面Bean。method属性指向切面类里的方法名。pointcut-ref引用的是切点id。
3.4 运行结果分析:通知是怎么串起来的
写个主方法跑一下正常路径:
package com.example; import com.example.service.AccountService; import org.springframework.context.ApplicationContext; import org.springframework.context.support.ClassPathXmlApplicationContext; public class MainTest { public static void main(String[] args) { ApplicationContext context = new ClassPathXmlApplicationContext("beans.xml"); AccountService service = context.getBean("accountService", AccountService.class); service.transfer("张三", "李四", 1000.00); System.out.println("---- 分割线 ----"); try { service.freezeAccount("622200001"); } catch (RuntimeException e) { System.out.println("主程序捕获异常:" + e.getMessage()); } } }正常路径transfer()的输出如下:
[环绕通知] 方法开始:transfer [前置通知] 开启事务,记录操作日志... 执行转账:张三 -> 李四,金额:1000.0 [后置返回通知] 方法正常返回,结果:null [最终通知] 清理资源,关闭连接... [环绕通知] 方法结束,耗时:5ms异常路径freezeAccount()的输出如下:
[环绕通知] 方法开始:freezeAccount [前置通知] 开启事务,记录操作日志... 冻结账户:622200001 [异常通知] 方法抛出异常:账户余额不足,冻结失败 [最终通知] 清理资源,关闭连接... 主程序捕获异常:账户余额不足,冻结失败从这个输出能看到三层结构:环绕通知在最外层,它包住了前置通知和后置通知;前置通知在目标方法执行之前触发;后置返回通知在正常返回后触发;异常通知只会在方法抛异常时触发;最终通知无论正常还是异常都会执行,等价于finally块。
4. 五种通知类型的XML写法、执行顺序与镜像理解
4.1 五种通知XML配置与语义对照
| 通知类型 | XML标签 | 语义 | 典型用途 |
|---|---|---|---|
| 前置通知 | <aop:before> | 目标方法执行前触发 | 权限校验、参数校验、开启事务 |
| 后置返回通知 | <aop:after-returning> | 目标方法正常返回后触发 | 记录成功日志、组装返回值 |
| 异常通知 | <aop:after-throwing> | 目标方法抛出异常后触发 | 异常告警、事务回滚 |
| 最终通知 | <aop:after> | 目标方法结束后触发,无论是否抛异常 | 资源释放、连接关闭 |
| 环绕通知 | <aop:around> | 完全包裹目标方法,可自定义前后逻辑 | 性能监控、分布式锁、事务模板 |
<aop:after-returning>和<aop:after>很多人分不清。记住一个关键区别:after-returning只在方法正常返回时执行,方法抛异常它就不管了;after等同于finally,不管成功失败都会执行。这也是“正常返回”和“结束”两种语义的差别。
<aop:after-throwing>里还有一个坑:它的throwing="ex"必须和方法参数名一致,字符串对不上,Spring启动时就报参数绑定错误。这个错误属于特别好排查的那种,因为它会在启动阶段直接暴露。
4.2 正常路径和异常路径的执行顺序实测
还是以上面的输出为例,我把执行顺序画成文本流程图:
正常路径的执行顺序:
环绕通知前段 -> 前置通知 -> 目标方法 -> 后置返回通知 -> 最终通知 环绕通知后段异常路径的执行顺序:
环绕通知前段 -> 前置通知 -> 目标方法(抛出异常) -> 异常通知 -> 最终通知 (环绕通知后段不执行,异常继续往上抛)需要特别说明的是,我在Spring 5.3.x上实测得出的顺序是这样。同一个切面里挂了多个不同类型的通知时,通知之间的精确顺序在不同版本下可能有细微差别,因为这严格来说并没有在规范里被完全锁定。所以开发时尽量不要依赖“多个不同类型通知之间的相对顺序”,而是把核心逻辑写进环绕通知里,用try-catch-finally自己控制。
4.3 around通知为什么能包住其他通知:从调用链看执行
环绕通知和其他通知有一个本质区别:其他通知是在代理链的某一环上执行一次,而环绕通知拥有ProceedingJoinPoint,它可以决定“目标方法到底什么时候执行”,甚至可以决定“目标方法还执不执行”。
从调用链的角度看,Spring AOP的代理对象内部维护了一条通知链。环绕通知在这条链上处于最外层anchor的位置,它调用joinPoint.proceed()时,代理链才继续往下走,依次触发前置通知、目标方法、后置通知。如果环绕通知根本不调用proceed(),那目标方法被“短路”了,下面的前置通知也不会执行。
这个特征非常有用。比如做接口限流,你可以先判断令牌桶里有没有令牌,没令牌直接返回一个包装好的“请求过于频繁”结果,根本不调用proceed()。做权限校验也一样,没有权限直接抛出异常或返回默认值,目标方法就不用执行了。这也是为什么很多基于AOP的通用组件,核心逻辑都写在环绕通知里。
5. 切点表达式execution:从语法到实战的完整拆解
5.1 execution表达式的基本结构
execution是Spring AOP用得最多、也最可靠的切点表达式,它按“看得见的签名”来匹配方法。基本结构是:
execution(访问修饰符 返回类型 包名.类名.方法名(参数列表) 异常类型)实际写的时候,修饰符和异常类型可以省略。比如我在例子里写的:
execution(* com.example.service.*.*(..))拆开就是:
*:返回类型是任意的。com.example.service.*:只匹配com.example.service这个包下的所有类,不包含子包。.*:匹配类中的任意方法名。(..):匹配任意参数列表。
如果想匹配子包也能匹配上,包的部分要写com.example.service..*,两个点表示包含子包。
5.2 高频写法与语义对照
| 切点表达式 | 匹配范围 |
|---|---|
execution(public * com.example.service.*.*(..)) | service包下所有public方法 |
execution(* com.example.service.UserService.*(..)) | 指定类中的所有方法 |
execution(* com.example..*.*(..)) | com.example及其所有子包中的所有方法 |
execution(* com.example.service.*.get*(..)) | service包下所有以get开头的方法 |
execution(* *(..)) | 所有类的所有方法(慎用) |
execution(* com.example.service.OrderService.save*(Long, ..)) | save开头,第一个参数是Long,后面参数任意 |
通配符的记忆口诀:*只能匹配一层,..能匹配多层。用在包路径里,..表示子包;用在参数列表里,..表示任意类型、任意个数的参数;单独一个*在参数列表里则表示恰好一个任意类型参数。
5.3 切点匹配失败的典型场景
切点表达式写错,最麻烦的一点是:Spring不会启动报错,只会静默不匹配。你配置了半天,写了个日志切面,运行起来发现日志一条都没打,第一反应往往是代码配错了,而不是表达式写错了。我列几个高频翻车场景:
- 包名多写了子包。
com.example.service.*匹配不到com.example.service.sub.xxx里的类,因为单层通配符不包含子包。如果业务类实际在子包里,必须改成com.example.service..*。 - 类名多打了个
Impl。老项目里ServiceImpl都是com.xxx.service.impl.UserServiceImpl这种路径,但表达式写成了com.xxx.service.*.*(..)就匹配不上。 - 返回类型写成了具体类型。接口方法返回
List<User>,你在表达式里写execution(* com.xxx.service.UserService.list(..))没问题,但如果你试图匹配具体泛型,比如List<com.xxx.User>,泛型在运行时会被擦除,基本都会翻车。 (..)和(*)混用。(..)表示任意参数,(*)表示恰好一个参数。有人想表达“任意参数”,写成了明文的方法名加(),结果只匹配无参方法,业务方法全被跳过了。
我的排查习惯是,切点不生效时先把表达式简化到最小范围,比如execution(* com.example.service.AccountService.transfer(..)),确定能匹配上之后,再逐步放宽条件。这样能很快定位是表达式的问题还是别的问题。
6. XML AOP踩坑实录:从启动报错到静默失效的排查链路
6.1 启动就报错:aop命名空间与依赖缺失
第一类问题是Spring容器启动阶段直接抛异常,这种其实还算友好,因为它至少给了明确的错误信息。最常见的两种:
The matching wildcard is strict, but no declaration can be found...或者org.xml.sax.SAXParseException。十有八九是XML里用了xmlns:aop和aop:前缀,但schemaLocation里没有写对应的spring-aop.xsd。解决办法就是把第一节里那段xmlns:aop和schemaLocation原样抄进去。java.lang.NoClassDefFoundError: org/aspectj/util/PartialOrder$PartialOrderWrapper。这就是前面说的aspectjweaver依赖缺失。Spring容器里加载<aop:config>时解析切点表达式需要AspectJ的类,没有它必然挂。
遇到这类问题我的处理顺序是:先检查XML头部的命名空间声明,再检查schemaLocation里的URL路径是否和spring-aop.xsd一致,最后看pom.xml里aspectjweaver有没有进来。三步走完基本能解决。
6.2 最隐蔽的坑:切点静默失效
第二类问题最坑人:启动全程无异常,但通知就是不执行。遇到这种问题,我第一步做的是先确认Bean是不是被代理了。
在测试代码里打印service.getClass(),如果是com.example.service.AccountService,说明Bean就是原始对象,代理根本没生成;如果是AccountService$$EnhancerBySpringCGLIB,说明代理生成了,那就是切点表达式没匹配上。
这个区分非常关键。它会直接把问题一半对一半分到两条排查路线上:
- Bean是原始对象:说明
<aop:config>没有生效,去检查<aop:aspect ref="logAspect">的ref是不是写错了Bean名,或者这个Bean根本没有被Spring扫描到。 - Bean是代理对象但通知没执行:直接怀疑切点表达式,用上一节说的缩小范围法去试。
另外还有一种隐蔽情况:<aop:config>里定义了切点,但<aop:aspect>里的通知标签写的是pointcut而不是pointcut-ref。如果用的是后者,切点必须定义在当前aspect内部;如果用了pointcut-ref引用了一个不存在的id,启动时会报错,这个倒不会静默。
6.3 自调用问题:同一个类里的方法调用为什么绕过了代理
这是我见过最经典的一个AOP失效场景。
public class AccountService { public void transfer(String from, String to, Double amount) { System.out.println("执行转账..."); this.updateBalance("张三", 1000.00); } public void updateBalance(String accountNo, Double amount) { System.out.println("更新余额..."); } }如果切点表达式是execution(* com.example.service.AccountService.*(..)),理论上两个方法应该都会被拦截。但实测你会发现,transfer被拦截了,里面调的updateBalance没有被拦截。因为在transfer方法内部,this指向的是当前执行的对象,也就是目标对象本身,而不是Spring创建的那个代理对象。代理对象虽然包裹着目标对象,但目标对象内部调用自己的另一个方法时,并没有走代理的入口。
解决自调用问题的常规姿势有三个:
- 拆分两个类,把互相调用的方法放到不同Bean里,这样调用别的Bean时会经过代理。
- 通过
AopContext.currentProxy()拿到当前代理对象,然后调用代理对象的方法,但<aop:config>上需要设置expose-proxy="true",得当心线程无关性问题。 - 把目标方法都放到事务、日志之外的地方,业务层调用时自己注意方法是入口方法还是内部方法。
我在维护老项目时遇到过这个问题,当时的代价就是一个库存扣减操作里,“扣减”方法没触发日志切面,导致线上数据异常时连个操作记录都没有。所以如果你在设计接口时知道某个类的某些方法会被切面拦截,内部互相调用要格外小心。
6.4 顺手聊一下XML文件怎么打开和编辑
既然这篇文章主题是XML,顺带说一个新手经常会问的细节:XML文件到底怎么打开和编辑。
不要用记事本打开XML然后硬改,缩进一乱就看不清结构了,而且编码极容易出问题。Windows上我建议直接用VS Code或者IDEA,IDEA对Spring XML有专门的语法提示和命名空间校验。打开一个beans.xml的时候,IDEA会帮你识别xmlns:aop并提示是否有未声明的标签。没有IDE环境的时候,也可以用Chrome直接拖动XML文件进去预览,或者用在线XML格式化工具快速整理结构。但涉及Spring配置修改,永远记住一条:在IDE里改完再用mvn test或启动容器验证,别改完就上生产。
7. XML AOP和注解AOP怎么选:我的取舍建议
7.1 两种方式横向对比
我自己的态度是:新项目能上注解就上注解,老项目里的XML配置除非要动那一块逻辑,否则尽量别去大改。这两种方式各有清晰的适用场景:
| 维度 | XML AOP | 注解AOP |
|---|---|---|
| 配置集中度 | 所有切面集中在一个或几个XML文件里 | 切面散布在各自的Java类上 |
| 可读性 | 切面关系和全局扫描清晰,但业务代码看不出切面痕迹 | 直接看注解就知道哪个方法被切了 |
| 开发效率 | 低,每次新增切面要改XML | 高,一个@Aspect类搞定 |
| 类型安全 | 弱,方法名用字符串,写错运行时才知道 | 相对强,IDE能提示方法签名 |
| 适合场景 | 老项目维护、切面需要动态调整、第三方类无法加注解 | 新项目、团队成员对注解熟悉 |
| 切点复用 | 顶层pointcut可被多个aspect引用 | 通过@Pointcut方法复用 |
注解AOP在代码里能直接看到@Before、@Around,查问题时顺着注解就能找到切面类。XML AOP的优点则在于所有拦截规则集中在一个配置文件里,对运维和架构审计更友好,生产环境排查时可以只开一个XML文件看全貌,不用翻遍整个项目找注解。这两种思路没有绝对的对错,纯粹是取舍。
7.2 advisor与aspect的区别:事务配置里的老朋友
讲到XML AOP,还有一个概念必须提,那就是<aop:advisor>。
advisor在Spring里是一个更古老的抽象:它等于“一条通知+一个切点”的最小组合。在XML配置里,<aop:advisor>通常和<tx:advice>搭配使用,实现声明式事务:
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:advice id="txAdvice" transaction-manager="transactionManager"> <tx:attributes> <tx:method name="save*" propagation="REQUIRED"/> <tx:method name="update*" propagation="REQUIRED"/> <tx:method name="find*" read-only="true"/> </tx:attributes> </tx:advice> <aop:config> <aop:advisor advice-ref="txAdvice" pointcut="execution(* com.example.service.*.*(..))"/> </aop:config>advisor和aspect最核心的区别是:advisor只能有一个通知和一个切点,而aspect可以挂多个通知。事务这种只需要一套切面逻辑的场景,用advisor正合适。你要是想在一个aspect里既做日志又做权限还做事务,那就用aspect。
7.3 一个过来人的选择思路
最后给个实际参考。
第一,简历上写“熟悉Spring AOP”,不能光会@Around一种写法。你至少要能说出XML AOP里的<aop:config>、aop:aspect、五种通知标签的语义区别,讲清楚advisor和aspect的差别,最好能现场写一个简单的execution切点表达式。
第二,接手老项目,优先把XML AOP里的切点表达式全部摘出来形成文档,标注每个切面对应哪几个业务方法。这些切点表达式就是老系统的“隐形地图”,不画出来,后面每个改动都像在盲人摸象。
第三,新项目如果不涉及特殊要求,直接注解AOP,开发效率高很多。但如果是对外发布的中间件、基础组件,XML方式反而更容易和不同业务方对接。
在写这一篇的过程里我又翻了翻spring-aop.xsd的标签定义,愈发觉得XMI配置虽然啰嗦,但对理解AOP的完整链路特别有帮助:你被迫把每一个概念都想清楚,才能在XML里准确写出对应的标签。把这一块啃下来之后,再回头用注解AOP会顺手得多。老项目的经验不白踩,这些坑填平之后,后面的Spring学习之路反而更顺了。