Spring AOP

一. AOP概述

1.1 什么是 AOP

AOP 全称 Aspect Oriented Programming(面向切面编程)

什么是面向切面编程呢? 切面就是指某⼀类特定问题, 所以AOP也可以理解为,面向特定方法编程. 什么是面向特定方法编程呢? 比如上个篇博客学习的 "登录校验" , 就是⼀类特定问题. 登录校验拦截器, 就是对 "登录校验" 这类问题的统⼀处理. 所以, 拦截器也是AOP的⼀种应用. AOP是⼀种思想, 拦截器是AOP 思想的⼀种实现. Spring框架实现了这种思想, 提供了拦截器技术的相关接口. 同样的, 统⼀数据返回格式和统⼀异常处理, 也是AOP思想的⼀种实现.

总结:AOP 是一种编程思想,用来把与业务无关**、**但到处都要用的通用功能(如日志、事务、权限、统计)抽离出来统一管理,不污染业务代码。(简单来说: AOP是⼀种思想, 是对某⼀类事情的集中处理.)

1.2 什么是 Spring AOP

AOP是⼀种思想, 它的实现方法法有很多, 有Spring AOP,也有AspectJ、CGLIB等.

Spring AOP是其中的⼀种实现方式.

前面我们不是学会了Spring 统一功能处理,那我们就是学会了Spring AOP吗?---当然不是

拦截器的请求维度是一次URL(也就是一次请求和响应),@ControllerAdvice 应用场景主要是全局异常处理, 数据绑定, 数据预处理 ,但是AOP作用维度更加细致,全面,可以根据包、类、方法名、参数等进行拦截), 能够实现更加复杂的业务逻辑.

比如,当我们要记录每个方法的执行时间,我们就要在每一个方法中都加上同样的代码逻辑,这样重复的学同样的代码太够麻烦,我们这时就可以将记录执行时间的代码统一抽取出来经行管理,这就是一种AOP思想;下面我们就来看如何应用spring AOP来实现这样的功能。

二. Spring AOP 快速入门

2.1 引入依赖

在 pom.xml 文件中引入依赖

XML 复制代码
<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

2.2 编写 Spring AOP 程序

记录controller中每个方法的执行时间

java 复制代码
@Slf4j
@Component//使用@Aspect必须加上@Component,将这个切面类交给Spring进行管理
@Aspect//表示一个切面
public class TimeRecordAspect {
    @Around("execution(* com.example.demo.controller.*.*(..))")
    public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {//这个 pjp 就是目标方法
        //记录开始时间
        long start=System.currentTimeMillis();
        //执行目标方法
        Object result=pjp.proceed();//proceed():执行目标方法 ,需要处理异常
        //记录结束时间
        long end=System.currentTimeMillis();
        log.info(pjp.getSignature()+"执行时间为:{} ms",end-start);//getSignature():获得目标方法名
    return result;
    }
}

运行程序,观察日志

对程序进行简单的讲解:

  • @Aspect: 标识这个类是切面类
  • @Around: 环绕通知(通知类型的一种,后面会介绍到),在目标方法的前后都会被执行.后面的表达式表示对哪些方法法进行增强/作用.
  • ProceedingJoinPoint.proceed() :让原始/目标方法执行

我们可以将方法分为三个部分,来理解@Around的环绕通知:

三. Spring AOP 详细

3.1 Spring AOP 基本概念

3.1.1 切点 (Pointcut)

切点(Pointcut), 也称之为"切入点" Pointcut 的作用就是提供⼀组规则,告诉程序员对哪些方法进行功能增强

其中上面的表达式 execution(* com.example.demo.controller.*.*(..)) 就是切点表达式.

3.1.2 连接点(Join Point)

满足切点表达式规则的方法,就是连接点.也就是可以被AOP控制的方法

execution(* com.example.demo.controller.*.*(..)) 对应的 com.example.demo.controller 路径下的所有方法都是一个连接点

java 复制代码
package com.example.demo.controller;
@RequestMapping("/book")
@RestController
public class BookController {
 @RequestMapping("/addBook")
 public Result addBook(BookInfo bookInfo) {
 //...代码省略 
 }
 @RequestMapping("/queryBookById")
 public BookInfo queryBookById(Integer bookId){
 //...代码省略 
 }
 @RequestMapping("/updateBook")
 public Result updateBook(BookInfo bookInfo) {
 //...代码省略 
 }
}

上述BookController 中的方法都是连接点

切点与连接点的联系:连接点是满足切点表达式的元素. 切点可以看做是保存了众多连接点的⼀个集合.

3.1.3 通知(Advice)

通知就是具体要做的工作, 指哪些重复的逻辑,也就是共性功能(最终体现为⼀个方法) 比如上述程序中记录业务方法的耗时时间, 就是通知.

3.1.4 切面 (Aspect)

切面 = 切点+ 通知

通过切面就能够描述当前AOP程序需要针对于哪些方法,什么时候执型什么样的操作.

切面所在的类,我们⼀般称为切面类(被 @Aspect 注解标识的类)

3.2 通知类型

上面我们讲了什么是通知,接下来学习通知的类型. @Around 就是其中⼀种通知类型, 表式环绕通知

注解类型 通知名称 执行时机
@Around 环绕通知 目标方法执行前 + 执行后
@Before 前置通知 目标方法执行前
@After 后置通知 目标方法执行后
@AfterReturning 返回后通知 目标方法正常返回后
@AfterThrowing 异常后通知 目标方法抛出异常后

注意:@Around 通知的方法签名要求:

  1. 参数 :必须包含 ProceedingJoinPoint(唯一能调用 proceed() 的参数);
  2. 返回值 :必须是 Object 类型(或其子类,如 String/Integer),不能是 void
  3. 异常 :必须声明 throws Throwable(因为 proceed() 会抛出任意异常)。

当上面这个几个注解都存在的时候,这几个注解的执行顺序是有先后顺序的,下面我们来实际运行以下,观察它们的执行顺序

java 复制代码
@Slf4j
@Component
@Aspect
public class TimeRecordAspect {
    //环绕通知
    @Around("execution(* com.example.demo.controller.*.*(..))")
    public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {
        log.info("do around 前");

        //执行目标方法
        Object result=pjp.proceed();

        log.info("do around 后");
        return result;
    }
    //前置通知 
    @Before("execution(* com.example.demo.controller.*.*(..))")
    public void doBefore() {
        log.info("do Before ");
    }
    //后置通知 
    @After("execution(* com.example.demo.controller.*.*(..))")
    public void doAfter() {
        log.info("do After ");
    }
    //返回后通知 
    @AfterReturning("execution(* com.example.demo.controller.*.*(..))")
    public void doAfterReturning() {
        log.info("do AfterReturning ");
    }
    //抛出异常后通知 
    @AfterThrowing("execution(* com.example.demo.controller.*.*(..))")
    public void doAfterThrowing() {
        log.info("do AfterThrowing ");
    }

}

日志:

程序正常运行的情况下(不发生异常), @AfterThrowing 标识的通知方法不会执行 从上图也可以看出来, @Around 标识的通知方法包含两部分,⼀个"前置逻辑", ⼀个"后置逻辑" . 其 中"前置逻辑" 会先于 @Before 标识的通知方法执行 , "后置逻辑" 会晚于 @After 标识的通知方法执行

下面是目标/原始方法发生异常的情况:

程序发生异常的情况下:

• @AfterReturning 标识的通知方法不会执行, @AfterThrowing 标识的通知方法执行了

• @Around 环绕通知中原始方法调用时有异常,通知中的环绕后的代码逻辑也不会在执行了(因为 原始方法调用出异常了)

如果将错误的地方处理一下(try catch)的情况下:

异常会被 "吃掉",导致 AfterReturning 执行,而 AfterThrowing 不会执行

场景 未加 try-catch (抛异常) 加 try-catch (捕获异常)
目标方法 抛出异常,中断 抛出异常,但被 catch 捕获
@AfterReturning 不执行 执行 (因为逻辑认为正常返回)
@AfterThrowing 执行 不执行 (异常没有向上抛出)
@After 执行 (finally) 执行 (finally)
Around 后续 proceed() 后的代码不执行 proceed() 后的代码继续执行

如果在捕获异常后在抛出去呢:

@Around通知的try-catch中捕获异常后重新抛出,会触发@AfterThrowing(异常通知)、执行@After(最终通知),但@AfterReturning(正常返回通知)不会执行,且@Around中抛出异常点后的代码也不再执行。

注意:

  • @Around 环绕通知需要调用 ProceedingJoinPoint.proceed() 来让原始方法执行,其他通知不需要考虑目标方法执行.
  • @Around 环绕通知方法的返回值,必须指定为Object,来接收原始方法的返回值,否则原始方法执行完毕,是获取不到返回值的.
  • 一个切面类可以有多个切点.
  • 在实际开发中, @Around 使用的更多

3.3 @PointCut

上面代码存在⼀个问题, 就是存在⼤量重复的切点表达execution(*com.example.demo.controller.*.*(..)) , Spring提供了 @PointCut 注解, 把公共的切点表达式提取出来, 需要用到时引用该切入点表达式即可.

使用@PointCut,上面代码可改为:

java 复制代码
@Slf4j
@Component
@Aspect
public class AspectDeamo {
    @Pointcut("execution(* com.example.demo.controller.*.*(..))")
    private void pt(){}

    //环绕通知
    @Around("pt()")
    public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {
        log.info("do around 前");

        //执行目标方法
        Object result=pjp.proceed();

        log.info("do around 后");
        return result;
    }
    //前置通知
    @Before("pt()")
    public void doBefore() {
        log.info("do Before ");
    }
    //后置通知
    @After("pt()")
    public void doAfter() {
        log.info("do After ");
    }
    //返回后通知
    @AfterReturning("pt()")
    public void doAfterReturning() {
        log.info("do AfterReturning ");
    }
    //抛出异常后通知
    @AfterThrowing("pt()")
    public void doAfterThrowing() {
        log.info("do AfterThrowing ");
    }

}

如果将 pt()的访问限定修饰符从 pubic 改为 private ,这个切点点就只能在当前切面类使用,如果想要在其它的切面类中使用,就需要将 private 改回 pubic,引用方式为: 全限定类名.方法名()

java 复制代码
@Slf4j
@Aspect
@Component
public class AspectDemo2 {
 //前置通知 
 @Before("com.example.demo.aspect.AspectDemo.pt()")
 public void doBefore() {
 log.info("执⾏ AspectDemo2 -> Before ⽅法");
 }
}

3.4 切面优先级 @Order

当一个项目有多个切面类存在,并且这些切面类的多个切入点都匹配到了同⼀个目标方法. 当目标方法运行的时候,这些切面类中的通知方法都会执行,那么这几个通知方法的执行顺序是什么样的呢?,下面我们通过代码来验证:(这里只展示了一个切面类,剩下两个切面类代码和它一样)

java 复制代码
@Slf4j
@Component
@Aspect
public class AspectDemo1 {
    @Pointcut("execution(* com.example.demo.controller.*.*(..))")
    private void pt(){}

    //环绕通知
    @Around("pt()")
    public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {
        log.info("do around 1 前");

        //执行目标方法
        Object result=pjp.proceed();

        log.info("do around 1 后");
        return result;
    }
    //前置通知
    @Before("pt()")
    public void doBefore() {
        log.info("do Before 1 ");
    }
    //后置通知
    @After("pt()")
    public void doAfter() {
        log.info("do After 1 ");
    }

通过上述程序的运行结果,可以看出:存在多个切面类时,默认按照切面类的类名字母排序:

  • @Before 通知:字母排名靠前的先执行
  • @After 通知:字母排名靠前的后执行

其实我们可以下面这个图,来理清当存在多个切面类时的执行顺序:

想要定义切面类的执行顺序可以通过 @Order 来定义,@Order 中数字越小,优先级越高

java 复制代码
@Aspect
@Component
@Order(3)
public class AspectDemo1 {
 //...代码省略 
}

@Aspect
@Component
@Order(2)
public class AspectDemo2 {
 //...代码省略 
}

@Aspect
@Component
@Order(1)
public class AspectDemo3 {
 //...代码省略 
}

3.5 切点表达式

上面的代码中, 我们⼀直在使用切点表达式来描述切点. 下面我们来介绍⼀下切点表达式的语法.

切点表达式常见有两种表达方式:

  1. execution(......):根据方法的签名来匹配
  2. @annotation(......): 根据注解匹配

3.5.1 execution 表达式

execution() 是最常常的切点表达式,用来匹配方法,语法为:

execution(<访问修饰符><返回类型> <包名.类名.方法(方法参数)> <异常> )

其中: 访问修饰符 和 异常 是可以省略的

切点表达式支持通配符表达:

通配符 作用说明 具体使用场景
* 匹配任意字符,只匹配一个元素 (返回类型,包,类名,方法或者方法参数) 1. 包名使用 * 表示任意包 (一层包使用一个 *) 2. 类名使用 * 表示任意类 3. 返回值使用 * 表示任意返回值类型 4. 方法名使用 * 表示任意方法 5. 参数使用 * 表示一个任意类型的参数
.. 匹配多个连续的任意符号,可以通配任意层级的包,或任意类型,任意个数的参数 1. 使用 .. 配置包名,标识此包以及此包下的所有子包2. 可以使用 .. 配置参数,任意个任意类型的参数

示例1:

execution(public String com.example.demo.controller.TestController.t1())

表示:com.example.demo.controller包下的TestController类中的public修饰,返回类型为String,方法名为t1,无参方法

示例2:

execution(* com.example.demo.controller.*.*(..))

表示:com.example.demo.controller包中所有类的所有方法

示例3:

execution(String com.example.demo.controller.TestController.t1())

表示:com.example.demo.controller包下TestController类中的返回类型为String,并且方法名为t1的无参方法

补( execution 表达式中的):

  • 访问修饰符:在 execution 表达式中,它是方法匹配的权限条件 ------ 只有方法的实际访问修饰符与表达式中指定的一致,才会被匹配;如果省略访问修饰符,则表示匹配所有访问修饰符的方法(不限制权限)。
  • 异常:不省略异常部分时,匹配的是「声明抛出指定异常或其子类异常」的方法(父异常表达式可匹配子类异常);

3.5.2 @annotation

execution表达式更适用有规则的, 如果我们要匹配多个无规则的方法呢----execution表达式就不适用了

这个时候我们使用 execution 这种切点表达式来描述就不是很方便了.我们可以借助自定义注解的方式以及另⼀种切点表达式 @annotation 来描述这⼀类的切点

使用 @annotation 可分为三步:

  1. 编写自定义注解

  2. 使用 @annotation 表达式来描述切点

  3. 在连接点的方法上添加自定义注解

  • 编写自定义注解

自定义注解,我们选择 Annotation

java 复制代码
package com.example.demo.aspect;


@Target({ ElementType.METHOD}) //定义注解为方法注解
@Retention(RetentionPolicy.RUNTIME) //注解生命周期
public @interface TimeRecord {
}

代码解释:

@Target标识了 Annotation 所修饰的对象范围,即该注解可以用在什么地方. 常用取值:

ElementType.TYPE:用于描述类、接⼝(包括注解类型)或enum声明

ElementType.METHOD:描述方法

ElementType.PARAMETER: 描述参数

ElementType.TYPE_USE:可以标注任意类型

@Retention 指Annotation被保留的时间长短,标明注解的⽣命周期

  • 使用 @annotation 表达式来描述切点
java 复制代码
@Slf4j
@Component
@Aspect
public class AspectDemo {
    @Pointcut("@annotation(com.example.demo.aspect.TimeRecord)")
    private void pt(){}

    //环绕通知
    @Around("pt()")
    public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {
        log.info("do around 1 前");

        //执行目标方法
        Object result=pjp.proceed();

        log.info("do around 1 后");
        return result;
    }
    //前置通知
    @Before("pt()")
    public void doBefore() {
        log.info("do Before 1 ");
    }
    //后置通知
    @After("pt()")
    public void doAfter() {
        log.info("do After 1 ");
    }


}
  • 在连接点的方法上添加自定义注解
java 复制代码
 @TimeRecord
    @RequestMapping("/t1")
    public void t1(){
        System.out.println("执行目标方法");
    }

表示:只有在方法上显式添加了 @TimeRecord 注解时,该方法才会被切面拦截,触发 @Around/@Before/@After 这些通知逻辑;没有加这个注解的方法,不会有任何切面行为。

查看日志:

四. Spring AOP 原理

上面我们学习了如何使用 Spring AOP,现在我们来学习Spring是如何实现 AOP的

Spring AOP是基于动态代理来实现AOP的

知道结论后,我们就需要了解两点:

  1. 什么是代理模式
  2. Spring AOP实现的是那种代理

4.1 代理模式

代理模式, 也叫委托模式.

代理模式定义:为其他对象提供⼀种代理以控制对这个对象的访问.它的作用就是通过提供⼀个代理类,让我们在调用目标方法的时候,不再是直接对目标方法进行调用,而是通过代理类间接调用.在某些情况下,⼀个对象不适合或者不能直接引用另⼀个对象,而代理对象可以在客户端和目标对象之间起到中介的作用.

使用代理前:

使用代理后:

代理模式的三个主要角色:

角色 核心定义 核心作用 典型示例
抽象主题(Subject) 以接口 / 抽象类定义的基础约定,规定目标对象和代理对象的公共方法规范 保证代理与目标对象接口一致,外界调用无需区分二者(符合里氏替换原则) 租房场景:RentHouse接口(定义rent()方法);Spring AOP:UserService的方法签名
目标对象(RealSubject) 代理模式中真正执行业务核心逻辑的角色,是代理对象包裹的核心 仅专注核心业务,不处理任何额外增强逻辑 租房场景:房东(仅负责租房);Spring AOP:UserService实例(仅处理查询用户核心逻辑)
代理对象(Proxy) 持有目标对象引用、实现抽象主题接口,对外伪装成目标对象的核心执行者 1. 控制访问:校验权限 / 参数,决定是否允许访问目标对象;2. 增强功能:在目标方法前后添加日志、计时等额外逻辑 租房场景:中介(筛选租客 + 收中介费);Spring AOP:动态代理对象(执行计时 / 日志通知逻辑)

代理模式可以在不修改被代理对象的基础上, 通过扩展代理类, 进行⼀些功能的附加与增强.

根据代理的创建时期,代理模式分为静态代理和动态代理.

  • 静态代理:由程序员创建代理类或特定⼯具自动生成源代码再对其编译,在程序运行前代理类的 .class文件就已经存在了.
  • 动态代理:在程序运行时,运用反射机制动态创建而成.

4.1.1 静态代理

静态代理是代理模式中最基础的一种实现方式,核心思想是在不修改目标对象代码的前提下,通过一个代理类来控制对目标对象的访问

之所以称为"静态",是因为代理类在编译期就已经确定(不是运行时动态生成的),代理类和目标类的关系在代码编写阶段就固定了。

4.1.2 动态代理

相比于静态代理来说,动态代理更加灵活. 我们不需要针对每个目标对象都单独创建⼀个代理对象,而是把这个创建代理对象的工作推迟到程序运行时由JVM来实现.也就是说动态代理在程序运行时,根据需要动态创建⽣成.

动态代理是代理模式的进阶实现方式,核心解决了静态代理 "一个目标类对应一个代理类、代码冗余" 的问题。它的关键特点是:代理类不是在编译期手动编写的,而是在程序运行时由框架(如 JDK、CGLIB)动态生成的。也就是说,你只需要编写一套通用的代理逻辑,就能代理任意多个目标类,无需为每个目标类单独写代理类。

Java也对动态代理进行了实现,并给我们提供了⼀些API,常见的实现方式有两种:

1. JDK动态代理

步骤

  1. 定义⼀个接口及其实现类(静态代理中的 HouseSubject 和 RealHouseSubject )

  2. 自定义InvocationHandler 并重写 invoke 方法,在invoke 方法中我们会调用目标方法(被代理类的方法)并自定义⼀些处理逻辑

  3. 通过Proxy.newProxyInstance(ClassLoader loader,Class[ ] interfaces,InvocationHandler h) 方法创建代理对象

2. CGLIB动态代理

JDK动态代理有⼀个最致命的问题是其只能代理实现了接⼝的类. 有些场景下,我们的业务代码是直接实现的,并没有接⼝定义.为了解决这个问题,我们可以用 CGLIB动态代理机制 CGLIB(Code Generation Library)是⼀个基于ASM的字节码生成库,它允许我们在运行时对字节码进行修改和动态⽣成. CGLIB 通过继承方式实现代理,很多知名的开源框架都使用到了CGLIB.

步骤:

  1. 定义⼀个类(被代理类)

  2. 自定义 MethodInterceptor 并重写 intercept 方法, intercept 用于增强目标方法,和 JDK 动态代理中的 invoke 方法类似 3. 通过 Enhancer 类的 create()创建代理类

代理类型 实现原理 核心限制
JDK 动态代理 基于 Java 反射,生成实现目标接口的代理类 只能代理接口,无法直接代理无接口的普通类
CGLIB 动态代理 基于 ASM 字节码框架,生成目标类的子类 可代理接口 / 类,但无法代理 final 类 /final 方法(无法继承 / 重写)

4.2 Spring AOP实现的是那种代理

Spring AOP是基于动态代理来实现AOP的

而 JDK 与 CGLIB 这两种动态代理实现,Spring AOP 都在使用

只不过 Spring AOP 默认使用CGLIB代理

JDK与CGLIB的使用条件:

  • JDK只能代理接口
  • CGLIB 即能代理接口,也能代理类

Spring AOP 具体使用那种代理是可以配置的-----spring.aop.proxy-target-class=true/false

配置值 代理方式 核心规则
false JDK 动态代理 ① 目标类实现了接口 → 用 JDK 动态代理; ② 目标类无接口 → 自动降级为 CGLIB 代理
true CGLIB 动态代理 强制使用 CGLIB 代理(无论目标类是否有接口)

补:如果强制让JDK代理类,会报错

相关推荐
阿聪谈架构2 小时前
第06章:AI RAG 检索增强生成 — 从零到生产(下)
人工智能·后端
第二只羽毛2 小时前
C++ 高并发内存池4
java·大数据·linux·c++·算法
有一个好名字2 小时前
常用注册中心大全(主流 5 个)介绍
java
NCIN EXPE2 小时前
SpringBoot教程(三十二) SpringBoot集成Skywalking链路跟踪
spring boot·后端·skywalking
yhyyht2 小时前
JVM调优学习笔记(一)
后端
lagrahhn2 小时前
python面向对象中__new__和__init__区别
后端·python·程序员
Aroaku2 小时前
Spring AOP:从 注解配置 到 通知执行顺序 详解
spring
watersink2 小时前
第7章 软件架构设计
java·开发语言
echome8882 小时前
Go 语言并发编程:Goroutine 与 Channel 实战指南
后端·golang