1、理解AOP

AOP:Aspect Oriented Program:面向切面编程。面向切面编程解决的,实现的是将组件功能的扩展通过动态植入的方式添加进来(不是修改源码来实现添加,也不是通过继承父类,实现功能扩展)。

在我们的项目中,总会遇到各种各样的需求,比如鉴权,记录日志或者记录一些用户的访问情况等信息,若是用代码去解决,那就回产生极大的代码量而且还都是重复的代码,非常冗余且不利于维护。

用鉴权来说,基本上每个接口都需要进行鉴权,若是每个接口都写一个鉴权操作,那这工程量既大又无法让人接受。当然,我们也可以直接写一个工具类,来进行鉴权操作,这样,至少代码冗余和可维护性的问题得到了解决,但是每个业务方法中依然要依次手动调用这个工具类,还是很不方便的。那有没有更好的方式呢?当然是有的,那就是AOP,AOP可以将鉴权、记录日志或等代码完全提取出来,通过一个节点将代码植入进去,完成所需的功能。

2、AOP体系结构

aop主要做了以下三件事:

  • 在哪里切入(即在哪个方法鉴权操作)
  • 什么时候切入(即在方法执行前还是执行中进行鉴权)
  • 切入后做什么(即做鉴权、记录日志等操作)

aop的体系结构如下:

  • Pointcut:切点。决定将鉴权从哪里切入业务代码(即织入切面)
    • 分为execution方式和annotation方式
      • execution:可以用路径表达式指定有哪些类要织入切面
      • annotation:可以指定被哪些注解修饰的代码织入切面
  • Advice:处理。包括处理时机和处理内容
    • 处理时机就是在什么时候执行处理内容
    • 处理内容就是要做什么事,比如鉴权
  • Aspect:切面,即Pointcut和Advice
  • JointPoint:连接点,是程序执行的一个点。例如一个方法的执行或者一个异常的处理
    • 在 Spring AOP 中,一个连接点总是代表一个方法执行
  • Weaving:织入,就是通过动态代理,在目标对象方法中执行处理内容的过程

3、AOP实例

在开始实例之前,创建一个spring boot项目,然后引入aop依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

创建一个接口返回JSON体,用于返回JSON数据:

@Data
public class WebApiResult<T> {

    private Boolean success;

    private String message;

    private int code;

    private T data;

    /**
     * 操作成功
     * @param message
     * @param data
     * @return
     */
    public static WebApiResult ok(String message,Object data){
        WebApiResult result = new WebApiResult();
        result.setSuccess(true);
        result.setMessage(message);
        result.setCode(200);
        result.setData(data);
        return result;
    }

    /**
     * 操作成功
     * @param message
     * @return
     */
    public static WebApiResult ok(String message){
        WebApiResult result = new WebApiResult();
        result.setSuccess(true);
        result.setMessage(message);
        result.setCode(200);
        result.setData(null);
        return result;
    }

    /**
     * 操作失败
     * @param message
     * @return
     */
    public static WebApiResult error(String message){
        WebApiResult result = new WebApiResult();
        result.setSuccess(false);
        result.setMessage(message);
        result.setCode(0);
        result.setData(null);
        return result;
    }
}

1、实例一

该例子为所有请求为get的接口都会打印出“get请求成功”。

1.创建一个切面类

只需要创建一个类,然后在类上加上**@Aspect**注解即可。

@Aspect 注解用来描述一个切面类,定义切面类的时候需要打上这个注解。

@Component 注解将该类交给 Spring 来管理。在这个类里实现advice。

@Aspect
@Component
public class MyAop01 {
    /**
     * 定义一个切点:所有被GetMapping注解修饰的方法会织入advice
     */
    @Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping)")
    private void myAop01Pointcut() {}

    /**
     * Before表示myAop01将在目标方法执行前执行
     */
    @Before("myAop01Pointcut()")
    public void myAop01(){
        // 处理逻辑
        System.out.println("get请求成功");
    }
}

2.创建一个接口,并创建一个get请求

@RestController
@RequestMapping(value = "/demo")
public class DemoController {
    @GetMapping(value = "/getTest")
    public WebApiResult aopTest() {
        return WebApiResult.ok("成功");
    }

    @PostMapping(value = "/postTest")
    public WebApiResult aopTest2(@RequestParam("id") String id) {
        return WebApiResult.ok("成功");
    }
}

3.实例测试

启动项目,然后访问http://localhost:8080/demo/getTest,会发现控制台打印了上述的话;

访问http://localhost:8080/demo/postTest,控制台没有打印。

2、实例二

实例二做复杂一点,具体为:

  • 创建一个注解:@MyAnnotation
  • 创建一个切面类,切点为拦截被@MyAnnotation修饰的方法,获取到接口的参数,并进行鉴权
  • 将@MyAnnotation标记在接口方法上

1.编写自定义注解

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

}

2.创建一个User类

@Data
public class User {
    
    private Integer uid;
    
    private String name;
}

3.创建一个切面类

@Aspect
@Component
@Order(1)
public class MyAop02 {
    /**
     * 定义一个切面,括号内写入自定义注解的路径
     */
    @Pointcut("@annotation(com.gggd.aopdemo.anno.MyAnnotation)")
    private void myAop02Pointcut() {
    }

    @Around("myAop02Pointcut()")
    public Object myAop02(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("===================第一个切面===================:");

        //获取请求参数
        Object[] objects = joinPoint.getArgs();
        Integer uid = ((User) objects[0]).getUid();
        String name = ((User) objects[0]).getName();
        System.out.println("uid1->>>>>>>>>>>>>>>>>>>>>>" + uid);
        System.out.println("name1->>>>>>>>>>>>>>>>>>>>>>" + name);

        // uid小于0则抛出非法uid的异常
        if (uid < 0) {
            return WebApiResult.error("非法uid");
        }
        //返回参数
        return joinPoint.proceed();
    }
}

4.创建接口方法

@PostMapping("test02")
@MyAnnotation
public WebApiResult demo2(@RequestBody User user){
    return WebApiResult.ok("成功");
}

5.测试

先构造正常参数请求接口,会发现正常;

构造负数uid请求接口,会返回非法uid提示。

有人会问,如果我一个接口想设置多个切面类进行校验怎么办?这些切面的执行顺序如何管理

很简单,一个自定义的AOP注解可以对应多个切面类,这些切面类执行顺序由@Order注解管理,该注解后的数字越小,所在切面类越先执行。

下面在实例中进行演示:

创建第二个AOP切面类,在这个类里实现第二步权限校验:

6.创建第二个切面类

@Aspect
@Component
@Order(0)
public class MyAop03 {
    @Pointcut("@annotation(com.gggd.aopdemo.anno.MyAnnotation)")
    private void myAop03Pointcut() {
    }

    @Around("myAop03Pointcut()")
    public Object myAop03(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("===================第二个切面===================:");

        //获取请求参数
        Object[] objects = joinPoint.getArgs();
        Integer uid = ((User) objects[0]).getUid();
        String name = ((User) objects[0]).getName();
        System.out.println("uid1->>>>>>>>>>>>>>>>>>>>>>" + uid);
        System.out.println("name1->>>>>>>>>>>>>>>>>>>>>>" + name);

        // name不是管理员则抛出异常
        if (!name.equals("admin")) {
            return WebApiResult.error("非法用户");
        }
        return joinPoint.proceed();
    }
}

7.测试

构建两个参数都异常的请况,会发现接口返回了非法用户,而不是非法uid了,这就证明了是@Order(0)的切面先执行了。

4、AOP相关注解

1、@Pointcut

@Pointcut 注解,用来定义一个切点,即上文中所关注的某件事情的入口,切入点定义了事件触发时机。

@Pointcut 注解指定一个切点,定义需要拦截的东西,这里介绍两个常用的表达式:一个是使用 execution(),另一个是使用 annotation()

execution()表达式:

execution(* club.gggd.controller..*.*(..))) 表达式为例:

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

annotation() 表达式:

annotation() 方式是针对某个注解来定义切点,比如我们对具有 @PostMapping 注解的方法做切面,可以如下定义切面:

@Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")
public void annotationPointcut() {}

然后使用该切面的话,就会切入注解是 @PostMapping 的所有方法。这种方式很适合处理 @GetMapping、@PostMapping、@DeleteMapping不同注解有各种特定处理逻辑的场景。还有就是如上面案例所示,针对自定义注解来定义切面。

2、@Around

@Around注解用于修饰Around增强处理,Around增强处理非常强大,表现在:

  • @Around可以自由选择增强动作与目标方法的执行顺序,也就是说可以在增强动作前后,甚至过程中执行目标方法。这个特性的实现在于:调用ProceedingJoinPoint参数的procedd()方法才会执行目标方法。
  • @Around可以改变执行目标方法的参数值,也可以改变执行目标方法之后的返回值。

Around增强处理有以下特点:

  • 当定义一个Around增强处理方法时,该方法的第一个形参必须是 ProceedingJoinPoint 类型(至少一个形参)。在增强处理方法体内,调用ProceedingJoinPointproceed方法才会执行目标方法:这就是@Around增强处理可以完全控制目标方法执行时机、如何执行的关键;如果程序没有调用ProceedingJoinPointproceed方法,则目标方法不会执行。

  • 调用ProceedingJoinPointproceed方法时,还可以传入一个Object[ ]对象,该数组中的值将被传入目标方法作为实参——这就是Around增强处理方法可以改变目标方法参数值的关键。这就是如果传入的Object[ ]数组长度与目标方法所需要的参数个数不相等,或者Object[ ]数组元素与目标方法所需参数的类型不匹配,程序就会出现异常。

  • @Around功能虽然强大,但通常需要在线程安全的环境下使用。因此,如果使用普通的BeforeAfterReturning就能解决的问题,就没有必要使用Around了。如果需要目标方法执行之前和之后共享某种状态数据,则应该考虑使用Around。尤其是需要使用增强处理阻止目标的执行,或需要改变目标方法的返回值时,则只能使用Around增强处理了。

下面,在前面例子上做一些改造,来观察@Around的特点。

自定义注解类不变。首先,定义接口类:

1.接口类新增方法

@PostMapping("test03")
@MyAnnotation
public WebApiResult demo3(@RequestBody User user){
    return WebApiResult.ok("成功", user);
}

2.创建切面类

@Component
@Aspect
public class MyAop04 {
    @Pointcut("@annotation(com.gggd.aopdemo.anno.MyAnnotation)")
    private void myAop04Pointcut() {
    }


    @Around("myAop04Pointcut()")
    public Object myAop04(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("===================开始增强处理===================");

        //获取请求参数
        Object[] objects = joinPoint.getArgs();
        Integer uid = ((User) objects[0]).getUid();
        String name = ((User) objects[0]).getName();
        System.out.println("id1->>>>>>>>>>>>>>>>>>>>>>" + uid);
        System.out.println("name1->>>>>>>>>>>>>>>>>>>>>>" + name);

        // 修改入参
        User user = new User();
        user.setUid(1000);
        user.setName("张三");
        objects[0] = user;

        // 将修改后的参数传入
        return joinPoint.proceed(objects);
    }
}

3.测试

请求接口则发现参数变成了aop中设计的参数。

3、@Before

@Before 注解指定的方法在切面切入目标方法之前执行,可以做一些记录日志处理,也可以做一些信息的统计,比如获取用户的请求 URL以及用户的 IP 地址等等,这个在做个人站点的时候都能用得到,都是常用的方法。例如下面代码:

@Aspect
@Component
@Slf4j
public class LogAspect {

    @Pointcut("@annotation(com.gggd.aopdemo.anno.MyAnnotation)")
    private void logAspectPointcut() {
    }

    /**
     * 在上面定义的切面方法之前执行该方法
     * @param joinPoint jointPoint
     */
    @Before("logAspectPointcut()")
    public void logAspect(JoinPoint joinPoint) {
        log.info("====doBefore方法进入了====");

        // 获取签名
        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);
    }
}

启动服务,随意请求一个被注解@MyAnnotation修饰的接口即可返回相应的日志。

JointPoint 对象很有用,可以用它来获取一个签名,利用签名可以获取请求的包名、方法名,包括参数(通过 joinPoint.getArgs()获取)等。

4、@After

@After 注解和 @Before 注解相对应,指定的方法在切面切入目标方法之后执行,也可以做一些完成某方法之后的 Log 处理。

@Aspect
@Component
@Slf4j
public class LogAspectAfter {
    /**
     * 定义一个切面,拦截 club.gggd.controller 包下的所有方法
     */
    @Pointcut("execution(* com.gggd.aopdemo.controller..*.*(..))")
    public void pointCut() {}

    /**
     * 在上面定义的切面方法之后执行该方法
     * @param joinPoint jointPoint
     */
    @After("pointCut()")
    public void doAfter(JoinPoint joinPoint) {

        log.info("==== doAfter 方法进入了====");
        Signature signature = joinPoint.getSignature();
        String method = signature.getName();
        log.info("方法{}已经执行完", method);
    }
}

启动服务,随意请求一个controller包中的接口即可返回相应的日志。

5、@AfterReturning

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

@Slf4j
@Aspect
@Component
public class AfterReturningDemo {
    
    @Pointcut("@annotation(com.gggd.aopdemo.anno.MyAnnotation)")
    public void pointCut() {}

    /**
     * 在上面定义的切面方法返回后执行该方法,可以捕获返回对象或者对返回对象进行增强
     * @param joinPoint joinPoint
     * @param result result
     */
    @AfterReturning(pointcut = "pointCut()", returning = "result")
    public void doAfterReturning(JoinPoint joinPoint, Object result) {

        Signature signature = joinPoint.getSignature();
        String classMethod = signature.getName();
        log.info("方法{}执行完毕,返回参数为:{}", classMethod, result);
        // 实际项目中可以根据业务做具体的返回值增强
        log.info("对返回参数进行业务上的增强:{}", result + "增强版");
        //获取方法返回体
        WebApiResult webApiResult = (WebApiResult) result;
        //获取并修改数据
        User u = (User) webApiResult.getData();
        u.setName("李四");
    }
}

启动服务,请求http://localhost:8080/demo/test03则发现返回的是李四。

需要注意的是,在 @AfterReturning 注解中,属性 returning 的值必须要和参数保持一致,否则会检测不到。该方法中的第二个入参就是被切方法的返回值,在 doAfterReturning 方法中可以对返回值进行增强,可以根据业务需要做相应的封装。

6、@AfterThrowing

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

@Aspect
@Component
@Slf4j
public class AfterThrowingDemo {
    /**
     * 定义一个切面,拦截 club.gggd.controller 包下的所有方法
     */
    @Pointcut("@annotation(com.gggd.aopdemo.anno.MyAnnotation)")
    public void pointCut() {}

    /**
     * 在上面定义的切面方法执行抛异常时,执行该方法
     * @param joinPoint jointPoint
     * @param ex ex
     */
    @AfterThrowing(pointcut = "pointCut()", throwing = "ex")
    public void afterThrowing(JoinPoint joinPoint, Throwable ex) {
        Signature signature = joinPoint.getSignature();
        String method = signature.getName();
        // 处理异常的逻辑
        log.info("执行方法{}出错,异常为:{}", method, ex.toString());
    }
}

创建一个接口,为了测试,手动写一个运行时异常:

@PostMapping("test04")
@MyAnnotation
public WebApiResult demo4(@RequestBody User user){
    String s = null;
    s.length();
    return WebApiResult.ok("成功", user);
}

启动服务,请求http://localhost:8080/demo/test04,即可打印异常。

7、切点指示器

Spring AOP借助AspectJ的切点表达式语言来定义Spring切面,下面是切点表达式中使用的指示器:

表达式 描述
execution 匹配方法执行的连接点,也就是哪些方法要应用切面
within 用来限定连接点必须在确定的类型或包中
this 用来限定连接点属于给定类型的一个实例(代理对象)
target 用来限定连接点属于给定类型的一个实例(被代理对象)
args 用来限定连接点,也就是方法执行时它的参数属于给定类型的一个实例
bean 用来指定某个bean执行切面方法
@annotation 用来限定连接点的方法拥有给定注解
@target 用来限定连接点属于一个执行对象(被代理对象)所属的拥有给定注解的类
@args 用来限定连接点,方法执行时传递的参数的运行时类型拥有给定注解
@within 用来限定连接点属于拥有给定注解的类型中

execution

execution(* club.gggd.controller..*.*(..))) 表达式为例:

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

例:

@Pointcut("execution(* com.gggd.aopdemo.controller..*.*(..))")
public void pointCut() {}

within

within指示器用来限定连接点必须在确定的类型或包中,即within指示器只拦截确定的类型,也就是跟它的接口无关,定义什么类型就拦截什么类型。

例:

@Before("within(com.gggd.aopdemo.controller.FruitController)")

@Pointcut("within(com.gggd.aopdemo.controller.FruitController)")

需要注意的是:within()所指定的连接点最小范围只能是类,而execution()所指定的连接点可以大到包,小到方法入参。 所以从某种意义上讲,execution()函数功能涵盖了within()函数的功能

this

this指示器用来限定连接点属于给定类型的一个实例(代理对象),不管方法是来源于哪个接口或类,只要代理对象的实例属于this中所定义的类型,那么这个方法就会被拦截。

例:

@Before("this(com.gggd.aopdemo.controller.FruitController)")

target

target指示器用来限定连接点属于给定类型的一个实例(被代理对象),这个指示器的语义跟this指示器是很相似的,但是它们有一个重要的区别,this中的实例指的是代理对象,而**target中的实例指的是被代理对象**。

例:

@Before("target(com.gggd.aopdemo.controller.FruitController)")

args

定义包含args指示器的切面,下面的切面表达式中额外定义了within指示器,这个主要是为了缩小args的使用范围。如果不加,Spring AOP会尝试去代理所有符合条件的对象,但是有些对象的访问会有限制,导致启动异常。这个也提醒我们使用AOP时必须要明确指定使用范围,否则会造成不可预料的错误。

@Before("within(com.gggd.aopdemo.controller.FruitController.*) && args(java.lang.Integer, java.lang.String)")

args指示器用来限定连接点,也就是方法执行时它的参数属于给定类型的一个实例,例如上方的例子,只有在FruitController类中,方法第一个参数为Integer类型,第二个参数为String类型的方法才会被拦截。

注意:args指示器不但对参数类型有要求,而且还会对参数个数、定义顺序有要求。

bean

bean用来对某个类进行拦截,也就是通过execution指定某个方法时,可能指定的这个方法是接口里的,这个接口又被很多个类实现,此时就可以使用bean对指定的类进行拦截。

例:

@Before("execution(com.gggd.aopdemo.server.Fruit.buy(..)) && bean(Apple)")

上方的例子中,Fruit是一个接口,里面有一个方法为buyAppleBanana分别实现了这个接口,例子中bean指定了Apple这个类,则在拦截时,只会拦截Apple这个类的buy方法。

@annotation

annotation() 方式是针对某个注解来定义切点,比如我们对具有 @PostMapping 注解的方法做切面,可以如下定义切面:

@Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")
public void annotationPointcut() {}

然后使用该切面的话,就会切入注解是 @PostMapping 的所有方法。这种方式很适合处理 @GetMapping、@PostMapping、@DeleteMapping不同注解有各种特定处理逻辑的场景。还有就是如上面案例所示,针对自定义注解来定义切面。

@target

**@target用来限定连接点属于一个执行对象(被代理对象)所属的拥有给定注解的类。**虽然@targettarget名称相似,但是它们用法是完全不同的,前者针对注解,后置针对类型。

例:

@Before("within(com.gggd.aopdemo.server.Fruit.*) && @target(org.springframework.validation.annotation.Validated)")

@target限定在一个执行对象的所属类,与它的父类接口无关,即@target指定的注解只能放在需要被增强的方法所属的类中,若该类实现了某接口,将注解放在该接口上是不生效的。

@args

@args用来限定连接点,方法执行时传递的参数的运行时类型拥有给定注解。这个给定的注解要么定义在方法参数的类型本身上,那么定义在的它的实现类上。比如RedAppleWeight它虽然继承了AppleWeight(拥有指定注解),但是它本身没有指定注解,相关方法不会被拦截。

例:

@Before("within(com.gggd.aopdemo.server.Fruit.*) && @args(com.fasterxml.jackson.databind.annotation.JsonDeserialize)")

@within

@within用来限定连接点属于拥有给定注解的类型中。@target针对执行对象所属的类,而@within针对执行对象所属的类型。另外所执行的方法必须属于拥有给定注解的类型中,比如redApple.print();被重写在RedApple类中,redApple.print(11);被定义在SweetFruit类中,当这两个类拥有指定注解后方法执行时才会被拦截。

例:

@Before("within(com.gggd.aopdemo.server.Fruit.*) && @within(org.springframework.validation.annotation.Validated)")

5、自定义注解实现脱敏

引入依赖

<!--引入lombok-->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
<!--引入validation,用于字段验证-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!--引入aop-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!--引入lang3-->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.12.0</version>
</dependency>

编写User实体类

@Data
public class SysUser {

    @NotNull(message = "uid不能为空")
    private Integer uid;

    private String name;

    @Pattern(regexp = "^[1][3,4,5,6,7,8,9][0-9]{9}$", message = "联系电话格式有误")
    private String tel;

    private String idCard;

}

编写脱敏类型枚举

public enum DesensitizeType {
    PHONE("手机号"),
    IDCARD("身份证"),
    NAME("姓名");

    private String value;

    DesensitizeType(String value) {
        this.value = value;
    }
}

编写脱敏注解

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Desensitization {
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Desensitize {
    /**
     * @return 需要脱敏的类型
     */
    DesensitizeType type();
}

编写切面类

@Aspect
@Component
public class DesensitizationAspect {

    /**
     * 声明一个切入点
     */
    @Pointcut("@annotation(com.gggd.aopdemo.anno.Desensitization)")
    private void desensitizePointcut() {}


    /**
     * 切入操作
     * @param result 方法的返回体
     */
    @AfterReturning(pointcut = "desensitizePointcut()", returning = "result")
    public void check(Object result){
        try {
            //获取方法返回体
            WebApiResult webApiResult = (WebApiResult) result;
            //获取需要脱敏的数据
            Object o = webApiResult.getData();
            //判断数据是否为集合
            if(o instanceof Collection){
                //为集合,则将集合数据取出进行脱敏
                Collection collection = (Collection) o;
                for(Object c : collection){
                    //开始脱敏
                    desensitize(c);
                }
            }else{
                //不为集合,开始脱敏
                desensitize(o);
            }
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }

    }
    
    /**
     * 进行脱敏类型判断
     * @param o
     * @throws IllegalAccessException
     */
    private void desensitize(Object o) throws IllegalAccessException{
        //获取对象的全部字段
        Field[] fields = o.getClass().getDeclaredFields();
        //迭代字段
        for(Field f : fields ){
            //判断字段是否存在@Desensitize注解
            if(f.isAnnotationPresent(Desensitize.class)){
                //注解存在,设置字段为可修改
                f.setAccessible(true);
                //获取注解的类型值
                Desensitize desensitize = f.getAnnotation(Desensitize.class);
                String value = String.valueOf(desensitize.type());
                //对值进行判断,启动相应的脱敏规则
                switch (value){
                    case "PHONE" :
                        //对手机号进行脱敏
                        desensitizePhone(f, o);
                        break;
                    case "IDCARD" :
                        //对身份证进行脱敏
                        desensitizeIDCard(f, o);
                        break;
                    case "NAME" :
                        //对姓名进行脱敏
                        desensitizeName(f, o);
                        break;
                    default:
                        break;
                }
            }
        }
    }

    /**
     * 脱敏手机号
     * @param f 
     * @param o
     * @throws IllegalAccessException
     */
    private void desensitizePhone(Field f, Object o) throws IllegalAccessException{
        //获取字段的值
        String phone = (String) f.get(o);
        //判断值是否为空
        if(StringUtils.isNotEmpty(phone)){
            if(phone.length() == 11){
                //通过正则表达式将手机号中间4位替换为*号
                String newTel = phone.replaceAll("(\\w{3})\\w*(\\w{4})", "$1****$2");
                //将值设置为修改后的值
                f.set(o, newTel);
            }
        }
    }

    /**
     * 脱敏身份证
     * @param f 
     * @param o
     * @throws IllegalAccessException
     */
    private void desensitizeIDCard(Field f, Object o) throws IllegalAccessException{
        //获取字段的值
        String idCard = (String) f.get(o);
        //判断值是否为空
        if (StringUtils.isNotEmpty(idCard)) {
            //对身份证号进行过脱敏操作
            if (idCard.length() == 15){
                idCard = idCard.replaceAll("(\\w{6})\\w*(\\w{3})", "$1******$2");
            }
            if (idCard.length() == 18){
                idCard = idCard.replaceAll("(\\w{6})\\w*(\\w{3})", "$1*********$2");
            }
            //将值设置为修改后的值
            f.set(o, idCard);
        }
    }

    /**
     * 脱敏姓名
     * @param f 
     * @param o
     * @throws IllegalAccessException
     */
    private void desensitizeName(Field f, Object o) throws IllegalAccessException{
        //获取字段的值
        String name = (String) f.get(o);
        //判断值是否为空且长度是否大于2位
        if (StringUtils.isNotEmpty(name) && name.length() >= 2) {
            //对姓名进行过脱敏操作
            String newName = "";
            if(name.length() == 2){
                newName = StringUtils.left(name, 1) + "*";
            }else{
                String start = "";
                for (int i = 0; i < name.length() - 2; i++) {
                    start += "*";
                }
                newName = name.replaceAll(name.substring(1, name.length() - 1), start);
            }
            //将值设置为修改后的值
            f.set(o, newName);
        }
    }
}

编写接口类

@RestController
@RequestMapping("/desensitizationDemo")
public class SysUserController {
    @GetMapping("desensitization")
    @Desensitization
    public WebApiResult<SysUser> check(@RequestBody SysUser u){
        //测试集合
//        List<SysUser> list = new ArrayList<>();
//        list.add(u);
//        SysUser u2 = new SysUser();
//        BeanUtils.copyProperties(u, u2);
//        u2.setUid(2);
//        list.add(u2);
//        SysUser u3 = new SysUser();
//        BeanUtils.copyProperties(u, u3);
//        u3.setUid(3);
//        list.add(u3);
//        return WebApiResult.ok("成功", list);

        //测试对象
        return WebApiResult.ok("成功", u);
    }
}

开始测试

先在需要脱敏的实体类上加入注解:

@Data
public class SysUser {

    @NotNull(message = "uid不能为空")
    private Integer uid;

    //脱敏注解,类型为:NAME
    @Desensitize(type = DesensitizeType.NAME)
    private String name;

    //脱敏注解,类型为:PHONE
    @Desensitize(type = DesensitizeType.PHONE)
    @Pattern(regexp = "^[1][3,4,5,6,7,8,9][0-9]{9}$", message = "联系电话格式有误")
    private String tel;

    //脱敏注解,类型为:IDCARD
    @Desensitize(type = DesensitizeType.IDCARD)
    private String idCard;
}

然后启动服务,

输入数据:

{
    "uid":"1",
    "name":"狗狗狗蛋儿",
    "tel":"18333333333",
    "idCard":"452123222222222222"
}

访问:http://localhost:8080/desensitizationDemo/desensitization

返回以下数据,脱敏成功。

{
    "success": true,
    "message": "成功",
    "code": 200,
    "data": {
        "uid": 1,
        "name": "狗***儿",
        "tel": "183****3333",
        "idCard": "452123*********222"
    }
}

接下来将接口类中测试对象的代码注释掉,将测试集合的代码取消注释,重启服务,再次请求接口,返回以下数据,脱敏成功。

{
    "success": true,
    "message": "成功",
    "code": 200,
    "data": [
        {
            "uid": 1,
            "name": "狗***儿",
            "tel": "183****3333",
            "idCard": "452123*********222"
        },
        {
            "uid": 2,
            "name": "狗***儿",
            "tel": "183****3333",
            "idCard": "452123*********222"
        },
        {
            "uid": 3,
            "name": "狗***儿",
            "tel": "183****3333",
            "idCard": "452123*********222"
        }
    ]
}

6、升级注解脱敏

上面的脱敏注解虽然达到了脱敏的功能,但是接口的返回体是固定的,适配性不是很好,于是,我对注解进行了改造,只需要传入返回体的Class和所需要的字段,即可自动解析返回脱敏后的数据。

1、修改@Desensitization注解

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Desensitization {
    
    /**
     * @return 接口返回体的Class
     */
    Class DataClass();

    /**
     * @return 选填,若为空则表示该返回体可直接进行脱敏,不为空则填入返回体中存放含有脱敏字段的类
     */
    String field() default "";
}

2、在DesensitizationAspect中进行改造

1.新增方法isCollection(Object result)

/**
 * 判断是否为集合并进行脱敏
 * @param result
 * @throws IllegalAccessException
 */
private void isCollection(Object result) throws IllegalAccessException{
    //判断是否为集合
    if(result instanceof Collection){
        Collection collection = (Collection) result;
        for (Object o : collection){
            desensitize(o);
        }
    }else {
        desensitize(result);
    }
}

2.对check(Object result)方法进行改造

public void check(JoinPoint joinPoint, Object result){
    try {
        //获取被切入的方法
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        //获取被切入方法上的注解
        Desensitization annotation = signature.getMethod().getAnnotation(Desensitization.class);
        //获取注解的value:field
        String field = annotation.field();
        //判断field是否为空,为空则返回的是一个对象或者集合
        if("".equals(field)){
            //判断是否为集合并脱敏
            isCollection(result);
        }else {
            //获取注解的value:dataClass
            Class dataClass = annotation.DataClass();
            //获取字段列表
            Field[] fields = dataClass.getDeclaredFields();
            //创建一个标志位,判断是否存在需要脱敏的字段
            boolean is = true;
            for (Field f : fields) {
                //获取到需要脱敏的字段
                if(f.getName().equals(field)){
                    is = false;
                    f.setAccessible(true);
                    Object o = f.get(result);
                    //判断是否为集合并脱敏
                    isCollection(o);
                }
            }
            //未找到需要脱敏的字段,抛出异常
            if(is){
                throw new RuntimeException("The field could not be found:" + field);
            }
        }
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }
}

3.测试

回到controller层的接口中,由于对@Desensitization注解进行了改造,新增了两个值,所以注解上需要加上值。

@GetMapping("desensitization")
//需要加入两个值
@Desensitization(DataClass = WebApiResult.class, field = "data")
public WebApiResult<SysUser> check(@RequestBody SysUser u){
    //测试集合
    List<SysUser> list = new ArrayList<>();
    list.add(u);
    SysUser u2 = new SysUser();
    BeanUtils.copyProperties(u, u2);
    u2.setUid(2);
    list.add(u2);
    SysUser u3 = new SysUser();
    BeanUtils.copyProperties(u, u3);
    u3.setUid(3);
    list.add(u3);
    return WebApiResult.ok("成功", list);

    //测试对象
    //        return WebApiResult.ok("成功", u);
}

启动服务,访问http://localhost:8080/desensitizationDemo/desensitization,发现依然可以进行脱敏。

接下来测试返回单个对象:

@GetMapping("check1")
//因为返回体是集合或者对象,所以不需要加field字段
@Desensitization(DataClass = User.class)
public User check1(@RequestBody User u){
    return u;
}

启动服务,访问http://localhost:8080/desensitizationDemo/check1,依然可以脱敏。

再测试集合:

@GetMapping("check2")
//因为返回体是集合或者对象,所以不需要加field字段
@Desensitization(DataClass = User.class)
public List<User> check2(@RequestBody User u){
    List<User> list = new ArrayList<>();
    list.add(u);
    User u2 = new User();
    BeanUtils.copyProperties(u, u2);
    u2.setUid(2);
    list.add(u2);
    User u3 = new User();
    BeanUtils.copyProperties(u, u3);
    u3.setUid(3);
    list.add(u3);
    return list;
}

启动服务,访问http://localhost:8080/desensitizationDemo/check2,依然可以脱敏。