SpringBoot AOP切面实现

AOP简介

​ AOP (Aspect Oriented Programming), 面向切面思想,是Spring的三大核心思想之一( 其余两个: IOC - 控制反转、DI - 依赖注入)

​ 在我们的程序中,经常存在一些系统性的需求,比如 权限校验日志记录统计等,这些代码会散落穿插在各个业务逻辑中,非常冗余且不利于维护,那么面向切面编程往往让我们的开发更加低耦合,也大大减少了代码量,同时呢让我们更专注于业务模块的开发,把那些与业务无关的东西提取出去,便于后期的维护和迭代

AOP体系与概念

简单地去理解,其实AOP要做三类事:

  1. 在哪里切入,也就是权限校验等非业务操作在哪些业务代码中执行 (Pointcut切点)
  2. 在什么时候切入,是业务代码执行前还是执行后 (Advice处理时机)
  3. 切入后做什么事,比如做权限校验、日志记录等 (Advice处理内容)

AOP的体系图

AOP相关名词概念

概念 说明
Pointcut 切点,决定处理如权限校验、日志记录等在何处切入业务代码中(即织入切面)。切点分为execution方式和 annotation 方式。前者可以用路径表达式指定哪些类织入切面,后者可以指定被哪些注解修饰的代码织入切面。
Advice 处理,包括处理时机和处理内容。处理内容就是要做什么事,比如校验权限和记录日志。处理时机就是在什么时机执行处理内容,分为前置处理(即业务代码执行前)、后置处理(业务代码执行后)等。
Aspect 切面,即 PointcutAdvice
Joint point 连接点,是程序执行的一个点。例如,一个方法的执行或者一个异常的处理。在 Spring AOP 中,一个连接点总是代表一个方法执行。
Weaving 织入,就是通过动态代理,在目标对象方法中执行处理内容的过程

Springboot中AOP的实现

AOP Maven依赖

1
2
3
4
5
<!-- springboot-aop包,包含AOP切面注解,Aspectd等相关注解 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

AOP相关注解

@Aspect

声明这是一个切面类

该注解要添加在类上,使用时需要与@Component注解一起用,表明同时将该类交给spring管理

1
2
3
4
5
@Component
@Aspect
public class ControllerAspect {

}

@Pointcut

用来定义一个切点, 定义Advice需要在何处切入业务代码(织入切面)中

该注解需要添加在方法上,该方法签名必须是 public void 类型,可以将@Pointcut 中的方法看作是一个用来引用的助记符,因为表达式不直观,因此我们可以通过方法签名的方式为此表达式命名。因此 @Pointcut 中的方法只需要方法签名,而不需要在方法体内编写实际代码

该注解有两个常用的表达式:execution()@annotation(), execution()使用项目中的引用定位, 来获取切点所在的位置, @annotation使用注解的方式,它会扫描自己方法体内的注解(自定义的注解或者当前框架现有的注解 如@RequestMapping等), 被这些注解定义的方法就会成为切点

execution()
1
2
3
4
5
6
7
8
9
10
@Aspect
@Component
public class MyAop {
//申明切点位置在 com.crisp.controller包下的所有方法
@Pointcut("execution(* com.crisp.controller..*.*(..))")
public void pointCut() {
/*@Pointcut中的方法仅用于扫描execution方法标识的类中的方法,
只需要方法签名,而不需要在方法体内编写实际代码*/
}
}

上述代码的@Pointcut注解中

  • 第一个 * :表示返回值类型,* 表示所有类型;
  • **包名**:标识需要拦截的包名;
  • **包名后的 ..**:表示当前包和当前包的所有子包,在本例中指 com.crisp.controller 包、子包下所有类;
  • 第二个 * :表示类名,* 表示所有类;
  • 最后的 *(..) :星号表示方法名,* 表示所有的方法,后面括弧里面表示方法的参数,两个句点表示任何参数

execution表达式

表达式语法:访问修饰符 返回值 包名.包名.包名…类名.方法名(参数列表)

标准的表达式写法范例:

1
2
public void com.crisp.controller.HelloController.helloAop()
1

访问修饰符可以省略

1
void com.crisp.controller.HelloController.helloAop()

返回值可以使用通配符*,表示任意返回值

1
* com.crisp.controller.HelloController.helloAop()

包名可以使用通配符,表示任意包,但是有几级包,就需要写几个*.

1
*  *.*.*.*.HelloController.helloAop()

包名可以使用...表示当前包及其子包

1
* *...HelloController.helloAop()

类名和方法名都可以使用*来实现通配

1
* *..*.*()

表达式中的参数列表,可以直接写数据类型:

  • 基本类型直接写名称 :例如,int
  • 引用类型写包名.类名的方式 :例如,java.lang.String
  • 可以使用通配符*表示任意类型,但是必须有参数
  • 可以使用表示有无参数均可,有参数可以是任意类型

全通配写法:* *..*.*(...)

@annotation()

采用框架现有的注解来声明切点

1
2
3
4
5
6
7
8
9
10
@Aspect
@Component
public class MyAop {
//申明切点位置在 使用了@PostMapping注解的方法
@Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")
public void pointCut() {
/*@Pointcut中的方法仅用于扫描@PostMapping作用下的方法,
只需要方法签名,而不需要在方法体内编写实际代码*/
}
}

然后使用该切面的话,就会切入注解是 @PostMapping 的所有方法。这种方式很适合处理 @GetMapping@PostMapping@DeleteMapping不同注解有各种特定处理逻辑的场景

采用自定义的注解来声明切点

1.编写一个自定义注解

1
2
3
4
5
6
7
8
//表示次注解可以标注在类和方法上
@Target({ElementType.METHOD, ElementType.TYPE})
//运行时生效
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
//定义一个变量,可以接受参数
String desc() default " ";
}

2.在某处方法上添加该注解

1
2
3
4
5
6
@GetMapping("/register")
@MyAnnotation(desc = "wow")
public ResponseJson register(@RequestParam("username")String username,
@RequestParam("password")String password,){

}

3.切面类代码

1
2
3
4
5
6
7
8
9
10
@Aspect
@Component
public class MyAop {
//申明切点位置在 使用了com.crisp.MyAnnotations.MyAnnotation注解的的所有方法
@Pointcut("@annotation(com.crisp.MyAnnotations.MyAnnotation)")
public void pointCut() {
/*@Pointcut中的方法仅用于扫描作用了自定义注解的的类中的方法,
只需要方法签名,而不需要在方法体内编写实际代码*/
}
}
@Pointcut的关系运算符
1
2
3
4
5
6
7
8
9
10
11
12
13
@Pointcut("execution(public * com.aismall.testaop.controller.HelloController.*(..))")
public void cutController(){
}

@Pointcut("execution(public * com.aismall.testaop.service.UserService.*(..))")
public void cutService(){
}

//使用 && 运算符,则cutAll()的作用等同于cutController 和 cutService 之和
@Pointcut("cutController() && cutService()")
public void cutAll(){
}

@Before

作用在用@Component注解和@Aspect作用的切面类的方法中, 标注了@Before的方法表示此方法是前置通知

前置通知, 顾名思义是在@Pointcut注解标注的切入点类中的方法执行前被调用

  1. 以@Pointcut使用@annotation方法来标注切入点为例, 先创建一个自定义注解
1
2
3
4
5
6
7
8
//表示次注解可以标注在类和方法上
@Target({ElementType.METHOD, ElementType.TYPE})
//运行时生效
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
//定义一个变量,可以接受参数
String value() default " ";
}
  1. 将注解作用在要标注的切入点上
1
2
3
4
5
6
7
8
9
10
@RESTController
@RequestMapping("/object")
public class ObjectController {
@GetMapping("/show")
@MyAnnotation("wow") //将showObject标注为切入点
public void showObject(@RequestParam("bucketName") String bucketName) {
System.out.println("ObjectController控制器的获取指定存储桶中所有对象的信息方法执行")
//业务代码已省略
}
}
  1. 书写切入面代码, 在前置通知的方法上标注@Before注解, 参数为标注了@Pointcut注解的切入点方法名
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
@Aspect
public class MyAop {

public final static Logger log = LoggerFactory.getLogger(MyAop.class);

@Pointcut("@annotation(MyAnnotation)")
public void beforeLog(){
// @Pointcut注解的方法内不用写任何内容
}

//参数为标注了@Pointcut注解的切入点方法名@Before("beforeLog()")
public void doBefore(){
log.info("业务方法执行!");
}

}

项目运行后, 当切入点方法执行时 (本例就是/object/show被访问后showObject方法被执行);

@Before 注解指定的方法在切面切入目标方法之前执行, 从而实现打印日志, 安全验证等功能

  1. 结果示例 (控制台打印日志)

    1
    2
    2023-03-10T21:09:27,347 INFO  [http-nio-9009-exec-1] com.crisp.miniostorage.aop.MyAop: 业务方法执行!
    2023-03-10T21:09:27,484 INFO [http-nio-9009-exec-1] com.crisp.miniostorage.controller.ObjectController: ObjectController控制器的获取指定存储桶中所有对象的信息方法执行

@After

作用在用@Component注解和@Aspect作用的切面类的方法中, 标注了@After的方法表示此方法是后置通知

后置通知, 顾名思义是在@Pointcut注解标注的切入点类中的方法执行后被调用

以@Pointcut使用execution定位的方法来标注切入点为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
@Aspect
public class MyAop {

public final static Logger log = LoggerFactory.getLogger(MyAop.class);


@Pointcut("execution(* com.crisp.miniostorage.controller.*.*(..))")
public void afterLog(){
// @Pointcut注解的方法内不用写任何内容
}

@After("afterLog()")
public void doAfter(JoinPoint joinPoint){
Signature signature = joinPoint.getSignature();
String name = signature.getName();
log.info(name+"方法执行完毕!");
}
}

项目运行后, 当切入点方法执行时 (本例子中是controller包下的任意方法时),

@After 注解指定的方法在切面切入目标方法之后执行

JoinPoint类可以获取切入点的信息, 在之后会详细介绍

结果示例:

1
2
2023-03-10T21:32:40,079 INFO  [http-nio-9009-exec-1] com.crisp.miniostorage.controller.ObjectController: ObjectController控制器的获取指定存储桶中所有对象的信息方法执行
2023-03-10T21:32:40,081 INFO [http-nio-9009-exec-1] com.crisp.miniostorage.aop.MyAop: showObject方法执行完毕!

@AfterReturning

@AfterReturning 注解和 @After 有些类似,区别在于 @AfterReturning 注解可以用来捕获切入方法执行完之后的返回值,对返回值进行业务逻辑上的增强处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Component
@Aspect
public class MyAop {

public final static Logger log = LoggerFactory.getLogger(MyAop.class);


@Pointcut("execution(* com.crisp.miniostorage.controller.ObjectController.*(..))") /*切入点03*/
public void afterReturningLog(){
// @Pointcut注解的方法内不用写任何内容
}


@AfterReturning(value = "afterReturningLog()",returning = "resultMsg")
public void doAfterReturning(JoinPoint joinPoint, ResponseJson resultMsg){
/*
后置返回
如果第一个参数为JoinPoint,则第二个参数为返回值的信息
如果第一个参数不为JoinPoint,则第一个参数为returning中对应的参数
returning:限定了只有目标方法返回值与通知方法参数类型匹配时才能执行后置返回通知,否则不执行,
参数为Object类型将匹配任何目标返回值
*/
log.info(resultMsg.toString());
}
}

后置返回的参数说明:

  • 如果第一个参数为JoinPoint,则第二个参数为返回值的信息
  • 如果第一个参数不为JoinPoint,则第一个参数为returning中对应的参数
  • returning:限定了只有目标方法返回值与通知方法参数类型匹配时才能执行后置返回通知,否则不执行,
    参数为Object类型将匹配任何目标返回值

结果示例:

1
2
2023-03-10T21:49:47,685 INFO  [http-nio-9009-exec-1] com.crisp.miniostorage.controller.ObjectController: ObjectController控制器的校验桶内是否存在重复的数据方法执行
2023-03-10T21:49:47,885 INFO [http-nio-9009-exec-1] com.crisp.miniostorage.aop.MyAop: ResponseJson{code=1, msg='校验成功', data=[ObjectVerifyInfo{objectName='XKCY(K}]~~[}AUT%FQ@S]A8.jpg', size='10.72KB', verifyMsg='文件1'}, ObjectVerifyInfo{objectName='XKCY(K}]~~[}AUT%FQ@S]A8.png', size='10.72KB', verifyMsg='文件1'}, ObjectVerifyInfo{objectName='bigImage.png', size='1.21MB', verifyMsg='文件2'}, ObjectVerifyInfo{objectName='bigImage02.png', size='1.21MB', verifyMsg='文件2'}]}

@AfterThrowing

当被切方法执行过程中抛出异常时,会进入 @AfterThrowing 注解的方法中执行,在该方法中可以做一些异常的处理逻辑。要注意的是 throwing 属性的值必须要和参数一致,否则会报错。该方法中的第二个入参即为抛出的异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
@Aspect
public class MyAop {

public final static Logger log = LoggerFactory.getLogger(MyAop.class);

@Pointcut("execution(* *..*.*())") /*切入点04(全局切入)*/
public void pointCut() {
// @Pointcut注解的方法内不用写任何内容
}

@AfterThrowing(value = "pointCut()",throwing = "t")
public void doAfterExpetion(JoinPoint joinPoint,Throwable t){
Signature signature = joinPoint.getSignature();
String name = signature.getName();
log.error(name+"方法抛出异常!--"+t.toString());
}
}

@Around

spring的通知(Advice)中 一共有五种通知,之前已经介绍了四种,为什么不把环绕通知和它们放在一起说,因为环绕通知可以把前面的四种通知都表示出来,而且环绕通知一般单独使用

环绕通知的使用:

  • Spring框架为我们提供了一个接口:ProceedingJoinPoint,该接口有一个方法proceed(),此方法就相当于明确调用切入点方法。
  • 该接口作为环绕通知的方法参数,在程序执行时,spring框架会为我们提供该接口的实现类供我们使用。
  • 增强代码写在调用proceed()方法之前为前置通知,之后为返回通知,写在catch中为异常通知,写在finally中为后置通知
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Aspect
@Component
public class MyAroundAop {

//切入点:待增强的方法
@Pointcut("execution("execution(* *..*.*())")
//切入点签名

public void logAround() {
System.out.println("pointCut签名。。。");
}

//环绕通知,环绕增强,相当于MethodInterceptor
@Around("logAround()")
public Object aroundAdvice(ProceedingJoinPoint pjp) {
Object rtValue = null;
try {
Object[] args = pjp.getArgs();//得到方法执行所需的参数

System.out.println("通知类中的aroundAdvice方法执行了[前置Advice]");

//可以对切入点方法的参数(args)在切面类中进行预处理,进行修改后把新的参数传入切入点方法
rtValue = pjp.proceed(args);//明确调用切入点方法(切入点方法)


System.out.println("通知类中的aroundAdvice方法执行了[后置返回Advice]");
System.out.println("返回通知:"+rtValue);
return rtValue;
} catch (Throwable e) {
System.out.println("通知类中的aroundAdvice方法执行了[异常Advice]");
throw new RuntimeException(e);
} finally {
System.out.println("通知类中的aroundAdvice方法执行了[后置Advice]");
}
}
}

AOP相关类

JoinPoint 对象

JoinPoint对象封装了SpringAop中切面方法的信息, 在切面方法中添加JoinPoint参数, 就可以获取到封装了该方法信息的JoinPoint对象

JoinPoint常用api

方法名 功能
Signature getSignature(); 获取封装了署名信息的对象,在方法名功能Signature对象中可以获取到目标方法名,所属类的Class等信息
Object[] getArgs(); 获取传入目标方法的参数对象,在@Around环绕通知中可以获取参数并且进行预处理, 然后再执行切入点方法proceed()
Object getTarget(); 获取被代理的对象(标注的切入点所在的类的对象)
Object getThis(); 获取代理对象

joinPoint.getSignature()示例

1
2
3
4
5
6
7
8
9
10
@Before("pointCut()")
public void doBefore(JoinPoint joinPoint) {
// 获取签名
Signature signature = joinPoint.getSignature();
// 获取切入的包名+类名
String declaringTypeName = signature.getDeclaringTypeName();
// 获取即将执行的方法名
String funcName = signature.getName();

}

joinPoint.getArgs()示例

1
2
3
4
5
6
7
	@Before("pointCut()")
public void doBefore(JoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
for (int i = 0; i < args.length; i++) {
System.out.println("第" + (i+1) + "个参数为:" + args[i]);
}
}

ServletRequestAttributes对象

也可以用来记录一些信息,比如获取请求的 URL 和 IP, 可以在advice通知内容中使用, 获取查看请求信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();

HttpServletRequest request = attributes.getRequest(); //获取request对象

//获取项目的获取项目的根路径(即项目本身的路径,即这里的context-path)
String contextPath = request.getContextPath();

//获取项目的根路径后的接口路径(即这里/basePath后面的部分)
String servletPath = request.getServletPath();

//获取"/"在机器中的实际地址
String realPath = request.getServletContext().getRealPath("/");

//获取根路径到地址结尾(包含根路径)
String requestURI = request.getRequestURI();

// 获取请求的地址链接(浏览器中输入的地址)
String requestURL = request.getRequestURL();

示例:

1
2
3
4
5
6
7
8
9
@Before("pointCut()")
public void doBefore(JoinPoint joinPoint) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 获取请求 URL
String url = request.getRequestURL().toString();
// 获取请求 IP
String ip = request.getRemoteAddr();
}

ProceedingJoinPoint对象

ProceedingJoinPoint对象是JoinPoint的子接口,该对象只用在@Around的切面方法中,
添加了

  1. Object proceed() throws Throwable //执行目标方法
  2. Object proceed(Object[] var1) throws Throwable //传入的新的参数去执行目标方法
    两个方法
  3. getArgs(); 获取切入点的参数

AOP 代码模板

笔记所用代码:

自定义注解类:

1
2
3
4
5
6
7
8
//表示次注解可以标注在类和方法上
@Target({ElementType.METHOD, ElementType.TYPE})
//运行时生效
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
//定义一个变量,可以接受参数
String value() default " ";
}

切面类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@Component
@Aspect
public class MyAop {

public final static Logger log = LoggerFactory.getLogger(MyAop.class);

@Pointcut("@annotation(MyAnnotation)") /*切入点01*/
public void beforeLog(){
// @Pointcut注解的方法内不用写任何内容
}

@Pointcut("execution(* com.crisp.miniostorage.controller.BucketController.*(..))") /*切入点02*/
public void afterLog(){
// @Pointcut注解的方法内不用写任何内容
}

@Pointcut("execution(* com.crisp.miniostorage.controller.ObjectController.*(..))") /*切入点03*/
public void afterReturningLog(){
// @Pointcut注解的方法内不用写任何内容
}

@Pointcut("execution(* *..*.*())") /*切入点04*/
public void pointCut() {
// @Pointcut注解的方法内不用写任何内容

}


@Before("beforeLog()")
public void doBefore(){
log.info("业务方法执行!");
}

@After("afterLog()")
public void doAfter(JoinPoint joinPoint){
Signature signature = joinPoint.getSignature();
String name = signature.getName();
log.info(name+"方法执行完毕!");
}

@AfterReturning(value = "afterReturningLog()",returning = "resultMsg")
public void doAfterReturning(JoinPoint joinPoint, ResponseJson resultMsg){
/*
后置返回
如果第一个参数为JoinPoint,则第二个参数为返回值的信息
如果第一个参数不为JoinPoint,则第一个参数为returning中对应的参数
returning:限定了只有目标方法返回值与通知方法参数类型匹配时才能执行后置返回通知,否则不执行,
参数为Object类型将匹配任何目标返回值
*/
log.info(resultMsg.toString());
}

@AfterThrowing(value = "pointCut()",throwing = "t")
public void doAfterExpetion(JoinPoint joinPoint,Throwable t){
Signature signature = joinPoint.getSignature();
String name = signature.getName();
log.error(name+"方法抛出异常!--"+t.toString());
}
}

示例模板:

自定义注解类: CrispAnnotation:

1
2
3
4
5
6
7
8
9
10
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface CrispAnnotation {
String value() default " ";
}

自定义切面类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;


@Component
@Aspect
public class CrispAop {

public final static Logger log = LoggerFactory.getLogger(CrispAop.class);

@Pointcut("execution(* com.crisp.项目包名.controller.*.*(..))")
public void controllerPoint(){

}

@Pointcut("execution(* com.crisp.项目包名.service.*.*(..))")
public void servicePoint(){

}

@Pointcut("controllerPoint()&&servicePoint()")
public void globalPoint() {

}

@Pointcut("@annotation(CrispAnnotation)")
public void annotationPoint(){

}


@Before("controllerPoint()")
public void doBefore(JoinPoint joinPoint){
Signature signature = joinPoint.getSignature();
String packageName = signature.getDeclaringTypeName();
String name = signature.getName();
Object[] args = joinPoint.getArgs();
String parameter = "";
for (int i = 0; i < args.length; i++) {
parameter+=args[i].toString();
}
log.info(packageName+"包下的"+name+"控制器方法执行[开始],所携带的参数为: "+parameter);
}

@AfterReturning(value = "controllerPoint()",returning = "object")
public void doControllerAfter(JoinPoint joinPoint,Object object){
Signature signature = joinPoint.getSignature();
String packageName = signature.getDeclaringTypeName();
String name = signature.getName();
log.info(packageName+"包下的"+name+"控制器方法执行[结束],返回值为: "+object.toString());
}

@AfterReturning(value = "servicePoint()",returning = "object")
public void doServiceAfter(JoinPoint joinPoint,Object object){
Signature signature = joinPoint.getSignature();
String packageName = signature.getDeclaringTypeName();
String name = signature.getName();
log.info(packageName+"包下的"+name+"业务逻辑方法执行[结束],返回值为: "+object.toString());
}

@AfterThrowing(value = "globalPoint()",throwing = "t")
public void doAfterExpetion(JoinPoint joinPoint,Throwable t){
Signature signature = joinPoint.getSignature();
String packageName = signature.getDeclaringTypeName();
String name = signature.getName();
log.error(packageName+"包下的"+name+"方法抛出异常!!! 异常为: "+t.toString());
}

}