4
AOP:把横切逻辑从业务代码里抠出来
Aspect-Oriented Programming
假设你有 100 个 Service 方法,每个方法都要打日志、算耗时、开事务、检查权限。如果把这些代码写进每个方法里,就是 100 份复制粘贴,改一处要动 100 个文件。AOP 的思路是:把这些"每个方法都要来一遍"的横切逻辑写成一个切面,让 Spring 自动织入到所有匹配的方法上。
论AOP 的五个名词,一次说清
切面(Aspect):横切逻辑本身,比如一个 LogAspect 类,里面写"方法前后干什么"。
切点(Pointcut):表达式,告诉 Spring"拦哪些方法"。比如 execution(* com.example.service.*.*(..)) 表示拦 service 包下所有方法。
通知(Advice):"什么时候执行横切逻辑"——方法执行前、后、返回后、抛异常后、前后都执行。
连接点(Join Point):程序执行过程中某个点,在 Spring AOP 里就是"方法调用"。
织入(Weaving):把切面代码和业务代码拼到一起的过程。Spring 用运行时动态代理完成。
Spring 怎么造代理的:JDK 动态代理 vs CGLIB
AOP 的"织入"本质是运行时给你的 Service 生成一个子类/实现类替身。Spring 有两种造替身的办法,面试常问。
| 方式 | 原理 | 限制 |
| JDK 动态代理 | 目标类实现了接口时,用 JDK 自带的 Proxy 在运行时生成一个实现同一接口的代理类。 | 必须有接口;代理对象是"接口的另一个实现",不是原类的子类。 |
| CGLIB | 目标类没接口时,用字节码技术在运行时生成目标类的子类,重写方法织入逻辑。 | 不能代理 final 类、final 方法(没法继承/重写)。 |
// JDK 动态代理原理:给接口生成替身(不依赖 CGLIB,JDK 自带)
Object proxy = Proxy.newProxyInstance(
clazz.getClassLoader(),
clazz.getInterfaces(), "> 必须有接口,否则玩不转
(p, method, args) -> {
System.out.println("[前置] 方法前织入");
Object result = method.invoke(target, args); "> 调真方法
System.out.println("[后置] 方法后织入");
return result;
});
论Spring Boot 3.x 默认用哪种
从 Spring Boot 2.x 起,默认用 CGLIB(spring.aop.proxy-target-class=true),不管你有没有接口,统一生成子类代理。好处是不用强行给 Service 抽接口,坏处是 final 类/方法切不进去。知道"有接口优先 JDK、没接口 CGLIB、Boot 默认 CGLIB"这一句就够。
五种通知类型
| 注解 | 触发时机 |
@Before | 目标方法执行前执行。常用于权限校验、参数日志。 |
@After | 目标方法执行后执行(无论正常返回还是抛异常),类似 finally。 |
@AfterReturning | 目标方法正常返回后执行,能拿到返回值。 |
@AfterThrowing | 目标方法抛异常后执行,常用于异常上报。 |
@Around | 环绕通知——前后都能拦,最强大也最常用。可以决定要不要调用原方法、改参数、改返回值。 |
实战:写一个性能监控切面,统计每个方法耗时
package com.example.aspect;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
// @Aspect 告诉 Spring 这是一个切面类
@Aspect
@Component
public class PerformanceAspect {
// 切点表达式:拦截 service 包下所有类的所有 public 方法
// execution(修饰符 返回类型 包.类.方法(参数) 异常)
@Pointcut("execution(public * com.example.service.*.*(..))")
public void serviceMethods() {}
// 环绕通知:方法执行前后都拦下来
@Around("serviceMethods()")
public Object measure(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
try {
// 调用真正的业务方法;不调 proceed() 业务就不会执行
return pjp.proceed();
} finally {
long cost = System.currentTimeMillis() - start;
System.out.printf("[%s] 耗时 %d ms%n",
pjp.getSignature().toShortString(), cost);
}
}
}
切点表达式 execution() 语法拆解
// 格式:execution(修饰符? 返回类型 包.类.方法(参数) 异常?)
execution(public * com.example.service.UserService.findUser(..))
// ↑ ↑ ↑ ↑ ↑
// 修饰符 返回类型 包名 类名 方法名
// (..) 表示任意参数个数、任意类型
// 常用通配:
// * 匹配任意字符(不跨包)
// .. 匹配任意多个包或任意多个参数
execution(* com.example..*.*(..)) // com.example 及其子包下所有类所有方法
用 AOP 之前和之后,代码长什么样
没有 AOP 时,每个方法都要手写日志和计时——业务代码被横切逻辑污染。用 AOP 后,业务方法干干净净,横切逻辑集中到切面里。
不用 AOP:业务代码里全是日志和计时
@Service
public class UserService {
public User findUser(long id) {
long start = System.currentTimeMillis();
log.info("findUser 开始,id={}", id);
try {
User u = userDao.findById(id);
log.info("findUser 成功");
return u;
} catch (Exception e) {
log.error("findUser 失败", e);
throw e;
} finally {
log.info("findUser 耗时 {} ms", System.currentTimeMillis() - start);
}
}
// 其他 50 个方法,每个都要复制一遍这段模板代码
}
用 AOP:业务方法只剩业务逻辑
@Service
public class UserService {
// 干净!没有任何日志、计时、try-catch
public User findUser(long id) {
return userDao.findById(id);
}
// 日志和计时全交给 PerformanceAspect 自动织入
}
效果:100 个方法 × 每个方法省 10 行模板 = 1000 行干净代码。
要改日志格式?只改切面,业务代码一行不动。
切点表达式速查:常用写法抄走
| 表达式 | 拦截什么 |
execution(* com.example.service.*.*(..)) | service 包下所有类的所有方法。 |
execution(* com.example.service..*.*(..)) | service 包及子包所有方法。 |
execution(public * com.example.UserService.*(..)) | UserService 的所有 public 方法。 |
execution(* *..*Service.*(..)) | 任何包下名字以 Service 结尾的类。 |
@annotation(com.example.Loggable) | 贴了 @Loggable 注解的方法。 |
within(com.example.web..*) | web 包下所有类。 |
bean(userService) | 指定 Bean 名的所有方法。 |
完整日志切面:五种通知类型一次看全
上面只演示了 @Around。真正的日志切面,通常要把 @Before、@After、@AfterReturning、@AfterThrowing、@Around 五种通知组合起来用——方法进来打一条、正常返回打一条、抛异常打一条、全程计时。下面是一个能直接跑的完整切面。
完整切面:拦截 service 包下所有方法,记录入参/返回值/异常/耗时
package com.example.aspect;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
import java.util.Arrays;
@Aspect // 标记这是一个切面
@Component // 切面本身也得是个 Bean,容器才会扫到它
public class LoggingAspect {
// 切点:统一定义"拦哪里",下面五个通知都复用它
@Pointcut("execution(* com.example.service.*.*(..))")
public void servicePointcut() {}
// @Before:目标方法执行前跑
@Before("servicePointcut()")
public void logBefore(JoinPoint jp) {
System.out.println("[Before] " + jp.getSignature().getName()
+ " 入参=" + Arrays.toString(jp.getArgs()));
}
// @AfterReturning:目标方法正常返回后跑,能拿到返回值
@AfterReturning(pointcut = "servicePointcut()", returning = "result")
public void logReturn(JoinPoint jp, Object result) {
System.out.println("[AfterReturning] " + jp.getSignature().getName()
+ " 返回=" + result);
}
// @AfterThrowing:目标方法抛异常后跑,能拿到异常对象
@AfterThrowing(pointcut = "servicePointcut()", throwing = "ex")
public void logThrow(JoinPoint jp, Exception ex) {
System.out.println("[AfterThrowing] " + jp.getSignature().getName()
+ " 抛异常=" + ex.getMessage());
}
// @After:无论正常返回还是抛异常都会跑(类似 finally)
@After("servicePointcut()")
public void logAfter(JoinPoint jp) {
System.out.println("[After] " + jp.getSignature().getName() + " 执行完毕");
}
// @Around:最强通知,能完全控制要不要调、传什么参数、改返回值
@Around("servicePointcut()")
public Object logAround(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
try {
// proceed() 就是去调真正的业务方法;不调它业务就不会执行
return pjp.proceed();
} finally {
System.out.printf("[Around] %s 总耗时 %dms%n",
pjp.getSignature().getName(),
System.currentTimeMillis() - start);
}
}
}
业务类(干干净净,一行日志都没有)
@Service
public class UserService {
public String findName(long id) {
if (id <= 0) throw new IllegalArgumentException("id 非法");
return "用户#" + id;
}
}
调用 userService.findName(42) 的实际控制台输出(顺序很关键):
[Before] findName 入参=[42]
[Around] findName 开始
[AfterReturning] findName 返回=用户#42
[After] findName 执行完毕
[Around] findName 总耗时 3ms
故意传 -1,让方法抛异常:
[Before] findName 入参=[-1]
[Around] findName 开始
[AfterThrowing] findName 抛异常=id 非法
[After] findName 执行完毕
[Around] findName 总耗时 1ms
# 注意:@AfterReturning 和 @AfterThrowing 是二选一的,正常走前一个,异常走后一个,@After 两个都走。
AOP 不生效的三大元凶
① 同类内部调用:你在 UserService 里写了 this.update(),这个 update 上的 @Transactional 或 @Around 不生效——因为 this 指向的是原始对象,不是代理对象。要让它生效,要么把方法挪到另一个 Bean,要么自己注入自己(自注入)。
② 非 public 方法:Spring AOP 基于动态代理,只对 public 方法生效。private/protected 方法上贴切面注解,静默失效。
③ 自己 new 出来的对象:new UserService() 出来的对象不归 Spring 管,切面根本不会织入。
本章面试题 · 第 4 章(AOP)
1.(原理题)Spring AOP 底层是怎么实现的?JDK 动态代理和 CGLIB 有什么区别?
查看答案
答案:Spring AOP 基于动态代理。JDK 动态代理:目标类实现了接口时,代理类也实现同一接口,用反射调方法;CGLIB:目标类没实现接口时,生成目标类的子类来代理。解析:Spring Boot 2.x 之后默认用 CGLIB(proxyTargetClass=true),不管有没有接口都生成子类代理,省得接口/实现两套折腾。CGLIB 不能代理 final 类和 final 方法。
2.(流程题)@Around 里如果忘了调 pjp.proceed(),会发生什么?
查看答案
答案:目标业务方法根本不会执行,直接返回 null。解析:@Around 的权力最大——你决定要不要放行、放行时传什么参数、要不要改返回值。proceed() 就是"放行去调原方法",忘了它,业务就被你"拦死"了。
3.(排错题)@Transactional 写在 UserService 的 update() 上,但 UserService 内部另一个方法 this.update() 调用它,事务不生效。为什么?
查看答案
答案:因为 AOP 是给你"换了个代理对象",容器里的 UserService 其实是个代理。this.update() 里的 this 是原始对象,不是代理,所以绕过了切面。解析:解法:把方法挪到另一个 Bean、或自注入(注入自己的代理再调用)。这是 AOP 最经典的坑,必考。