Spring AOP 详解
一、什么是 AOP
AOP,即 Aspect Oriented Programming(面向切面编程),简单来说就是针对一类事情的集中处理。在我们之前讲到拦截器,就是针对用户登录进行了统一处理,统一数据返回、统一异常处理,就实现了 AOP。
上述的拦截器、统一数据返回、统一异常处理,虽然是 Spring 进行实现的,但并不代表 AOP 就是 Spring 的。也就是说,AOP 是一种思想,任何人都可以实现。常见的实现方式有 Spring AOP、AspectJ,CGLIB等。下面针对 Spring AOP 进行讲解。
二、使用 Spring AOP
下面我们来看一下如何使用 AOP 来进行编写代码。
现有下面场景,需要针对 controller 类中的每一个方法记录其运行时间,这段逻辑的代码虽然只有几行,但如果需要在每一个代码中进行实现,就使得代码变得冗余,这里我们就可以使用 Spring AOP 来进行开发。
在使用 Spring AOP 之前需要引入以下依赖:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId>
</dependency>
记录方法耗时代码如下:
@Slf4j
@Aspect
@Component
public class TimeRecordAspect {/*** 记录 controller 下的方法运行的时间* @param pjp* @return* @throws Throwable*/@Around("execution(* com.gjm.demo.controller.*.*(..))")public Object timeRecord(ProceedingJoinPoint pjp) throws Throwable {log.info("开始记录时间");long begin = System.currentTimeMillis();Object proceed = pjp.proceed();long end = System.currentTimeMillis();log.info("方法耗时: " + (end - begin) + "ms");log.info("记录时间结束");return proceed;}
}
controller 测试代码如下:
@Slf4j
@RequestMapping("/test")
@RestController
public class TestController {@RequestMapping("/method1")public void method1() {log.info("运行 method1");for (int i = 0; i < 1000000; i++) {}}
}
代码运行结果如下:
在这里我们看到,method1 方法的运行时间成功记录。下面针对 Spring AOP 进行详细讲解。
三、Spring AOP核心概念
核心概念有:切点、切面、通知、连接点。下面是这几个概念在具体的代码中表示哪一部分。
1、切点
切点的作用就是告诉程序对哪些方法记录时间。
execution(* com.gjm.demo.controller.*.*(..))
上面这一段代码就是切点表达式,表示的意思是对于 controller 下的所有类、所有方法、不论参数个数均生效。切点表达式在下面会进行详细解释。
2、连接点
连接点就是 AOP 程序对哪些方法生效,这些方法就是连接点。
连接点就是满足切点的各个元素,切点就是所有连接点的集合。
3、通知
通知就是 AOP 具体要执行的逻辑,也就是上面的记录方法耗时。
4、切面
切面 = 切点 + 通知,即当前 AOP 程序针对哪些方法,具体实行什么操作。
四、通知类型
通知类型一共有五种:
@Around : 环绕通知,在连接点前运行前后均会执行;
@Before : 前置通知,在连接前运行之前执行;
@After : 后置通知,在连接点运行之后执行,无论是否有异常都会执行;
@AfterReturning : 返回后通知,在连接点运行结束后执行,有异常就不会执行;
@AfterThrowing : 异常后通知,当连接点运行时发生异常时执行。
AOP 代码如下:
@Slf4j
@Aspect
@Component
public class TimeRecordAspect {/*** 记录 controller 下的方法运行的时间* @param pjp* @return* @throws Throwable*/@Around("execution(* com.gjm.demo.controller.*.*(..))")public Object timeRecord(ProceedingJoinPoint pjp) throws Throwable {log.info("开始记录时间");long begin = System.currentTimeMillis();Object proceed = pjp.proceed();long end = System.currentTimeMillis();log.info("方法耗时: " + (end - begin) + "ms");log.info("记录时间结束");return proceed;}@Before("execution(* com.gjm.demo.controller.*.*(..))")public void doBefore() {log.info("do before");}@After("execution(* com.gjm.demo.controller.*.*(..))")public void doAfter() {log.info("do after");}@AfterReturning("execution(* com.gjm.demo.controller.*.*(..))")public void doAfterReturning() {log.info("do afterReturning");}@AfterThrowing("execution(* com.gjm.demo.controller.*.*(..))")public void doAfterThrowing() {log.info("do afterThrowing");}
}
连接点代码如下:
@Slf4j
@RequestMapping("/test")
@RestController
public class TestController {@RequestMapping("/method1")public void method1() {log.info("运行 method1");for (int i = 0; i < 1000000; i++) {}}@RequestMapping("/method2")public void method2() {log.info("运行 method2");int a = 1 / 0;}
}
method1 运行结果如下:
method2 运行结果如下:
上面是程序正常运行和运行时发生异常的结果。
从第二个结果可以看出,当连接点发生异常时,@Around 后面的逻辑就不会执行,但 @After 会执行。
通过上面的结果可以看出,我们可以将 @Before,@After,@AfterRunning,@AfterThrowing 中的逻辑移至 @Around 中 :
“do before” 就相当于 “开始记录时间”;
“do after”、“do afterRunning” 就相当于 “记录时间结束”;
Object proceed = pjp.proceed();
这一段代码就相当于执行连接点方法,我们可以加上 try - catch,这样就能捕获住连接点运行时发生的异常,但必须使用 Throwable 进行捕获,于是,“do afterThrowing” 就相当于 try - catch,改动过的代码如下:
@Slf4j
@Aspect
@Component
public class TimeRecordAspect {@Around("execution(* com.gjm.demo.controller.*.*(..))")public Object timeRecord(ProceedingJoinPoint pjp) throws Throwable {log.info("do before");long begin = System.currentTimeMillis();Object proceed = null;try {proceed = pjp.proceed();} catch (Throwable e){log.info("do afterThrowing");return proceed;}long end = System.currentTimeMillis();log.info("方法耗时: " + (end - begin) + "ms");log.info("do after");return proceed;}}
代码运行结果如下:
根据上面的运行结果,可以看出:@Around 可以代替 @Before、@After、@AfterRunning、@AfterThrowing。
五、@Pointcut
在上面的代码中,当我们使用了多个相同的切点表达式时,就会显得代码过于冗余,这样我们就可以将切点表达式单独定义出来,代码如下:
@Slf4j
@Aspect
@Component
public class TimeRecordAspect {@Pointcut("execution(* com.gjm.demo.controller.*.*(..))")private void pt() {}@Around("pt()")public Object timeRecord(ProceedingJoinPoint pjp) throws Throwable {log.info("do before");long begin = System.currentTimeMillis();Object proceed = null;try {proceed = pjp.proceed();} catch (Throwable e){log.info("do afterThrowing");return proceed;}long end = System.currentTimeMillis();log.info("方法耗时: " + (end - begin) + "ms");log.info("do after");return proceed;}@Before("pt()")public void doBefore() {log.info("do before");}@After("pt()")public void doAfter() {log.info("do after");}@AfterReturning("pt()")public void doAfterReturning() {log.info("do afterReturning");}@AfterThrowing("pt()")public void doAfterThrowing() {log.info("do afterThrowing");}
}
如果在别的类中也想使用这个切点时,就需要将 pt 设为 public,引用方式为:全限定类名 . 方法名(),代码如下:
@Slf4j
@Aspect
@Component
public class AspectDemo {@Pointcut("com.gjm.demo.aspect.TimeRecordAspect.pt()")public void pt(){}@Before("pt()")public void doBefore() {log.info("do before2");}@After("com.gjm.demo.aspect.TimeRecordAspect.pt()")public void doAfter() {log.info("do after2");}
}
可以重新定义一个切点 pt ,也可以在注解中直接使用 com.gjm.demo.aspect.TimeRecordAspect.pt() 。
六、切面优先级
当我们定义了多个切点类时,这些切点类的执行顺序时怎样的呢?
现有如下代码:
@Slf4j
@Aspect
@Component
public class AspectDemo1 {@Before("execution(* com.gjm.demo.controller.*.*(..))")public void doBefore() {log.info("do before1");}@After("execution(* com.gjm.demo.controller.*.*(..))")public void doAfter() {log.info("do after1");}
}
另外还有三个与之相同的类,类名依次为AspectDemo2,AspectDemo3,AspectDemo4,代码运行结果如下:
这里我们可以看到,依次执行了 AspectDemo1,AspectDemo2,AspectDemo3,AspectDemo4。这是 Spring 给我们排的序,那如果我们想要自定义切面类的执行顺序呢?
可以使用 @Order 注解,这是类注解,注解中的数字越小,切面的优先级越高。
现将 AspectDemo1 的 @Order 设为4,AspectDemo2 的 @Order 设为3,AspectDemo3 的 @Order 设为2,AspectDemo4 的 @Order 设为1,代码运行结果如下:
这样就依次执行了AspectDemo4,AspectDemo3, AspectDemo2, AspectDemo1。
代码执行图如下:
七、切点表达式
在上面的代码中,我们使用切点表达式来描述 AOP 作用与哪些连接点,常用的切点表达式有两种,分别为:
execution:根据方法名进行匹配;
@annotation:根据注解匹配。
下面进行一一介绍。
1、execution
execution 的标准语法如下:
execution(<访问修饰符> <返回类型> <包名.类名.⽅法(⽅法参数)> <异常>)
上面的代码中我们使用的切点表达式如下:
execution(* com.gjm.demo.controller.*.*(..))
二者的关系如下图所示:
访问修饰符:public、private、protected、default,可省略;
返回类型:连接点方法的返回类型;
包名:连接点所在的包的全限定名;
类名:连接点所在的类;
方法:即连接点;
异常:连接点所抛出的异常,可省略。
切点表达式支持通配符表达。
* :匹配任意字符,只匹配一个元素
包名使用 * 表示匹配任意包,一个包名使用一个 *;
类名使用 * 表示任意类,一个 * 可以匹配多个类;
返回值使用 * 表示任意类型的返回值;
方法名使用 * 表示任意方法;
参数使用 * 表示一个任意类型的参数,只匹配一个参数。
. . :匹配多个连续的 *
使用 . . 配置包名,即匹配该包下的多个子包;
使用 . . 配置参数,即匹配多个任意类型的参数。
2、@annotation
execution 适用与匹配有规则的连接点,例如一个类下的多个方法,但如果想要匹配两个类下的多个方法,就需要使用注解来完成。
这个注解不是 Spring 为我们提供的,而是我们自己创造的。
下面是自定义注解的方法:
① 先声明自定义的注解,需表明该注解的作用对象(使用 @Target 注解)、生命周期(使用 @Retention 注解)。
常见的作用对象有以下几个:
ElementType.TYPE :描述类、接口或 enum 声明;
ElementType.METHOD :描述方法;
ElementType.PARAMETER :描述参数
ElementType.TYPE_USE :描述任意类型
在这里我们使用 @Target(ElementType.METHOD)。
常见的声明周期有以下几个:
RetentionPolicy.SOURCE:注解仅存在于源代码中,编译后会被丢弃
RetentionPolicy.CLASS:编译时注解,仅存在于源代码和字节码中,运行时会被丢弃
RetentionPolicy.RUNTIME:运行时注解,存在于源代码、字节码和运行中
在这里我们使用 @Retention(RetentionPolicy.RUNTIME)。
注解声明代码如下:
@Target(ElementType.METHOD) //作用范围
@Retention(RetentionPolicy.RUNTIME) //生命周期
public @interface MyAspect {
}
② 定义该注解的用途,即加上这个注解后需要做什么。
在这里,我们需要记录方法运行的时间,代码如下:
@Slf4j
@Aspect
@Component
public class MyTimeRecordAspect {@Around("@annotation(com.gjm.demo.aspect.MyAspect)")public Object timeRecord(ProceedingJoinPoint pjp) throws Throwable {log.info("do before");long begin = System.currentTimeMillis();Object proceed = null;try {proceed = pjp.proceed();} catch (Throwable e){log.info("do afterThrowing");return proceed;}long end = System.currentTimeMillis();log.info("方法耗时: " + (end - begin) + "ms");log.info("do after");return proceed;}
}
在 @Around 注解中需要使用 @annotation + 自定义注解的全限定类名,也可以使用 @Before 等注解,代码运行结果如下:
当然,在 @annotation 后面也可以使用别的注解的全限定类名,这样在使用别的该注解时就可以执行下面的逻辑。
八、Spring AOP 的实现方式(常见面试题)
1、基于 @Aspect 进行实现,即 @Around、@Before、@After、@AfterRunning、@AfterThrowing;
2、基于自定义注解,即 @annotation;
3、基于 Spring API(少见);
4、基于代理模式(更少见)。