Spring AOP
引言
AOP ( Aspect Oriented Programming ) :面向切面编程
AOP 是一种思想,表示对某一类事情的集中处理。Spring AOP 是一个框架,它是对 AOP 思想的实现。
为什么要使用 AOP 思想?
想象一个场景,我们在做用户验证的时候,除了登录和注册不需要做用户验证之外,几乎其他所有页面都需要先验证用户登录的状态,验证成功后,后面的代码才能继续走下去。以往我们的处理方式,是将每个 Controller 都要写一遍用户验证,然而当功能越来越多的时候,即 Controller 越多的时候,那么我们要写的登录验证也越来越多,而这些方法又是相同的,这么多的方法就会增加代码修改和维护的成本。此时,我们就可以考虑利用 AOP 来统一处理了。
而对于功能统一,且使用地方较多的功能,就可以优先考虑 AOP 思想。
一、AOP 组成
1. 切面 (Aspect)
切面包含了通知、切点和切面的类,相当于 AOP 实现某个功能的集合。
2. 连接点 (Join Point)
所有可能触发 AOP 的页面 / 数据,可以被称为连接点。
3. 切点 (Pointcut)
切点相当于保存了众多连接点的一个集合,如果把切点看作成一个表格,那么连接点就是表格中的一个个数据。另外,切点相当于增强的方法。
4. 通知 (Advice)
通知规定了 AOP 执行时机和执行方法。切面的工作就被称之为通知。
AOP 有五种通知:
① 前置通知
② 后置通知
③ 抛出异常之后的通知
④ 返回数据之后的通知
⑤ 环绕通知
二、Spring AOP 的使用
步骤1 添加依赖
由于在创建 Spring Boot 的时候,页面没有为我们提供 AOP 依赖,所以我们在 maven 仓库中,搜索如下依赖,并添加至 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.3</version>
</dependency>
步骤2 添加切面和切点
切点指的是具体要处理的某一类问题,比如用户登录权限验证就是一个具体的问题;记录所有方法的执行日志就是一个具体的问题。
@Aspect // 当前类是一个切面
@Component
public class UserAspect {
// 定义一个切点(设置拦截规则)
@Pointcut("execution(* com.example.demo.controller.UserController.*(..))")
public void point1() {
}
}
注意:
① 在代码中,我们通过 " @Pointcut " 这个注解来定义一个切点,并在注解的括号中注明表达式,表示拦截规则。
② point1 方法为空方法,它并不需要有方法体,它本身的方法名只是作为一个 " 标识 " 作用,这样后面的 Advice 通知就可以使用当前的切点。( 根据不同的拦截规则,切点可以设置多个 )
③ " @Pointcut " 注解后面的表达式遵循了 AspectJ 语法。
AspectJ 语法
execution() 是最常用的切点函数,用来匹配方法,比方说,我们需要对非法用户进行拦截,那么就可以在后端的方法代码中,利用 execution() 函数进行拦截,匹配到哪些方法,哪些方法就需要用来拦截非法用户。
execution(<修饰符><返回类型><包.类.方法(参数)><异常>)
其中,返回类型、方法、参数都不能省略。
AspectJ 支持的三种通配符:
* : 匹配任意字符,只匹配一个元素(包,或类,或方法,或方法参数)
.. : 匹配任意字符,可以匹配多个元素,在表示类时,必须和 * 联合使用
+ : 表示按照类型匹配指定类本身包括其所有子类,必须跟在类名后面
示例:
execution(* com.cad.demo.User.*(..)) :匹配 User 类里的所有方法
execution(* com.cad.demo.User+.*(..)) :匹配该类的子类包括该类的所有方法
execution(* com.cad.*.*(..)) :匹配 com.cad 包下的所有类的所有方法
execution(* com.cad..*.*(..)) :匹配 com.cad 包下、子孙包下所有类的所有方法
execution(* addUser(String, int)) :匹配 addUser 方法,且第一个参数类型是 String,第二个参数类型是 int.
步骤3 添加 Advice 通知
在 UserAspect 类中,实现切面:
@Aspect // 当前类是一个切面
@Component
public class UserAspect {
// 定义一个切点(设置拦截规则)
@Pointcut("execution(* com.example.demo.controller.UserController.*(..))")
public void point1() {
}
// 1. 定义 point1 切点的前置通知
@Before("point1()") // 选择 point1 切点,即遵循其拦截规则
public void doBefore() {
System.out.println("前置通知:被执行了");
}
// 2. 定义 point1 切点的后置通知
@After("point1()")
public void doAfter() {
System.out.println("后置通知:被执行了");
}
// 3. 定义 point1 切点的返回之后通知
@AfterReturning("point1()")
public void doAfterReturning() {
System.out.println("执行了 AfterReturning 方法");
}
// 4. 定义 point1 切点的异常通知
@AfterThrowing("point1()")
public void doAfterThrowing() {
System.out.println("执行了 AfterThrowing 方法");
}
// 5.定义 point1 切点的环绕通知
@Around("point1()")
public Object doAround(ProceedingJoinPoint joinPoint) {
Object result = null;
System.out.println("环绕通知:开始");
try {
// 执行目标方法,以及目标方法所对应的相应的通知
result = joinPoint.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
System.out.println("环绕通知:结束");
return result;
}
}
在 UserController 类中,实现与前端交互:
@RestController
public class UserController {
@RequestMapping("/hello1")
public String hello1() {
System.out.println("hello1 方法被执行了");
return "你好,世界!";
}
@RequestMapping("/hello2")
public String hello2() {
System.out.println("hello2 方法被执行了");
int a = 10 / 0;
return "你好,世界!";
}
}
前端访问 hello1 与 hello2 路径后,查看 IDEA 控制台:
在上面的输出结果中,我们可以清晰地看到五个 Advice 通知的执行顺序。鉴于此,我们就可以在后端的代码中,对前端传来的参数进行一些处理,亦可以对后端即将要返回的数据进行优化。
比方说登录验证,我们就可以在前置通知中,设置一个拦截器,也就是说在前端与后端交互的 Controller 层之前,就可以直接拦截非法用户。
比方说异常处理,如果用户输入了一个参数,导致服务器异常,而此异常后端并没有事先意料到,我们就可以在后置通知中,将原本 500 的错误状态码封装成一个 json 数据给前端,前端再拿此 json 数据优化成一个用户看得懂的保存信息。(这是一件非常有意义的事情,因为用户并不是程序员,他们根本看不懂后端的错误提示,甚至连前端拿到 500 这样的错误信息,也不知道后端出现了什么问题。)
三、使用 AOP 统计某个类中每个方法的执行时间
对上面的代码做一些修改:
在 UserAspect 类中,实现切面:
@Aspect // 当前类是一个切面
@Component
public class UserAspect {
// 定义一个切点(设置拦截规则)
@Pointcut("execution(* com.example.demo.controller.UserController.*(..))")
public void point1() {
}
// 使用 AOP 统计 UserController 每个方法的执行时间
@Around(("point1()"))
public Object doAround2(ProceedingJoinPoint joinPoint) {
// Spring 框架提供的 StopWatch,类似于 System.currentTimeMillis() 时间戳
StopWatch stopWatch = new StopWatch();
Object result = null;
try {
stopWatch.start();
// 执行目标方法,以及目标方法所对应的相应的通知
result = joinPoint.proceed();
stopWatch.stop();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
System.out.println(joinPoint.getSignature().getDeclaringType() + "." +
joinPoint.getSignature().getName() + " 方法花费的时间" +
stopWatch.getTotalTimeMillis()+ "ms");
return result;
}
}
在 UserController 类中,实现与前端交互:
@RestController
public class UserController {
@RequestMapping("/hello2")
public String hello2() {
System.out.println("hello2 方法被执行了");
return "你好,世界!";
}
@RequestMapping("/hello3")
public String hello3() throws InterruptedException {
Thread.sleep(3000);
System.out.println("hello3 方法被执行了");
return "hello, world!";
}
}
四、Spring AOP 的实现原理
Spring AOP 是构建在动态代理基础上,因此 Spring 对 AOP 的支持局限于方法级别的拦截。我们知道,Spring Boot 单元测试的最小单元是方法级别的,所以可以看见,Spring AOP 的拦截功能做到了非常精准。
如下图所示,以往我们前端直接就可以与后端交互了,但是这就会带来安全隐患,当有 AOP 作为后端代理后,它就会在前端访问时,进行一些校验和拦截,显然数据在传输过程中更加安全。
Spring AOP 动态代理实现的技术
1. JDK Proxy (JDK 动态代理)
2. GGLB Proxy
JDK Proxy 是官方提供的动态代理,但实际上它并不好用,所有 Spring AOP 一般来说会优先使用后者。当后者无法正常使用时,才会采用前者。
需要明确,GGLB Proxy 是通过继承代理对象来实现动态代理的,即子类拥有父类的所有功能的方式。但它不能代理最终类 (被final 修饰的类)。