SpringBoot使用AOP详解

一、介绍

AOP(Aspect Oriented Programming)意为:面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。AOP是OOP的延续,是软件开发中的一个热点,也是Spring框架中的一个重要内容,是函数式编程的一种衍生范型。

利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。

好吧,以上来自百度百科

我知道,大家肯定是一头雾水。我们直接进入使用,用实践来进行理解。

二、使用

1)初识术语

本来很想直接上代码,但是不行,需要先过一遍AOP的概念术语,相关的专业名词。

先来看看下面这个需求,现在有一堆接口,需要统计他们耗时的一个需求,我们应该怎么做?

image-20220519153245178

笨方法,在每个接口方法前后,放置起止时间进行计算。为什么说这是笨方法,自然是笨啦

  • 一个两个还行,方法多了该怎么办

  • 起止时间的计算是一种系统功能,原本方法中的功能是业务功能。两种不同的功能混杂在一起,非常难受

  • 对架构,对维护不友好

那么上述的问题,可以使用AOP来解决,我们直接来看这个图

image-20220519191151702

什么切入点、通知、连接点的,还是不懂怎么办。不要着急,借助下一章节的简单案例协助进行理解。

2)简易使用

首先,我们要使用AOP,先得引入对应的包,maven依赖如下。本文的springBoot版本是2.6.4,仅供参考

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 主要是这个依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

我们先写一个HelloController.java,这里面的hello方法就是我们需要进行增强的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.banmoon.test.controller;

import cn.hutool.core.util.StrUtil;
import com.banmoon.test.dto.ResultData;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class HelloController {

@RequestMapping("/hello")
public ResultData<String> hello(@RequestParam(required = false) String name) throws Exception {
if(StrUtil.isBlank(name))
throw new UnsupportedOperationException("无名,不允许操作");
log.info("模拟业务操作:{}", name);
return ResultData.success(name);
}

}

还有一个我喜欢写的统一返回类

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
package com.banmoon.test.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ResultData<T> {

private Integer errCode;

private String errMsg;

private T data;

public static ResultData success(){
return new ResultData(0, "", null);
}

public static ResultData fail(String errMsg){
return new ResultData(-1, errMsg, null);
}

public static <T> ResultData success(T t) {
return new ResultData(0, "成功", t);
}
}

方法接口已经写好,下面就是重头戏

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
package com.banmoon.test.aspect;

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.date.TimeInterval;
import com.banmoon.test.dto.ResultData;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;

@Slf4j
@Aspect
@Component
public class HelloAspect {

@Pointcut("execution(public * com.banmoon.test.controller..*.*(..) throws Exception)")
public void pointcut() {
}

@Before("pointcut()")
public void before(JoinPoint point){
log.info("前置通知");
}

@After("pointcut()")
public void after(JoinPoint point) {
log.info("后置通知");
}

@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) {
log.info("环绕通知");
try {
// 计时
TimeInterval timer = DateUtil.timer();
// 执行方法,连接点
Object result = joinPoint.proceed();
// 查看耗时
log.info("耗时:{}", timer.interval());
return result;
} catch (Throwable throwable) {
return ResultData.fail("服务器繁忙,请稍后再试");
}
}

@AfterReturning("pointcut()")
public void afterReturning(JoinPoint point) {
log.info("返回通知");
}

@AfterThrowing(value = "pointcut()", throwing = "t")
public void afterThrowing(JoinPoint point, Throwable t) {
log.info("异常通知");
}

}

来访问这个接口http://localhost:8080/hello?name=banmoon,直接来看控制台

image-20220520110015629

再来访问下接口GET http://localhost:8080/hello?name=,由于name没有传参,所以报错是必然的,我们来看下结果

image-20220520105820174

同时,接口返回的数据

image-20220520105907654

通知是如何加强方法的,以及通知见的执行顺序,通过一目了然了,如下图

image-20220520110925835

如此一来,现在可以解释一下,相关的AOP术语了

  • 切面(Aspect):一般是指被@Aspect修饰的类,代表着某一具体功能的AOP逻辑。

  • 切入点(Pointcut):选择哪些增强的方法,上述体现的是@pointcut注解和execution表达式

  • 通知(Advice):对目标方法的增强

    • 环绕通知(@Around):内部执行连接点(方法),对其进行增强
    • 前置通知(@Before):在执行连接点前执行
    • 后置通知(@After):在执行连接点后执行
    • 返回通知(@AfterReturning):在连接点返回后执行
    • 异常通知(@AfterThrowing):在连接点爆出异常后执行
  • 连接点(JoinPoint):就是那些被切入点选中的方法啦

3)切入点表达式

切入点表达式,就功能而言。就是为了选择哪些方法需要被增强的一个方法选择表达式。表达式有以下这些种类

表达式类型 功能
execution() 匹配方法,最全的一个
args() 匹配形参类型
@args() 匹配形参类型上的注解
@annotation() 匹配方法上的注解
within() 匹配类路径
@within() 匹配类上的注解
this() 匹配类路径,实际上AOP代理的类
target() 匹配类路径,目标类
@target() 匹配类上的注解

3.1)execution表达式

额,不好描述,直接上图

image-20220519092804483

其中,以下几点需要注意

  • 访问修饰符异常类型可以省略,其余都是必填的

  • 方法参数,..代表所有参数

  • 类路径中,..代表多层路径,包括当前包的类和子包的类


举几个常用的表达式

1
2
3
4
5
6
7
8
9
10
// 所有方法
execution(* *..*(..))
// 指定参数,即入参本身的类型,不能放其接口、父类
execution(* *..*(java.lang.String, java.lang.String)
// 指定方法前缀
execution(* *..*.prefix*(..))
// 指定方法后缀
execution(* *..*.*suffix(..))
// 组合,增强所有方法,但是去掉指定前缀和指定后缀的方法
execution(* *..*(..)) && (!execution(* *..prefix*(..)) || !execution(* *..*suffix(..)))

IDEA工具上,通知的方法左边可以查看到那些被切入点选择中的方法,非常好用,如下图所示

image-20220520164649378

3.2)arg

在上面提到execution表达式,在限制参数时,只能使用本身的类型,而不能使用参数的接口、父类来进行限制。

所以arg表达式就出现了,如下

1
2
3
4
5
6
7
8
9
10
11
12
// 现在有两个方法,他们的入参都是CharSequence接口的子类
public class HelloController {

public String user(String name) {
return "成功";
}

public String role(StringBuffer name) {
return "成功";
}

}
1
2
3
4
// 增强入参为CharSequence或其子类的方法
arg(java.lang.CharSequence)
// 一般来说,arg表达式要配合execution表达式一起使用,单独使用不会很直观
execution(* com.banmoon.test.controller..*.*(..)) && args(java.lang.CharSequence)

3.3)@args

args不同,@args是找到指定注解修饰的入参方法。

有点拗口,是什么意思呢?简单看下面这几个类和方法

1
2
3
4
5
6
7
8
9
10
// 现在几个DTO,上面标注了一个注解
@MyTestAspect
public class UserDTO {

}

@MyTestAspect
public class RoleDTO {

}
1
2
3
4
5
6
7
8
9
10
11
12
// 现在有两个方法,分别使用了上面两个DTO作为形参
public class HelloController {

public String user(UserDTO user) {
return "成功";
}

public String role(RoleDTO role) {
return "成功";
}

}

那么我们怎么写切入点的表达式呢,同时增强这两个方法,我们可以使用@args表达式

1
2
3
4
5
6
// 写出注解的全路径就好了
@args(com.banmoon.test.annotation.MyTestAspect)
// 也可以使用通配符、前缀、后缀
@args(com.banmoon.test.annotation.*)
@args(com.banmoon.test.annotation.Prefix*)
@args(com.banmoon.test.annotation.*Suffix)

3.4)@annotation

如果上面的@args表达式是限制形参是否有某注解的话,那么@annotation这个表达式就是限制了方法上的注解

在方法上标注的注解,可以使用此表达式来进行限制,举例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 现在有两个方法,两个方法上都有注解
public class HelloController {

@MyTestAspect
public String user(String name) {
return "成功";
}

@MyTestAspect
public String role(String name) {
return "成功";
}

}
1
2
3
4
5
6
// 增强被指定注解修饰的方法
@annotation(com.banmoon.test.annotation.MyTestAspect)
// 指定前缀的注解修饰的方法
@annotation(com.banmoon.test.annotation.Prefix*)
// 指定后缀的注解修饰的方法
@annotation(com.banmoon.test.annotation.*Suffix)

3.5)within

找到指定的类,下面的所有方法都将得到增强。

1
2
3
4
5
6
// 指定controller包下面类的所有方法
within(com.banmoon.test.controller..*)
// 还可以指定注解类,类下的所有方法
within(@com.banmoon.test.annotation.MyTestAspect *)
// 还可以指定接口,接口下面的方法将被增强
within(com.banmoon.test.controller.IController+)

接口反应有问题?如下,只会增强接口中有的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface IController {

public String user(String name);

}

// 现在有个类,实现了上面的接口
public class HelloController implements IController{

public String user(String name) {
return "成功";
}

public String role(String name) {
return "成功";
}

}

3.6)@within

等同于within的注解方式,注解作用在类上,增强类下面的方法

1
@within(com.banmoon.test.annotation.MyTestAspect))

3.7)this和target

1
2
3
// 写法都一样
this(com.banmoon.test.controller.HelloController)
target(com.banmoon.test.controller.HelloController)

很像几乎一样,但它们的区别,也很难区分,你需要提前了解AOP是使用了什么代理,是怎么进行代理的

Spring AOP默认是cglib代理,是通过子类继承目标父类/接口,实现对应的方法进行代理。当了解到这里就明白下面的区别了

  • this:增强的是代理对象,代理对象可能会没有HelloController的方法

  • target:增强的是目标对象本身,可以增强目标对象的方法

3.8)@target

增强在类上标注了指定注解的方法,如下

1
@target(com.banmoon.test.annotation.MyTestAspect)

三、案例

1)接口日志打印

切面展示

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
package com.banmoon.blog.business.aspect;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;

@Slf4j
@Aspect
@Component
public class LogAspect {

// 切点
@Pointcut("execution(* com.banmoon.blog.controller..*(..))" +
" && !@annotation(com.banmoon.blog.business.annotation.NotLogAspect)")
public void aspect() {
}



@Before("aspect()")
public void doBefore(JoinPoint joinPoint){
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
Object[] args = joinPoint.getArgs();
Object arg = args==null || args.length==0? null: args[0];
String argStr;
try {
argStr = JSON.toJSONString(arg, SerializerFeature.WriteMapNullValue);
} catch (Exception e) {
argStr = null;
}
log.info("========================= start =========================");
log.info("请求路径 :{}", request.getRequestURL().toString());
log.info("请求方法 :{}", request.getMethod());
log.info("class类 :{}#{}", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName());
log.info("请求地址 :{}", request.getRemoteAddr());
log.info("请求参数 :{}", argStr);
}

@Around("aspect()")
public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object result = proceedingJoinPoint.proceed();// 执行切点
String resultStr = JSON.toJSONString(result);
if(resultStr.length()>300)
resultStr = resultStr.substring(0, 300) + "......";
log.info("请求出参 :{}", resultStr);
log.info("请求耗时 :{} ms", System.currentTimeMillis()-start);
log.info("========================== end ==========================" + System.lineSeparator());
return result;
}

}

NotLogAspect.java注解

1
2
3
4
5
6
7
8
9
10
11
package com.banmoon.blog.business.annotation;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NotLogAspect {

String name() default "";
}

查看日志打印的效果

image-20220521120132702

2)记录请求日志系统

日志系统

四、最后

AOP的使用就到这了,具体如何使用AOP还是要看大家有什么需求。它可以做到

  • 接口方法日志的收集

  • 接口方法的权限校验

  • 前后对出入参的修改,先查缓存这种需求

能完成的很多,看自己的功能需要吧。

官方文档:大家记得对着文档看啊

我是半月,祝你幸福!!!