
1. 为什么说AOP是后端进阶的第一道坎从“重复代码地狱”说起如果你跟我一样是从Java Web开发一路走过来大概率经历过这样一种状态Controller里写了十几二十个接口每个接口都有一段几乎一模一样的“开始计时、校验参数、打印日志、执行方法、打印耗时”代码。更别提那些分布在全项目各处的“登录校验”“权限判断”“事务控制”逻辑——你以为是写在了Service里实际上是一个方法一个方法地在重复粘贴。我当初在跟着黑马的程序员课程走Java Web后端进阶部分的时候第一次接触到AOP这个概念第一反应是“这不就是拦截器吗Spring MVC不早有了吗”后来认真把所有原理吃透才发现自己之前的理解太浅了。AOPAspect 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 AOPSpring框架自己实现的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动态代理基于继承的代理CGLIBCode Generation Library走的是另一条路它直接在运行时生成目标类的子类子类通过重写父类的方法来实现增强。因为是基于继承所以CGLIB可以代理没有实现接口的普通类。底层用的是ASM字节码生成技术性能较好。CGLIB的增强逻辑里有一个关键类MethodInterceptorpublic 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.xMaven依赖里除了常规的spring-boot-starter-web外必须引入AOP模块dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependencyspring-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 ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT fail(String message) { ResultT result new Result(); result.code 401; result.message message; return result; } }返回结构的统一是后面登录校验切面能否“优雅拦截”的关键——未登录时我们能构造一个规范的Result返回给前端而不是直接抛异常导致前端收到500。第三步最简单的控制器RestController RequestMapping(/admin) public class AdminController { GetMapping(/user/list) OperationLog(查询用户列表) public ResultListString listUser() { // 模拟业务执行 return Result.success(Arrays.asList(张三, 李四, 王五)); } GetMapping(/user/detail) OperationLog(查询用户详情) public ResultString userDetail(RequestParam Long userId) { return Result.success(用户详情 userId); } GetMapping(/dashboard) public ResultString 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)否则拿不到代理。把自身方法调用单独抽到一个类中通过容器注入那个类的代理对象再调用比如新建一个StockServiceOrderService注入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方法是不能被代理的因为子类无法重写私有方法。所以如果发现某个方法切面没生效先检查它的修饰符publicprotectedprivate而且不能是final。还有一种情况是同一个类中方法A调用方法BB本身是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中合理重新抛出异常异步线程拿不到RequestContextThreadLocal数据不跨线程提前提取数据传入异步任务自定义注解找不到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: trueComponent Aspect ConditionalOnProperty(name aop.login-check-enabled, havingValue true) public class LoginCheckAspect { }这样在生产上你依然开着开关本地调试或者线上排查异常时可以临时关闭不用改动业务代码也不用反复重启大改配置。这种开关思路在Spring Boot项目中非常实用AOP场景尤其适合——毕竟切面的隐形特性决定了它一旦出问题定位成本比普通业务代码高得多能在关键时刻一键关掉有时候能救你于水火。AOP这个主题我学习断断续续两轮第一轮看视频看完感觉懂了一写全废第二轮从源码和实际案例入手才算真正融会贯通。如果你也正在学这部分内容建议不要急着撸框架源码先把我这套案例里的代码全部手打一遍然后犯错、排查、修正这个过程比看任何教程都有用。把AOP的“动态代理”四个字刻进脑子里你之后的Spring事务、Spring拦截器、甚至很多中间件原理学起来都会一路畅通。