目录
什么是AOP?
什么是Spring AOP?
AOP的快速入门
引入依赖
编写程序
Spring AOP核心概念
切点
连接点
通知
切面
通知类型
环绕通知(@Around)
前置通知(@Before)
后置通知(@After)
返回通知(@AfterReturning)
异常通知(@AfterThrowing)
@PointCut
@Order
切点表达式
execution 表达式
@annotation
自定义注解
描述切点
添加自定义注解
什么是AOP?
AOP(Aspect-Oriented Programming,面向切面编程):是一种软件开发的编程范式,旨在将横切关注点(cross-cutting concerns)与核心业务逻辑分离,以提高代码的模块化性、可维护性和复用性
我们首先来理解,什么是面向切面编程?
切面,就是指某一类特定问题,因此AOP也可以理解为面向特定方法编程
例如:在实现登录逻辑时,登录校验 就是一类特定的问题,而登录校验拦截器,就是对登录校验 这类问题的统一处理,因此,拦截器也是 AOP 的一种应用
AOP是一种思想,拦截器是AOP思想的一种实现,统一数据返回格式 和 统一异常处理,也是 AOP思想的一种实现
简而言之,AOP是一种思想,是对某一类问题的集中处理
什么是Spring AOP?
AOP是一种思想,它的实现方法有很多(如 Spring AOP、AspectJ、CGLIB)
也就是说,Spring AOP 是 AOP 的一种实现方式
AOP的快速入门
我们首先来看一个例子:
假设此时某些方法的执行效率较低,耗时较长,需要对接口进行优化
那么,我们需要定位出耗时较长的方法,再对其进行优化
那么,如何进行定位呢?
我们可以统计每个方法的耗时,在方法运行前和方法运行后,记录下方法的开始时间和结束时间,两者之差就是该方法的耗时
采用上述方法确实可以计算出耗时,从而定位方法,但是,当方法较多时,若我们在每个方法中都要记录耗时时间,就会增加很多重复的工作量
此时,我们就可以使用 AOP,在保持原始方法不动的基础上,对特定的方法进行功能增强
接下来,我们就来学习 Spring AOP 是如何实现的
首先,需要引入 AOP 依赖
引入依赖
在 pom.xml 中添加配置:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
接下来,就可以编写 AOP 程序了
编写程序
@Slf4j
@Component
@Aspect
public class TimeAspect {
@Around("execution(* com.example.demo.controller.*.*(..))")
public Object recordTime(ProceedingJoinPoint joinPoint) throws Throwable {
// 记录开始时间
long startTime = System.currentTimeMillis();
// 执行方法
Object result = joinPoint.proceed();
// 记录结束时间
long endTime = System.currentTimeMillis();
// 打印耗时时间
log.info("耗时: " + (endTime - startTime) + " ms");
return result;
}
}
在 com.example.demo.controller 目录下创建 TestController,并记录 t1 方法执行时间:
@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {
@RequestMapping("/t1")
public String t1() {
log.info("TestController t1...");
return "t1";
}
}
运行程序,观察日志:
我们先来对 recordTime 进行简单理解:
@Aspect:标识这是一个切面类
@Around:环绕通知,在目标方法前后都会执行,() 中的表达式表示对哪些方法进行增强
joinPoint.proceed:执行原方法
可以将代码划分为三个部分:
此时,我们就通过 AOP 程序完成了接口执行耗时的统计
在上述程序中,我们可以看到 AOP 可以在不修改原始方法的基础上,对原始的方法进行了功能的增强,这样减少了重复代码,让我们维护起来更加方便,也提高了开发效率
接下来,我们就对 AOP 进行进一步的学习
Spring AOP核心概念
切点
切点(Pointcut):也称为 切入点,提供一组规则,用来定义在何处切入(即在哪些方法或类上应用增强逻辑),告诉程序对哪些方法进行功能增强
其中, execution(* com.example.demo.controller.*.*(..)) 就是切点表达式
连接点
连接点(Join Point):在程序执行过程中可以插入切面的点,也就是满足切点表达式规则的方法,即可以被 AOP 控制的方法
execution(* com.example.demo.controller.*.*(..)) 中,com.example.demo.controller 路径下的方法,都是连接点
连接点和切点的关系:
连接点是满足表达式的元素,切点可以看做是保持了多个连接点的一个集合
例如:
切点表达式:一班所有学生
连接点:张三、李四、王五...
通知
通知(Advice):在切点定义的连接点上要执行的代码,也就是要执行的逻辑
在 AOP 面向切面编程中,我们将重复的代码逻辑抽取出来单独定义,而这部分代码就是通知的内容
切面
切面(Aspect):切面描述的是当前 AOP 程序需要针对哪些方法,在什么时候执行什么样的操作
切面(Aspect) = 切点(Pointcut) + 通知(Advice)
切面所在的类,一般称之为切面类(被 @Aspect 注解标识的类)
通知类型
通知是切点匹配时执行的代码块,根据执行时机的不同,通知可分为以下几种类型:
环绕通知(@Around)
环绕通知在切点方法执行前后都可以执行,可以控制切点方法是否执行
// 环绕通知
@Around("execution(* com.example.demo.controller.*.*(..))")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("AspectDemo around start...");
// 执行方法
Object result = joinPoint.proceed();
log.info("AspectDemo around end...");
return result;
}
@Around 通知的返回值是一个 Object 类型,代表原方法的返回值,在原方法执行完毕后,可以对其进行处理后再返回
若是不返回,则无法将目标方法的返回值传递给调用者,可能会导致程序行为不符合预期
例如:
@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {
@RequestMapping("/t1")
public String t1() {
log.info("TestController t1...");
return "t1";
}
}
运行程序,并访问: 127.0.0.1:8080/test/t1
观察结果:
不进行返回:
@Around("execution(* com.example.demo.controller.*.*(..))")
public void doAround(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("AspectDemo around start...");
// 执行方法
Object result = joinPoint.proceed();
log.info("AspectDemo around end...");
}
再次运行程序,并访问: 127.0.0.1:8080/test/t1
可以看到,当不进行返回时,并不能正确显示返回结果 "t1"
因此,在使用环绕通知时,不要忘了返回目标方法的返回值
我们再来看参数 ProceedingJoinPoint
我们先看 JoinPoint 接口:
JoinPoint 是 Spring AOP 中的一个接口, 表示在应用程序执行过程中某个特定的点,通常是方法的调用,通过 JoinPoint 可以获取关于被拦截方法的各种信息,例如:
可以使用 getSignature() 方法可以获取被拦截方法的签名,包括方法名、返回类型和参数类型等信息;可以通过 getArgs() 方法获取被拦截方法的参数数组
JoinPoint 常用于前置通知、后置通知 和 异常通知
而 ProceedingJoinPoint 也是 Spring AOP 中的一个接口,它扩展了 JoinPoint 接口,主要用于环绕通知中控制目标方法的执行,通过 ProceedingJoinPoint 可以获取方法的相关信息,并能够在环绕通知中调用目标方法
其中,proceed() 方法用于执行目标方法,若在环绕通知中需要执行目标方法,就可以调用该方法,由于被拦截的方法可能会抛出各种异常,因此,proceed() 方法为了保证在环绕通知中能够正确处理目标方法的异常情况,在其签名中声明了可能会抛出 Throwable,让我们可以根据自己的需求捕获和处理异常
前置通知(@Before)
前置通知是在切点方法执行之前执行的通知
@Slf4j
@Aspect
@Component
public class AspectDemo {
// 前置通知
@Before("execution(* com.example.demo.controller.*.*(..))")
public void doBefore(JoinPoint joinPoint) {
// log.info("AspectDemo doBefore...");
log.info("AspectDemo doBefore... " + joinPoint.getSignature());
}
}
运行程序,访问 127.0.0.1:8080/test/t1,观察日志:
前置方法在目标方法运行前执行
对于参数 JoinPoint,若是不需要获取被拦截方法的信息,也可以不进行传递
// 前置通知
@Before("execution(* com.example.demo.controller.*.*(..))")
public void doBefore() {
log.info("AspectDemo doBefore...");
}
后置通知(@After)
后置通知是在切点方法执行后执行的通知,无论是否抛出异常都会执行
// 后置通知
@After("execution(* com.example.demo.controller.*.*(..))")
public void doAfter() {
log.info("AspectDemo doAfter...");
}
测试:
@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {
@RequestMapping("/t1")
public String t1() {
log.info("TestController t1...");
return "t1";
}
@RequestMapping("/t2")
public String t2() {
log.info("TestController t2...");
int k = 10 / 0;
return "t2";
}
}
运行结果:
可以看到,无论是否抛出异常,后置通知都执行了
返回通知(@AfterReturning)
返回通知是在切点方法成功执行后执行的通知,当抛出异常时不会执行
// 返回后通知
@AfterReturning("execution(* com.example.demo.controller.*.*(..))")
public void doAfterReturn(JoinPoint joinPoint) {
log.info("AspectDemo doAfterReturn...");
}
执行 t1 和 t2 方法:
只有 t1执行完后执行了 返回通知
异常通知(@AfterThrowing)
异常通知是在切点方法抛出异常后执行的通知,可以获取到异常信息
// 抛出异常后通知
@AfterThrowing("execution(* com.example.demo.controller.*.*(..))")
public void doAfterThrowing() {
log.info("AspectDemo doAfterThrowing...");
}
执行 t1 和 t2 方法:
只有 t2 方法抛出了异常之后执行了 异常通知
那么,当这些通知同时出现时,它们的执行先后顺序是怎样的呢?
我们来进行测试:
@Slf4j
@Aspect
@Component
public class AspectDemo {
// 环绕通知
@Around("execution(* com.example.demo.controller.*.*(..))")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("AspectDemo around start...");
// 执行方法
Object result = joinPoint.proceed();
log.info("AspectDemo around end...");
return result;
}
// 前置通知
@Before("execution(* com.example.demo.controller.*.*(..))")
public void doBefore() {
log.info("AspectDemo doBefore...");
}
// 后置通知
@After("execution(* com.example.demo.controller.*.*(..))")
public void doAfter() {
log.info("AspectDemo doAfter...");
}
// 返回后通知
@AfterReturning("execution(* com.example.demo.controller.*.*(..))")
public void doAfterReturn(JoinPoint joinPoint) {
log.info("AspectDemo doAfterReturn...");
}
// 抛出异常后通知
@AfterThrowing("execution(* com.example.demo.controller.*.*(..))")
public void doAfterThrowing() {
log.info("AspectDemo doAfterThrowing...");
}
}
运行程序,观察日志:
可以看到,程序正常运行情况下:
@AfterThrowing 标识的通知方法不会执行
目标方法执行前:@Around 的前置逻辑会先于 @Before 标识的通知方法执行
目标方法执行后:@AfterReturning 标识的通知方法在程序结束后最先执行,其次是 @After 标识的通知方法,最后是 @Around 的后置逻辑
而当出现异常情况时:
@AfterReturning 标识的通知方法不会执行
@AfterThrowing 标识的通知方法执行了
目标方法执行前:@Around 的前置逻辑会先于 @Before 标识的通知方法执行
目标方法执行后: @AfterThrowing 标识的通知方法先执行, @After 标识的通知方法后执行,由于抛出了异常,@Around 的后置逻辑未被执行
@PointCut
在上述实现通知方法时,出现了一个问题:
就是出现了大量重复的切点表达式: execution(* com.example.demo.controller.*.*(..))
Spring 提供了 @PointCut 注解,可以将公共的切点表达式提取出来,需要用到时引入该切点表达式即可
@Slf4j
@Aspect
@Component
public class AspectDemo {
// 定义切点(公共的切点表达式)
@Pointcut("execution(* com.example.demo.controller.*.*(..))")
private void pt(){}
// 环绕通知
@Around("pt()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("AspectDemo around start...");
// 执行方法
Object result = joinPoint.proceed();
log.info("AspectDemo around end...");
return result;
}
// 前置通知
@Before("pt()")
public void doBefore() {
log.info("AspectDemo doBefore...");
}
// 后置通知
@After("pt()")
public void doAfter() {
log.info("AspectDemo doAfter...");
}
// 返回后通知
@AfterReturning("pt()")
public void doAfterReturn(JoinPoint joinPoint) {
log.info("AspectDemo doAfterReturn...");
}
// 抛出异常后通知
@AfterThrowing("pt()")
public void doAfterThrowing() {
log.info("AspectDemo doAfterThrowing...");
}
}
当切点定义使用 private 修饰时,仅能在当前切面类使用
若其他切面类也要使用当前切点定义时,需要将 private 修改为 public
// 定义切点(公共的切点表达式)
@Pointcut("execution(* com.example.demo.controller.*.*(..))")
public void pt(){}
且在其他切面类引用的方式为:全限定类名.方法名()
@Slf4j
@Aspect
@Component
public class AspectDemo2 {
// 前置通知
@Before("com.example.demo.aspect.AspectDemo.pt()")
public void doBefore() {
log.info("AspectDemo2 doBefore...");
}
}
@Order
当有多个切面类,且这些切面类的多个切入点都匹配到了同一个目标方法时,那么这些切面类的通知方法都会执行,那么,它们的执行顺序是怎样的呢?
我们来测试一下:
@Slf4j
@Aspect
@Component
public class AspectDemo1 {
// 定义切点(公共的切点表达式)
@Pointcut("execution(* com.example.demo.controller.*.*(..))")
public void pt(){}
// 前置通知
@Before("pt()")
public void doBefore() {
log.info("AspectDemo1 doBefore...");
}
// 后置通知
@After("pt()")
public void doAfter() {
log.info("AspectDemo1 doAfter...");
}
}
@Slf4j
@Aspect
@Component
public class AspectDemo2 {
// 前置通知
@Before("com.example.demo.aspect.AspectDemo1.pt()")
public void doBefore() {
log.info("AspectDemo2 doBefore...");
}
// 后置通知
@After("com.example.demo.aspect.AspectDemo1.pt()")
public void doAfter() {
log.info("AspectDemo2 doAfter...");
}
}
@Slf4j
@Aspect
@Component
public class AspectDemo3 {
// 前置通知
@Before("com.example.demo.aspect.AspectDemo1.pt()")
public void doBefore() {
log.info("AspectDemo3 doBefore...");
}
// 后置通知
@After("com.example.demo.aspect.AspectDemo1.pt()")
public void doAfter() {
log.info("AspectDemo3 doAfter...");
}
}
@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {
@RequestMapping("/t1")
public String t1() {
log.info("TestController t1...");
return "t1";
}
}
运行程序,访问:http://127.0.0.1:8080/test/t1
观察日志:
可以看到:
当存在多个切面类时,默认按照切面类的类名字母排序:
@Before 通知:字母排名靠前的先执行
@After 通知:字母排名靠前的后执行
但我们能否自己指定切面类的执行顺序呢?
Spring 为我们提供了 @Order 注解,用来控制这些切面通知的执行顺序,先执行优先级较高的切面,再执行优先级较低的切面,最后执行目标方法
再次运行程序,观察日志:
可以发现:
@Order 注解标识的切面类,执行顺序为:
@Before 通知:数字小的先执行
@After 通知:数字小的后执行
Order 值越小,优先级越高
优先级高的切面类先执行,优先级低的切面类后执行,最后再执行目标方法
目标方法执行后,优先级低的切面类先执行,优先级高的切面类后执行
切点表达式
在上述代码中,我们一直在使用切点表达式来描述切点,接下来,我们就来详细介绍一下切点表达式的语法:
切点表达式有两种常见的表达方式:
(1)execution(...):根据方法的签名来匹配
(2)@annotation(...):根据注解来匹配
我们首先来看 execution 表达式
execution 表达式
execution() 是最常用的切点表达式,用来匹配方法,语法为:
execution(<访问修饰符> <返回类型> <包名.类名.方法名(方法参数)> <异常>)
其中,访问修饰符 和 异常 可以省略
切点表达式中可以使用通配符:
*:匹配任意个字符,只匹配一个元素(返回类型、包、类名、方法名或方法参数)
返回值使用 * 表示任意返回类型
包名使用 * 表示任意包(一层包使用一个 *)
类名使用 * 表示任意类
方法名使用 * 表示任意方法
参数使用 * 表示任意类型的参数
..:匹配多个连续的任意符号,可以通配任意层级的包,或任意类型、任意个数的参数
使用 .. 配置包名,表示此包和包下的所有子包
使用 .. 配置参数,表示任意个任意类型的参数
例如:
execution(public String com.example.demo.controller.TestController.*(..))
匹配 TestController 类中所有 public 修饰的,返回值为 String 类型的方法
execution(public int com.example.demo.controller.TestController.t1())
匹配 TestController 下 public 修饰,返回值为 int,方法名为 t1 的无参方法
execution(* com.example.demo.controller.*.*(String, int))
匹配 com.example.demo.controller 包下参数类型为 (String, int) 的方法
execution(* com.example.demo..*(..))
匹配 com.example.demo 包下(包括子包)的所有类中的所有方法
execution(* com.example.demo.TestController.*(..) throws IOException)
匹配 TestController 类中抛出 IOException 异常的方法
@annotation
execution 表达式更适用于有规则的方法,但是,若我们要匹配多个无规则的方法,例如 TestController 中的 t1()、UserController 中的 login()
此时,我们使用 execution 切点表达式来描述就不太方便了
我们可以借助自定义注解的方式和另一种切点表达式 @annotation 来描述这一类的切点
实现步骤:
(1)编写自定义注解
(2)使用 @annotation 表达式来描述切点
(3)在连接点方法上添加自定义注解
我们先准备测试代码:
@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {
@RequestMapping("/t1")
public String t1() {
log.info("TestController t1...");
return "t1";
}
@RequestMapping("/t2")
public String t2() {
log.info("TestController t2...");
return "t2";
}
}
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
@RequestMapping("/login")
public boolean login(String name, String password) {
return true;
}
}
自定义注解
创建一个注解类:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAspect {
}
@Target 标识了 Annotation 所修饰的对象范围,即该注解用在什么地方
常用取值:
ElementType.TYPE:用于描述类、接口(包括注解类型)或 enum 声明
ElementType.FIELD:用于描述类的字段(属性)
ElementType.METHOD:用于描述方法
ElementType.CONSTRUCTOR:用于描述构造方法
ElementType.PACKAGE:用于包声明
ElementType.TYPE_USE:用于标注任意类型
@Retention 指 Annotation 被保留的时间长短,标明注解的生命周期
RetentionPolicy.SOURCE:注解仅在源代码中保留,编译时会被丢弃。这意味着注解不会出现在字节码中,也不会在运行时可用,例如 @Slf4j、@Data
RetentionPolicy.CLASS:注解在编译时被保留(存在于源代码和字节码中),但在运行时不再可用。也就意味着在编译时和字节码中可以通过反射获取到该注解的信息,但实际运行时无法获取,常用于一些框架和工具的注解
RetentionPolicy.RUNTIME:运行时注解,存在于源代码、字节码 和 运行时中。它既会保留在字节码中,也可以通过反射获取,常用于一些需要运行时处理的注解,如 Spring 的 @Controller、@ResponseBody
描述切点
使用 @annotation 切点表达式定义切点,只对 @MyAspect 生效
@Slf4j
@Aspect
@Component
public class MyAspectDemo {
// 环绕通知
@Around("@annotation(com.example.demo.config.MyAspect)")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("AspectDemo around start...");
// 执行方法
Object result = joinPoint.proceed();
log.info("AspectDemo around end...");
return result;
}
}
添加自定义注解
在 TestController 中的t1() 和 UserController 中的 login() 方法上添加自定义注解 @MyAspect:
@MyAspect
@RequestMapping("/t1")
public String t1() {
log.info("TestController t1...");
return "t1";
}
@MyAspect
@RequestMapping("/login")
public boolean login(String name, String password) {
return true;
}
运行程序,测试:
可以看到,t1() 和 login() 方法切面通知被执行,t2() 方法切面通知未被执行