1. 为什么说AOP是后端进阶的第一道坎:从“重复代码地狱”说起
如果你跟我一样,是从Java Web开发一路走过来,大概率经历过这样一种状态:Controller里写了十几二十个接口,每个接口都有一段几乎一模一样的“开始计时、校验参数、打印日志、执行方法、打印耗时”代码。更别提那些分布在全项目各处的“登录校验”“权限判断”“事务控制”逻辑——你以为是写在了Service里,实际上是一个方法一个方法地在重复粘贴。
我当初在跟着黑马的程序员课程走Java Web后端进阶部分的时候,第一次接触到AOP这个概念,第一反应是“这不就是拦截器吗,Spring MVC不早有了吗?”后来认真把所有原理吃透,才发现自己之前的理解太浅了。AOP(Aspect Oriented Programming,面向切面编程)不是简单地在请求进来时拦一道,而是一种“在不修改已有业务代码的前提下,往现有逻辑横向平铺公共能力”的编程思想。它跟IOC(控制反转)一样,属于Spring框架的两大核心,也是从“会写Spring”往“理解Spring”进阶的分水岭。
这篇文章我会完全按照“是什么、为什么、怎么用、踩了什么坑”这个学习路径来写,把我自己在学习黑马程序员AOP章节时的笔记、代码、反思都整理出来。重点讲清楚Spring AOP的实现原理(JDK动态代理 + CGLIB动态代理)、核心术语(切面、通知、切点、连接点、织入)、从零搭建一个包含登录校验和操作日志的真实案例,最后是工作中真正会遇到的高频问题排查。这篇笔记适合刚学完Spring基础、准备进阶AOP的Java后端开发者,也适合工作了一两年但一直停留在“会用@Aspect但说不清原理”阶段的朋友。
2. AOP到底解决了什么问题:先把“重复代码”这个痛点揭穿
2.1 没有AOP的时候,业务代码长什么样
先做一个最简单的模拟。假设现在有一个订单模块,我需要给每个Service方法加上“方法执行耗时统计”和“日志记录”,在没有AOP之前,代码会写成这样:
public class OrderService { public Order createOrder(OrderDTO dto) { long start = System.currentTimeMillis(); try { logger.info("开始创建订单:{}", dto.getOrderNo()); // 核心业务逻辑,比如校验库存、生成订单号、落库 Order order = doCreateOrder(dto); logger.info("订单创建成功:{}", order.getOrderNo()); return order; } catch (Exception e) { logger.error("订单创建失败:{}", dto.getOrderNo(), e); throw e; } finally { long cost = System.currentTimeMillis() - start; logger.info("创建订单耗时:{}ms", cost); } } public Order cancelOrder(Long orderId) { long start = System.currentTimeMillis(); try { logger.info("开始取消订单:{}", orderId); // 核心业务逻辑,比如检查订单状态、退款 Order order = doCancelOrder(orderId); logger.info("订单取消成功:{}", orderId); return order; } catch (Exception e) { logger.error("订单取消失败:{}", orderId, e); throw e; } finally { long cost = System.currentTimeMillis() - start; logger.info("取消订单耗时:{}ms", cost); } } }看到没有,业务逻辑只占了中间那几行,辅助功能的代码量是业务代码的好几倍。这只是两个方法,如果一个项目里有几十上百个Service方法,每个方法都手写这套“开始日志、异常日志、结束耗时”的逻辑,等于整个项目被日志和计时代码糊满了。更痛苦的是,一旦日志规范变了(比如要求加一个traceId贯穿全链路),你得全局搜索替换,成千上万个地方一个个改。
2.2 面向切面的核心思路:把“横切关注点”单独拎出来
上面说的“日志记录、耗时统计、事务控制、权限校验”这些东西,有一个共同特点:它们不属于某个业务自身逻辑,而是像一道道横截面一样穿过多层业务——所以被称为横切关注点(Cross-cutting Concern)。OOP(面向对象编程)擅长处理的是纵向的“类和类之间的关系”,但对于这种横向贯穿的公共逻辑,OOP天生没有好办法,只能靠继承或者组合去模板化,仍然无法根治重复。
AOP的思路很直接:把横切关注点单独抽出来做一个模块,这个模块叫切面(Aspect);然后通过某种“代理”机制,在不改动业务代码的前提下,把切面里的逻辑织入到业务方法执行前后。业务代码永远只关心自己的订单逻辑,日志和计时全部由切面去管。
我用一个生活化的例子来帮新手理解:你去餐厅点菜,后厨炒菜是业务逻辑,而“上菜之前先洗碗、上菜之后记录顾客评价”是横切关注点。没有AOP的时候,每个厨师炒完菜都要自己洗碗、自己记账;有了AOP,就相当于雇了一个专门的后勤团队,厨师只管炒菜,后勤团队会自动完成洗碗和记录。
2.3 Spring AOP vs AspectJ:两个经常被搞混的概念
这一步是理解 AOP 的关键。很多教程把 Spring AOP 和 AspectJ 混在一起说,导致初学者雾里看花。我必须用最简单的话把关系理清楚:
- Spring AOP:Spring框架自己实现的AOP,底层基于动态代理,分JDK动态代理和CGLIB动态代理两种方式,只能对Spring容器中管理的Bean方法进行拦截,一般只支持方法级别的切点。
- AspectJ:一个独立的、完整的AOP框架,可以在编译期、加载期进行字节码织入,能力比Spring AOP强大得多(甚至能拦截字段赋值、构造器调用)。
- Spring用到的其实只是AspectJ的注解和表达式语法:
@Aspect、@Before、@Around这些注解是AspectJ提供的,但底层真正干活的是Spring自己的动态代理机制,并不是完整的AspectJ织入器。
这个点面试也特别爱问,大家一定要记牢。Spring AOP 选择动态代理而不是直接引入 AspectJ,核心原因是动态代理可以在运行时无侵入地完成织入,不需要额外的编译步骤,跟Spring的IOC容器契合度极高。代价是它只能作用于Spring管理的Bean,并且方法必须是可被代理的(后面细说)。
3. AOP核心概念一次吃透:从切面到织入全拆解
3.1 六个关键术语,一个都不能含糊
在动手写AOP代码前,先把这六个术语背熟并理解透。它们就像武侠小说里的内功心法,代码只是招式。
| 术语 | 英文 | 通俗解释 | 类比 |
|---|---|---|---|
| 切面 | Aspect | 横切关注点被封装的模块,一个切面可以包含多个通知 | 后勤团队 |
| 连接点 | Join Point | 程序执行过程中的某个特定位置(方法调用前、后、异常时) | 事件发生的时刻 |
| 切入点 | Pointcut | 一组连接点的集合,用于定位哪些方法要被增强 | 具体哪些菜需要后勤团队服务 |
| 通知 | Advice | 要在切入点上执行的逻辑,分为Before、After、Around等 | 后勤团队具体干什么活 |
| 目标对象 | Target | 被切面增强的原始业务对象 | 炒菜的厨师 |
| 织入 | Weaving | 把切面逻辑加到目标对象上,创建出代理对象的过程 | 给厨师安排后勤团队 |
对于Spring AOP来说,连接点实际上就是方法调用的边界——方法的执行前、返回后、抛出异常后。切入点解决的是“哪些方法需要被切入”,通常用表达式来描述,比如“拦截com.example.service包下面所有以Service结尾的类的所有方法”。通知解决的是“切入后干什么”。
3.2 通知的五种类型:优先掌握@Around
通知(Advice)就是切面中真正要执行的代码块,Spring支持五种通知类型:
- @Before:目标方法执行之前执行,适合做参数校验、权限前置检查。
- @AfterReturning:目标方法正常返回后执行,能拿到返回值,适合做结果日志记录。
- @AfterThrowing:目标方法抛异常后执行,适合处理异常告警、封装异常信息。
- @After:目标方法执行之后(不管正常返回还是异常)都会执行,类似
finally。 - @Around:最强大的通知类型,能在方法调用前后自定义整套逻辑,内部需要手动调用
ProceedingJoinPoint.proceed()去执行目标方法。
我的建议是新手直接优先掌握@Around,理由是它一统天下:你在@Around里可以手动控制目标方法要不要执行(比如登录校验不通过直接拦截,不调用proceed())、可以捕获返回值做修改、可以包一层try-catch处理异常。学好了@Around,其他四种自然就理解了。
来一个最直接的@Around示例,统计方法执行耗时:
@Aspect @Component public class CostTimeAspect { private static final Logger logger = LoggerFactory.getLogger(CostTimeAspect.class); // 切点:拦截service包下所有类的所有方法 @Around("execution(* com.example.service.*.*(..))") public Object logCostTime(ProceedingJoinPoint pjp) throws Throwable { long startTime = System.currentTimeMillis(); try { // 执行目标方法,其实就是反射调用 return pjp.proceed(); } finally { long costTime = System.currentTimeMillis() - startTime; MethodSignature signature = (MethodSignature) pjp.getSignature(); logger.info("方法 [{}] 执行耗时: {}ms", signature.getName(), costTime); } } }注意几个细节:pjp.proceed()返回的是Object,原样返回才能不影响调用方的返回值;pjp.getSignature()拿到的是方法签名,可以转换成MethodSignature从而获得方法名、参数名、注解等信息;finally块保证即使方法抛异常也能记录耗时。
3.3 Spring AOP的底层支撑:JDK动态代理和CGLIB动态代理
这是整个AOP学习中技术含量最高、面试最容易深挖的点。Spring AOP底层到底怎么在不改业务代码的前提下增强方法的?答案就藏在“动态代理”四个字里。
JDK动态代理:基于接口的代理
JDK动态代理是Java原生提供的,核心类就两个:java.lang.reflect.Proxy和java.lang.reflect.InvocationHandler。工作原理是:在运行时动态生成一个实现了目标接口的新类(这个类在内存中生成字节码),该新类作为代理类,持有InvocationHandler引用;当外部调用代理类的方法时,实际上会进入InvocationHandler.invoke()方法,由它负责去调用目标对象,并在前后插入增强逻辑。
用代码来理解:
public class JdkProxyDemo { public interface UserService { void addUser(String name); } public static class UserServiceImpl implements UserService { @Override public void addUser(String name) { System.out.println("添加用户:" + name); } } public static void main(String[] args) { UserServiceImpl target = new UserServiceImpl(); // 使用JDK动态代理,代理对象和原对象都实现了同一个接口 UserService proxy = (UserService) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), (proxyInstance, method, methodArgs) -> { System.out.println("【JDK代理】方法执行前..."); Object result = method.invoke(target, methodArgs); System.out.println("【JDK代理】方法执行后..."); return result; }); proxy.addUser("张三"); } }运行结果非常直观:调用proxy.addUser()时,先进代理逻辑,再进目标方法。JDK动态代理的限制很明确:目标对象必须实现至少一个接口,否则JDK无法生成代理类。为什么?因为生成的代理类需要实现接口来保证方法签名一致,如果目标类没有接口,就用代理类去强制实现目标类的所有方法。
CGLIB动态代理:基于继承的代理
CGLIB(Code Generation Library)走的是另一条路:它直接在运行时生成目标类的子类,子类通过重写父类的方法来实现增强。因为是基于继承,所以CGLIB可以代理没有实现接口的普通类。底层用的是ASM字节码生成技术,性能较好。
CGLIB的增强逻辑里有一个关键类MethodInterceptor:
public class CglibProxyDemo { public static class OrderService { // 注意:这个类没实现任何接口 public void createOrder() { System.out.println("创建订单"); } } public static void main(String[] args) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(OrderService.class); enhancer.setCallback((MethodInterceptor) (obj, method, args1, proxy) -> { System.out.println("【CGLIB代理】方法执行前..."); Object result = proxy.invokeSuper(obj, args1); System.out.println("【CGLIB代理】方法执行后..."); return result; }); OrderService proxy = (OrderService) enhancer.create(); proxy.createOrder(); } }这里最需要注意的是proxy.invokeSuper(obj, args1),它调用的是被代理类父类的原始方法,不能直接method.invoke(obj, args1),否则会陷入死循环——因为obj本身就是CGLIB生成的代理子类,再调用目标方法又会进入拦截器逻辑,无限递归直到栈溢出。这个坑特别经典,面试官稍加追问就能看出你到底是真写过CGLIB还是只背了概念。
Spring Boot默认用哪个?
很多老教程说Spring框架默认使用JDK动态代理,接口存在则用JDK,否则用CGLIB。这个说法在Spring Boot 2.x以后已经过时了。Spring Boot 2.x及之后的版本,默认强制使用CGLIB动态代理,即使类实现了接口也不再自动切换到JDK代理。原因很简单:项目里很多增强场景(比如你用的是第三方库里的类,或者配置类,或者某些没有接口的Service)需要CGLIB,统一用CGLIB可以避免两类代理切换带来的各种“莫名其妙”问题。唯一的代价是CGLIB要求目标类不能被final修饰,被final修饰的方法是没法被重写增强的。
4. 从零搭建AOP实战案例:登录校验 + 操作日志,带完整可运行代码
4.1 需求分析与方案设计
理论知识讲再多,不动手写还是会忘。我这里设计一个贴近真实业务的完整案例:一个模拟的后台接口,要求所有以/admin开头的接口都必须校验登录状态,同时记录每次操作的调用日志(用户、方法、参数、耗时),并且两个横切关注点互相独立。
这个案例非常典型,因为登录校验和操作日志是所有管理系统都绕不开的功能。设计中我刻意不乱用中间件,只用Spring Boot + AOP就能实现,方便你把精力完全放在AOP本身。
技术选型和理由:
- 切点表达式用
execution类型,可以直接定位到controller包下的所有请求方法。 - 操作日志用自定义注解
@OperationLog标注,方便灵活指定哪些接口需要记日志,避免所有接口都被记录导致日志量爆炸。 - 登录校验用@Around,如果未登录直接返回统一错误对象,不继续执行业务逻辑——这样彻底阻断未授权访问。
- 操作日志用@Around + 自定义注解,既能在方法前后打印参数和耗时,也能通过
RequestContextHolder拿到当前请求的HttpServletRequest。
4.2 工程结构和核心依赖
项目基于Spring Boot 2.7.x,Maven依赖里除了常规的spring-boot-starter-web外,必须引入AOP模块:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>spring-boot-starter-aop实际上会自动引入spring-boot-starter(已经被web依赖带进来了)、spring-aop、aspectjweaver等关键jar包,有了它才能在Spring Boot项目里使用@Aspect注解。这里有个小知识点:如果在传统Spring XML项目或者老Spring注解项目里,还需要显式加@EnableAspectJAutoProxy;但在Spring Boot中,AOP的自动配置类AopAutoConfiguration已经默认开启了AspectJ注解的支持,所以不需要手动加这个注解。
工程结构如下:
src/main/java/com/example/aopdemo ├── AopDemoApplication.java ├── annotation │ └── OperationLog.java ├── aspect │ ├── LoginCheckAspect.java │ └── OperationLogAspect.java ├── controller │ └── AdminController.java ├── vo │ └── Result.java └── util └── UserContext.java4.3 完整代码逐段拆解
第一步:定义一个操作日志注解
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OperationLog { String value() default ""; }这里必须注意@Retention要设置为RUNTIME,否则运行时反射拿不到注解,切面就找不到该拦截哪些方法了。@Target限定注解只能添加在方法上,避免使用者误加到类上导致切面匹配失败。
第二步:统一返回结果类
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "success"; result.data = data; return result; } public static <T> Result<T> fail(String message) { Result<T> result = new Result<>(); result.code = 401; result.message = message; return result; } }返回结构的统一是后面登录校验切面能否“优雅拦截”的关键——未登录时我们能构造一个规范的Result返回给前端,而不是直接抛异常导致前端收到500。
第三步:最简单的控制器
@RestController @RequestMapping("/admin") public class AdminController { @GetMapping("/user/list") @OperationLog("查询用户列表") public Result<List<String>> listUser() { // 模拟业务执行 return Result.success(Arrays.asList("张三", "李四", "王五")); } @GetMapping("/user/detail") @OperationLog("查询用户详情") public Result<String> userDetail(@RequestParam Long userId) { return Result.success("用户详情:" + userId); } @GetMapping("/dashboard") public Result<String> dashboard() { return Result.success("工作台数据"); } }注意看,这个Controller里一点关于登录和日志的代码都没有,这就是AOP“无侵入”特点的最好体现。
第四步:登录校验切面
@Aspect @Component public class LoginCheckAspect { // 拦截AdminController下的所有方法 @Around("execution(* com.example.aopdemo.controller.AdminController.*(..))") public Object checkLogin(ProceedingJoinPoint pjp) throws Throwable { // 从当前请求上下文中获取Request和Session ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes == null) { return Result.fail("请求上下文不存在"); } HttpServletRequest request = attributes.getRequest(); HttpSession session = request.getSession(false); Object loginUser = session == null ? null : session.getAttribute("loginUser"); if (loginUser == null) { return Result.fail("未登录,请先登录"); } // 把登录用户信息放到本地线程变量,方便业务代码取用 UserContext.set(loginUser); try { return pjp.proceed(); } finally { UserContext.clear(); } } }这里我特意用了request.getSession(false),目的在于避免“为检查登录状态而强制创建Session”的反向副作用——如果你用getSession(true),就算用户没登录,服务器也会给这个请求创建一个无用的Session,白白浪费内存,还产生额外的协同处理开销。这是很多新手容易忽略的优化点。
第五步:操作日志切面
@Aspect @Component public class OperationLogAspect { private static final Logger logger = LoggerFactory.getLogger(OperationLogAspect.class); // 拦截所有标注了@OperationLog的方法 @Around("@annotation(com.example.aopdemo.annotation.OperationLog)") public Object recordLog(ProceedingJoinPoint pjp) throws Throwable { MethodSignature signature = (MethodSignature) pjp.getSignature(); Method method = signature.getMethod(); OperationLog operationLog = method.getAnnotation(OperationLog.class); String operationDesc = operationLog.value(); // 从RequestContextHolder里取用户和IP ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); String username = "anonymous"; String ip = "unknown"; if (attributes != null) { HttpServletRequest request = attributes.getRequest(); ip = getClientIp(request); Object loginUser = request.getSession(false) == null ? null : request.getSession(false).getAttribute("loginUser"); if (loginUser != null) { username = loginUser.toString(); } } long startTime = System.currentTimeMillis(); try { Object result = pjp.proceed(); long costTime = System.currentTimeMillis() - startTime; logger.info("操作日志 => 用户: {}, 操作: {}, IP: {}, 参数: {}, 耗时: {}ms", username, operationDesc, ip, Arrays.toString(pjp.getArgs()), costTime); return result; } catch (Throwable e) { logger.error("操作日志 => 用户: {}, 操作: {}, IP: {}, 异常: {}", username, operationDesc, ip, e.getMessage()); throw e; } } private String getClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } // X-Forwarded-For格式为 client, proxy1, proxy2,取第一个 if (ip != null && ip.contains(",")) { ip = ip.split(",")[0].trim(); } return ip; } }@annotation(com.example.aopdemo.annotation.OperationLog)这个切点表达式表达了“只要方法上标注了指定注解,就拦截”,非常灵活。注意日志切面在方法抛异常时,一定要throw e把异常原样往外抛,否则异常被吞掉,业务方收不到失败信号,这是切面代码里的大忌。
4.4 切点表达式的选择:execution vs @annotation
从我上面两个切面可以看出,切点表达式可以走两条主流路线,实际项目中经常会组合使用:
- execution表达式:按方法所在包、类、方法签名来定位,适合“对某个范围全体生效”的场景。标准写法:
execution(修饰符 返回值类型 包名.类名.方法名(参数类型列表)),支持通配符*和..。比如execution(* com.example.service.*.*(..))表示拦截service包下所有类的所有方法;execution(* com.example.service..*.*(..))表示拦截service包及其子包下所有类的所有方法。 - @annotation表达式:按方法上的注解定位,适合“只对显式标记的方法生效”的场景。比如只有加上
@OperationLog的方法才记录日志,精确到方法级。 - @within表达式:按类上的注解定位,表示拦截所有被某个注解标注的类的所有方法。
实际开发中,如果需求是“该包下所有接口都要做登录认证”,用execution更合适;如果需求是“记录日志的接口要灵活控制”,用自定义注解更优雅。两种可以同时存在,比如先定义一个切面用execution实现全局登录校验,另一个切面用@annotation实现操作日志,两个切面互不干扰,这正是AOP模块化的魅力。
4.5 切面优先级与执行顺序:@Order带来的确定性
两个切面同时切入一个方法时,谁先执行?默认情况下Spring会根据切面类的类名排序等不确定因素来确定顺序,这在生产环境是非常危险的——如果登录校验切面和日志切面顺序搞反,日志切面就会先被执行,导致未登录的用户请求也被记录为“正常操作”。解决办法是用@Order注解显式控制优先级。
@Aspect @Component @Order(1) public class LoginCheckAspect { ... } @Aspect @Component @Order(2) public class OperationLogAspect { ... }编号越小优先级越高。@Order(1)的登录校验先执行,校验通过后才进入@Order(2)的日志切面,再由日志切面调用proceed()进入真正的业务方法。这里有一个重要的执行顺序细节:多个切面嵌套时,前置逻辑按@Order从小到大执行,后置逻辑按@Order从大到小执行(类似洋葱模型,一层层往里剥,处理完再一层层往外走)。面试官很喜欢把这个跟SpringMVC的过滤器链执行顺序放在一起考,你理解了这个模型,其他框架的拦截器原理也能很快通。
5. 常见问题与排查技巧实录:这些坑我全踩过
5.1 切面不生效:方法内部this调用导致代理失效
这是一个几乎人人都踩过的坑,我当时也困惑了很久。看下面这段代码:
@Service public class OrderService { public void createOrder() { // 新增库存,这里是this调用自身类中的另一个方法 this.decreaseStock(); } @LogAop public void decreaseStock() { // 扣减库存 } }你期望decreaseStock方法上的@LogAop生效,结果发现调用createOrder时,decreaseStock上的日志逻辑压根没有执行。为什么?因为Spring把增强后的代理对象注入了外部调用方,但**this引用指向的是原始对象,而不是代理对象**,所以this.decreaseStock()走的是原始方法,自然不经过切面。
解决方案有三个:
- 通过
AopContext.currentProxy()拿到当前代理对象,然后调用其方法,但这需要在启动类上开启@EnableAspectJAutoProxy(exposeProxy = true),否则拿不到代理。 - 把自身方法调用单独抽到一个类中,通过容器注入那个类的代理对象再调用(比如新建一个
StockService,OrderService注入StockService)。 - 大多数场景下,这件事之所以发生,是因为你把本该由不同职责类处理的方法堆在了同一个类里。拆类才是更干净的做法。
设计一段代码演示AopContext方案:
@Service public class OrderService { public void createOrder() { // 通过AopContext拿到代理对象 ((OrderService) AopContext.currentProxy()).decreaseStock(); } @LogAop public void decreaseStock() { // 扣减库存 } }注意此方案需要启动类添加@EnableAspectJAutoProxy(exposeProxy = true)。
5.2 切面不生效:Spring Boot 2.x默认CGLIB带来的final方法问题
CGLIB本质是生成目标类的子类,所以被增强的类如果被final修饰、或者目标方法被final修饰,那就没法重写,切面自然无效。另外private方法是不能被代理的,因为子类无法重写私有方法。所以如果发现某个方法切面没生效,先检查它的修饰符:public>protected>private,而且不能是final。
还有一种情况是同一个类中方法A调用方法B,B本身是private,你想在B上加@Before来增强A的行为——这个是绝对行不通的,因为B根本不会走代理。
5.3 切面生效但异常处理乱了:@AfterThrowing和@Around同时存在时的执行顺序
这个问题比较隐蔽。假设你同时写了@Around和@AfterThrowing,而@Around内部又自己catch了异常没有往外抛,那@AfterThrowing永远收不到异常通知。看这个例子:
@Around("execution(* com.example.service.*.*(..))") public Object aroundAdvice(ProceedingJoinPoint pjp) { try { return pjp.proceed(); } catch (Exception e) { // 记录了异常日志,但没有重新抛出 return Result.fail(e.getMessage()); } } @AfterThrowing(value = "execution(* com.example.service.*.*(..))", throwing = "e") public void afterThrowing(JoinPoint joinPoint, Exception e) { // 这里的代码不会执行,因为异常被@Around吞掉了 }所以你要非常清楚地意识到:异常处理的通知之间,是否抛出异常决定了下一个通知能否被触发。如果熔断了异常又不重新throw,上层的@AfterThrowing就会“失明”。最好的实践是:在@Around中捕获异常,根据业务需要转换结构,但最终用throw new RuntimeException(...)或者以其他方式让异常链路继续传播,除非你确实需要熔断。
5.4 切点表达式一直匹配不上:最容易忽略的包名写错
初学者最常犯的错误是切点表达式里的包名写得跟实际代码不完全一致。execution(* com.example.service.*.*(..))中的*匹配的是一个单词,不会跨包匹配子包下的类。如果你的Service在com.example.service.impl包下面,上面那个表达式匹配不到。
正确的写法应该是:
// 匹配service子包下所有类 @Around("execution(* com.example.service..*.*(..))")注意service..*表示service包及任意子包。这个小细节超容易踩坑,我当初排查了半小时,结果就是少了一个点。建议你在编写切点后,先在日志里确认拦截的方法数量是否符合预期,不要等到运行时才发现。
可以临时加一个类似下面的日志来验证:
@PostConstruct public void checkAspect() { // 启动时打印切面是否被扫描到,以及切点匹配的方法列表 // 实际项目中可以通过AOP工具类或者断点查看 System.out.println("切面已加载: " + this.getClass().getSimpleName()); }5.5 在切面中使用异步线程:RequestContextHolder可能为null
我踩过的另外一个实战坑:在@Around里把日志的持久化操作丢到了异步线程池中,结果异步线程里再去拿RequestContextHolder.getRequestAttributes()时,拿到的全是null。原因很简单,RequestContextHolder默认绑定的是当前线程的ThreadLocal数据,异步线程根本拿不到主线程的Request对象。
解决办法只有一条:在进入异步线程之前,把必要的数据(用户名、IP、参数、耗时)先提取出来存成局部变量,然后再丢到线程池里处理。千万不能在异步任务里去获取与请求绑定的上下文。如果确实需要在异步线程中用到用户信息,建议用UserContext这类自定义的ThreadLocal工具,或者在你的异步线程启动时显式传递。
用一个最精简的例子说明正确做法:
@Around("@annotation(com.example.aopdemo.annotation.OperationLog)") public Object logAsync(ProceedingJoinPoint pjp) throws Throwable { // 在当前线程准备好数据 String username = getUsernameFromRequest(); Object[] args = pjp.getArgs(); long start = System.currentTimeMillis(); Object result = pjp.proceed(); long cost = System.currentTimeMillis() - start; // 把数据传进异步任务,绝不在异步任务里拿RequestContextHolder asyncLogExecutor.execute(() -> saveLog(username, args, cost)); return result; }这个细节也很考验实际经验,纸上谈兵的同学很容易忽略。
5.6 AOP与Spring事务的“相爱相杀”:代理失效导致事务不回滚
AOP和@Transactional在Spring底层都依赖动态代理,所以它们的失效原因是一致的。最常见的一个案例:OrderService的createOrder方法标注了@Transactional,方法内部捕获了异常没有抛出去,事务管理器感知不到异常,自然不会回滚。这在非AOP场景下同样成立,但很多人因为知道了AOP后,又容易把锅甩给AOP。
另外一个高频场景是同类调用导致的@Transactional失效——你调用this.process(),内部虽然标注了@Transactional,但因为走的是原始对象而非代理对象,事务不会生效。这个和第一小节“方法内部this调用导致代理失效”是一模一样的原理。可见动态代理原理是否真的理解,直接决定了这些疑难杂症你能不能快速定位。
5.7 常见问题速查表
我把上述问题整理成一张速查表,方便你阅读时把重点提炼出来:
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 切面不生效,但启动没报错 | 切点表达式包名/注解写错;类没被Spring管理(缺@Component) | 检查切点表达式,确认目标Bean被容器扫描 |
| 只拦截了部分方法 | 切点表达式使用了*但没匹配子包 | 改用..*匹配所有子包 |
| 同类内部方法调用不增强 | this调用走原始对象,不走代理 | 用AopContext.currentProxy()或拆类 |
| 类或方法被final修饰无法增强 | CGLIB无法继承final类、重写final方法 | 去掉final修饰 |
| @AfterThrowing一直没有执行 | 异常被@Around吞掉未抛出 | 在@Around中合理重新抛出异常 |
| 异步线程拿不到RequestContext | ThreadLocal数据不跨线程 | 提前提取数据,传入异步任务 |
| 自定义注解找不到 | @Retention没有设为RUNTIME | 检查注解定义 |
6. 我从这个项目里学到的AOP实战心得
这块不是官方文档会告诉你的东西,全是我自己一点一点试错、翻源码、看Spring文档积累出来的个人判断,分享出来希望能帮大家少走弯路。
第一个感受是:AOP是给“稳定的横切关注点”用的,不是给你频繁变更业务的捷径。如果你把特别不稳定的业务逻辑(比如商品价格计算规则)写进AOP里面,后期这个规则一变,你就要去改切面代码,比改业务代码更麻烦,因为你可能根本想不到这些逻辑藏在切面里。AOP的核心使用面就应该是那几个非常稳定且横切的关注点——认证鉴权、日志埋点、事务控制、性能监控、多数据源切换、分布式锁、接口幂等等。记住这个边界,你就知道哪些代码适合放切面,哪些代码应该老老实实留在Service里。
第二个感受是:要学会活用多个切面,而不是把啥都塞进同一个@Around。项目里我见过一个巨无霸切面,一个方法内同时做了登录校验、日志记录、耗时监控、数据权限过滤、缓存清理,最后那一坨几百行代码谁也改不动。正确的做法是切成多个切面,每个切面职责单一,用@Order控制优先级。这样排查问题的时候,关闭一个切面或者调整一个切面的顺序都很方便,现代代码强调可维护性,AOP领域一样如此。
第三个感受是:调AOP的bug,建议先确认“代理有没有生效”。很多人一遇到切面不工作,就以为是表达式写错,在代码里翻来覆去找半天。我的排查路径第一站永远是看Spring日志或者用断点查看被注入的Bean到底是原始类还是代理类。比如idea里看变量类型,如果显示OrderService$$EnhancerBySpringCGLIB$$abcdef,说明代理生效了;如果显示还是OrderService,说明根本没走到代理生成那一步,这时候问题大概率在组件扫描、切面声明或者代理配置上。确定代理存在之后,再去查切点表达式和通知逻辑,效率至少翻倍。
第四个感受是:纯AOP技术本身其实不难,难的是把AOP和其他框架特性结合起来理解。比如Spring事务就是一个用AOP实现的典型横切逻辑,Spring MVC拦截器也跟AOP是兄弟机制,分布式锁用AOP实现比手动硬编码优雅得多。当你能把AOP当成一种思维模型去套用,才真正达到了“后端进阶”的水平。
最后说一个特别实用的小技巧:如果你想暂时禁用某个切面做调试,不用去注释代码然后重启,可以在application.yml里配置一个开关,然后在切面注解处用@ConditionalOnProperty控制切面Bean是否加载:
aop: login-check-enabled: true operation-log-enabled: true@Component @Aspect @ConditionalOnProperty(name = "aop.login-check-enabled", havingValue = "true") public class LoginCheckAspect { }这样在生产上你依然开着开关,本地调试或者线上排查异常时可以临时关闭,不用改动业务代码,也不用反复重启大改配置。这种开关思路在Spring Boot项目中非常实用,AOP场景尤其适合——毕竟切面的隐形特性决定了它一旦出问题,定位成本比普通业务代码高得多,能在关键时刻一键关掉,有时候能救你于水火。
AOP这个主题我学习断断续续两轮,第一轮看视频看完感觉懂了,一写全废;第二轮从源码和实际案例入手,才算真正融会贯通。如果你也正在学这部分内容,建议不要急着撸框架源码,先把我这套案例里的代码全部手打一遍,然后犯错、排查、修正,这个过程比看任何教程都有用。把AOP的“动态代理”四个字刻进脑子里,你之后的Spring事务、Spring拦截器、甚至很多中间件原理学起来都会一路畅通。