掘金 后端 ( ) • 2024-04-20 17:24

1. 什么是Spring AOP

AOP的全称是Aspect Oriented Programming,也就是面向切面编程,是一种思想。它是针对OOP(面向对象编程)的一种补充,是对某一类事情的集中处理。比如一个博客网站的登陆验证功能,在用户进行新增、编辑、删除博客等操作前都需要进行用户的登陆验证,我们在对这些业务编码时,都需要考虑一下用户的登录验证。对于这种功能统一,且使用较多的功能,就可以考虑通过AOP来统一处理了。 引入AOP的思想之后,我们在处理其他业务的时候就不需要再考虑如登录验证这样的其他功能。

Spring AOP是Spring公司针对AOP思想提供的一种实现方式,除了登录验证的功能之外,Spring AOP还可以实现:统一日志记录、统一返回格式设置、统一异常处理等。

2. AOP的基本术语

2.1 切面(Aspect)

切面相当于AOP实现的某个功能的集合,比如说登录验证功能,切面是由切点(Pointcut)和通知(Advice)组成的

2.2 连接点(Join Point)

连接点是应用执行过程中能够插入切面的一个点。比如说一个博客系统,包含许多业务,其中可以插入切面的业务都可以称为连接点。

2.3 切点(Pointcut)

Pointcut的作用就是提供一组规则来匹配连接点(Join Point),比如说对于博客系统,它有一些url是不需要做登录验证功能的,比如注册业务,通过切点提供的这么一组规则,在不需要登录验证的地方,它就不会进行登录验证。

2.4 通知(Advice)

通知是切面要完成的工作,它定义了切面的具体实现是什么、何时使用,描述了切面要完成的工作以及何时执行这个宫欧的问题。Spring切面类中,可以在方法上使用以下注解,会设置方法为通知方法,在满足条件后会通知本方法进行调用:

  • 前置通知(@Before):在目标方法调用之前执行。
  • 后置通知(@After):在目标方法返回或抛出异常后调用。
  • 返回之后通知(@AfterReturning):在目标方法返回后调用。
  • 抛异常后通知(@AfterThrowing):在目标方法抛出异常后调用。
  • 环绕通知(@Around):通知包裹了目标方法,在被目标方法执行之前和调用之后执行自定义的行为。

2.5 图解

以用户的登录验证为例,用图来让大家更好的理解上述定义体现的思想:

Untitled Diagram.drawio-3.png

接下来我们就来对面向切面编程的思想进行实现,如果看到这还是没有很理解AOP的思想的话,可以结合后面的实现代码再来看看这张图表达的意思。

3. Spring AOP 使用

Spring AOP的使用大体分为下面四步:

  1. 添加 Spring AOP的依赖
  2. 连接点方法的编写
  3. 定义切面和通知
  4. 定义切点

3.1 原生Maven项目中Spring AOP的使用

Spring AOP同样有两种实现方式,一种是使用xml配置的方式,一种是使用注解的方式。

在这里我将在原生的Maven项目中使用两种方式来实现搭乘地铁的业务,使用AOP的方式编写一个切面,并在切面里运用五种通知来实现搭乘地铁业务的安全检查(前置通知)、刷卡进出站(环绕通知)、通知异常(异常通知)、到达通知(返回后通知)、记录行程(后置通知)

3.1.1 xml方式使用Spring AOP

源码位置:spring-aop

1. 添加依赖

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context</artifactId>
  <version>5.2.9.RELEASE</version>
</dependency>

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-aspects</artifactId>
  <version>5.2.9.RELEASE</version>
</dependency>

2. 连接点方法的编写:在service包下新建一个SubwayService来实现乘坐地铁的业务

public class SubwayService {
    public void takeSubway() {
        System.out.println("乘坐地铁,行驶中...");
        //int n = 10/0;
    }
}

3. 定义切面类和通知方法:在aspect包下新增一个SubwayAspect类,并在里面编写对应的通知方法

public class SubwayAspect {
    public void securityCheckAdvice() {
        System.out.println("前置通知:开始安全检查");
    }
    public void recordAdvice() {
        System.out.println("后置通知:记录本次行程");
    }

    public void expressionAdvice() {
        System.out.println("异常通知:运行过程出现异常");
    }

    public void swipeAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("环绕通知start:开始并刷卡进站");
        joinPoint.proceed();
        System.out.println("环绕通知finish:结束并刷卡出站");
    }

    public void arriveDestination() {
        System.out.println("返回后通知:到达目的地");
    }
}

注意事项:swipeAdvice()是环绕通知的方法,由于其是环绕通知,因此会在连接点方法开始前和结束后的时候分别执行不同的逻辑,因此需要使用一个ProceedingJoinPoint的对象来对应连接点的方法,并使用proceed()来执行连接点方法,分别在joinPoint.proceed();语句执行的前后编写编写环绕通知。

光编写完切面和通知还没什么用,还需要在xml文件中配置才行。

配置schema路径,直接复制即可:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
        https://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context
        https://www.springframework.org/schema/context/spring-context.xsd
        http://www.springframework.org/schema/aop
        https://www.springframework.org/schema/aop/spring-aop.xsd">

</beans>

将类注册到Spring容器中,这一步不多赘述:

<bean id="subwayAspect" class="com.chenshu.xml_aop.aspect.SubwayAspect"></bean>
<bean id="subwayService" class="com.chenshu.xml_aop.service.SubwayService"></bean>

配置AspectAdvice

  1. <beans>标签内添加一对<aop:config>的标签,有关aop的配置都放在这里面
  2. <aop:config>标签内添加一对<aop:aspect>标签,id属性的值自己定义,用于标识一个切面的idref属性里面的值对应前面注册入Spring的Aspect类的beanid
  3. <aop:aspect>标签内配置通知方法,不同的通知方法对应不同的标签,其中有method属性对应前面编写的通知方法的方法名,以及一个pointcut-ref来定义该通知的切点,由于我们的切点还未定义,因此这里用一个"?"替代
<aop:config>

    <aop:aspect id="subwayAspect" ref="subwayAspect">
        <aop:before method="securityCheckAdvice" pointcut-ref="?"/>
        <aop:after method="recordAdvice" pointcut-ref="?"/>
        <aop:after-throwing method="expressionAdvice" pointcut-ref="?"/>
        <aop:around method="swipeAdvice" pointcut-ref="?"/>
        <aop:after-returning method="arriveDestination" pointcut-ref="?"/>
    </aop:aspect>

</aop:config>

4. 编写切点:这里我们就要根据切点表达式来创建一个切点用于描述一组匹配规则

【引入】切点表达式

一个切点表达式就是形如上面expression属性中的内容,切点表达式中可以包括以下内容:

  • execution(表达式前缀)
  • 权限修饰符(如public、private)
  • 方法返回类型(如void、String)
  • 包名
  • 类名
  • 方法名
  • 方法的参数列表

下面是一些切点表达式的示例:

  • execution(public * com.example.service.SomeService.*(..)):匹配 com.example.service.SomeService 类中所有 public 方法。
  • execution(public void com.example.service.SomeService.*(..)):匹配 com.example.service.SomeService 类中所有 public void 方法。
  • execution(* com.example.service.*.*(..)):匹配 com.example.service 包下所有类的所有方法。
  • execution(* com.example.service.SomeService.*(..)):匹配 com.example.service.SomeService 类的所有方法。
  • execution(* com.example.service.SomeService.*(String)):匹配 com.example.service.SomeService 类中接受一个 String 类型参数的所有方法。
  • execution(* *(..)):匹配任何类中的任何方法。

了解了切点表达式后我们就可以编写切点了:

<aop:pointcut id="takeSubway" expression="execution(* com.chenshu.xml_aop.service.SubwayService.takeSubway())"/>

该切点的名字为takeSubway,切点表达式的意思是匹配com.chenshu.xml_aop.service.SubwayService这个类下的名为takeSubway无传入参数的方法。

然后我们就可以在通知中填入pointcut-ref属性的值了,完整的aop配置如下:

<aop:config>
    <aop:pointcut id="takeSubway" expression="execution(* com.chenshu.xml_aop.service.SubwayService.takeSubway())"/>

    <aop:aspect id="subwayAspect" ref="subwayAspect">
        <aop:before method="securityCheckAdvice" pointcut-ref="takeSubway"/>
        <aop:after method="recordAdvice" pointcut-ref="takeSubway"/>
        <aop:after-throwing method="expressionAdvice" pointcut-ref="takeSubway"/>
        <aop:around method="swipeAdvice" pointcut-ref="takeSubway"/>
        <aop:after-returning method="arriveDestination" pointcut-ref="takeSubway"/>
    </aop:aspect>

</aop:config>

编写测试类进行测试:

public class Application {
    public static void main(String[] args) {
        ApplicationContext context =
                new ClassPathXmlApplicationContext("spring-aop.xml");
        SubwayService subwayService  =
                context.getBean("subwayService", SubwayService.class);
        subwayService.takeSubway();
    }
}

【总结】不同通知的执行顺序

在没有发生异常的情况下结果如下,通过结果可以了解不同通知的执行顺序:

前置通知:开始安全检查
环绕通知start:开始并刷卡进站
乘坐地铁,行驶中...
返回后通知:到达目的地
环绕通知finish:结束并刷卡出站
后置通知:记录本次行程

由于没有出现异常因此看不到抛异常后的通知

制造一个算数异常:

public class SubwayService {

    public void takeSubway() {
        System.out.println("乘坐地铁,行驶中.。。");
        int n = 10/0;
    }
}

发生异常的情况下结果如下,通过结果可以了解不同通知的执行顺序:

前置通知:开始安全检查
环绕通知start:开始并刷卡进站
乘坐地铁,行驶中...
异常通知:运行过程出现异常
后置通知:记录本次行程

由于方法执行一半就抛出异常,因此没有返回后的通知以及环绕通知的后半段

3.1.2 注解实现使用Spring AOP

源码位置:spring-aop_2

谈到Spring AOP的注解,就不得不谈到Spring AOP和AspectJ的关系:Spring AOP 的注解是基于 AspectJ 注解的一种简化和封装。这意味着你可以使用 AspectJ 注解来定义切面,但实际的织入过程是由 Spring AOP 来完成的。

添加依赖和xml方式是一样的,这里就不赘述了。

注解的方式只需要在xml中添加下面两个标签:上面是组件注解的扫描路径,下面是aspectj注解的声明

<context:component-scan base-package="com.chenshu.aop_annotation"/>
<aop:aspectj-autoproxy/>

编写Aspect类:

  1. Aspect类上添加@Aspect注解
  2. 定义一个方法,并在方法上使用@Pointcut注解将其声明一个切点,并在注解内添加value属性的值(切点表达式)
  3. 在通知方法上使用@Before(前置通知)、@After(后置通知)、@AfterThrowing(抛异常后通知)、@Around(环绕通知)、@AfterReturning(返回后的通知)注解声明不同类型的通知,并在注解属性中添加上一步定义的切点"myPointcut()"
@Component
@Aspect
public class SubwayAspect {
    @Pointcut(value = "execution(* com.chenshu.aop_annotation.service.SubwayService.takeSubway())")
    private void myPointcut() {}

    @Before("myPointcut()")
    public void securityCheckAdvice() {
        System.out.println("前置通知:开始安全检查");
    }

    @After("myPointcut()")
    public void recordAdvice() {
        System.out.println("后置通知:记录本次行程");
    }

    @AfterThrowing("myPointcut()")
    public void expressionAdvice() {
        System.out.println("异常通知:运行过程出现异常");
    }

    @Around("myPointcut()")
    public void swipeAdvice(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("环绕通知start:开始并刷卡进站");
        joinPoint.proceed();
        System.out.println("环绕通知finish:结束并刷卡出站");
    }

    @AfterReturning("myPointcut()")
    public void arriveDestination() {
        System.out.println("返回后通知:到达目的地");
    }
}

3.2 Spring Boot项目中Spring AOP的使用

源码位置:MyBatis_demo

这里我直接基于上一篇文章中的代码来演示Spring AOP的使用。

1. 添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

2. 新建一个aspect包编写切面类LoginAspect

@Component
@Aspect
public class LoginAspect {
    @Pointcut("execution(* com.chenshu.mybatis_demo.controller.UserController.*(..))")
    public void myPointcut() {}

    @Before("myPointcut()")
    public void before() {
        System.out.println("进行登录验证");
    }
}

3. 测试切面是否生效:

由于我编写的切面中的前置方法对所有UserController类下的方法都生效,这里我直接访问一下UserControllergetUsers方法的路由"/getall"

image.png

查看日志信息:我们发现在执行getUsers()方法之前成功执行了前置通知

进行登录验证
Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@3e84be0f] was not registered for synchronization because synchronization is not active
2024-04-20 17:07:40.813  INFO 40289 --- [nio-8080-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2024-04-20 17:07:40.890  INFO 40289 --- [nio-8080-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
JDBC Connection [HikariProxyConnection@212054083 wrapping com.mysql.cj.jdbc.ConnectionImpl@3ef56d86] will not be managed by Spring
==>  Preparing: select * from userinfo
==> Parameters: 
<==    Columns: id, username, password, photo, createtime, updatetime, state
<==        Row: 1, zhang, 12345, doge.png, 2024-04-19 13:09:45, 2024-04-19 13:09:45, 1
<==        Row: 2, lisi, 123, , 2024-04-19 13:31:01, 2024-04-19 13:31:01, 1
<==        Row: 3, wangwu, 123, , 2024-04-19 14:17:29, 2024-04-19 14:17:29, 1
<==      Total: 3