CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Spring Boot 实战入门/课时/Spring AOP 入门
RawBlame
课时目录16 节

Spring Boot 实战入门

  1. 01Spring Boot 是什么
  2. 02Starter 与自动配置
  3. 03配置与 Profile
  4. 04IoC 与依赖注入
  5. 05Bean 作用域与生命周期
  6. 06Spring AOP 入门
  7. 07Spring MVC 与 REST
  8. 08参数校验与异常处理
  9. 09JDBC 与 MyBatis
  10. 10Spring Data JPA 入门
  11. 11事务管理
  12. 12Spring Security 基础
  13. 13JWT 与 API 鉴权
  14. 14单元测试与 MockMvc
  15. 15Actuator 与健康检查
  16. 16综合练习与进阶路线

Spring AOP 入门

切面、通知类型;日志与权限横切关注点。

30 分钟

Spring AOP 入门

这一课你将能做到

  1. 给全部 Service 方法加上耗时统计切面,业务代码零改动
  2. 写对 execution 切点表达式,并用自定义注解 + @annotation 精确控制拦截范围
  3. 复现并解释「内部调用导致切面/事务失效」的 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(...) 直接调的是真实对象,绕过了代理。三种解法(按推荐度):

  1. 拆类:把 archive 挪到另一个 Service,通过注入调用(顺带改善设计);
  2. 自注入代理:注入自己的代理 private final TaskService self;(构造器注入自身类型的 ObjectProvider 或 @Lazy 自身),用 self.archive(...);
  3. AopContext.currentProxy()(需开启 exposeProxy = true,较少用)。

记住因果链:AOP 生效的前提是「调用经过代理」→ 一切「没生效」先查「这次调用走没走代理」。

讲清楚要点

  1. Spring AOP 是运行时代理,不是编译期织入:目标实现了接口默认 JDK 动态代理(接口反射实现),没接口用 CGLIB(生成子类)。这带来两个推论:代理只能拦外部调用;final 方法无法被 CGLIB 覆盖。
  2. 切面也是 Bean:@Aspect 必须配合 @Component(或 @Bean)注册进容器才生效——「写了切面没生效」第一检查项。
  3. @Around 的三要素缺一不可:pjp.proceed() 放行、返回 proceed() 的结果、异常继续抛。少放行方法不执行,吞返回值调用方拿到 null,吞异常全局错误处理失效。
  4. 优先「注解式精确拦截」,慎用全包扫描:execution 全局表达式适合日志/性能这类真·全局关注点;有业务语义的动作(审计、幂等、限流)用自定义注解,意图显式、范围可控。
  5. 与 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..*(两个点)

卡点急救

  1. 切面写了完全没反应:按序检查——starter-aop 引了没;@Aspect、@Component 都标了没;切点表达式能匹配到目标方法(先用最宽的 execution(* com.example..*.*(..)) 验证,再逐步收紧);调用是否经过代理。
  2. 耗时日志把响应体/密码等敏感参数打出来了:pjp.getArgs() 是原始参数,生产切面要对参数脱敏(跳过指定类型、掩码处理),或只打方法名和耗时。
  3. 循环依赖报错发生在切面类上:切面里注入了被切的目标 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 默认最低优先级,位于最内层,保证业务切面(如审计)在事务内执行。

自测三问

  1. @Around 通知方法必须包含哪三个动作?
  2. execution 表达式 execution(* com.example.taskflow.service..*Service.*(..)) 拦截哪些方法?
  3. this.method() 调用为什么切面不生效?给出两种解法。

小结一句话

AOP 用运行时代理把横切逻辑「包」在 Bean 方法外——业务零侵入获得日志、计时、审计能力,而「调用必须经过代理」是它一切规则的源头。

延伸

  • Spring 官方文档:AOP
  • AspectJ 切点表达式速查
  • 下一课:Spring MVC 与 REST——把方法变成 HTTP 接口,搭出完整的任务 CRUD。
加载本节练习…
← 上一课Bean 作用域与生命周期下一课 →Spring MVC 与 REST