Spring AOP 入门
切面、通知类型;日志与权限横切关注点。
Spring AOP 入门
这一课你将能做到
- 给全部 Service 方法加上耗时统计切面,业务代码零改动
- 写对 execution 切点表达式,并用自定义注解 +
@annotation 精确控制拦截范围
- 复现并解释「内部调用导致切面/事务失效」的 self-invocation 问题
为什么要学
你需要在每个 Service 方法上打印「方法名 + 参数 + 耗时」。手写的话:几十个类、几百个方法,全是复制粘贴,改一次日志格式要全局替换。这类横切多个模块的通用逻辑(日志、计时、权限、事务、限流),就是 AOP(面向切面编程)的主场。
人话:AOP 把「通用动作」写成一份切面,容器在运行时用代理自动把它「包」在目标方法外面。你写 taskService.create(...),实际执行的是代理的 create(...),它先做日志、再调用你的真方法、再收尾。事务注解 @Transactional 的底层正是 AOP——学懂这课,后面第 11 课的「事务失效」你一眼就能看穿。
先建立图像
代理的执行模型(@Around 通知最直观):
调用方 taskService.create(task)
↓ 实际进入的是代理对象
┌─────────────── 代理 ───────────────┐
│ 前置动作(记录开始时间、日志) │
│ ↓ proceed() 放行 │
│ 你的真实 TaskService.create(...) │
│ ↓ 真方法返回/抛异常 │
│ 后置动作(计算耗时、异常日志) │
└────────────────────────────────────┘
核心术语人话对照:
| 术语 | 人话 |
|---|
| 切面 @Aspect | 装着「通用动作」的类 |
| 连接点 JoinPoint | 每一个可被拦截的方法调用点 |
| 切点 Pointcut | 用表达式圈出「拦哪些方法」 |
| 通知 Advice | 拦到之后干什么:@Before / @After / @Around 等 |
| 织入 Weaving | 把切面套到目标上的过程(Spring 用运行时代理实现) |
跟着做
步骤 1:引入 AOP starter
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
Spring Boot 检测到该依赖即自动开启 AOP(spring.aop.auto=true 是默认值,无需手动 @EnableAspectJAutoProxy)。
步骤 2:第一个切面——Service 全量耗时日志
先补一个真实的业务方法(配合前面课程的 TaskService):
package com.example.taskflow.service;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Service
public class ReportService {
public String generate(String type) throws InterruptedException {
Thread.sleep(150); // 模拟耗时
return "报表[" + type + "]-" + UUID.randomUUID().toString().substring(0, 8);
}
}
写切面:
package com.example.taskflow.aspect;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Arrays;
@Aspect // 我是切面
@Component // 切面本身也要交给容器
public class PerfLogAspect {
private static final Logger log = LoggerFactory.getLogger(PerfLogAspect.class);
// 拦截 service 包(含子包)下所有类的所有方法
@Around("execution(* com.example.taskflow.service..*.*(..))")
public Object timeLog(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
String target = pjp.getSignature().toShortString(); // 如 ReportService.generate(..)
try {
Object result = pjp.proceed(); // 放行:执行真实方法
long cost = System.currentTimeMillis() - start;
log.info("[性能] {} 参数={} 耗时={}ms", target, Arrays.toString(pjp.getArgs()), cost);
return result;
} catch (Throwable e) {
long cost = System.currentTimeMillis() - start;
log.error("[性能] {} 执行异常 耗时={}ms 异常={}", target, cost, e.getMessage());
throw e; // 异常必须继续抛出,不能吞
}
}
}
@GetMapping("/api/report")
public String report() throws InterruptedException {
return reportService.generate("日报");
}
[性能] ReportService.generate(..) 参数=[日报] 耗时=153ms
ReportService 一行没改,日志和计时已经全局生效——这就是 AOP 的价值。
步骤 3:读懂 execution 表达式
execution(修饰符? 返回类型 包名.类名.方法名(参数类型) 异常?)
| 表达式 | 含义 |
|---|
execution(* com.example.taskflow.service..*.*(..)) | service 包及子包下所有类的所有方法 |
execution(public * com.example.taskflow..*Service.*(..)) | 任何包中以 Service 结尾的类的 public 方法 |
execution(* com.example.taskflow.service.TaskService.create(..)) | 精确到某个方法(参数任意) |
within(com.example.taskflow.controller..*) | controller 包及子包下的所有连接点 |
记忆点:* 匹配一个段,.. 匹配任意层级(包)或任意个数参数。写宽了误伤性能,写窄了漏拦截,改完表达式要实际调用验证。
步骤 4:自定义注解 + @annotation——最常用的精确拦截
业务里更常见的需求是「只拦标记过的方法」。定义注解:
package com.example.taskflow.aspect;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AuditLog {
String action() default "";
}
@Aspect
@Component
public class AuditLogAspect {
private static final Logger log = LoggerFactory.getLogger(AuditLogAspect.class);
@Around("@annotation(auditLog)") // 参数名与切面方法参数名对应
public Object audit(ProceedingJoinPoint pjp, AuditLog auditLog) throws Throwable {
long start = System.currentTimeMillis();
try {
Object result = pjp.proceed();
log.info("[审计] 操作={} 方法={} 耗时={}ms", auditLog.action(),
pjp.getSignature().toShortString(), System.currentTimeMillis() - start);
return result;
} catch (Throwable e) {
log.warn("[审计] 操作={} 失败:{}", auditLog.action(), e.getMessage());
throw e;
}
}
}
@Service
public class TaskService {
@AuditLog(action = "删除任务")
public void deleteTask(long taskId) {
// 真实删除逻辑
}
}
这套「自定义注解 + @annotation 切面」是真实项目里最常用的 AOP 打开方式:权限校验、幂等控制、接口限流都长这样。
步骤 5:五种通知类型与执行顺序
@Aspect
@Component
public class OrderDemoAspect {
@Pointcut("execution(* com.example.taskflow.service.ReportService.*(..))")
public void reportPointcut() { } // @Pointcut 抽出表达式复用,方法名即切点引用
@Before("reportPointcut()")
public void before(JoinPoint jp) { System.out.println("[Before] 方法即将执行"); }
@AfterReturning(pointcut = "reportPointcut()", returning = "ret")
public void afterReturning(JoinPoint jp, Object ret) { System.out.println("[AfterReturning] 返回=" + ret); }
@AfterThrowing(pointcut = "reportPointcut()", throwing = "ex")
public void afterThrowing(JoinPoint jp, Exception ex) { System.out.println("[AfterThrowing] 异常=" + ex.getMessage()); }
@After("reportPointcut()")
public void after(JoinPoint jp) { System.out.println("[After] 方法已结束(finally 语义)"); }
}
正常返回时的顺序:Before → 真方法 → AfterReturning → After;抛异常时:Before → 真方法 → AfterThrowing → After。日常需求首选 @Around(既能前后包夹,又能改返回值、控异常);只需要「成功后做事」才用 AfterReturning。
步骤 6:复现 self-invocation——切面失效的第一大坑
在同一个类里,方法 A 内部调用方法 B(B 上有 @AuditLog):
@Service
public class TaskService {
@AuditLog(action = "归档任务")
public void archive(long taskId) {
System.out.println("归档中...");
}
public void batchArchive() {
this.archive(1L); // 内部调用 = this.archive,不经过代理!
}
}
调用 batchArchive() 时,archive 的审计日志不会打印。原因:切面织入在代理上,this.archive(...) 直接调的是真实对象,绕过了代理。三种解法(按推荐度):
- 拆类:把 archive 挪到另一个 Service,通过注入调用(顺带改善设计);
- 自注入代理:注入自己的代理
private final TaskService self;(构造器注入自身类型的 ObjectProvider 或 @Lazy 自身),用 self.archive(...);
AopContext.currentProxy()(需开启 exposeProxy = true,较少用)。
记住因果链:AOP 生效的前提是「调用经过代理」→ 一切「没生效」先查「这次调用走没走代理」。
讲清楚要点
- Spring AOP 是运行时代理,不是编译期织入:目标实现了接口默认 JDK 动态代理(接口反射实现),没接口用 CGLIB(生成子类)。这带来两个推论:代理只能拦外部调用;final 方法无法被 CGLIB 覆盖。
- 切面也是 Bean:@Aspect 必须配合 @Component(或 @Bean)注册进容器才生效——「写了切面没生效」第一检查项。
- @Around 的三要素缺一不可:
pjp.proceed() 放行、返回 proceed() 的结果、异常继续抛。少放行方法不执行,吞返回值调用方拿到 null,吞异常全局错误处理失效。
- 优先「注解式精确拦截」,慎用全包扫描:execution 全局表达式适合日志/性能这类真·全局关注点;有业务语义的动作(审计、幂等、限流)用自定义注解,意图显式、范围可控。
- 与 AspectJ 的区别要能说清:Spring AOP 只拦截 Spring Bean 的方法执行,运行时代理,够覆盖 95% 场景;AspectJ 是编译/加载期织入,能拦构造器、字段访问等更细粒度,需要额外织入器,特殊性能场景才引入。
你可能会懵的地方
-
切面抛出异常后,业务方法还执行吗?
分情况:@Around 里在 proceed() 之前抛异常,真方法不会执行(可用作权限拦截/限流);之后抛异常则真方法已执行完。因此「拦截器式切面」的前置校验必须写在 proceed 前面。
-
为什么给切面方法加 @Transactional 可能不生效?
切面本身也是 Bean,若你的切面切了自己同类的方法会陷入 self-invocation;更常见的是切面顺序问题(@Order 控制多个切面的包裹顺序)。原则:切面里只做横切动作,事务交给业务层。
-
同一个方法被多个切面命中,顺序谁说了算?
实现 Ordered 接口或 @Order 注解,值越小越靠外层。@Transactional 的默认优先级是 Ordered.LOWEST_PRECEDENCE,所以自定义切面默认比事务更靠外。
-
AOP 能拦截 static 方法和 private 方法吗?
不能。代理模式下只拦「Bean 的公开实例方法」。需要拦私有逻辑,说明方法该抽到别的 Bean 里了。
-
@Around 里修改了返回值,调用方拿到的就是改后的吗?
是。代理把 proceed() 的返回值处理后 return 给调用方,你可以包装、替换甚至缓存这个结果——统一响应包装、结果脱敏都可以在这层做。
错法对照
| 错法 | 问题 | 正解 |
|---|
| @Aspect 类忘加 @Component | 切面不进容器,完全不生效 | @Aspect + @Component 一起标 |
| @Around 里不写 return pjp.proceed() | 调用方永远拿到 null | 必须放行并返回结果 |
| catch 后不重新 throw | 异常被吞,全局异常处理器失效 | throw e 继续上抛 |
| 在同类中 this.xxx() 期望触发切面/事务 | 内部调用绕过代理 | 拆类或经代理对象调用 |
切点表达式写 service.*.*(..) 以为含子包 | 子包方法全部漏拦 | 子包用 service..*(两个点) |
卡点急救
- 切面写了完全没反应:按序检查——starter-aop 引了没;@Aspect、@Component 都标了没;切点表达式能匹配到目标方法(先用最宽的
execution(* com.example..*.*(..)) 验证,再逐步收紧);调用是否经过代理。
- 耗时日志把响应体/密码等敏感参数打出来了:
pjp.getArgs() 是原始参数,生产切面要对参数脱敏(跳过指定类型、掩码处理),或只打方法名和耗时。
- 循环依赖报错发生在切面类上:切面里注入了被切的目标 Bean,形成环。切面不应持有业务 Bean,需要数据时改用 ThreadLocal 或事件机制传递。
面试常问
-
Spring AOP 的实现原理?JDK 动态代理和 CGLIB 的区别?
运行时动态代理。JDK:基于接口,Proxy + InvocationHandler,目标必须实现接口;CGLIB:继承目标类生成子类覆写方法,不要求接口,但 final 类/方法无法代理。Spring Boot 默认全用 CGLIB(spring.aop.proxy-target-class=true)。
-
AOP 的应用场景举几个?
声明式事务(@Transactional)、统一日志与性能监控、接口限流/幂等、权限校验、缓存(@Cacheable)、异步(@Async)——共性是「与业务正交、多处复用」。
-
为什么类内部方法调用(this 调用)注解会失效?
切面/事务靠代理织入,this 调用走的是原始对象而非代理,通知逻辑没机会执行。解决:拆类、注入自身代理、AopContext.currentProxy()。
-
@Order 怎么影响多个切面的执行顺序?
值越小优先级越高、越先执行且越晚结束(像洋葱,外层包裹内层)。@Transactional 默认最低优先级,位于最内层,保证业务切面(如审计)在事务内执行。
自测三问
- @Around 通知方法必须包含哪三个动作?
- execution 表达式
execution(* com.example.taskflow.service..*Service.*(..)) 拦截哪些方法?
this.method() 调用为什么切面不生效?给出两种解法。
小结一句话
AOP 用运行时代理把横切逻辑「包」在 Bean 方法外——业务零侵入获得日志、计时、审计能力,而「调用必须经过代理」是它一切规则的源头。
延伸