前阵子有个朋友找我调一个Spring MVC老项目的Bug,现象是接口偶尔返回慢,但代码里找不到任何耗时操作。后来发现日志散落得到处都是,几百个Controller方法里,有打印入参的、有记耗时的、有拼操作日志的,逻辑都是同一套,只是复制的次数太多,风格已经走样了。我给他开的药方很简单:把AOP用起来。
Spring MVC项目里,AOP这个东西常常被讲得很玄乎,什么"面向切面编程"、什么"横切关注点",听起来像论文术语,实际上它就是帮你把"每个方法都要干的重复事情"抽出来,集中处理,然后让业务代码变干净。这篇文章不绕弯子,直接讲清楚三件事:AOP在Spring MVC里到底能干哪些实事、传统XML配置和注解配置分别怎么写、以及配置完之后最常踩的坑怎么排查。适合那些被重复代码折磨过、或者给Service层加切面却发现完全没生效的Java开发者。
1. 为什么Spring MVC项目离不开AOP
1.1 日志、权限、性能监控:那些四处粘贴的样板代码
接触过真实业务系统的都知道,一个Controller方法的标准开场白往往是这样的:
@PostMapping("/order/create") public Result createOrder(@RequestBody OrderDTO dto) { log.info("创建订单,入参:{}", JSON.toJSONString(dto)); long start = System.currentTimeMillis(); try { // 权限校验 if (!checkPermission(getCurrentUserId())) { return Result.fail(403, "无权限"); } // 业务逻辑 Order order = orderService.create(dto); log.info("创建订单成功,耗时:{}ms", System.currentTimeMillis() - start); return Result.success(order); } catch (Exception e) { log.error("创建订单异常,参数:{}", JSON.toJSONString(dto), e); return Result.fail(500, "系统繁忙"); } }这段代码有毛病吗?单看没问题。但如果你有50个类似的接口,这50个接口里就塞了50份日志打印、50份手动计时、50份权限校验。更要命的是,有一天产品说要统一日志格式,把"入参"改成"请求参数",你得全局搜索替换50个位置,漏掉一个,线上日志就花式出问题。
AOP解决的就是这种"横切关注点"。它把这些重复动作定义成一个切面,在方法执行前、执行后、抛异常时自动插入逻辑,业务方法本身不再关心这些事。打个不太严谨的比方:你家小区原来每户都要自己下楼扔垃圾,现在物业在每个楼层设了一个垃圾口,你只需要把垃圾放进管道,物业统一处理。这个"管道"就是AOP的通知机制。
1.2 哪些场景适合用AOP,哪些场景千万别硬上
根据我在实际项目里的经验,AOP最适用的场景有几类:
- 请求日志与操作审计:记录谁在什么时间调用了什么接口,入参出参是什么,改了什么数据。这是最常见的AOP用途。
- 权限与参数校验:统一的登录状态校验、接口级权限判断,在切面里做前置检查。
- 性能监控与耗时统计:给接口或Service方法统一计算耗时,超过阈值就告警。
- 异常兜底处理:统一捕获异常、转换错误码,避免每个方法手写try-catch。
- 事务管理:Spring的@Transactional本质就是AOP的应用,只是框架帮你封装好了。
- 缓存处理:某些查询方法的缓存读取和回填,用切面可以做到不改业务代码就加上缓存。
但有两类场景,我的建议是不要硬上AOP:
第一类是业务规则高度个性化的场景。比如订单状态流转,每个状态下的处理逻辑差异极大,你没法用一段通用逻辑统一切入,硬写的话切面里全是if-else,反而把代码搞得更乱。
第二类是超高频且对耗时极度敏感的方法。AOP本质是代理调用,会在方法调用链上多一层拦截。绝大多数业务系统里这层拦截的耗时可以忽略不计,但如果是一个每秒被调用几十万次的纯内存计算,多一次代理分派、多一次切点判断,积累起来也是可感知的性能开销。碰到这种代码,别用AOP,老老实实写清楚。
2. Spring MVC里AOP的代理原理与双容器问题
2.1 JDK动态代理与CGLIB的取舍
要搞懂AOP在Spring MVC里的行为,先得明白Spring AOP的实现根基:动态代理。Spring AOP不是在编译期把切面代码织入到业务类里的,它是在运行时生成一个代理对象,把这个代理对象放进Spring容器,业务代码拿到的其实是代理对象的引用。
Spring生成代理有两种方式:
- JDK动态代理:要求目标对象必须实现接口。它基于接口生成一个Proxy对象,只能代理接口中声明的方法。
- CGLIB:不要求实现接口,它生成目标类的子类,在子类里重写目标方法,以此实现代理。
这两种方式各有什么坑,我用一张表说清楚:
| 对比项 | JDK动态代理 | CGLIB |
|---|---|---|
| 目标要求 | 必须实现接口 | 不需要接口 |
| 生成机制 | 基于接口生成Proxy对象 | 基于继承生成子类 |
| 方法限制 | 只能拦截接口中声明的方法 | 不能被final修饰 |
| 性能 | 创建代理快,调用较慢 | 创建代理慢,调用较快 |
| 默认选择 | Spring默认优先 | 当无接口或指定proxyTargetClass时使用 |
Spring的默认策略是:目标类有接口就走JDK动态代理,没有接口才走CGLIB。如果你在配置里声明了<aop:aspectj-autoproxy proxy-target-class="true"/>,或者Spring Boot里设置了spring.aop.proxy-target-class=true,那就会强制走CGLIB。在新版本的Spring Boot里,CGLIB已经是默认选项,因为大多情况下不需要目标实现接口,出错概率更低。
2.2 根容器和子容器:为什么切面在Controller层好使、在Service层失效
这可能是Spring MVC中AOP最隐蔽的一个坑,比任何切点表达式都令人头疼。
传统Spring MVC项目通常有两个Spring容器:
- 根容器:由
ContextLoaderListener创建的ApplicationContext,通常加载applicationContext.xml,负责管理Service、DAO、数据源等业务层和基础设施层的Bean。 - 子容器:由
DispatcherServlet创建的WebApplicationContext,通常加载spring-mvc.xml,负责管理Controller、HandlerMapping等Web层组件。
子容器能看到根容器里的Bean,但根容器看不到子容器里的Bean。引用关系是单向的。
这个机制对AOP配置有什么影响?我举一个亲历的例子。某个项目里我把切面类写在了spring-mvc.xml的组件扫描路径下,<aop:aspectj-autoproxy/>也在spring-mvc.xml里开启了。结果Controller层的切面一切正常,Service层的切面怎么都不生效。
原因说穿了很简单:切面注册在子容器,而Service Bean在根容器。哪怕切点表达式匹配到了Service的方法,子容器里的代理机制管不到根容器里的Bean——它只对子容器自己管理的Bean生效。反过来,如果切面配置在根容器里,对根容器管理的Service生效,但Controller在子容器里,又不在管辖范围内。
所以传统Spring MVC项目里配置AOP,最基本的一条经验是:要对哪一层的Bean做切面,切面就必须和那一层Bean在同一个容器里。我的习惯是,像日志、权限、异常处理这类需要同时覆盖Controller和Service的切面,统一配置在根容器的applicationContext.xml里,保证两边都能命中。如果只做Web层的拦截(比如记录接口入参出参),那放在spring-mvc.xml也没问题。
Spring Boot项目没有这种双容器结构,基本一个容器管所有,所以这个坑在Spring Boot里几乎遇不到。这也是很多从Boot转回MVC项目的同学一脸懵的原因。
2.3 切点表达式怎么写才能精准命中
切点决定切面作用于哪些方法。表达式写错了,要么切不到任何方法,要么切到一大片不该切的对象,引发各种诡异问题。常用的execution表达式格式是:
execution(修饰符 返回类型 包路径.类名.方法名(参数))几个我在项目里常用的写法:
execution(public * com.example.controller.*.*(..)):拦截com.example.controller包下所有Controller类的所有public方法,参数任意。execution(* com.example.service.impl.*.*(..)):拦截service.impl包下所有类的所有方法。execution(* com.example..*.*(..)):双点号表示包及子包递归匹配,全包扫描,慎用,范围太大。@annotation(com.example.annotation.OperLog):切到所有标注了@OperLog注解的方法,这种搭配自定义注解的方式在日志审计场景里极其好用。within(com.example.controller..*):按类型匹配,匹配包内所有类的所有方法。
写切点表达式时最需要注重的不是"能不能匹配上",而是"会不会误匹配"。比如上面说的com.example..*.*(..)这种写法,会把Spring内部的一些Bean也扫进去,某些类还没初始化完就被代理触发,可能抛出意想不到的异常。我个人的习惯是先精确到包,再按需放宽,不要一开始就图省事写大范围。
3. 从零配置一个Controller层请求日志切面
3.1 传统Spring MVC的XML配置
传统SSM结构的Spring MVC项目,如果还没有引入AOP相关依赖,第一步要先把Maven依赖加上。我一般是这样配的:
<dependency> <groupId>org.springframework</groupId> <artifactId>spring-aop</artifactId> <version>5.3.29</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-aspects</artifactId> <version>5.3.29</version> </dependency> <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> <version>1.9.19</version> </dependency>三个依赖各管一摊:spring-aop提供AOP基础支持,spring-aspects提供注解驱动的@Aspect支持,aspectjweaver提供切点表达式解析。缺了aspectjweaver,execution表达式解析就会出问题,切面直接不生效。
然后是配置文件。假设要在根容器里启用注解式AOP,applicationContext.xml需要这样写:
<?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" xmlns:context="http://www.springframework.org/schema/context" 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 http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd"> <!-- 手动开启@Aspect注解支持 --> <aop:aspectj-autoproxy proxy-target-class="true"/> <!-- 切面类交给Spring管理 --> <bean id="requestLogAspect" class="com.example.aspect.RequestLogAspect"/> </beans>如果你更喜欢纯XML声明切面而不写Java注解类,也可以用<aop:config>标签:
<aop:config proxy-target-class="true"> <aop:aspect id="webLogAspect" ref="requestLogAspect"> <aop:pointcut id="controllerPointcut" expression="execution(* com.example.controller.*.*(..))"/> <aop:around method="around" pointcut-ref="controllerPointcut"/> </aop:aspect> </aop:config>这种方式要求RequestLogAspect类里有一个名为around的方法,方法签名是Object around(ProceedingJoinPoint pjp)。XML配置的好处是切点和切面完全外部化,但缺点是改起来不够灵活,注解方式才是现在的主流,下面重点讲。
3.2 基于@Aspect注解的全套代码
用注解驱动,先确保配置里开启了<aop:aspectj-autoproxy/>,然后写切面类。
@Component @Aspect public class RequestLogAspect { private static final Logger log = LoggerFactory.getLogger(RequestLogAspect.class); // 定义切点:匹配controller包下所有类的所有方法 @Pointcut("execution(* com.example.controller.*.*(..))") public void controllerPointcut() { } // Before:方法执行前 @Before("controllerPointcut()") public void before(JoinPoint joinPoint) { String methodName = joinPoint.getSignature().getName(); Object[] args = joinPoint.getArgs(); log.info("请求开始,方法:{},入参:{}", methodName, JSON.toJSONString(args)); } // AfterReturning:正常返回 @AfterReturning(pointcut = "controllerPointcut()", returning = "result") public void afterReturning(JoinPoint joinPoint, Object result) { log.info("请求结束,方法:{},响应:{}", joinPoint.getSignature().getName(), JSON.toJSONString(result)); } // Around:最灵活的全包围 @Around("controllerPointcut()") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); Object result; try { result = joinPoint.proceed(); return result; } finally { long cost = System.currentTimeMillis() - start; log.info("方法 {} 耗时:{}ms", joinPoint.getSignature().toShortString(), cost); } } }几个容易混淆的点:
@Before、@AfterReturning、@Around都是通知注解,它们可以同时存在于一个切面类里,Spring按配置顺序织入。@AfterReturning有一个returning属性,它把目标方法的返回值绑定通知方法的入参,这样通知里才能拿到返回值。注意returning的对象类型不能写宽了,否则Spring会跳过匹配。不想依赖返回值的场景,直接写@AfterReturning("controllerPointcut()"),方法不声明returning参数即可。
切面类上加@Component是为了让Spring把它当Bean管理,加@Aspect是告诉Spring这个类里有切面逻辑,两个注解缺一不可。Spring Boot项目里也是一样的用法。
3.3 简化:Spring Boot下的三步启用
如果你用的是Spring Boot,整个流程会被压缩到极其简单。Spring Boot自带了一个AopAutoConfiguration,只要类路径下存在org.aspectj.lang.annotation.Aspect,它就自动开启@EnableAspectJAutoProxy,连配置都省了。
第一步,引入依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>第二步,写一个和上面一模一样的切面类,加@Component和@Aspect。
第三步,确认启动类或配置类没有被手动作弊操作——如果有人手动设置了spring.aop.auto=false,那就需要自己加@EnableAspectJAutoProxy。
如果你想强制走CGLIB代理,在application.yml里写一行:
spring: aop: proxy-target-class: true新版本的Spring Boot默认本来就是proxy-target-class=true,所以这一行大多数时候不用写。
4. 切面不生效?我梳理过的高频排查链路
4.1 自查清单:从依赖到切点一份全过
AOP最大的痛点不是写,而是"写了没反应"。我整理了一份排查清单,按照这个顺序从外到内查,基本都能定位:
第一,确认依赖。如果连aspectjweaver都没有,切面类上的注解根本不会被解析。去mvn dependency:tree里看一眼,确认有没有org.aspectj:aspectjweaver和org.springframework:spring-aop。
第二,确认自动代理是否开启。XML项目查<aop:aspectj-autoproxy/>,Boot项目查spring.aop.auto是否被改成了false。没有这个开关,你写的@Aspect类只是一个普通Bean,不产生任何拦截动作。
第三,确认切面类被Spring扫描到。切面类必须是一个注册过的Bean。查一下@Component有没有加、包扫描路径是否覆盖了切面类所在的包。有个很隐蔽的情况:切面类放在某个子包里,但ComponentScan只扫描了Controller包,切面类根本没进Spring容器,那自然是静悄悄的。
第四,确认目标Bean所在容器和切面Bean所在容器一致。Spring MVC双容器的问题前面提到过,这里不再重复,但排查的时候一定要想一遍。我最常见的场景就是同事把切面和Controller放在同一个spring-mvc.xml里,又说Service层切面不生效。
第五,确认切点表达式。把切点表达式复制出来,手动写个测试方法往里套一遍,看是否匹配。一个很实用的临时调试手段:在切面通知的入口直接加一行System.out.println("aspect exec"),如果能看到这行输出,说明切点已经命中了目标,问题大概率在后面的逻辑里。
4.2 内部调用与public方法限制:最容易被忽视的两个边界
排查清单解决的是"切面根本没启用"的问题,但有一些场景是切面启用正常、特定方法却没被切到。
最经典的内部调用问题,看这段代码:
@Service public class UserServiceImpl implements UserService { public User getUser(String id) { // 业务逻辑 return queryFromDb(id); } public User getUserWithDetail(String id) { // 这里通过this调用,不会走AOP代理 User user = this.getUser(id); // 其他逻辑 return user; } }假设你的切点表达式配的是execution(* com.example.service..*.*(..)),理论上getUserWithDetail和getUser都会走到代理。但实际执行时,getUserWithDetail里通过this.getUser(id)调用的是目标对象自己的方法,不是代理对象的方法。因为Spring容器里注入的Bean是被代理后的对象,但this指向的是代理内部那个原始对象。结果就是:其他类调getUser会被拦截,而同类内部调getUser永远绕过了切面。
解决方式有几种:
- 注入自身代理:在类里
@Autowired或@Resource注入UserService,内部调用时用注入的实例。 - 使用AopContext:在配置里开启
exposeProxy=true,代码里用AopContext.currentProxy()拿到代理。XML场景对应<aop:aspectj-autoproxy expose-proxy="true"/>。 - 把内部调用的方法拆到另一个Bean里,通过其他Bean的引用来调用,这也是最符合Spring设计思路的做法。
另一个边界是public方法限制。Spring AOP基于代理实现,JDK动态代理本来就只能拦截接口里的方法,而CGLIB虽然生成子类,但Spring对非public方法的处理也很谨慎,很多场景下private方法根本不会被拦截。结论很简单:想让切面稳定命中,目标方法就写成public。
4.3 怎么确认当前Bean确实被代理了
排查到某一步,你可能已经怀疑"这个Bean到底有没有被代理"。怎么确认?有几个土办法,非常直观。
第一种,启动日志法。Spring启动时,如果某个Bean被代理,日志里会出现Bean 'xxx' is a CGLIB proxy之类的提示,不过不是所有日志级别都默认打印,你可以临时把org.springframework.aop的日志级别调到DEBUG。
第二种,代码里检查类的结构。在目标类里临时注入一个自引用,打印它的class:
@Autowired private UserService userService; public void checkProxy() { System.out.println(userService.getClass()); System.out.println(userService instanceof UserService); }如果输出的是class com.sun.proxy.$Proxy123,说明是JDK动态代理;如果输出的是类似class com.example.service.impl.UserServiceImpl$$EnhancerBySpringCGLIB$$abcdef,说明是CGLIB代理。如果输出就是class com.example.service.impl.UserServiceImpl本身,那肯定没代理,问题大概率是前面说的自动代理没开启或切面类没进容器。
第三种,用BeanFactory后置处理器探查。写一个BeanPostProcessor,在postProcessAfterInitialization里把目标Bean的class打出来,能看到完整代理链结构,但这个方法侵入性强,一般排查用第二种就够。
5. 实测后的扩展玩法与性能注意事项
5.1 自定义注解切面:操作审计日志的标准姿势
前面说的都是"切所有Controller方法",这种切法简单粗放,但业务上常常只需要对个别特殊接口做审计。更精准的玩法是自定义一个注解,谁标注了谁才被切。
先定义一个注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OperLog { String module() default ""; String action() default ""; }切面里用@annotation表达式匹配这个注解:
@Aspect @Component public class OperLogAspect { @Pointcut("@annotation(com.example.annotation.OperLog)") public void operLogPointcut() { } @Around("operLogPointcut() && @annotation(operLog)") public Object recordOperLog(ProceedingJoinPoint joinPoint, OperLog operLog) throws Throwable { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); String username = getCurrentUsername(request); Object result; try { result = joinPoint.proceed(); } catch (Exception e) { saveOperLog(username, operLog.module(), operLog.action(), "失败", e.getMessage()); throw e; } saveOperLog(username, operLog.module(), operLog.action(), "成功", null); return result; } }注意这里有一个小技巧:@annotation(operLog)把注解实例绑定到通知方法的参数上,这样切面里才能拿到注解上写的module和action值,实现"同一个切面、根据标注产生不同的审计记录"。
Controller里的用法:
@OperLog(module = "订单模块", action = "创建订单") @PostMapping("/order/create") public Result createOrder(@RequestBody OrderDTO dto) { // 业务逻辑 }这种方式的好处是审计规则完全由业务方决定,不想审计的接口不加注解就行,切面不需要维护一份方法清单。
5.2 接口耗时统计与慢接口告警
性能监控是AOP的另一个高频扩展。用@Around把整个调用包起来,记录开始时间和结束时间,超过阈值就打点或发告警。
@Aspect @Component public class PerformanceAspect { private static final Logger log = LoggerFactory.getLogger(PerformanceAspect.class); @Pointcut("execution(* com.example.service.impl.*.*(..))") public void servicePointcut() { } @Around("servicePointcut()") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); Object result; try { result = joinPoint.proceed(); return result; } finally { long cost = System.currentTimeMillis() - start; if (cost > 1000) { log.warn("慢调用警告:{} 耗时 {}ms", joinPoint.getSignature().toShortString(), cost); } } } }有些人会把耗时统计和请求日志写在同一个切面里,省掉一个类的维护成本。我个人的建议是分开写,两个切面各管各的,一个挂了不影响另一个。多切面作用于同一个目标方法时,执行顺序可以通过@Order注解控制,数字越小优先级越高,这个顺序在切面之间有关联时非常关键,比如权限校验必须优于日志记录。
5.3 别把切面写成性能黑洞
最后聊点实在的性能问题。AOP本身分派成本很低,但很多人写着写着就在切面里干了重活,把性能拖垮了还不自知。我的几条经验:
不要在切面里做同步数据库写入。比如上面的操作审计,如果每来一个请求就同步插一条数据库记录,在高并发下数据库压力会剧增。可靠的做法是把审计日志提交到线程池异步处理,或者丢进消息队列。注意线程池的拒绝策略要有兜底,别让日志把业务流量带崩。
切点范围写窄写精确。前面说过,execution(* com.example..*.*(..))这种全包匹配看起来省事,但会让Spring为包下大量无关Bean也生成代理。代理本身有创建成本,Bean数量多的时候启动时间和内存占用都会上升。能定位到哪一层就定位到哪一层。
避免在切面里做远程调用。切面里调第三方接口,一旦第三方超时,所有被拦截方法的耗时都会被拖长,而且排查问题时很难发现是切面造成的。如果真有这种需求,一定要加超时控制、熔断和降级。
多切面时注意顺序陷阱。举个例子,如果同时存在事务切面和自定义性能统计切面,性能统计的@Around在最外层、事务的@Around在内层,你看到的耗时里就包含了事务提交的时长;反过来,就得考虑事务可能还没提交就已经打了统计日志。具体顺序要结合业务预期来定。
我自己的另一个体会是:AOP在Spring MVC项目里,最划算的投入往往是"先搭一个请求日志切面",它几乎不涉及业务逻辑,却能立刻让所有接口的调用情况变得可观测。等日志稳定了、没有误报了,再往上面加权限、加审计、加性能监控,每一步都在已有能力上叠加,出错时也容易排查。很多人一上来就写一个巨大的多通知切面,里面什么逻辑都有,结果日志、权限、事务互相干扰,反而把AOP的维护成本拉得比复制样板代码还高。这真不是夸张,我在项目里见过太多类似的案例了。