前言
前面我们学习了 SpringBoot 统一功能处理,这篇文章我将为大家分享 Spring 框架的第二大核心——AOP(第一大核心是 IOC)
1. 什么是 AOP
AOP(Aspect Oriented Programming)是一种编程范型,意为面向切面编程,什么是⾯向切面编程呢?切面就是指某⼀类特定问题,所以AOP也可以理解为面向特定⽅法编程,它通过预编译和运行期动态代理的方式实现程序功能的统一维护。AOP可以看作是OOP(面向对象编程)的延续,是软件开发中的一个热点,也是Spring框架中的一个重要内容,同时也是函数式编程的一种衍生范型。
AOP的目标是实现对业务逻辑的各个部分进行隔离,从而降低业务逻辑各部分之间的耦合度,提高程序的可重用性,并提升开发效率。在AOP中,目标类代表核心代码和业务逻辑,而额外功能(也即AOP的功能)则包括日志处理、事务处理、异常处理、性能分析等。通过将目标类与额外功能结合,可以生成代理类。
AOP的原理基于Java的动态代理机制。通过预编译方式和运行期动态代理,AOP能够实现程序功能的统一维护,使得开发者能够更加专注于业务逻辑的实现,而无需过多关注其他非核心功能。
AOP 是面向某一特定问题的编程?那前面的统一功能处理什么呢?其实前面学习的 统一功能处理是 AOP 的具体实现和应用。AOP是⼀种思想,拦截器是AOP思想的⼀种实现。Spring框架实现了这种思想,提供了拦截器技术的相关接口。
2. 什么是 Spring AOP
知道了什么是 AOP,那么什么是 Spring AOP 呢?AOP 是一种思想,而 Spring AOP、AspectJ、GGLIB等叫做 AOP 的实现。
那么我们前面学习的拦截器、统一数据返回格式、统一异常处理这些 AOP 的实现不够吗?其实是不够的,拦截器作⽤的维度是URL(⼀次请求和响应),@ControllerAdvice 应⽤场景主要是全局异常处理(配合⾃定义异常效果更佳),数据绑定,数据预处理。AOP作⽤的维度更加细致(可以根据包、类、⽅法名、参数等进⾏拦截),能够实现更加复杂的业务逻辑。
假设一个项目中开发了很多业务功能,但是呢?由于一些业务的执行效率比较低,耗时较长,所以我们就需要对接口进行优化。首先需要做的就是找到耗时较长的业务方法,那么我们如何知道每个业务方法执行的时间呢?一个简单的方法就是可以通过获取到业务方法刚执行时候的时间 start,再记录这个业务方法刚结束时候的时间 end,用 end - statr,就得到了该业务方法的执行时间,那么,是否意味着我们需要在每个方法中都加上这段代码呢?
public void function1() { long startTime = System.currentTimeMillis(); test(); long endTime = System.currentTimeMillis(); log.info("function1执行耗时" + (endTime - startTime)); }
如果每个方法都加上这样的逻辑的话,如果业务中的方法很多的话,那么这也是一个很大的工作量,所以这时候就可以用到我们的 AOP 了,AOP可以做到在不改动这些原始⽅法的基础上,针对特定的⽅法进行功能的增强。
3. Spring AOP 的使用
引入 AOP 依赖
AOP 属于第三方库,要想使用的话,就需要在 pom.xml 文件中引入对应的 AOP 依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>
编写 AOP 程序
首先我们需要在类上加上 @Aspect
注解,表明这个类是一个 AOP 类:
import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; @Slf4j @Aspect @Component public class TimeAspect { @Around("execution(* com.example.springbootbook2.controller.*.*(..))") public Object recordTime(ProceedingJoinPoint pjp) throws Throwable { long begin = System.currentTimeMillis(); //执行原始方法 Object result = pjp.proceed(); long end = System.currentTimeMillis(); log.info(pjp.getSignature() + "执行耗时:{}ms",(end - begin)); return result; } }
- @Aspect:标识这是⼀个切面类
- @Around:环绕通知,在目标⽅法的前后都会被执⾏。后⾯的表达式表示对哪些方法进行增强
- pjp.proceed():让原始⽅法执行
因为 @Around
注解的作用,这个代码被分为了三个部分:
看看执行效果:
可以看到:使用 AOP 可以在不更改原来业务代码的基础上,做出一些额外的工作。
AOP 面向切面编程的优势:
代码无侵入:不修改原始的业务方法,就可以对原始的业务方法进行功能的增强或是功能的改变
减少重复代码
提高开发效率
维护方便
既然 AOP 面向切面编程有这么多优势,拿那么接下来我们来详细的学习一下 Spring AOP。
4. Spring AOP 详解
4.1 Spring AOP 的概念
Spring AOP 需要了解的概念主要有:
- 切点
- 连接点
- 通知
- 切面
4.1.1 切点
切点(PointCur)也成为切入点,作用是提供一组规则,告诉程序哪些方法来进行功能增强。也就类似前面拦截器中的配置拦截路径。
也就是这个:
execution(* com.example.springbootbook2.controller.*.*(..))
叫做切点表达式,这里后面为大家详细介绍。
4.1.2 连接点
满足切点表达式规则的方法,就是连接点,也就是可以被 AOP 控制的方法,参考上面的例子也就是 com.example.springboot2.controller
包下的所有类中的所有方法都叫做连接点。在我们上面的例子中主要体现在 pjp 参数中:
通过这个参数的 proceed()
方法可以执行目标方法。
4.1.3 通知
通知就是具体要做的工作,指那些重复的逻辑,也就是共性功能(最终体现为一个方法):
在AOP面向切面编程中,我们把这部分重复的代码逻辑抽取出来单独定义,这部分代码就是通知的内容。
4.1.4 切面
切面(Aspect)就是 切点(PointCut) + 通知(Advice)。
通过切面就能够描述当前 AOP 程序需要针对于哪些⽅法,在什么时候执⾏什么样的操作。切面既包含了通知逻辑的定义,也包括了连接点的定义。
一个切面类可以存在多个切面。
4.2 通知类型
Spring AOP 中的通知类型有以下几种:
@Around:环绕通知,此注解标注的通知方法在目标方法前,都被执行
@Before:前置通知,此注解标注的通知方法在目标方法前被执行
@After:后置通过,此注解标注的通知方法在目标方法后被执行,⽆论是否有异常都会执行
AfterReturning:返回后通知,此注解标注的通知方法在目标方法后被执行,有异常不会被执行
@AfterThrowing:异常后通知,此注解标注的通知方法发生异常后执行
接下来我们通过一个例子来了解这几种通知类型:
import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.*; import org.springframework.stereotype.Component; @Slf4j @Aspect @Component public class AspectDemo { @Before("execution(* com.example.springaop.controller.*.*(..))") public void doBefore() { log.info("执行 Before 方法"); } @After("execution(* com.example.springaop.controller.*.*(..))") public void doAfter() { log.info("执行 After 方法"); } @AfterReturning("execution(* com.example.springaop.controller.*.*(..))") public void doAfterReturning() { log.info("执行 AfterReturning 方法"); } @AfterThrowing("execution(* com.example.springaop.controller.*.*(..))") public void doAfterThrowing() { log.info("执行 AfterThrowing 方法"); } @Around("execution(* com.example.springaop.controller.*.*(..))") public Object doAround(ProceedingJoinPoint pjp) throws Throwable { log.info("Around 方法开始执行"); Object result = pjp.proceed(); log.info("Around 方法执行后"); return result; } }
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @Slf4j @RestController @RequestMapping("test") public class TestController { @RequestMapping("/t1") public String t1() { log.info("目标方法执行"); return "hello"; } }
这里少了一个日志就是 AfterThrowing
方法打印的日志,这是因为我们这个方法没有出现错误,所以我们重新定义一个内部会剖出异常的方法:
@RequestMapping("/t2") public Integer t2() { log.info("目标方法2执行"); Integer result = 10/0; return result; }
可以看到这几个通知类型的执行顺序:
目标方法中不出现异常:Around通知类型的目标方法执行前的逻辑——>Before通知类型——>目标方法——>AfterReturning通知类型——>After通知类型——>Around通知类型的目标方法执行之后的逻辑
目标方法中出现异常:Around通知类型的目标方法执行前的逻辑——>Before通知类型——>目标方法——>AfterThrowing通知类型——>After通知类型
注意:
@Around 环绕通知需要调用 ProceedingJoinPoint.proceed() 来让原始方法执行,其他通过不需要考虑目标方法的执行
@Around 环绕通知方法的返回值,必须指定为 Object,来接收原始方法的返回值,否则原始方法执行完毕,是获取不到返回值的
⼀个切面类可以有多个切点
4.3 切点
通过前面的例子,我们可以发现,同样的切点表达式写了很多,那么是否有一种方法可以节省相同切点表达式的书写呢?当然是可以的,我们程序员可是以“懒”著称的。
@Pointcut("切点表达式") 修饰限定词 返回值 方法名(){}
public class AspectDemo { @Pointcut("execution(* com.example.springaop.controller.*.*(..))") private void pt(){} @Before("pt()") public void doBefore() { log.info("执行 Before 方法"); } @After("pt()") public void doAfter() { log.info("执行 After 方法"); } @AfterReturning("pt()") public void doAfterReturning() { log.info("执行 AfterReturning 方法"); } @AfterThrowing("pt()") public void doAfterThrowing() { log.info("执行 AfterThrowing 方法"); } @Around("pt()") public Object doAround(ProceedingJoinPoint pjp) throws Throwable { log.info("Around 方法开始执行"); Object result = pjp.proceed(); log.info("Around 方法执行后"); return result; } }
我们这里 @PointCut 注解的方法的修饰限定词是 private,该切点表达式只能在当前类中使用,如果我们想要在当前项目的其他切面类中使用这个提取出来的切点表达式的话,需要将方法的修饰限定词改为 public,并且引用这个切点表达式的方法需要使用 全限定类名.方法名()。
@Pointcut("execution(* com.example.springaop.controller.*.*(..))") public void pt(){}
@Slf4j @Component @Aspect public class AspectDemo2 { @Before("com.example.springaop.aspect.AspectDemo.pt()") public void deBefore() { log.info("执行 AspectDemo2 中的 Before 方法"); } }
4.4 切面优先级 @Order注解
当存在多个切面的时候,并且这些切面类的多个切入点都匹配到了同一个目标方法,当目标方法运行的时候,这些切面类中的通知方法都会执行,这些通知方法的执行顺序会遵守两个原则:
- 前面通知类型的先后顺序
- 切面类的类名排序
我们创建出三个切面类,来看多个切面类匹配到了同一个目标方法之后的通知方法的执行顺序:
通过这个运行结果可以知道,当存在多个切面类时,默认按照切面类名字母排序:
- @Before 通知:字母排名靠前的先执行
- @After 通知,字母排名靠前的后执行
就可以形象的看成这个模型:
但是根据切面类的类名字母排序的话,很不方便,所以为了切面类执行顺序更好的控制,就出现了一个注解 @Order()
用来控制多个切面类的执行顺序。
可以看到,通过使用 @Order
注解可以控制多个切面类的执行顺序。
4.5 切点表达式
前面我们使用切点表达式来描述切点,接下来我们来详细介绍一下切点表达式的语法。
切点表达式常见的有两种表达方式:
- execution(…):根据方法的签名来匹配
- @annotation(…):根据注解匹配
4.5.1 execution 切点表达式
execution() 是最常用的切点表达式,用来匹配方法,它的语法为:
execution(<访问修饰符> <返回类型> <包名.类名.方法(方法参数)> <异常>)
其中访问修饰符和异常可以省略。
切点表达式支持通配符表达:
*:匹配任意字符,只匹配一个元素(返回类型,包,类名,方法或者方法参数)
包名使用 * 表示任意包(一层包使用一个*)
类名使用 * 表示任意类
返回值使用 * 表示任意返回值类型
⽅法名使用 * 表示任意⽅法
参数使用 * 表示⼀个任意类型的参数
… :匹配多个连续的任意符号,可以通配任意层级的包,或任意类型,任意个数的参数
使用 … 配置包名,表示此包以及此包下的所有子包
可以使用 … 配置参数,任意个任意类型的参数
示例:
TestController 下的 public修饰,返回类型为 String 方法名为 t1,⽆参⽅法
execution(public String com.example.demo.controller.TestController.t1())
省略访问修饰符:
execution(String com.example.demo.controller.TestController.t1())
匹配所有返回类型:
execution(* com.example.demo.controller.TestController.t1())
匹配 TestController 下的所有无参方法:
execution(* com.example.demo.controller.TestController.*())
匹配 TestController 下的所有方法:
execution(* com.example.demo.controller.TestController.*(..))
匹配 controller 包下所有的类的所有方法:
execution(* com.example.demo.controller.*.*(..))
匹配所有包下⾯的 TestController:
execution(* com..TestController.*(..))
匹配 com.example.demo 包下,子孙包下的所有类的所有⽅法:
execution(* com.example.demo..*(..))
4.5.2 @annotation
execution 表达式适用于更符合规则的,如果我们要匹配更多无规则的方法的话,使用 execution 表达式就会很吃力。
假设我需要捕获到 t2 和 t3 方法,使用 execution 表达式该如何写呢?因为这两个方法的返回值不同,使用 execution 表达式无法同时表示出这两个方法。所以这时候就需要使用 @annotation
注解来捕获到更多无规则的方法。
4.5.2.1 自定义注解
如何使用 @annotaion
呢?首先我们需要自定义出注解。
在新建 Java class 的时候选择 Annotation:
然后里面的内容我们就实现一个最简单的注解,参考 @Component
注解来构造一个自定义的注解:
@Target 标识了 Annotation 所修饰的对象范围,即该注解可以用在什么地方:
ElementType.TYPE:⽤于描述类、接⼝(包括注解类型)或enum声明
ElementType.METHOD:描述方法
ElementType.PARAMETER:描述参数
ElementType.TYPE_USE:可以标注任意类型
@Retention 指Annotation 被保留的时间长短,标明注解的⽣命周期:
RetentionPolicy.SOURCE:表示注解仅存在于源代码中,编译成字节码后会被丢弃。这意味着在运行时无法获取到该注解的信息,只能在编译时使⽤。比如 @SuppressWarnings ,以及lombok提供的注解 @Data ,@Slf4j
RetentionPolicy.CLASS:编译时注解。表示注解存在于源代码和字节码中,但在运行时会被丢弃。这意味着在编译时和字节码中可以通过反射获取到该注解的信息,但在实际运行时⽆法获取。通常⽤于⼀些框架和⼯具的注解
RetentionPolicy.RUNTIME:运行时注解。表示注解存在于源代码,字节码和运行时中。这意味着在编译时,字节码中和实际运行时都可以通过反射获取到该注解的信息。通常⽤于⼀些需要在运行时处理的注解,如Spring的 @Controller @ResponseBody
我们一般就使用 RUNTIME 来指明注解的存在时间。
package com.example.springaop.aspect; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface MyAspect { }
4.5.2.2 切面类
使用 @annotation 切点表达式定义切点,只对 @MyAspect ⽣效:
@annotation 中需要填入我们自定义注解的全限定名。
@Slf4j @Component @Aspect public class MyAspectDemo { @Before("@annotation(com.example.springaop.aspect.MyAspect)") public void before() { log.info("执行 MyAspectDemo 的 Before 方法"); } @After("@annotation(com.example.springaop.aspect.MyAspect)") public void after() { log.info("执行 MyAspect 的 After 方法"); } }
4.5.2.3 添加自定义注解
@Slf4j @RestController @RequestMapping("test") public class TestController { @RequestMapping("/t1") public String t1() { log.info("目标方法执行"); return "hello"; } @MyAspect @RequestMapping("/t2") public String t2() { log.info("我爱Java,我要成为Java高手"); return "hello world"; } @MyAspect @RequestMapping("/t3") public Integer t3() { log.info("目标方法2执行"); Integer result = 10/0; return result; } }
可以看到通过使用 @annotation
可以更加灵活的捕获到无规则的方法。