楼层: 首页/ 软件技术/ Spring 核心框架/ AOP:把横切逻辑从业务代码里抠出来
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 最经典的坑,必考。