SpringBoot 的两大核心
  1. IoC:控制反转
  2. AOP:面向切面编程

概念

AOP(Aspect Oriented Programming,面向切面编程、面向方面编程),其实就是面向特定方法编程。AOP 是 OOP(面向对象编程)的一种延续,二者互补,并不对立。AOP 是一种编程思想,动态代理是这种思想的主流实现方式。

AOP 之所以叫面向切面编程,是因为它的核心思想就是将横切关注点从核心业务逻辑中分离出来,形成一个个的切面(Aspect)

image.png

应用场景:

  • 日志记录:自定义日志记录注解,利用 AOP,一行代码即可实现日志记录。
  • 性能统计:利用 AOP 在目标方法的执行前后统计方法的执行时间,方便优化和分析。
  • 事务管理:@Transactional 注解可以让 Spring 为我们进行事务管理比如回滚异常操作,免去了重复的事务管理逻辑。@Transactional注解就是基于 AOP 实现的。
  • 权限控制:利用 AOP 在目标方法执行前判断用户是否具备所需要的权限,如果具备,就执行目标方法,否则就不执行。例如,SpringSecurity 利用@PreAuthorize 注解一行代码即可自定义权限校验。
  • 接口限流:利用 AOP 在目标方法执行前通过具体的限流算法和实现对请求进行限流处理。
  • 缓存管理:利用 AOP 在目标方法执行前后进行缓存的读取和更新。

优势:

  1. 代码无侵入
  2. 减少重复代码
  3. 提高开发效率
  4. 维护方便

AOP 的常见实现方式有动态代理、字节码操作等方式。

Spring AOP 是 Spring 框架的高级技术,旨在管理 bean 对象的过程中,丰要通过底层的动态代理机制,对特定的方法进行编程。

AOP是一种思想,Spring AOP是这个思想的一种实现。AOP和Spring AOP的关系相当于IoC和DI之间的关系一样。

引入 AOP 依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

一些概念:

  • 横切关注点 Cross-cutting Concerns :多个类或对象中的公共行为(如日志记录、事务管理、权限控制、接口限流、接口幂等等)。
  • 连接点 JoinPoint:可以被 AOP 控制的方法(暗含方法执行时的相关信息)
  • 切面 Aspect:对横切关注点进行封装的类,一个切面是一个类。切面可以定义多个通知,用来实现具体的功能。描述通知与切入点的对应关系(通知 + 切入点)
    • 通知 Advice:指那些重复的逻辑,也就是共性功能 (最终体现为一个方法)
    • 切入点 PointCut:匹配连接点的条件,通知仅会在切入点方法执行时被应用。一个切点是一个表达式,它用来匹配哪些连接点需要被切面所增强。切点可以通过注解、正则表达式、逻辑运算等方式来定义。
  • 目标对象 Target:通知所应用的对象。
  • 织入 Weaving:织入是将切面和目标对象连接起来的过程,也就是将通知应用到切点匹配的连接点上。常见的织入时机有两种,分别是编译期织入(Compile-Time Weaving 如:AspectJ)和运行期织入(Runtime Weaving 如:AspectJ、Spring AOP)。

image.png

入门示例

一个 AOP 程序的示例:为 DeptServletImpl 类的所有方法增加一层统计其运行时间逻辑。

QQ截图20240511212142.png

解释:

  • TimeAspect 是一个 AOP 程序。
    • @Aspect 注解说明当前类是 AOP 类(切面类)。
    • 这个类中的模板方法执行以下操作:
      1. 记录开始时间
      2. 调用原始方法运行
      3. 记录结束时间并输出日志
    • @Around 注解填写切入点表达式,针对某些方法进行编程

执行流程:

image.png

SpringAOP 的底层是基于动态代理实现的。程序最后注入的就不是目标对象,而是代理对象。代理对象完成了我们所编写的增强的功能。

通知类型

通知类型 名称 在目标方法执行位置 备注 IDEA 中的图标
@Around 环绕通知 前、后 如果原始方法抛异常,那么原始方法后的代码就不会执行 aroundAdvice.svg
@Before 前置通知 beforeAdvice.svg
@After 后置通知 无论是否有异常都执行,因此又叫做最终通知 afterAdvice.svg
@AfterReturning 返回后通知 发生异常不会执行 afterReturningAdvice.svg
@AfterThrowing 抛出异常后通知 - 发生异常后执行 img

image.png

关于环绕通知:

  • @Around 环绕通知需要自己调用 ProceedingJoinPoint.proceed() 来让原始方法执行,其他通知不需要考虑目标方法执行
  • @Around 环绕通知方法的返回值,必须指定为 Object,来接收原始方法的返回值。

@PointCut:该注解的作用是将公共的切点表达式抽取出来,需要用到时引用该切点表达式即可。被注解的方法访问级别:

  • private:仅能在当前切面类中引用该表达式
  • public:在其他外部的切面类中也可以引用该表达式

切面类示例

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
@Slf4j
@Component
@Aspect
public class MyAspect1 {

// 抽取切入点表达式,避免代码重复
@Pointcut("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
public void pt(){} // 公有函数

@Before("pt()")
public void before(){ log.info("before ..."); }

@Around("pt()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
log.info("around before ...");

//调用目标对象的原始方法执行
Object result = proceedingJoinPoint.proceed();

log.info("around after ...");
return result;
}

@After("pt()")
public void after(){ log.info("after ..."); }

@AfterReturning("pt()")
public void afterReturning(){ log.info("afterReturning ..."); }

@AfterThrowing("pt()")
public void afterThrowing(){ log.info("afterThrowing ..."); }
}

通知顺序

通知顺序:当有多个切面的切入点都匹配到了目标方法,目标方法运行时,多个通知方法都会被执行。

  1. 不同切面类中,默认按照切面类的类名字母排序
    • 目标方法前的通知方法:字母排名靠前的先执行
    • 目标方法后的通知方法:字母排名靠前的后执行
  2. @Order(number) 加在切面类上来数字控制顺序
    • 目标方法前的通知方法:数字小的先执行
    • 目标方法后的通知方法:数字小的后执行

切入点表达式

切入点表达式:描述切入点方法的一种表达式。主要用来决定项目中的哪些方法需要加入通知。

常见形式:

  • execution(...):根据方法的签名来匹配
  • @annotation(...):根据注解匹配

execution()

execution 主要根据方法的返回值、包名、类名、方法名、方法参数等信息来匹配,语法为:

1
2
3
4
5
6
execution([访问修饰符] 返回值 [packageName.className]methodName(methodParams) [throws 异常])

// packageName.className 不建议省略,否则容易导致匹配范围过大,性能降低
// 上面的异常指的是方法上声明抛出的异常,不是实际抛出的异常
// 方法参数类型写全类名,比如java.lang.Integer
// 多个切入点表达式可以通过 || 连接

可以使用的通配符:

  • *:单个独立的任意符号,可以通配任意返回值、包名、类名、方法名、任意类型的一个参数,也可以通配包、类、方法名的一部分
  • ..:多个连续的任意符号,可以通配任意层级的包,或任意类型、任意个数的参数

根据业务需要,可以使用 &&||! 来组合比较复杂的切入点表达式。

一些示例:

1
2
3
execution(* com.*.service.*.update*(*))
execution(* com.*.uuanqin..DeptService.*(..))
execution(* *(..)) // 最为通配的方法,慎用
1
2
3
4
5
6
@Pointcut("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")

@Pointcut(
"execution(* com.itheima.service.impl.DeptServiceImpl.list(..)) || "+
"execution(* com.itheima.service.impl.DeptServiceImpl.delete(..))"
)

书写建议:

  • 所有业务方法名在命名时尽量规范,方便切入点表达式快速匹配。如:查询类方法都是 find 开头,更新类方法都是 update 开头。
  • 描述切入点方法通常基于接口描述,而不是直接描述实现类,增强拓展性。
  • 在满足业务需要的前提下,尽量缩小切入点的匹配范围。如:包名匹配尽量不使用 ..,可以使用 * 匹配单个包。

@annotation()

@annotation 切入点表达式,用于匹配标识有特定注解的方法。

自定义注解:

1
2
3
@Retention(RetentionPolicy.RUNTIME) // 注解运行时有效
@Target(ElementType.METHOD) // 注解标识在方法上
public @interface MyLog {}

切入点表达式示例,它将作用于标注有 @MyLog 的方法上:

1
@Pointcut("@annotation(your.package.MyLog)")

连接点

在 Spring 中用 JoinPoint 抽象了连接点,用它可以获得方法执行时的相关信息,如目标类名、方法名、方法参数等。

两种对象获取信息的方式是一致的:

  • 对于 @Around 通知,获取连接点信息只能使用 ProceedingJoinPoint
  • 对于其他四种通知,获取连接点信息只能使用 JoinPoint,它是 ProceedingJoinPoint 的父类型

获取目标对象的相关信息示例:

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
//切面类
@Slf4j
@Aspect
@Component
public class MyAspect8 {

@Pointcut("execution(* com.uuanqin.service.DeptService.*(..))")
private void pt(){}

@Before("pt()")
public void before(JoinPoint joinPoint){
log.info("MyAspect8 ... before ...");
//1. 获取 目标对象的类名 .
String className = joinPoint.getTarget().getClass().getName();
log.info("目标对象的类名:{}", className);

//2. 获取 目标方法的方法名 .
String methodName = joinPoint.getSignature().getName();
log.info("目标方法的方法名:{}",methodName);

//3. 获取 目标方法运行时传入的参数 .
Object[] args = joinPoint.getArgs();
log.info("目标方法运行时传入的参数:{}", Arrays.toString(args));
}

@Around("pt()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("MyAspect8 around before …");

//1. 获取 目标对象的类名 .
String className = joinPoint.getTarget().getClass().getName();
log.info("目标对象的类名:{}", className);

//2. 获取 目标方法的方法名 .
String methodName = joinPoint.getSignature().getName();
log.info("目标方法的方法名:{}",methodName);

//3. 获取 目标方法运行时传入的参数 .
Object[] args = joinPoint.getArgs();
log.info("目标方法运行时传入的参数:{}", Arrays.toString(args));

//4. 放行 目标方法执行 .
Object result = joinPoint.proceed(); // 我们可以对返回值进行篡改

//5. 获取 目标方法运行的返回值 .
log.info("目标方法运行的返回值:{}",result);

log.info("MyAspect8 around after …");
return result;
}
}

应用实例

记录操作日志

日志信息包含:操作人、操作时间、执行方法的全类名、执行方法名、方法运行时参数、返回值、方法执行时长。

思路:需要对所有业务类中的增、删、改 方法添加统一功能,使用 AOP 技术最为方便。

步骤:

  • 引入 AOP 起步依赖
  • 准备好日志相关的表结构,并引入实体类
  • 准备好一个自定义注解,实现切入点表达式的 @annotation() 方法。
  • 定义切面类,完成记录操作日志的逻辑。

权限控制

背景:在 Controller 中,一些方法需要获取管理员权限才能调用。我们可以通过加上注解的方式实现权限控制。

定义一些权限常量:

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
public interface UserConstant {

/**
* 用户登录态键
*/
String USER_LOGIN_STATE = "user_login";

// region 权限

/**
* 默认角色
*/
String DEFAULT_ROLE = "user";

/**
* 管理员角色
*/
String ADMIN_ROLE = "admin";

/**
* 被封号
*/
String BAN_ROLE = "ban";

// endregion
}

定义注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 权限校验
*
* @author uuanqin
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCheck {

/**
* 必须有某个角色
*
* @return
*/
String mustRole() default "";

}

AOP 切面实现类:

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
/**
* 权限校验 AOP
*/
@Aspect
@Component
public class AuthInterceptor {

/**
* 执行拦截
*
* @param joinPoint
* @param authCheck
* @return
*/
@Around("@annotation(authCheck)")
public Object doInterceptor(ProceedingJoinPoint joinPoint, AuthCheck authCheck) throws Throwable {
String mustRole = authCheck.mustRole();
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();

// 一些权限校验逻辑。比如查数据库等操作,检查用户的权限字段是否和mustRole匹配。
// 不匹配抛异常或者返回错误信息等。

// 如果通过权限校验,则放行
return joinPoint.proceed();
}
}

在 Controller 中可以通过加上一条注解即可实现权限校验:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 更新(仅管理员)
*
* @param interfaceInfoUpdateRequest
* @return
*/
@PostMapping("/update")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Boolean> updateInterfaceInfo(@RequestBody InfoUpdateRequest InfoUpdateRequest) {
// 省略一些校验逻辑
return ResultUtils.success(result);
}

关于 Request 不同的获取方式详看这篇文章:RequestContextHolder详解(获取request对象的四种方法) - 周文豪 - 博客园 (cnblogs.com)

全局异常处理器

@RestControllerAdvice 是 Spring 框架提供的一个切面注解,用于定义全局异常处理器和全局数据绑定设置。它结合了 @ControllerAdvice@ResponseBody 两个注解的功能:

  • @ControllerAdvice 是一个用于定义全局控制器增强(即全局异常处理和全局数据绑定)的注解。通过使用 @ControllerAdvice,我们可以将异常处理和数据绑定逻辑集中到一个类中,避免在每个控制器中重复编写相同的异常处理代码。
  • @ResponseBody 是用于指示控制器方法返回的对象将被直接写入响应体中的注解。它告诉 Spring 将方法的返回值序列化为 JSON 或其他适当的响应格式,并将其作为 HTTP 响应的主体返回给客户端。

作用:

  • 捕获代码中所有的异常,内部消化,让前端得到更详细的业务报错 / 信息
  • 同时屏蔽掉项目框架本身的异常(不暴露服务器内部状态)
  • 集中处理,比如记录日志

全局异常处理器切面类示例写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 全局异常处理器
* @author uuanqin
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

@ExceptionHandler(BusinessException.class)
public BaseResponse<?> businessExceptionHandler(BusinessException e) {
log.error("BusinessException", e);
return ResultUtils.error(e.getCode(), e.getMessage());
}

@ExceptionHandler(RuntimeException.class)
public BaseResponse<?> runtimeExceptionHandler(RuntimeException e) {
log.error("RuntimeException", e);
return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "系统错误");
}
}

注意事项:

  • @RestControllerAdvice 并不能捕获所有异常,例如 Error 类的子类(如 OutOfMemoryError)通常无法被捕获。
  • 某些异常可能会被其他全局异常处理器或框架层面的异常处理机制捕获,而不会被 @RestControllerAdvice 处理。
  • Error 及其子类:ErrorThrowable 的子类,表示严重的错误,通常由虚拟机抛出,如 OutOfMemoryErrorStackOverflowError 等。这些异常通常意味着应用程序处于不可恢复的状态,因此无法被 @RestControllerAdvice 捕获。
  • ThreadDeathThreadDeathError 的子类,它表示线程意外终止的异常。与其他 Error 一样,ThreadDeath 异常也无法被 @RestControllerAdvice 捕获。
  • VirtualMachineError 及其子类:VirtualMachineErrorError 的子类,表示与 Java 虚拟机相关的错误,如 InternalErrorUnknownError 等。这些错误通常与虚拟机的内部状态或配置有关,无法被 @RestControllerAdvice 捕获。

下面罗列一些上面全局异常处理器配套的类。

自定义异常类:

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
/**
* 自定义异常类
*
* @author uuanqin
*/
public class BusinessException extends RuntimeException {

/**
* 错误码
*/
private final int code;

public BusinessException(int code, String message) {
super(message);
this.code = code;
}

public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}

public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.code = errorCode.getCode();
}

public int getCode() {
return code;
}
}

自定义错误码示例:

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
/**
* 自定义错误码
* @author uuanqin
*/
public enum ErrorCode {

SUCCESS(0, "ok"),
PARAMS_ERROR(40000, "请求参数错误"),
NOT_LOGIN_ERROR(40100, "未登录"),
NO_AUTH_ERROR(40101, "无权限"),
NOT_FOUND_ERROR(40400, "请求数据不存在"),
FORBIDDEN_ERROR(40300, "禁止访问"),
SYSTEM_ERROR(50000, "系统内部异常"),
OPERATION_ERROR(50001, "操作失败");

/**
* 状态码
*/
private final int code;

/**
* 信息
*/
private final String message;

ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}

public int getCode() {
return code;
}

public String getMessage() {
return message;
}

}

返回工具类:

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
/**
* 返回工具类
*
* @author uuanqin
*/
public class ResultUtils {

/**
* 成功
*/
public static <T> BaseResponse<T> success(T data) {
return new BaseResponse<>(0, data, "ok");
}

/**
* 失败
*/
public static BaseResponse error(ErrorCode errorCode) {
return new BaseResponse<>(errorCode);
}

/**
* 失败
*/
public static BaseResponse error(int code, String message) {
return new BaseResponse(code, null, message);
}

/**
* 失败
*/
public static BaseResponse error(ErrorCode errorCode, String message) {
return new BaseResponse(errorCode.getCode(), null, message);
}
}

抛异常工具类:

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
/**
* 抛异常工具类
*
* @author uuanqin
*/
public class ThrowUtils {

/**
* 条件成立则抛异常
*/
public static void throwIf(boolean condition, RuntimeException runtimeException) {
if (condition) {
throw runtimeException;
}
}

/**
* 条件成立则抛异常
*/
public static void throwIf(boolean condition, ErrorCode errorCode) {
throwIf(condition, new BusinessException(errorCode));
}

/**
* 条件成立则抛异常
*/
public static void throwIf(boolean condition, ErrorCode errorCode, String message) {
throwIf(condition, new BusinessException(errorCode, message));
}
}

本文参考