1 理解AOP
1.1 什么是AOP
AOP(Aspect Oriented Programming),面向切面思想,是Spring的三大核心思想之一(两外两个:IOC-控制反转、DI-依赖注入)。
那么AOP为何那么重要呢?在我们的程序中,经常存在一些系统性的需求,比如权限校验、日志记录、统计等,这些代码会散落穿插在各个业务逻辑中,非常冗余且不利于维护。例如下面这个示意图:
有多少业务操作,就要写多少重复的校验和日志记录代码,这显然是无法接受的。当然,用面向对象的思想,我们可以把这些重复的代码抽离出来,写成公共方法,就是下面这样:
这样,代码冗余和可维护性的问题得到了解决,但每个业务方法中依然要依次手动调用这些公共方法,也是略显繁琐。有没有更好的方式呢?有的,那就是AOP,AOP将权限校验、日志记录等非业务代码完全提取出来,与业务代码分离,并寻找节点切入业务代码中:
1.2 AOP体系与概念
简单地去理解,其实AOP要做三类事:
在哪里切入,也就是权限校验等非业务操作在哪些业务代码中执行。
在什么时候切入,是业务代码执行前还是执行后。
切入后做什么事,比如做权限校验、日志记录等。
因此,AOP的体系可以梳理为下图:
一些概念详解:
- Pointcut:切点,决定处理如权限校验、日志记录等在何处切入业务代码中(即织入切面)。切点分为execution方式和annotation方式。前者可以用路径表达式指定哪些类织入切面,后者可以指定被哪些注解修饰的代码织入切面。
- Advice:处理,包括处理时机和处理内容。处理内容就是要做什么事,比如校验权限和记录日志。处理时机就是在什么时机执行处理内容,分为前置处理(即业务代码执行前)、后置处理(业务代码执行后)等。
- Aspect:切面,即Pointcut和Advice。
- Joint point:连接点,是程序执行的一个点。例如,一个方法的执行或者一个异常的处理。在 Spring AOP 中,一个连接点总是代表一个方法执行。
- Weaving:织入,就是通过动态代理,在目标对象方法中执行处理内容的过程。
2 AOP实例
实践出真知,接下来我们就撸代码来实现一下AOP。
- 使用 AOP,首先需要引入 AOP 的依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
2.1 第一个实例
接下来,我们先看一个极简的例子:所有的get请求被调用前在控制台输出一句"get请求的advice触发了"。
具体实现如下:
- 1.创建一个AOP切面类,只要在类上加个 @Aspect 注解即可。@Aspect 注解用来描述一个切面类,定义切面类的时候需要打上这个注解。@Component 注解将该类交给 Spring 来管理。在这个类里实现advice:
package com.jingudi.framework.log.log.aspect;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
/**
* @author 1060785272@qq.com
* @version 1.0
* @description: TODO
* @date 2022-4-10 下午 9:31
*/
@Aspect
@Component
public class LogAdvice {
// 定义一个切点:所有被GetMapping注解修饰的方法会织入advice
@Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping)")
private void logAdvicePointcut(){}
@Before("logAdvicePointcut()")
public void logAdvice(){
// 这里只是一个示例,你可以写任何处理逻辑
System.out.println("get请求的advice触发了");
}
}
- 2.随便创建一个接口类,内部创建一个get请求
(必须要有@GetMapping):
@ApiOperation("查询用户列表")
@GetMapping
public PageResult<UserEntity> getUserList(QueryUserVo vo) {
return userService.getUserList(vo);
}
2.2 第二个实例
下面我们将问题复杂化一些,该例的场景是:
自定义一个注解PermissionsAnnotation
创建一个切面类,切点设置为拦截所有标注PermissionsAnnotation的方法,截取到接口的参数,进行简单的权限校验
将PermissionsAnnotation标注在测试接口类的测试接口test上
具体的实现步骤:
- 1.使用@Target、@Retention、@Documented自定义一个注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PermissionAnnotation{
}
- 2.创建第一个AOP切面类,,只要在类上加个@Aspect 注解即可。@Aspect 注解用来描述一个切面类,定义切面类的时候需要打上这个注解。@Component 注解将该类交给 Spring 来管理。在这个类里实现第一步权限校验逻辑:
package com.jingudi.advice;
import com.alibaba.fastjson.JSONObject;
import com.jingudi.modules.system.dto.DictDetailDto;
import lombok.extern.log4j.Log4j;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.*;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
/**
* @author 1060785272@qq.com
* @version 1.0
* @description: TODO
* @date 2022-4-10 下午 9:56
*/
@Aspect
@Component
@Order(1)
@Slf4j
public class PermissionFirstAdvice {
// 定义一个切面,括号内写入第1步中自定义注解的路径
@Pointcut("@annotation(com.jingudi.annotation.PermissionsAnnotation)")
private void permissionCheck() {
}
@Before("permissionCheck()")
public void beforeAdvice(JoinPoint joinPoint){
// 这里只是一个示例,你可以写任何处理逻辑
System.out.println("---------Before触发了----------");
// 获取签名
Signature signature = joinPoint.getSignature();
// 获取切入的包名
String declaringTypeName = signature.getDeclaringTypeName();
// 获取即将执行的方法名
String funcName = signature.getName();
log.info("即将执行方法为: {},属于{}包", funcName, declaringTypeName);
// 也可以用来记录一些信息,比如获取请求的 URL 和 IP
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 获取请求 URL
String url = request.getRequestURL().toString();
// 获取请求 IP
String ip = request.getRemoteAddr();
log.info("用户请求的url为:{},ip地址为:{}", url, ip);
}
@Around("permissionCheck()")
public Object permissionCheckFirst(ProceedingJoinPoint joinPoint) throws Throwable {
// 这里只是一个示例,你可以写任何处理逻辑
System.out.println("---------Around触发了----------");
//获取请求参数,详见接口类
Object[] objects = joinPoint.getArgs();
System.out.println(objects);
// Integer id = ((JSONObject) objects[0]).getInteger("id");
DictDetailDto object1 = (DictDetailDto)objects[0];
// 修改入参
JSONObject object = new JSONObject();
return joinPoint.proceed(objects);
}
@AfterReturning(pointcut = "permissionCheck()", returning = "result")
public void afterReturningAdvice(JoinPoint joinPoint, Object result){
// 这里只是一个示例,你可以写任何处理逻辑
System.out.println("---------AfterReturning触发了----------");
Signature signature = joinPoint.getSignature();
String classMethod = signature.getName();
log.info("方法{}执行完毕,返回参数为:{}", classMethod, result);
// 实际项目中可以根据业务做具体的返回值增强
log.info("对返回参数进行业务上的增强:{}", result + "增强版");
}
@AfterThrowing(pointcut = "permissionCheck()", throwing = "ex")
public void afterThrowing(JoinPoint joinPoint, Throwable ex) {
Signature signature = joinPoint.getSignature();
String method = signature.getName();
// 处理异常的逻辑
log.info("执行方法{}出错,异常为:{}", method, ex);
}
@After("permissionCheck()")
public void afterAdvice(JoinPoint joinPoint){
// 这里只是一个示例,你可以写任何处理逻辑
System.out.println("---------After触发了----------");
Signature signature = joinPoint.getSignature();
String method = signature.getName();
log.info("方法{}已经执行完", method);
}
}
@Order(0)
AOP加载顺序(切面加载顺序)
总结:
-
前置通知
在目标方法执行之前执行执行的通知。 -
环绕通知
在目标方法执行之前和之后都可以执行额外代码的通知。 -
后置通知
在目标方法执行之后执行的通知。 -
异常通知
在目标方法抛出异常时执行的通知。 -
最终通知
是在目标方法执行之后执行的通知。
以上5种都可以额外接收一个JoinPoint参数,来获取目标对象和目标方法相关信息,但一定要保证必须是第一个参数。
五种通知的执行顺序:
-
在目标方法没有抛出异常的情况下
前置通知
环绕通知的调用目标方法之前的代码
目标方法
环绕通知的调用目标方法之后的代码
后置通知
最终通知 -
在目标方法抛出异常的情况下
前置通知
环绕通知的调用目标方法之前的代码
目标方法
抛出异常
异常通知
最终通知 -
如果存在多个切面
多切面执行时,采用了责任链设计模式。
切面的配置顺序决定了切面的执行顺序,多个切面执行的过程,类似于方法调用的过程,在环绕通知的proceed()执行时,
去执行下一个切面或如果没有下一个切面执行目标方法,从而达成了如下的执行过程: