Skip to content

AOP是spring框架的第二大核心. AOP:Aspect Oriented Programming(面向切面编程、面向方面编程).面向切面编程就是面向特定方法编程。

概念理解 - 例子

Pasted image 20250629160911 除了传统的计时方法: 这个功能如果通过AOP来实现,我们只需要单独定义下面这一小段代码即可,不需要修改原始的任何业务方法即可记录每一个业务方法的执行耗时。 Pasted image 20250629160955 所以,AOP的优势主要体现在以下四个方面: - 减少重复代码:不需要在业务方法中定义大量的重复性的代码,只需要将重复性的代码抽取到AOP程序中即可。 - 代码无侵入:在基于AOP实现这些业务功能时,对原有的业务代码是没有任何侵入的,不需要修改任何的业务代码。 - 提高开发效率 - 维护方便 - AOP是一种思想,而在Spring框架中,对这种思想进行了实现,那我们要学习的就是Spring AOP。

AOP基础

入门

  • 需求:统计部门管理各个业务层方法执行耗时。
  • 原始方式: 在原始的实现方式中,我们需要在业务层的也一个方法执行执行,获取方法运行的开始时间; 然后运行原始的方法逻辑; 最后在每一个方法运行结束时,获取方法运行结束时间,计算执行耗时。 Pasted image 20250629161101
  • 导入依赖:在 pom.xml 文件中导入 AOP 的依赖
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
    
  • 编写AOP程序:针对于特定方法根据业务需要进行编程
    @Component//表示交给spring管理
    @Aspect //当前类为切面类
    @Slf4j
    public class RecordTimeAspect {
    
        @Around("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")//最终业务层的所有的方法在运行的时候都会执行这段AOP程序,最终统计出所有业务层方法的执行耗时
        public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {
            //记录方法执行开始时间
            long begin = System.currentTimeMillis();
    
            //执行原始方法
            Object result = pjp.proceed();//原始方法的返回值可能各种各样,所以统一用object接收
    
            //记录方法执行结束时间
            long end = System.currentTimeMillis();
    
            //计算方法执行耗时
            log.info("方法执行耗时: {}毫秒",end-begin);//可以使用pjp.getSignature()获取到方法签名,此时就可以知道是哪一个方法运行的耗时
            return result;
        }
    }
    
    我们通过AOP入门程序完成了业务方法执行耗时的统计,那其实AOP的功能远不止于此,常见的应用场景如下:
  • 记录系统的操作日志
  • 权限控制
  • 事务管理:我们前面所讲解的Spring事务管理,底层其实也是通过AOP来实现的,只要添加@Transactional注解之后,AOP程序自动会在原始方法运行前先来开启事务,在原始方法运行完毕之后提交或回滚事务 这些都是AOP应用的典型场景。

核心概念

连接点:JoinPoint,可以被AOP控制的方法(暗含方法执行时的相关信息)

  • 连接点指的是可以被aop控制的方法。例如:入门程序当中所有的业务方法都是可以被aop控制的方法.
  • 在SpringAOP提供的JoinPoint当中,封装了连接点方法在执行时的相关信息。(后面会有具体的讲解) Pasted image 20250629161652

通知:Advice,指哪些重复的逻辑,也就是共性功能(最终体现为一个方法)

  • 在入门程序中是需要统计各个业务方法的执行耗时的,此时我们就需要在这些业务方法运行开始之前,先记录这个方法运行的开始时间,在每一个业务方法运行结束的时候,再来记录这个方法运行的结束时间。
  • 是在AOP面向切面编程当中,我们只需要将这部分重复的代码逻辑抽取出来单独定义。抽取出来的这一部分重复的逻辑,也就是共性的功能。 Pasted image 20250629161832

切入点:PointCut,匹配连接点的条件,通知仅会在切入点方法执行时被应用。

  • 可以简单理解为实际被AOP控制的方法
  • 在通知当中,我们所定义的共性功能到底要应用在哪些方法上?此时就涉及到了切入点pointcut概念。切入点指的是匹配连接点的条件。通知仅会在切入点方法运行时才会被应用。
  • 在aop的开发当中,我们通常会通过一个切入点表达式来描述切入点(后面会有详解)。
  • 连接点不一定是切入点,切入点一定是连接点
  • 假如:切入点表达式改为DeptServiceImpl.list(),此时就代表仅仅只有list这一个方法是切入点。只有list()方法在运行的时候才会应用通知。
  • 连接点表示的"可以"被控制,切入点表示的"实际上被控制的" ### 切面:Aspect,描述通知与切入点的对应关系(通知+切入点) 当通知和切入点结合在一起,就形成了一个切面。通过切面就能够描述当前aop程序需要针对于哪个原始方法,在什么时候执行什么样的操作。 Pasted image 20250629162059 而切面所在的类,称之为切面类(被@Aspect注解标识的类)。

目标对象:Target,通知所应用的对象

目标对象指的就是通知所应用的对象,我们就称之为目标对象。 Pasted image 20250629162144

功能增强

Spring的AOP底层是基于动态代理技术来实现的,也就是说在程序运行的时候,会自动的基于动态代理技术为目标对象生成一个对应的代理对象。在代理对象当中就会对目标对象当中的原始方法进行功能的增强。 Pasted image 20250629162345 1. 我们写了上面两个代码之后,右下角的是实际上自动生成的东西. 2. DeptController注入的是右下角的代理对象. Pasted image 20250719171509

以下是gemini的一些解释:

好的,根据您提供的笔记 [Javaweb16 - AOP](.md),我们来探讨一下“切入点”和“连接点”这两个名称的含义。这两个词翻译得非常形象,理解了字面意思,就基本理解了它们在AOP中的作用。

### 1. 连接点 (JoinPoint)

**为什么叫“连接点”?**

您可以把它想象成程序执行流程中的一个个“**节点**”或“**连接处**”。在您的笔记中提到:

> 连接点:JoinPoint,可以被AOP控制的方法(暗含方法执行时的相关信息)
> 连接点指的是**可以被aop控制的方法**。例如:入门程序当中所有的业务方法都是可以被aop控制的方法。

这个“连接”的含义在于,它是我们**可以**把新功能(比如日志、事务)**连接**到现有业务代码上的一个**潜在位置**。

*   **把程序想象成一条线**:程序的执行就像一条从头走到尾的线。
*   **方法调用是线上的节点**:每个方法的调用、执行、返回,都是这条线上的一个关键节点。
*   **连接点就是这些节点**:AOP允许我们在这些节点上“挂载”或“连接”上额外的逻辑。在Spring AOP中,这个节点特指**方法的执行**。

所以,“连接点”这个名字的含义是:它是程序流程中一个**可供连接**额外逻辑的**点**。它是一个广泛的概念,代表了**所有可能**被增强的地方。

---

### 2. 切入点 (Pointcut)

**为什么叫“切入点”?**

如果说“连接点”是所有可能的节点,那么“切入点”就是我们**实际选择**要动手的那个**点**。这个“切入”二字非常传神,它意味着我们要在这里“切开”一个口子,把我们的新功能(通知 Advice)“插入”进去。

您的笔记中对此有非常精辟的总结:

> 切入点:PointCut,匹配连接点的条件,通知仅会在切入点方法执行时被应用。
> ...
> - 连接点不一定是切入点,切入点一定是连接点
> - 连接点表示的"可以"被控制, 切入点表示的"实际上被控制的"

*   **“切”的动作**:这个字代表了一个精确的、有目的性的动作。我们不是对所有“连接点”都进行操作,而是通过一个表达式(Pointcut Expression)来精确地定位我们感兴趣的那些点。
*   **“入”的动作**:定位到点之后,我们就要把我们的“通知(Advice)”放**入**这个执行流程中。

所以,“切入点”这个名字的含义是:它是我们通过特定规则筛选出来的,**实际要切入**并植入新逻辑的**点**。

### 总结与比喻

我们可以用一个更生活化的比喻来理解:

假设一栋大楼里有很多房间,每个房间都装了电源插座。

*   **连接点 (JoinPoint)**:就是这栋楼里**所有**的电源插座。每一个插座都是一个可以连接电器的潜在位置。
*   **切入点 (Pointcut)**:是您制定的一个**规则**,比如“所有卧室里床头柜旁边的插座”。这个规则精确地筛选出了一部分插座。这些被选中的插座就是“切入点”。
*   **通知 (Advice)**:是您想使用的电器,比如一盏台灯(它代表了像“记录日志”这样的具体功能)。
*   **切面 (Aspect)**:就是整个行动计划:“把台灯(通知)插到所有卧室床头柜旁边的插座(切入点)上”。

通过这个比喻,您就能明白:
- **连接点**是“可能性”的集合(所有插座)。
- **切入点**是“现实性”的选择(被规则选中的插座),它的名字强调了“切入”这一精确、实际发生的操作。
您这个问题问得非常好!继“连接点”和“切入点”之后,“通知”这个词的翻译也确实很有讲究。它的理解可以从它的英文原词 **Advice** 和中文翻译 **通知** 两个角度来看,两者都能帮助我们加深理解。

### 1. 从英文原词 "Advice" (建议) 理解

在AOP的术语中,“通知”的英文是 **Advice**。Advice 的本意是“建议”、“忠告”或“劝告”。这个词选得非常精妙,因为它暗示了一种**非侵入性**的关系。

我们可以把AOP的运行机制想象成一个**顾问(Aspect)**在给一个正在执行任务的**员工(Target Object)**提供建议。

*   **员工 (目标对象)**:专注于自己的核心业务(比如 `DeptServiceImpl` 里的增删改查逻辑)。
*   **顾问 (切面)**:站在一旁观察,但不直接插手员工的核心工作。
*   **时机 (切入点)**:顾问根据预设的规则,在员工工作的某个关键节点(比如“准备删除部门数据之前”或“新增员工成功之后”)介入。
*   **建议 (Advice)**:顾问在那个时机点给出的具体操作建议。这个“建议”就是一段代码。比如:
    *   `@Before` (前置通知): "在你开始删除数据**之前**,我**建议**你先记录一下操作日志。"
    *   `@AfterReturning` (返回后通知): "你已经成功完成了任务,我**建议**你现在更新一下统计数据。"
    *   `@AfterThrowing` (异常通知): "你的任务出错了,我**建议**你立刻发送一封警报邮件。"

从这个角度看,**Advice** 是切面在不打扰核心业务的前提下,对其执行流程提出的“附加操作建议”。核心业务本身并不知道这些“建议”的存在,它只管做自己的事,这完美体现了AOP“代码无侵入”的特点。

### 2. 从中文翻译 "通知" 理解

中文翻译成“通知”也同样传神,它侧重于**事件驱动**的视角。

您可以把程序的执行想象成一个流程,当执行到某个“切入点”时,AOP框架会向“切面”发出一个**通知**。

*   当程序即将执行目标方法时,框架会发出一个“**事前通知**”。您的切面收到这个通知后,就会执行 `@Before` 标注的方法。
*   当目标方法成功执行完毕时,框架会发出一个“**返回后通知**”。您的切面收到后,就会执行 `@AfterReturning` 标注的方法。
*   无论方法是否成功,只要执行结束,框架就会发出一个“**事后通知**”,触发 `@After` 方法。

所以,“通知”这个词强调的是:**在特定的时间点,AOP框架会告知(通知)你某个事件发生了,然后你可以执行相应的响应动作。**

在您的笔记 [Javaweb16 - AOP](.md) 中,各种通知类型就是最好的证明:

| 注解 | 中文名 | “通知”的含义 |
| :--- | :--- | :--- |
| `@Before` | 前置通知 | **通知**你:目标方法马上要开始了! |
| `@After` | 后置通知 | **通知**你:目标方法已经结束了(无论成败)! |
| `@AfterReturning` | 返回后通知 | **通知**你:目标方法成功返回了! |
| `@AfterThrowing` | 异常后通知 | **通知**你:目标方法抛出异常了! |
| `@Around` | 环绕通知 | **通知**你:目标方法被包围了,你可以完全控制它的执行。 |

### 总结

*   **Advice (建议)**:强调的是切面为目标对象提供的**附加操作**,体现了其“非侵入”的顾问角色。
*   **通知 (Notification)**:强调的是AOP框架在特定时间点向切面发出的**事件信号**,切面根据信号执行相应的代码。

两者结合起来,就完美地描述了 **Advice** 的本质:**它是在收到框架发出的某个特定事件通知后,所执行的那段具体的、提供附加功能的代码逻辑。**
或者说,通知是一个"动作",切入点是一个"被操作的五排",两者组成在一起就是切面.

AOP进阶

通知类型

在入门程序当中,我们已经使用了一种功能最为强大的通知类型:Around环绕通知。

@Component
@Aspect //当前类为切面类
@Slf4j
public class TimeAspect {

    @Around("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {
        //记录方法执行开始时间
        long begin = System.currentTimeMillis();
        //执行原始方法
        Object result = pjp.proceed();
        //记录方法执行结束时间
        long end = System.currentTimeMillis();
        //计算方法执行耗时
        log.info("方法执行耗时: {}毫秒",end-begin);
        return result;
    }
}
只要我们在通知方法上加上了@Around注解,就代表当前通知是一个环绕通知。

Spring AOP 通知类型

@Around 环绕通知,此注解标注的通知方法在目标方法前、后都被执行 @Before 前置通知,此注解标注的通知方法在目标方法前被执行 @After (最为常用的) 后置通知,此注解标注的通知方法在目标方法后被执行,无论是否有异常都会执行 @AfterReturning 返回后通知,此注解标注的通知方法在目标方法后被执行,有异常不会执行 @AfterThrowing 异常后通知,此注解标注的通知方法发生异常后执行 样例:

@Slf4j
@Component
@Aspect
public class MyAspect1 {
    //前置通知
    @Before("execution(* com.itheima.service.*.*(..))")
    public void before(JoinPoint joinPoint){
        log.info("before ...");

    }

    //环绕通知
    @Around("execution(* com.itheima.service.*.*(..))")
    public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        log.info("around before ...");

        //调用目标对象的原始方法执行
        Object result = proceedingJoinPoint.proceed();

        //原始方法如果执行时有异常,环绕通知中的后置代码不会在执行了

        log.info("around after ...");
        return result;
    }

    //后置通知 无论是否有异常都会执行
    @After("execution(* com.itheima.service.*.*(..))")
    public void after(JoinPoint joinPoint){
        log.info("after ...");
    }

    //返回后通知(程序在正常执行的情况下,会执行的后置通知)
    @AfterReturning("execution(* com.itheima.service.*.*(..))")
    public void afterReturning(JoinPoint joinPoint){
        log.info("afterReturning ...");
    }

    //异常通知(程序在出现异常的情况下,执行的后置通知)
    @AfterThrowing("execution(* com.itheima.service.*.*(..))")
    public void afterThrowing(JoinPoint joinPoint){
        log.info("afterThrowing ...");
    }
}
- @Around环绕通知需要自己调用 ProceedingJoinPoint.proceed() 来让原始方法执行(就像刚才的示例代码一样),其他通知不需要考虑目标方法执行 - @Around环绕通知方法的返回值,必须指定为Object,来接收原始方法的返回值,否则原始方法执行完毕,是获取不到返回值的。

抽取

Pasted image 20250629163946 pring提供了@PointCut注解,该注解的作用是将公共的切入点表达式抽取出来,需要用到时引用该切入点表达式即可。

@Slf4j
@Component
@Aspect
public class MyAspect1 {

    //切入点方法(公共的切入点表达式)
    @Pointcut("execution(* com.itheima.service.*.*(..))")
    private void pt(){}

    //前置通知(引用切入点)
    @Before("pt()")
    public void before(JoinPoint joinPoint){
        log.info("before ...");

    }

    //环绕通知
    @Around("pt()")
    public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        log.info("around before ...");

        //调用目标对象的原始方法执行
        Object result = proceedingJoinPoint.proceed();
        //原始方法在执行时:发生异常
        //后续代码不在执行

        log.info("around after ...");
        return result;
    }

    //后置通知
    @After("pt()")
    public void after(JoinPoint joinPoint){
        log.info("after ...");
    }

    //返回后通知(程序在正常执行的情况下,会执行的后置通知)
    @AfterReturning("pt()")
    public void afterReturning(JoinPoint joinPoint){
        log.info("afterReturning ...");
    }

    //异常通知(程序在出现异常的情况下,执行的后置通知)
    @AfterThrowing("pt()")
    public void afterThrowing(JoinPoint joinPoint){
        log.info("afterThrowing ...");
    }
}
需要注意的是:当切入点方法使用private修饰时,仅能在当前切面类中引用该表达式, 当外部其他切面类中也要引用当前类中的切入点表达式,就需要把private改为public,而在引用的时候,具体的语法为:
@Slf4j
@Component
@Aspect
public class MyAspect2 {
    //引用MyAspect1切面类中的切入点表达式
    @Before("com.itheima.aspect.MyAspect1.pt()")
    public void before(){
        log.info("MyAspect2 -> before ...");
    }
}

通知顺序

当在项目开发当中,我们定义了多个切面类,而多个切面类中多个切入点都匹配到了同一个目标方法。此时当目标方法在运行的时候,这多个切面类当中的这些通知方法都会运行。 - 在不同切面类中,默认按照切面类的类名字母排序: - 目标方法前的通知方法:字母排名靠前的先执行 - 目标方法后的通知方法:字母排名靠前的后执行 如果我们想控制通知的执行顺序有两种方式: 1. 修改切面类的类名(这种方式非常繁琐、而且不便管理) 2. 使用Spring提供的@Order注解

@Slf4j
@Component
@Aspect
@Order(2)  //切面类的执行顺序(前置通知:数字越小先执行; 后置通知:数字越小越后执行)
public class MyAspect2 {
    //前置通知
    @Before("execution(* com.itheima.service.*.*(..))")
    public void before(){
        log.info("MyAspect2 -> before ...");
    }

    //后置通知 
    @After("execution(* com.itheima.service.*.*(..))")
    public void after(){
        log.info("MyAspect2 -> after ...");
    }
}

切入点表达式

切入点表达式:描述切入点方法的一种表达式 - 作用:主要用来决定项目中的哪些方法需要加入通知 - 常见形式: - execution(……):根据方法的签名来匹配 Pasted image 20250629171730 - @annotation(……) :根据注解匹配 Pasted image 20250629171746 要匹配带有Log注解的方法,里面写的就是Log注解的全类名

execution(最主流的方法)

匹配模式: execution(访问修饰符? 返回值 包名.类名.?方法名(方法参数) throws 异常?) Pasted image 20250629171838 不建议省略包名类名.(因为会导致扫描整个项目,寻找对应的函数.从而影响性能) 参数如果是包装类型或者是一个对象,那么要写全类名.(如java.lang.integer) 可以使用通配符描述切入点 - * :单个独立的任意符号,可以通配任意返回值、包名、类名、方法名、任意类型的一个参数,也可以通配包、类、方法名的一部分 - .. :多个连续的任意符号,可以通配任意层级的包,或任意类型、任意个数的参数 注意区别通配符和"可省略的". 切入点表达式的语法规则: 1. 方法的访问修饰符可以省略

execution(void com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))
2. 返回值可以使用*号代替(任意返回值类型)
execution(* com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))
3. 包名可以使用*号代替,代表任意包(一层包使用一个
execution(* com.itheima.*.*.DeptServiceImpl.delete(java.lang.Integer))
4. 使用..配置包名,标识此包以及此包下的所有子包
execution(* com..DeptServiceImpl.delete(java.lang.Integer))  
5. 类名可以使用
号代替,标识任意类
execution(* com..*.delete(java.lang.Integer))
6. 方法名可以使用*号代替,表示任意方法
 execution(* com..*.*(java.lang.Integer))
7. 可以使用 * 配置参数,一个任意类型的参数
execution(* com.itheima.service.impl.DeptServiceImpl.delete(*))
8. 可以使用.. 配置参数,任意个任意类型的参数
execution(* com..*.*(..))
- 根据业务需要,可以使用 且(&&)、或(||)、非(!) 来组合比较复杂的切入点表达式。
execution(* com.itheima.service.DeptService.list(..)) || execution(* com.itheima.service.DeptService.delete(..))
- 描述切入点方法通常基于接口描述,而不是直接描述实现类,增强拓展性
execution(* com.itheima.service.DeptService.*(..))
- 在满足业务需要的前提下,尽量缩小切入点的匹配范围。如:包名匹配尽量不使用 ..,使用 * 匹配单个包
execution(* com.itheima.*.*.DeptServiceImpl.find*(..))

annotation

如果我们要匹配多个无规则的方法,比如:list()和 delete()这两个方法。这个时候我们基于execution这种切入点表达式来描述就不是很方便了。而在之前我们是将两个切入点表达式组合在了一起完成的需求,这个是比较繁琐的。我们可以借助于另一种切入点表达式 @annotation 来描述这一类的切入点,从而来简化切入点表达式的书写。 实现步骤: 1. 编写自定义注解

@Target(ElementType.METHOD)//@Target是元注解(注解的注解,修饰注解的注解。后面参数代表在方法上生效。
@Retention(RetentionPolicy.RUNTIME)//@Retention也是元注解,代表这个注解什么时候生效。后面参数代表是在运行是生效。
public @interface LogOperation{
}
2. 在业务类要做为连接点的方法上添加自定义注解
@Slf4j
@Service
public class DeptServiceImpl implements DeptService {
    @Autowired
    private DeptMapper deptMapper;

    @Override
    @LogOperation //自定义注解(表示:当前方法属于目标方法)
    public List<Dept> list() {
        List<Dept> deptList = deptMapper.list();
        //模拟异常
        //int num = 10/0;
        return deptList;
    }

    @Override
    @LogOperation //自定义注解(表示:当前方法属于目标方法)
    public void delete(Integer id) {
        //1. 删除部门
        deptMapper.delete(id);
    }


    @Override
    public void save(Dept dept) {
        dept.setCreateTime(LocalDateTime.now());
        dept.setUpdateTime(LocalDateTime.now());
        deptMapper.save(dept);
    }

    @Override
    public Dept getById(Integer id) {
        return deptMapper.getById(id);
    }

    @Override
    public void update(Dept dept) {
        dept.setUpdateTime(LocalDateTime.now());
        deptMapper.update(dept);
    }
}
切面类:
@Slf4j
@Component
@Aspect
public class MyAspect6 {
    //针对list方法、delete方法进行前置通知和后置通知

    //前置通知
    @Before("@annotation(com.itheima.anno.LogOperation)")
    public void before(){
        log.info("MyAspect6 -> before ...");
    }

    //后置通知
    @After("@annotation(com.itheima.anno.LogOperation)")
    public void after(){
        log.info("MyAspect6 -> after ...");
    }
}
Pasted image 20250719201927

案例

需求:将案例(Tlias智能学习辅助系统)中增、删、改相关接口的操作日志记录到数据库表中 - 就是当访问部门管理和员工管理当中的增、删、改相关功能接口时,需要详细的操作日志,并保存在数据表中,便于后期数据追踪。 操作日志信息包含: - 操作人、操作时间、执行方法的全类名、执行方法名、方法运行时参数、返回值、方法执行时长 - 问题3:既然要基于AOP面向切面编程的方式来完成的功能,那么我们要使用 AOP五种通知类型当中的哪种通知类型? - 答案:环绕通知 @Around。因为所记录的操作日志当中包括:操作人、操作时间,访问的是哪个类、哪个方法、方法运行时参数、方法的返回值、方法的运行时长。方法返回值,是在原始方法执行后才能获取到的。方法的运行时长,需要原始方法运行之前记录开始时间,原始方法运行之后记录结束时间。通过计算获得方法的执行耗时。基于以上的分析我们确定要使用Around环绕通知。 - 答案:使用 @annotation 来描述切入点表达式。要匹配业务接口当中所有的增删改的方法,而增删改方法在命名上没有共同的前缀或后缀。此时如果使用execution切入点表达式也可以,但是会比较繁琐。 当遇到增删改的方法名没有规律时,就可以使用 @annotation切入点表达式 - 另外,我们是对Controller进行切入. Pasted image 20250629175403

准备

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
-- 操作日志表
create table operate_log(
    id int unsigned primary key auto_increment comment 'ID',
    operate_emp_id int unsigned comment '操作人ID',
    operate_time datetime comment '操作时间',
    class_name varchar(100) comment '操作的类名',
    method_name varchar(100) comment '操作的方法名',
    method_params varchar(1000) comment '方法参数',
    return_value varchar(2000) comment '返回值, 存储json格式',
    cost_time int comment '方法执行耗时, 单位:ms'
) comment '操作日志表';
引入实体类
package com.itheima.pojo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class OperateLog {
    private Integer id; //ID
    private Integer operateEmpId; //操作人ID
    private LocalDateTime operateTime; //操作时间
    private String className; //操作类名
    private String methodName; //操作方法名
    private String methodParams; //操作方法参数
    private String returnValue; //操作方法返回值
    private Long costTime; //操作耗时
}
引入mapper
package com.itheima.mapper;

import com.itheima.pojo.OperateLog;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface OperateLogMapper {

    //插入日志数据
    @Insert("insert into operate_log (operate_emp_id, operate_time, class_name, method_name, method_params, return_value, cost_time) " +
            "values (#{operateEmpId}, #{operateTime}, #{className}, #{methodName}, #{methodParams}, #{returnValue}, #{costTime});")
    public void insert(OperateLog log);

}
自定义注解
/**
 *  自定义注解,用于标识哪些方法需要记录日志
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogOperation {
}
定义切面类
import com.itheima.anno.LogOperation;
import com.itheima.mapper.OperateLogMapper;
import com.itheima.pojo.OperateLog;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.Arrays;

@Aspect
@Component
public class OperationLogAspect {

    @Autowired
    private OperateLogMapper operateLogMapper;

    // 环绕通知
    @Around("@annotation(log)")
    public Object around(ProceedingJoinPoint joinPoint, LogOperation log) throws Throwable {
        // 记录开始时间
        long startTime = System.currentTimeMillis();
        // 执行方法
        Object result = joinPoint.proceed();
        // 当前时间
        long endTime = System.currentTimeMillis();
        // 耗时
        long costTime = endTime - startTime;

        // 构建日志对象
        OperateLog operateLog = new OperateLog();
        operateLog.setOperateEmpId(getCurrentUserId()); // 需要实现 getCurrentUserId 方法
        operateLog.setOperateTime(LocalDateTime.now());
        operateLog.setClassName(joinPoint.getTarget().getClass().getName());
        operateLog.setMethodName(joinPoint.getSignature().getName());
        operateLog.setMethodParams(Arrays.toString(joinPoint.getArgs()));
        operateLog.setReturnValue(result.toString());
        operateLog.setCostTime(costTime);

        // 插入日志.可见我们用了mapper层.
        operateLogMapper.insert(operateLog);
        return result;
    }

    // 示例方法,获取当前用户ID
    private int getCurrentUserId() {
        // 这里应该根据实际情况从认证信息中获取当前登录用户的ID
        return 1; // 示例返回值
    }//注意,如果这里需要获得用户id,那么就需要jwt令牌,可见下方.
}
在需要的地方都加入注解即可.
@RestController
@RequestMapping("/clazzs")
public class ClazzController {

    @Autowired
    private ClazzService clazzService;

    /**
     * 新增班级
     */
    @LogOperation
    @PostMapping
    public Result save(@RequestBody Clazz clazz){
        clazzService.save(clazz);
        return Result.success();
    }
}    

连接点

,连接点可以简单理解为可以被AOP控制的方法。 我们目标对象当中所有的方法是不是都是可以被AOP控制的方法。而在SpringAOP当中,连接点又特指方法的执行。 在Spring中用JoinPoint抽象了连接点,用它可以获得方法执行时的相关信息,如目标类名、方法名、方法参数等。 - 对于@Around通知,获取连接点信息只能使用ProceedingJoinPoint类型 - Pasted image 20250629180809 - - 对于其他四种通知,获取连接点信息只能使用JoinPoint,它是ProceedingJoinPoint的父类型 - Pasted image 20250629180828

获取登录的员工

在刚才的程序中,我希望我能在数据库中记录是哪个登录的员工在操作.这样,我们在过滤器执行的时候把它记录一下即可. - TokenFilter 中已经解析了令牌的信息,如何传递给AOP程序、Controller、Service呢(因为fileter并没有直接调用给AOP程序、Controller、Service)

ThreadLocal

Pasted image 20250629181117 对于get,如果没有这个变量,那么会返回null. 主线程操作: Pasted image 20250719203606 这个ThreadLocal<string>本身就是一个工具类. 很像末影箱,同一个末影箱被同一个人在任何时候访问都是一样,但是不同的人访问的绝对不一样.

Pasted image 20250719203658 子线程是获取不到的. 局部变量: 原理: Pasted image 20250719203750 Pasted image 20250719203801 这里的threadLocals就是线程专属的局部变量. Pasted image 20250629182830

ps:下面是对这个的解释:

您好!作为一个C++程序员,您的这个困惑非常正常,因为这正好触及了Java和C++在并发编程模型和对象管理上的一些差异。您的理解“每个线程的局部变量本身不就是独立的吗”是完全正确的,但这和ThreadLocal要解决的问题不在同一个维度。

我们来逐步分析,解开您的疑惑。

1. 您理解的“局部变量” vs ThreadLocal 关注的“变量”

首先,我们明确一下“局部变量”的定义。在C/C++和Java中,这通常指在方法(函数)内部声明的变量。

public void myMethod() {
    int myLocalVariable = 10; // 这是一个局部变量
    // ...
}

您说得完全没错,像myLocalVariable这样的变量是存储在每个线程各自的栈(Stack)上的。当线程A调用myMethod时,它有自己的myLocalVariable;当线程B调用myMethod时,它也有一个完全独立的myLocalVariable。它们之间天生就是隔离的,互不干扰,绝对安全。

那么,ThreadLocal要解决什么问题呢?

ThreadLocal解决的是跨方法调用、但又需要限制在单个线程内的变量共享问题。它通常用于类的成员变量(实例变量或静态变量),而不是方法内的局部变量。

想象一个场景:我们有一个全局单例对象(Singleton),或者一个被多个线程共享的实例对象。这个对象里有一个成员变量。

// 一个被多线程共享的类
public class SharedObject {
    private String status; // 成员变量

    public void process() {
        // ... 多个步骤
        this.status = "Step 1";
        // ... 调用其他方法
        anotherMethod();
    }

    private void anotherMethod() {
        // ...
        System.out.println(this.status); // 读取status
    }
}

如果线程A和线程B同时调用SharedObject实例的process()方法,它们会共享同一个status变量。这就会导致线程安全问题:

  • 线程A将status设置为"Step 1"。
  • 此时CPU切换到线程B,线程B也将status设置为"Step 1",然后可能继续修改为"Step 2"。
  • CPU再切换回线程A,它继续执行,但此时status可能已经被线程B篡改了。

2. ThreadLocal 的作用:让成员变量“局部化”

ThreadLocal就是为了解决上述问题。它提供了一种机制,可以将一个“看起来”是成员变量的东西,在每个线程中都创建一个独立的副本。

看图中的解释:

  • ThreadLocal 不是一个Thread,而是Thread局部变量(更准确地说是“线程本地变量”)。
  • ThreadLocal为每个线程提供一份单独的存储空间

如图所示,Thread-1Thread-2等每个线程对象内部,都有一个自己的ThreadLocalMap。当你通过一个ThreadLocal对象(我们称之为tl)去setget值时,实际操作的是当前线程内部的那个Maptl对象本身充当了这个Map的Key,你要存的值是Value。

这样一来:

  • 线程A调用 tl.set("Data-A"),实际上是把 ("Data-A") 存入了 Thread-1 内部的 ThreadLocalMap
  • 线程B调用 tl.set("Data-B"),实际上是把 ("Data-B") 存入了 Thread-2 内部的 ThreadLocalMap

它们访问的是同一个ThreadLocal对象tl,但获取到的是各自线程独立的值,从而实现了线程间的隔离。

3. C++ 程序员的视角:与 thread_local 的类比

为了让您更好地理解,我们可以直接和C++11引入的关键字 thread_local 做一个类比。它们的设计思想和要解决的问题几乎是一样的。

在C++中,如果你有一个全局变量或静态变量,它是所有线程共享的:

#include <iostream>
#include <thread>

// 全局变量,所有线程共享
int global_var = 0;

void worker() {
    global_var++;
    std::cout << "Thread " << std::this_thread::get_id()
              << ", global_var: " << global_var << std::endl;
}

如果你创建多个线程运行workerglobal_var会产生竞争条件,输出会很混乱。

但是,如果你使用 thread_local

#include <iostream>
#include <thread>

// 线程局部存储。每个线程都将拥有它自己的、独立于其他线程的副本。
thread_local int thread_local_var = 0;

void worker_tls() {
    thread_local_var++;
    std::cout << "Thread " << std::this_thread::get_id()
              << ", thread_local_var: " << thread_local_var << std::endl;
}

现在,每个线程运行worker_tls时,它们操作的都是自己私有的thread_local_var副本。一个线程的修改不会影响另一个线程。

所以,您可以简单地将 Java 的 ThreadLocal 理解为 C++ thread_local 关键字在Java中的一种实现方式。

4. 为什么需要它?典型的应用场景

ThreadLocal最常见的用途是在一个线程处理一个任务的整个生命周期中,传递一些“上下文”信息,而又不想通过冗长的方法参数列表来层层传递。

经典案例:Web服务器中的用户信息

  1. 一个HTTP请求到达服务器,服务器为这个请求分配一个线程来处理。
  2. 在处理开始时,代码通过解析Session或Token,获取了当前用户的身份信息(比如User对象)。
  3. 这个请求的处理过程可能会调用很多深层的方法,比如OrderService -> PaymentService -> LogUtil。这些方法可能都需要知道当前操作是哪个用户发起的。
  4. 如果没有ThreadLocal,你就必须把User对象作为参数,在所有的方法调用链上传递:process(User user) -> orderService.createOrder(User user, ...) -> paymentService.pay(User user, ...)。这非常繁琐且不优雅。
  5. 有了ThreadLocal,就可以在请求处理开始时,将User对象存入一个ThreadLocal<User>变量中。之后,在线程处理该请求的任何地方,都可以随时通过这个ThreadLocal变量get()出当前用户的信息,而无需传递参数。
public class UserContextHolder {
    // 创建一个ThreadLocal来存储User对象
    private static final ThreadLocal<User> holder = new ThreadLocal<>();

    public static void setUser(User user) {
        holder.set(user);
    }

    public static User getUser() {
        return holder.get();
    }

    public static void clear() {
        holder.remove(); // 防止内存泄漏
    }
}

// 在请求处理的入口处
User user = getUserFromRequest(request);
UserContextHolder.setUser(user);

// 在任何深层业务逻辑中
public void someDeepLogic() {
    User currentUser = UserContextHolder.getUser();
    // 使用currentUser进行操作...
}

// 在请求处理结束时(非常重要)
UserContextHolder.clear();

总结

  • 您是对的:方法内的局部变量本身就是线程安全的,不需要ThreadLocal
  • 困惑点在于范围ThreadLocal解决的不是方法内局部变量的问题,而是跨方法的、属于同一个对象(通常是单例或共享对象)的成员变量在多线程环境下的隔离问题。
  • C++类比:Java的ThreadLocal在思想上等价于C++11的thread_local存储说明符,它提供了一种创建“线程作用域”的变量的机制。
  • 核心价值:它允许你创建一个虽然在代码结构上看起来像全局或静态变量,但实际上在每个线程中都有独立副本的变量,从而优雅地解决了在线程内部传递上下文信息的问题,同时避免了线程安全风险。

希望这个解释能帮助您从C++程序员的视角理解ThreadLocal的设计初衷!

实际操作

Pasted image 20250629181135 这上面这五步,实际上是一个线程.这个线程是tomcat服务器在请求到达之后,从线程池里拿出来专门用来处理这个请求的. Pasted image 20250629183356 可以看到,确实是同一个线程.

代码的本质就是: 定义ThreadLocal操作的工具类,用于操作当前登录员工ID;从过滤器中获取当前员工ID,存入线程存储空间ThreadLocalMap,在AOP切面类中要获取当前登录ID时,从线程存储空间中获取即可。 强调:结束之后需要将存储的ThreadLocal删除.这是因为,用户自己创建的ThreadLocal,在线程被归还到线程池之后不会被清除. 1. 定义ThreadLocal操作的工具类,用于操作当前登录员工ID。

//在 com.itheima.utils 引入工具类 CurrentHolder
package com.itheima.utils;

public class CurrentHolder {

    private static final ThreadLocal<Integer> CURRENT_LOCAL = new ThreadLocal<>();

    public static void setCurrentId(Integer employeeId) {
        CURRENT_LOCAL.set(employeeId);
    }

    public static Integer getCurrentId() {
        return CURRENT_LOCAL.get();
    }

    public static void remove() {
        CURRENT_LOCAL.remove();
    }
}
我们看完下面的代码再看上面,会发现如果没有ThreadLocal,那么这就是一个静态类,里面有一个全局共享的Integer变量.但是一旦有了之后,每个变量都有自己的了. 2. 在TokenFilter中,解析完当前登录员工ID,将其存入ThreadLocal(用完之后需将其删除)。
package com.itheima.filter;

import com.itheima.utils.CurrentHolder;
import com.itheima.utils.JwtUtils;
import io.jsonwebtoken.Claims;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;

@Slf4j
@WebFilter(urlPatterns = "/*")
public class TokenFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        //1. 获取请求的url地址
        String uri = request.getRequestURI(); // /employee/login
        //String url = request.getRequestURL().toString(); // http://localhost:8080/employee/login

        //2. 判断是否是登录请求, 如果url地址中包含 login, 则说明是登录请求, 放行
        if (uri.contains("login")) {
            log.info("登录请求, 放行");
            filterChain.doFilter(request, response);
            return;
        }

        //3. 获取请求中的token
        String token = request.getHeader("token");

        //4. 判断token是否为空, 如果为空, 响应401状态码
        if (token == null || token.isEmpty()) {
            log.info("token为空, 响应401状态码");
            response.setStatus(401); // 响应401状态码
            return;
        }

        //5. 如果token不为空, 调用JWtUtils工具类的方法解析token, 如果解析失败, 响应401状态码
        try {
            Claims claims = JwtUtils.parseJWT(token);
            Integer empId = Integer.valueOf(claims.get("id").toString());
            CurrentHolder.setCurrentId(empId);
            log.info("token解析成功, 放行");
        } catch (Exception e) {
            log.info("token解析失败, 响应401状态码");
            response.setStatus(401);
            return;
        }

        //6. 放行
        filterChain.doFilter(request, response);

        //7. 清空当前线程绑定的id
        CurrentHolder.remove();
    }
}
3. 在AOP程序中,从ThreadLocal中获取当前登录员工的ID。
package com.itheima.aop;

import com.itheima.anno.LogOperation;
import com.itheima.mapper.OperateLogMapper;
import com.itheima.pojo.OperateLog;
import com.itheima.utils.CurrentHolder;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.Arrays;

@Aspect
@Component
public class OperationLogAspect {

    @Autowired
    private OperateLogMapper operateLogMapper;

    // 环绕通知
    @Around("@annotation(log)")
    public Object around(ProceedingJoinPoint joinPoint, LogOperation log) throws Throwable {
        // 记录开始时间
        long startTime = System.currentTimeMillis();
        // 执行方法
        Object result = joinPoint.proceed();
        // 当前时间
        long endTime = System.currentTimeMillis();
        // 耗时
        long costTime = endTime - startTime;

        // 构建日志对象
        OperateLog operateLog = new OperateLog();
        operateLog.setOperateEmpId(getCurrentUserId()); // 需要实现 getCurrentUserId 方法,见下面
        operateLog.setOperateTime(LocalDateTime.now());
        operateLog.setClassName(joinPoint.getTarget().getClass().getName());
        operateLog.setMethodName(joinPoint.getSignature().getName());
        operateLog.setMethodParams(Arrays.toString(joinPoint.getArgs()));
        operateLog.setReturnValue(result.toString());
        operateLog.setCostTime(costTime);

        // 插入日志
        operateLogMapper.insert(operateLog);
        return result;
    }

    // 示例方法,获取当前用户ID
    private int getCurrentUserId() {
        return CurrentHolder.getCurrentId();
    }
}