先说一个我真实踩过的坑。几年前我给一个内部系统加操作日志,需求很朴素:所有Service方法记录调用参数、耗时、异常信息。我第一版写得很老实,每个方法里手写日志,粘了几十遍,改到第三个模块就开始怀疑人生了——同样的代码重复出现,漏掉几个方法根本不知道。后来同事提醒我:Spring不是有AOP吗?你用切面啊。我这才去认真啃AOP,啃完才发现以前被那些术语吓住了。
AOP,全称Aspect Oriented Programming,面向切面编程。说人话就是:把日志、事务、权限、性能统计这种“横着贯穿”的逻辑从业务方法里抽出去,由框架在运行时统一织入。这篇文章不让你背概念,我从“它到底解决了什么问题”开始讲,然后把Spring AOP的代理机制拆开看,最后带你把一个能落地的日志切面完整写出来,再盘点几个我实际使用中遇到的坑。适合刚学完Spring基础、对AOP一直“半懂不懂”的读者,也适合准备在项目里引入统一日志、权限、限流的开发者。
1. 先想明白一件事:AOP到底帮你做了什么
1.1 从一段没有AOP的日子说起
假设你要写一个下单接口,业务代码可能长这样:
public Order createOrder(OrderDTO dto) { // 1. 校验用户权限 checkAuth(); // 2. 开启事务 beginTransaction(); try { // 3. 核心业务逻辑 Order order = doCreateOrder(dto); // 4. 记录操作日志 log.info("下单成功: {}", order.getId()); // 5. 提交事务 commit(); return order; } catch (Exception e) { rollback(); log.error("下单失败", e); throw e; } }问题很明显:真正跟业务强相关的只有第3行代码,其他全部是“配角”逻辑。它们不会只出现在下单方法里,而是出现在所有业务方法里,登录要校验、查询要校验、删除要校验、更新要校验。这种跨模块遍布的公共需求,业内叫横切关注点。
横切关注点跟业务逻辑的关系是“竖直交织”的:业务方法往下走,日志、事务、权限从旁边横着切进来。你在每一个方法里手写一遍,代码就会疯狂膨胀。更要命的是,这些代码一旦散落各处,改造成本和遗漏风险会随着系统变大而指数级上升。AOP的核心价值,就是把这些横切逻辑抽出来放到一个独立位置,然后声明“这些方法需要插入”,剩下的交给运行时处理。
1.2 为什么抽个工具类、继承一下、装饰一下都不够
有朋友肯定会说:抽一个公共方法,每个业务方法里调用一下不就行了?确实能减少重复代码,但本质上还是侵入式的,业务方法仍然“知道”日志逻辑的存在,还必须在正确的位置手动调用。手动意味着可能遗忘,遗忘意味着功能静默丢失。我在真实项目里就见过因为新同事漏写了一行调用,导致某条链路完全没有日志,最后排查了半天。
继承的方式也有局限。把公共逻辑放到父类模板方法里,会让子类为了横向逻辑去纵向继承,这个类本来不需要任何父类,结果为了日志被迫继承一个BaseService,时间一长整个继承树变得特别臃肿,而且改父类影响面极大。
装饰器模式倒是可以拦截,但需要给每个类都写一个包装类,模板代码量降不下来。
AOP的解法是完全声明式的:在切面里写清楚“哪些方法、什么时机、执行什么逻辑”,业务类不用动任何一个字符。后面新加一个Service方法,只要它符合切点表达式,切面自动就生效。这个“解耦”是其他手段给不了的,也是AOP存在的根本原因。
2. 五个术语别硬背,把场景代入就记住了
2.1 连接点与切点:一个是大全集,一个是筛选条件
很多教程一上来甩五个名词:Join Point、Pointcut、Advice、Aspect、Weaving,然后要求背下来。我当年就是这么被劝退的。现在换个说法。
程序运行过程中,每个方法的每一次调用,都可以看作一个“时刻”,这个“时刻”就是一个连接点(Join Point)。它是全集概念,因为方法数量太多了,不可能每一个都处理一遍。
切点(Pointcut)是筛选条件。它用表达式挑出你真正关心的一批连接点。比如execution(* com.demo.service..*.*(..))表达的意思是:匹配com.demo.service包及其子包下所有类的所有方法,参数任意。被切点选中的那些方法,将来才可能被切面逻辑影响。
这样记就不会乱:连接点是“候选大名单”,切点是“最终入选名单”。看到Pointcut就想起“表达式”,看到Join Point就想起“被匹配的方法执行瞬间”。
2.2 五类通知:运行时机不同,名称不同
通知(Advice)是切点命中后要执行的代码,按时机分成五种。它们最大的区别是“什么时候跑”、“能不能控制目标方法执行”。我用一张表给你列明白:
| 通知类型 | 执行时机 | 典型场景 | 能否调用目标方法 |
|---|---|---|---|
| @Before | 目标方法调用前 | 权限校验、参数预校验 | 不能 |
| @After | 目标方法结束后(无论正常还是异常) | 清理资源、收尾日志 | 不能 |
| @AfterReturning | 目标方法正常返回后 | 记录返回值、发送通知 | 不能 |
| @AfterThrowing | 目标方法抛出异常后 | 异常监控、告警 | 不能 |
| @Around | 包裹目标方法的整个调用过程 | 性能统计、事务、限流 | 能,通过proceed() |
最容易搞混的是@After和@AfterReturning。打个比方:@After对应try-finally里的finally,不管方法成功还是抛异常都会执行;@AfterReturning则只在正常返回后执行。如果你用@After记录“操作成功”,异常场景也会被记成功,这就是埋雷。
@Around是最灵活的,相当于把整个方法调用包在中间,你可以决定先做什么、什么时候调用目标方法、拿到结果后做什么、异常怎么处理。但它也是最容易出事的,后面我专门讲。
2.3 切面与织入:把规则和动作打包,再缝进业务
切面(Aspect)就是把切点表达式和通知代码打包在一起的一个类。它同时回答两个问题:在哪儿干活(切点)、干什么活(通知)。比如你定义一个LogAspect,里面写了匹配service包的方法,又写了耗时日志逻辑,这就是一个完整切面。
织入(Weaving)是把切面应用到目标对象、生成代理对象的过程。想成裁缝把线缝进布就行了。Spring AOP的织入发生在运行期:容器创建Bean时发现这个Bean的方法被切点匹配,就返回一个代理对象而不是原始对象。业务代码里注入一个Service时,拿到手的有可能就是代理对象,只是你感知不到,方法一调用切面逻辑就自动执行了。
理解到这层就够了。后面在源码里看到JoinPoint、Pointcut这些词汇,你不会觉得那是天书。
3. 理解Spring AOP绕不开的一件事:代理机制
3.1 没有代理,切面代码怎么插进编译好的类
先想一个底层问题:Spring怎么在方法调用前后插入代码?它不能修改你编译好的class文件,所以只能在运行期换一个对象,这就是代理。
代理对象是目标对象的“外壳”,调用方拿到的其实是这个外壳。外壳和目标类实现相同接口,或者继承目标类。方法一调,壳把控制权交给某个拦截器,由拦截器决定先执行切面逻辑、再调用真实目标方法、最后还要做什么。
可以把代理理解成房产中介:你想联系房东(目标对象),但实际对话的是中介(代理对象)。中介能在房东出场前后帮你加一层服务,比如记录看房时间、核验身份。Spring AOP的整个机制就是围绕这个中介展开的。理解了代理,AOP后面对你来说就是一层窗户纸。
3.2 JDK动态代理:前提是目标类必须有接口
JDK动态代理是Java自带的方案,核心是Proxy.newProxyInstance。它要求目标类必须有接口,生成的代理类也实现了同样的接口。
写一个极简示意:
public class LogInvocationHandler implements InvocationHandler { private final Object target; public LogInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { long start = System.currentTimeMillis(); Object result = method.invoke(target, args); System.out.println("耗时: " + (System.currentTimeMillis() - start)); return result; } }使用时通过Proxy.newProxyInstance(target.getClass().getClassLoader(), target.getClass().getInterfaces(), handler)生成代理。
这里藏着一个年代久远的坑:如果代码里把注入类型写成实现类而不是接口,容器返回的是代理对象,它实现的接口而不是你的实现类,强转时直接抛ClassCastException。老一批Spring开发者几乎都踩过这个。
3.3 CGLIB代理:用生成子类的方式搞定无接口类
CGLIB是运行期生成目标类子类,子类覆写目标方法,在覆写逻辑里插入切面代码,底层通过ASM操作字节码。它不要求接口,所以适用于大量根本没有接口的Service类。
但CGLIB也有局限:final类没法代理,final方法没法覆写,对final方法加切面会静默失效。还有,目标类里非public方法也无法被代理。
这里得纠正一个过时认知:很多老教程说“Spring默认有接口用JDK,没接口用CGLIB”,那是Spring Boot 1.x时代的老黄历了。从Spring Boot 2.x开始,AOP默认配置已经强制使用CGLIB,因为现在的业务项目里没有接口的Service类太常见,继续用JDK动态代理反而容易出注入类型问题。
3.4 Spring如何选择代理方式,以及性能怎么看
Spring的选择逻辑概括为:如果设置了proxyTargetClass=true,强制CGLIB;否则看目标是否实现了接口,有接口用JDK,没接口用CGLIB。Boot的AOP自动配置通常就是那个“强制CGLIB”。
关于性能,两个代理创建阶段有差异:JDK动态代理创建快,但早期对接口方法反射调用偏慢;CGLIB创建代理时要生成字节码,有一次性开销,但调用期经过优化后并不差。对绝大多数业务系统来说,这个性能差距根本感知不到,选型不用纠结,理解底层原理防止踩坑才更重要。
4. 实操:把日志切面写出来并落地
4.1 依赖引入与AOP开启方式
Spring Boot项目里加一个依赖就够了:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>Boot的自动配置会帮你开启AOP支持,不需要额外写@EnableAspectJAutoProxy。如果你还在用传统Spring XML项目,需要先加<aop:aspectj-autoproxy/>或者用注解开启。这是新手最容易困惑的地方:在Boot里怎么不写任何开关就能用?答案是starter里的自动配置类已经把活干完了。
4.2 定义切点与环绕通知,做最简单的耗时统计
直接上一个模板,把这段放进你的项目就能跑:
@Aspect @Component public class ServiceLogAspect { private static final Logger log = LoggerFactory.getLogger(ServiceLogAspect.class); @Pointcut("execution(* com.demo.service..*.*(..))") public void servicePointcut() { } @Around("servicePointcut()") public Object around(ProceedingJoinPoint pjp) throws Throwable { String methodName = pjp.getSignature().getDeclaringTypeName() + "." + pjp.getSignature().getName(); long start = System.currentTimeMillis(); Object result; try { result = pjp.proceed(); return result; } finally { long cost = System.currentTimeMillis() - start; log.info("方法[{}]耗时[{}]ms", methodName, cost); } } }几个关键点:
- @Pointcut方法本身不需要具体逻辑,它只是切点表达式的载体。
- ProceedingJoinPoint只能在@Around里使用,它比普通JoinPoint多了一个
proceed()方法,负责放行并调用目标方法。不调用它,目标方法根本不会执行。 - 我用finally包裹是为了确保异常情况下也能记录耗时。如果只在正常返回后记录,异常的时候日志就丢了。
这里可以现场验证:任意在com.demo.service包下写一个普通Service,调用一下,控制台立刻打印耗时。业务代码里一个字符都不用动。
4.3 从包路径切面升级为注解驱动切面
固定的包路径切面粒度还是有点粗。实际项目里经常会遇到这种需求:只想给某些核心操作记录操作日志,不想给每一个查询方法都记录。这时候最优雅的方案是自定义注解。
先定义一个注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OperLog { String value() default ""; }再改造切面:
@Around("@annotation(operLog)") public Object around(ProceedingJoinPoint pjp, OperLog operLog) throws Throwable { String methodName = pjp.getSignature().getDeclaringTypeName() + "." + pjp.getSignature().getName(); log.info("操作类型: {}, 方法: {}", operLog.value(), methodName); return pjp.proceed(); }Spring会把匹配到的那个注解实例自动注入到方法参数里,这样你就能在切面中读到注解上的属性。业务方法上只加一行@OperLog("创建订单")即可,侵入性控制得相当好。这套“注解标记+切面匹配”的组合,是我在真实项目中用的最多的方案,比按包路径切面灵活得多。
4.4 切点表达式速查表,避免写出匹配失败的规则
execution表达式的结构是:修饰符、返回类型、包路径、类名、方法名、参数。
常用写法:
- 拦截包下所有类的所有方法:
execution(* com.demo.service..*.*(..)) - 拦截某个类的所有方法:
execution(* com.demo.service.OrderService.*(..)) - 拦截某个具体方法:
execution(* com.demo.service.OrderService.createOrder(..)) - 拦截类上带指定注解的所有方法:
@within(org.springframework.stereotype.Service) - 拦截方法上带指定注解的方法:
@annotation(com.demo.annotation.OperLog)
多个表达式可以用||(或)、&&(与)、!(非)组合。
最容易写错的是括号里的参数匹配:..代表任意参数;(String, Integer)代表两个固定参数;(*)代表任意一个参数。返回类型和包名写错也会导致静默不匹配。这里多说一句:切点表达式匹配不到任何方法时,Spring不会报错,程序一切正常,只是切面永远不触发。这个特征非常坑,后面排查章节细说。
5. 踩坑实录:AOP不生效时,从这四个方向排查
5.1 同类内部自调用:切面直接失联
这是Spring AOP最经典的大坑。场景是:OrderService里createOrder方法内部调用了同一个类的updateStock方法,你给updateStock加了@Transactional或自定义切面,结果发现完全没有生效。
原因就是代理。代理拦截的是外部对目标对象的调用,但当createOrder用this.updateStock()时,这个this是原始对象,不是容器里那个代理对象,切面逻辑根本没有进入的机会。
解决方案有三个:
- 最推荐:把updateStock挪到另一个Service里,由createOrder注入那个Service调用。跨Bean调用一定走代理,事务和切面都正常。
- 在类里注入自己:
@Autowired private OrderService self;,然后self.updateStock()。 - 开启exposeProxy后,用
((OrderService) AopContext.currentProxy()).updateStock()。
我的真实体会是:方法不生效时,第一反应应该看这个调用是从本类内部发出的,还是从别的Bean发出的。拆出去不只是为了修复问题,更符合单一职责。
5.2 私有方法和final方法:代理根本插不上手
CGLIB通过生成子类并覆写方法来代理,private方法对子类不可见,final方法禁止覆写,这两种情况在运行期都不会有切面效果。有时切点表达式明明匹配了,日志却不输出,就优先查这类结构问题。
遇到“必须给私有方法加切面”的需求,我的建议是重新审视设计:这个方法是不是职责放错了位置?把它提升为public并放到合适的类中,往往才是正解,而不是跟代理机制对着干。
5.3 切点表达式没匹配上,系统还静默运行
前面提过,Spring对空匹配的切点不报错。我第一次写切面就栽在这上面:表达式里一个包名拼错了,程序正常启动,业务正常跑,就是没有任何日志,排查了很久。
这里分享两个调试技巧:
- 在Around方法第一行加日志,输出当前拦截到的方法全名,立刻知道切面有没有进来。
- 直接检查容器中Bean的实际类型:如果注入的Service实际类型是
OrderService$$EnhancerBySpringCGLIB$$xxxx这种结构,说明代理已生成;如果就是OrderService本体,说明代理没生效,从Bean注册和AOP自动配置查起。
5.4 环绕通知里的proceed()是总开关,别乱吞异常
@Around里忘记调用proceed(),目标方法直接“蒸发”,这不是报错,而是整个方法不执行。另一个隐蔽问题是:你在Around里捕获了异常又没重新抛出,事务切面感知不到异常,就不会触发回滚。这种事我亲眼见过同事排查了很久,最后发现是环绕通知把异常吞了。
保持一个简单可靠的写法:如果Around里没有特殊需求,就直接return pjp.proceed(),不要画蛇添足地加try/catch。如果你需要记录异常,catch住之后也要重新throw,让上层或事务切面继续处理。
5.5 多个切面的执行顺序:用@Order控制
一个方法可能同时命中日志切面、事务切面、权限切面。它们之间的先后顺序靠@Order注解控制,数值越小越先执行。比如权限切面必须最先进入,事务其次,日志放最后,这样日志记录的时间才包含权限校验和事务提交的完整耗时。
测试顺序有个小技巧:在每个切面里打印一个标识,比如aspect order = 1,看控制台输出顺序就能判断排序是否正确,不用瞎猜。
最后说点实在的。我自己当初学AOP,第一遍对着教程硬背术语,背完就忘。后来把“代理”这两个字想通了,整个AOP的图景一下子就清晰起来——所谓AOP,本质上就是Spring在运行期换了一个代理对象,这个代理在方法调用时拦截一下、处理一下、再放行。五个概念,其实是一套流程的五个环节,不是让你死记硬背的名词列表。
再留一个小技巧:调试切面不生效时,最省事的办法是在切面里打断点,然后看Idea Variables面板里当前Bean实例的完整类型。如果类型是JdkDynamicAopProxy或CglibAopProxy相关结构,说明代理已经生成,问题多半在切点表达式;如果直接就是你的原始Service对象,说明代理压根没创建,从Bean注册和AOP自动配置开始查。这个办法帮我省了不知道多少排查时间,今天一并分享给你。