【Spring】深入探索 Spring AOP:概念、使用与实现原理解析


前言

在现在的软件开发中,面向对象编程(Object-Oriented Programming,即 OOP)已经成为主流的编程范式,它以对象作为程序的基本构件单元,通过封装、继承和多态等特性来组织代码,实现可维护性和可扩展性的应用程序。但是,随着软件规模的不断扩大和复杂度的增加,OOP 在某些情况下可能就面临一些挑战了。

一个常见的问题是,业务逻辑和横切关注点(cross-cutting concerns)之间的耦合问题。所谓的 横切关注点就是那些在应用程序中分布广泛,与核心业务逻辑代码相互交织在一起的功能,例如登录判断、日志记录、事务管理、权限控制等 。将这些关注点与核心业务逻辑紧密耦合在一起会导致代码的重复和难以维护。因此,在这种情况下,一种面向切面编程(Aspect-Oriented Programming,简称 AOP)的编程范式应运而生。

一、初识 Spring AOP

在传统的面向对象编程中,我们通过封装、继承和多态等方式来组织代码,将相关的功能逻辑封装到对象的方法中。然而,在实际开发中,除了核心的业务逻辑,往往还有一些与之关系不大但却必要的功能,如日志记录、性能监测、安全控制等。这些功能通常会散布在代码中的多个地方,导致代码的重复性和难以维护性。

1.1 什么是 AOP

面向切面编程(Aspect-Oriented Programming,简称 AOP)正是为了解决这种代码重复性和难以维护的问题而产生的一种编程范式。AOP 的核心思想是将横切关注点(cross-cutting concerns)从主要的业务逻辑中分离出来,以模块化的方式进行管理。横切关注点是那些在应用程序中分布广泛、与核心业务逻辑交织在一起的功能,例如日志、事务、权限等。

AOP 将系统划分为两个主要部分:核心关注点和横切关注点核心关注点即应用程序的主要业务逻辑,而横切关注点则是与核心关注点无关但却必要的功能

AOP 通过将横切关注点抽象成称为切面(Aspect)的模块,然后将其独立于核心业务逻辑进行管理和维护。这样一来,我们可以在不修改主要业务逻辑的情况下,灵活地添加、修改或删除各种功能,从而提高了代码的可维护性和可扩展性。

1.2 什么是 Spring AOP

就如同 IoC 与 DI 之间的关系一样,AOP是一种思想,而 Spring AOP 则是 AOP 思想的一种实现。同时,Spring AOP也是 Spring 框架中的一个重要模块,它允许开发者能够轻松地实现面向切面编程。

简单来说,Spring AOP 建立在传统的 AOP 概念之上,提供了一种更简单和便捷方式来管理横切关注点。它通过代理技术在核心关注点的前、后或环绕执行切面的逻辑,从而实现了横切关注点的模块化。与传统的面向对象编程相比,使用 Spring AOP 可以更好地分离关注点,使得代码更加清晰、可维护性更高。

Spring AOP 支持两种类型的代理:基于接口的 JDK 动态代理基于类的 CGLIB 动态代理。它还提供了一系列的注解和配置选项,让开发者能够灵活地定义切面和通知(Advice),并将其应用到不同的目标对象上。

二、AOP 的核心概念

在面向切面编程(AOP)中,有几个核心概念是关键的,它们帮助我们理解如何实现横切关注点的管理和应用。这些概念包括切面、切点、通知和连接点,它们一起构成了 AOP 的基础。

2.1 切面(Aspect)

切面(Aspect)由切点(Pointcut)和通知(Advice)组成,它是对横切关注点的抽象化。它定义了在哪些切点上应该执行什么样的横切逻辑。切面是一个模块化的单元,它将通用的横切关注点从主要业务中分离出来,以便更好的管理和维护。

更加通俗地说,切面就相当于 Java 中的一个类,它代表了某一方面的具体内容,类似于一个代码模块。

  • 举例来说,我们可以把用户登录判断看作一个“切面”,其中包含了在用户登录过程中需要执行的逻辑。同样地,日志的统计记录也是一个“切面”,其中包括了在代码执行时需要记录日志的相关操作。
  • 切面就像是一个装有特定功能的工具箱,每个工具箱里都放着一组相关的操作。在代码中,切面定义了在哪些地方(切点)以及如何执行这些操作。当我们需要在不同的地方应用相似的功能时,我们可以创建一个切面,将相关的操作封装在里面。

2.2 切点(Pointcut)

切点(Pointcut)可以被理解为匹配连接点(Join Point)的谓词,或者说它是一组规则的集合,用于选择在哪些连接点上应用横切逻辑。切点使用 AspectJ pointcut expression language 来描述这些规则,该语言允许您灵活地定义匹配条件,以便精确地选择特定的连接点。

AspectJ pointcut expression language 是一种用于定义切点(Pointcut)的表达式语言,它是 AspectJ 框架中的一个关键部分。这个表达式语言允许我们灵活地描述在哪些连接点上应该应用通知(Advice)。

切点的作用就是为我们提供了一种方式来定义规则,以便筛选出满足条件的连接点。当连接点满足切点定义的规则时,我们就可以将通知(Advice)应用到这些连接点上,从而在这些特定的位置插入横切逻辑。这使得我们可以有选择地在代码中插入特定的行为,而不需要将横切关注点与主要业务逻辑耦合在一起。

2.3 通知(Advice)

在 AOP 中,切面不仅仅是一个抽象概念,它是有着明确目标的。切面需要完成特定的任务,而在 AOP 术语中,这些任务被称为通知(Advice)通知定义了切面的具体行为:它解决了切面是什么、何时使用以及在何时执行这些行为的问题

在 Spring 的切面类中,可以使用不同的注解来标记方法作为通知方法,这些通知方法在满足条件时会被调用:

  • 前置通知使用 @Before:通知方法会在目标方法调用之前执行,可以执行一些预处理操作。

  • 后置通知使用 @After:通知方法会在目标方法返回或抛出异常后调用,可以执行一些善后操作。

  • 返回之后通知使用 @AfterReturning:通知方法会在目标方法成功返回后调用,可以处理返回值。

  • 抛异常后通知使用 @AfterThrowing:通知方法会在目标方法抛出异常后调用,可以处理异常情况。

  • 环绕通知使用 @Around:通知包裹了被通知的方法,在目标方法调用之前和之后执行自定义的行为,也可以控制是否调用目标方法以及如何处理返回值。

2.4 连接点(Join Point)

连接点代表了程序执行过程中的实际点,它包括了所有能够触发切点的点。换句话说,连接点是切点在代码中的实际发生的实例。通知(Advice)会在连接点上执行,从而实现横切关注点的添加或修改。连接点是 AOP 中被拦截的点。

AOP 整个组成部分的概念如下图所示,以多个页面都要访问用户登录权限为例:

除了切面、切点、通知和连接点之外,AOP 还涉及一些其他重要的概念和术语。以下是一些相关的概念:

  1. 引入(Introduction): 引入是一种特殊类型的通知,它允许向现有的类添加新方法或字段。因此实现了可以将新功能引入到现有的类中,而无需修改其代码。

  2. 目标对象(Target Object): 目标对象是应用程序中的对象,被切面影响和拦截的对象。切面会在目标对象的连接点上应用通知。

  3. 代理(Proxy): 代理是切面对目标对象的包装。它允许切面将通知插入到目标对象的连接点上,实现横切逻辑。代理可以通过静态代理、JDK 动态代理或 CGLIB 动态代理来实现。

  4. 织入(Weaving): 织入是将切面与目标对象连接的过程。在编译时、加载时或运行时,切面的通知被插入到目标对象的连接点上,从而实现横切关注点的逻辑。

  5. 通知顺序(Advice Order): 如果在一个切点上有多个通知,通知的执行顺序可能很重要。通知顺序定义了多个通知在切点上执行的先后顺序。

  6. 切面优先级(Aspect Priority): 如果在应用中存在多个切面,切面的优先级可以影响通知的执行顺序。切面优先级决定了多个切面之间的先后执行顺序。

  7. 动态切面(Dynamic Aspects): 动态切面是在运行时根据某些条件确定是否要应用切面的一种机制。允许根据需要动态地选择是否将切面应用于目标对象。

三、Spring AOP 的使用

以上的概念可以说都是晦涩难懂的,下面通过使用 Spring AOP 来模拟实现一下 AOP 的功能,可以更好的帮助我们理解 AOP。此时我们完成的目标是拦截 UserController 中的所有方法,每次调用其中一个方法的时候,都会执行相应的通知事件。

使用 Spring AOP 的步骤如下:

  1. 添加 Spring AOP框架支持;
  2. 定义切面和切点;
  3. 定义相关的通知方法。

3.1 添加 Spring AOP框架支持

pom.xml 中添加如下配置:

<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-aop -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
    <version>2.7.14</version>
</dependency>

3.2 定义切面和切点

切面代表的是某一方面的具体内容,比如用户登录权限的判断,而此处的切面就是对UserController类的处理。其中的就是就是定义具体的拦截规则。例如下面的代码:

@Aspect // 表示定义一个切面(类)
@Component
public class UserAspect {
    
    
    // 创建切点(方法)定义拦截规则
    @Pointcut("execution(public * com.example.demo.controller.UserController.*(..))")
    public void pointcut() {
    
    
    }
}

在这段代码中,创建了一个切面类 UserAspect,并使用 @Aspect 注解标记它为一个切面。同时,使用 @Component 注解将这个切面声明为一个 Spring 管理的组件,以便让 Spring 容器能够管理和识别它。

UserAspect 类中,使用 @Pointcut 注解定义了一个切点,该切点的拦截规则是匹配 UserController 类中的所有公共方法(public * com.example.demo.controller.UserController.*(..))。这意味着我们的切点会在 UserController 类的所有公共方法上触发。

需要注意的是,pointcut 方法的方法体可以为空,因为 @Pointcut 注解仅用于定义切点表达式,实际的逻辑代码会在通知方法中实现。

切点表达式说明:
切点表达式是用来定义切点的匹配规则,它决定了在哪些连接点上应用通知。AspectJ 支持三种通配符来构建切点表达式:

  • *:匹配任意字符,只匹配一个元素(包、类、或方法、方法参数)。
  • ..:匹配任意字符,可以匹配多个元素。在表示类时,必须和 * 联合使用,例如:com.cad..* 表示 com.cad 包下的所有子孙包中的所有类。
  • +:表示按照类型匹配指定类的所有类。必须跟在类名后面,如 com.cad.Car+ 表示继承该类的所有子类包括本身。

切点表达式通常使用 execution() 切点函数,它是最常用的用来匹配方法的切点函数。

切点表达式示例:
以下是一些切点表达式的示例,以便更好地理解如何构建切点规则:

  • execution(* com.cad.demo.User.*(..)):匹配 User 类中的所有方法。
  • execution(* com.cad.demo.User+.*(..)):匹配 User 类的子类中的所有方法,包括本身。
  • execution(* com.cad.*.*(..)):匹配 com.cad 包下所有类的所有方法。
  • execution(* com.cad..*.*(..)):匹配 com.cad 包下、以及其所有子孙包中的所有类的所有方法。
  • execution(* addUser(String, int)):匹配 addUser 方法,且第一个参数类型是 String,第二个参数类型是 int

3.3 定义相关的通知方法

通知定义的是被拦截的方法具体要执行的业务,比如用户登录权限验证方法就是具体要执行的业务,而此次只是进行简单的打印输操作。在 Spring AOP 中,可以在方法上使用以下注解,会设置方法为通知方法,在满足条件后会通知本方法进行调用:

  • 前置通知使用 @Before:通知方法会在目标方法调用之前执行,可以执行一些预处理操作。

  • 后置通知使用 @After:通知方法会在目标方法返回或抛出异常后调用,可以执行一些善后操作。

  • 返回之后通知使用 @AfterReturning:通知方法会在目标方法成功返回后调用,可以处理返回值。

  • 抛异常后通知使用 @AfterThrowing:通知方法会在目标方法抛出异常后调用,可以处理异常情况。

  • 环绕通知使用 @Around:通知包裹了被通知的方法,在目标方法调用之前和之后执行自定义的行为,也可以控制是否调用目标方法以及如何处理返回值。

此处具体实现的通知代码如下:

// 创建一个切面(类)
@Aspect
@Component
public class UserAscept {
    
    
    // 创建切点(方法)定义拦截规则
    @Pointcut("execution(public * com.example.demo.controller.UserController.*(..))")
    public void pointcut() {
    
    
    }

    // 前置通知
    @Before("pointcut()")
    public void doBefore() {
    
    
        System.out.println("执行了前置通知:" + LocalDateTime.now());
    }

    // 后置通知
    @After("pointcut()")
    public void doAfter() {
    
    
        System.out.println("执行了后置通知:" + LocalDateTime.now());
    }

    // 返回后通知
    @AfterReturning("pointcut()")
    public void doAfterReturning() {
    
    
        System.out.println("执行了返回后通知:" + LocalDateTime.now());
    }

    // 抛异常后通知
    @AfterThrowing("pointcut()")
    public void doAfterThrowing() {
    
    
        System.out.println("抛异常后通知:" + LocalDateTime.now());
    }

    // 环绕通知
    @Around("pointcut()")
    public Object doAround(ProceedingJoinPoint joinPoint) {
    
    

        Object proceed = null;
        System.out.println("Around 方法开始执行:" + LocalDateTime.now());
        try {
    
    
            // 执行拦截的方法
            proceed = joinPoint.proceed();
        } catch (Throwable e) {
    
    
            e.printStackTrace();
        }
        System.out.println("Around 方法结束执行: " + LocalDateTime.now());
        return proceed;
    }
}

在这段代码中,定义了不同类型的通知方法,每个方法都使用了相应的注解标记,如 @Before@After@AfterReturning@AfterThrowing@Around。这些注解将通知方法与切点(使用 pointcut() 方法定义的切点)关联起来。当调用 UserController 中的方法时,就会被切点拦截,然后执行这些相应的通知方法。

关于上述环绕方法的说明:

其中,环绕通知在 Spring AOP 中是最为特殊和灵活的通知类型。它允许开发人员完全控制拦截的方法调用,包括在目标方法执行前后以及捕获异常时都能够插入自定义的逻辑。这种特性使得环绕通知非常强大,适用于需要全面控制横切逻辑的场景。

在上述代码中展示了如何实现环绕通知。环绕通知使用 @Around 注解标记,通知方法的参数是一个 ProceedingJoinPoint 对象,它允许我们在适当的时候调用 proceed() 方法来执行拦截的方法。

在这个环绕通知方法中:

  1. 方法的参数 joinPoint 是一个 ProceedingJoinPoint 对象,它代表了目标方法的调用点,可以在需要的时候调用 proceed() 方法来执行拦截的方法。

  2. 可以在 proceed() 方法调用前后插入自定义的逻辑,实现特定的横切行为。

  3. 可以捕获可能抛出的异常,对异常情况进行处理。

  4. 通过返回值,您可以控制目标方法的返回值或进行适当的返回值处理。

四、Spring AOP 的实现原理

4.1 动态代理

Spring AOP 是建立在动态代理的基础之上的,因此它的支持范围局限于方法级别的拦截。这意味着 Spring AOP 主要用于拦截和管理方法调用,而不能直接拦截类级别的操作。

在 Spring AOP 中,有两种方式来实现动态代理:JDK Proxy 和 CGLIB。默认情况下,Spring AOP 会根据目标类是否实现接口来选择使用哪种代理方式:

  • JDK Proxy: 如果目标类实现了接口,Spring AOP 将使用 JDK Proxy 来生成代理类。JDK Proxy 基于 Java 标准库中的 java.lang.reflect.Proxy 类实现,要求目标类必须实现至少一个接口。

  • CGLIB: 如果目标类没有实现接口,Spring AOP 将使用 CGLIB 来生成代理类。CGLIB 是一个功能强大的第三方库,它可以在运行时动态地创建类的子类,以实现代理。

这意味着,当使用 Spring AOP 时,如果目标类实现了接口,Spring 将会使用 JDK Proxy 来创建代理。如果目标类没有实现接口,Spring 将使用 CGLIB 来创建代理。在某些情况下,您可能需要注意 CGLIB 代理可能会带来的一些细微影响,例如 final 方法无法被拦截等。

4.2 JDK 动态代理

JDK Proxy 是 Java 标准库中的一种动态代理机制。 它基于目标类实现的接口来创建代理对象,从而在方法调用前后插入横切逻辑。JDK Proxy 主要用于拦截实现了接口的类的方法调用。

JDK Proxy 的实现方式:

  1. 首先,通过实现 InvocationHandler 接口创建一个方法调用处理器。这个处理器定义了在拦截方法调用时要执行的逻辑。
  2. 然后,使用 Proxy 类来创建代理对象。Proxy 类的 newProxyInstance() 方法接受一个 ClassLoader、一个接口数组和一个 InvocationHandler 对象作为参数,然后动态生成代理类的字节码,创建代理实例。

JDK Proxy 的限制:

  • JDK Proxy 要求目标类必须实现至少一个接口。它无法直接拦截没有实现接口的类的方法调用。
  • JDK Proxy 创建的代理对象实现了目标类实现的接口,因此代理对象只能调用接口中定义的方法。

4.3 CGLIB 动态代理

CGLIB 是一个功能强大的第三方库,用于在运行时创建类的子类,从而实现代理。 它的主要特点在于可以在运行时动态地生成类的子类,而不需要目标类实现接口,从而实现拦截和增强目标类的方法调用。

CGLIB 的特点和优势:

  • CGLIB 可以代理未实现接口的类。这使得它适用于更广泛的场景,包括那些没有接口的类。
  • CGLIB 使用继承机制来实现代理,生成目标类的子类,并在子类中插入横切逻辑。因此,它可以拦截目标类的所有方法,无论是实例方法还是静态方法。
  • 由于使用继承来生成代理类,CGLIB 无法代理被声明为 final 的方法。这是一个需要注意的限制。
  • CGLIB 的代理速度通常比 JDK Proxy 稍慢,因为它涉及到创建和加载代理类的字节码。

4.4 JDK Proxy 和 CGLIB 动态代理的区别

当使用 Spring AOP 创建代理时,可以选择使用 JDK Proxy 或 CGLIB 来实现动态代理。这两种代理方式有一些区别,以下是它们的总结:

JDK Proxy:

  • 基于 Java 标准库中的 java.lang.reflect.Proxy 类实现。
  • 要求目标类必须实现至少一个接口,因为它基于接口来创建代理。
  • 通过实现代理接口的方法,在方法调用前后插入横切逻辑。
  • 由于使用接口作为代理的基础,生成的代理对象只能调用接口中定义的方法。
  • 通常情况下,JDK Proxy 的性能相对较高。

CGLIB:

  • 是一个第三方库,用于在运行时生成类的子类实现代理。
  • 不需要目标类实现接口,可以代理未实现接口的类。
  • 通过继承机制,在目标类的子类中插入横切逻辑。
  • 能够拦截目标类的所有方法,包括实例方法和静态方法。
  • 由于使用继承和生成代理类的字节码,CGLIB 无法代理被声明为 final 的方法。
  • 代理速度相对较慢,因为涉及生成和加载代理类的字节码。

如何选择:

  • 如果目标类实现了接口,首选使用 JDK Proxy。它提供了更高的性能,适用于基于接口的代理。
  • 如果目标类没有实现接口,或者需要拦截未实现接口的类,可以选择使用 CGLIB。它更加灵活,适用于更广泛的代理场景。

猜你喜欢

转载自blog.csdn.net/qq_61635026/article/details/132207155