小小注解验证轻松拿捏


一、需求分析

1. 场景需求

所谓注解验证即将通过注解的方法实现对象属性的合法性校验,从而让代码专注于业务功能实现,使得代码更为简洁可读性更高。

试想这么一个场景,你需要在当前系统开发一个对外接口,而对于接口入口数据需要根据一定规则进行校验,对于非法数据的需要对应进行的拦截。

针对这类场景,最简单的方式即对于接口入参数据通过 if 逐个判断是否合法,但当参数属性过多时,整个代码结构将会便会十分繁杂。相对于此类处理方法,如果能通过注解的方法直接定义相应的规则显然代码则更为简洁。

2. 实现思路

注解验证的实现思路也十分简单,通过定义不同的注解对应不同的验证规则,在使用时将其作用于对应的属性之上,在运行时通过反射的方式获取属性的值并根据对应的注解规则执行校验。

3. 代码实现

下面通过最基本属性非空校验为例,首先让定义注解 @NotBlank,声明其作用域为 FIELD 即类属性。

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface NotBlank {

    /**
     * message 定义抛出的异常信息
     */
    String message() default "";

}

完成注解定义后实现对应的规则验证类,分为两个步骤:

  • 判断属性是否标识了对应注解;
  • 如果不存在则返回,存在则按照对应的规则执行处理;
public class NotBlankChecker {
    public void check(Object obj, Field field) throws Exception {
        // 若不存在注解则返回
        if (field.isAnnotationPresent(NotBlank.class)) {
            return;
        }

        // 反射获取对象属性值
        Object o = field.get(obj);
        if (Objects.isNull(o) || StringUtils.isBlank(o.toString())) {
            // 为空根据 message 抛出异常
            String msg = annotation.message();
            if (StringUtils.isBlank(msg)) {
                msg = "The field of {" + field.getName() + "} can't be blank";
            }
            throw new IllegalArgumentException(msg);
        }
    }
}

定义了对应的规则校验器之后就可以创建统一的验证入口,新建 FieldValidate 类,其实现内容如下:

  • 通过反射获取对应类中定义的所有字段;
  • 遍历字段集合执行上述定义的校验规则器;
public class FieldValidate {

    private static final NotBlankChecker checker = new NotBlankChecker();

    public static <T> void validate(T t) throws Exception {
        if (Objects.isNull(t)) {
            return;
        }

        Class<?> cls = t.getClass();
        // 获取对象的所有字段
        Field[] fields = cls.getDeclaredFields();
        for (Field field : fields) {
            // 设置反射访问权限
            field.setAccessible(true);
           
            // 执行对应的验证规则
            checker.check(obj, field)
        }
    }
}

4. 示例演示

至此所有的准备工作都已经完成,下面通过示例演示效果。

首先定义一个参数实体类 User,并在字段添加上 @NotBlank 注解。

public class User {

    @NotBlank
    private String name;
}

其对应的使用方式如下,在业务代码中即可避免编写重复的 if 判断逻辑。

public void demo5() {
    User user = new User();
    // 执行规则校验
    FieldValidate.validate(user);
}

二、分组关联

1. 基本介绍

上述的示例已经实现我们最开始的功能,但还存在一类场景没有覆盖到。

即定义参数实体可能用于多个接口,而上述的定义方式针对此类场景则无法根据不同接口进行区分,因此需要添加分组功能以达到该目的。

2. 功能实现

分组实现思路也并不难,需要定义注解为可重复声明,之前介绍注解的文章中已经详细展开介绍了,这里实现机制就不再具体展开了,可参考之前的文章: Java注解基础介绍

除了改造为可重复之外,还需要在注解中添加参数 group 进行分组。

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NotBlankGroup {

    NotBlank[] value() default {};

}


@Repeatable(NotBlankGroup.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface NotBlank {

    /**
     * 分组
     */
    Integer group() default 0;

    String message() default "";

}

同样修改之前的 NotBlankChecker 规则验证器,在 filter 方法中过滤了指定 group 的分组注解。

除此之外实现上并无区别,完整代码如下:

public class NotBlankChecker {
    public void check(Object obj, Field field, Integer group) throws Exception {
        // 若不存在注解则返回
        NotBlank annotation = this.filter(field, group);
        if (Objects.isNull(annotation)) {
            return;
        }

        // 反射获取对象属性值
        Object o = field.get(obj);
        if (Objects.isNull(o) || StringUtils.isBlank(o.toString())) {
            // 为空根据 message 抛出异常
            String msg = annotation.message();
            if (StringUtils.isBlank(msg)) {
                msg = "The field of {" + field.getName() + "} can't be blank";
            }
            throw new IllegalArgumentException(msg);
        }
    }

    /**
     * 根据分组过滤
     */
    private NotBlank filter(Field field, int group) {
        if (!field.isAnnotationPresent(NotBlank.class) && !field.isAnnotationPresent(NotBlankGroup.class)) {
            return null;
        }

        NotBlank[] annotations = field.getAnnotationsByType(NotBlank.class);
        annotations = Arrays.stream(annotations)
                .filter(it -> it.group() == group)
                .toArray(NotBlank[]::new);
        if (annotations.length == 0) {
            return null;
        }
        if (annotations.length > 1) {
            throw new ValidateException("Field group is duplicate!");
        }
        return annotations[0];
    }
}

3. 示例演示

修改之前定义的参数实体类,添加上对应的分组 group 参数。

public class User {

    @NotBlank(group = 1)
    private String name;
}

对应测试示例如下,可以看到上述定义的分组为 1,因此在执行 validate(user, 0) 则不会触发空校验。

public void demo5() {
    User user = new User();

    FieldValidate.validate(user, 0);
    FieldValidate.validate(user, 1);
}

三、字段联动

1. 基本介绍

除了上述提到的这些功能,Spring 中提供了 EL 表达式可以动态执行校验,因此我们即可利用该特性实现字段属性间的联动效果。

2. EL表达式

在项目工程中添加 spring-context 依赖,配置如下:

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>5.3.9</version>
</dependency>

引入依赖后我们编写一个验证工具类校验 EL 表达式结果是否为 True

public class ExpressionUtils {

    private static final ExpressionParser parser = new SpelExpressionParser();

    /**
     * @param t  对象
     * @param el EL表达式
     */
    public static <T> boolean evaluateBoolean(T t, String el) {
        Expression expression = parser.parseExpression(el);
        StandardEvaluationContext context = new StandardEvaluationContext(t);
        Boolean result = expression.getValue(context, Boolean.class);
        return Boolean.TRUE.equals(result);
    }
}

下面主要介绍一下 EL 表达式的编写方式,表达式编写并不复杂下面直接通过示例演示。

public class ExpressionTest {

    @Test
    public void demo1() {
        User alex = new User("Alex");
        User beth = new User("Beth");

        String expression = "true";
        System.out.println(ExpressionUtils.evaluateBoolean(alex, expression));
        System.out.println(ExpressionUtils.evaluateBoolean(beth, expression));
    }

    /**
     * $: 用于属性占位符解析。
     * #: 用于引用上下文中的变量和方法。
     * {}: 与 $ 一起使用,用于明确标识属性占位符的边界,特别是在复杂字符串中。
     */
    @Test
    public void demo2() {
        User alex = new User("Alex");
        User beth = new User("Beth");

        // "#root" 表示当前对象引用
        String expression1 = "#root.name =='Alex'";
        System.out.println(ExpressionUtils.evaluateBoolean(alex, expression1));
        System.out.println(ExpressionUtils.evaluateBoolean(beth, expression1));

        // 可省略直接通过字段属性名进行引用
        String expression2 = "name =='Alex'";
        System.out.println(ExpressionUtils.evaluateBoolean(alex, expression2));
        System.out.println(ExpressionUtils.evaluateBoolean(beth, expression2));
    }

    /**
     * 通过 "T()" 调用类
     */
    @Test
    public void demo3() {
        User alex = new User(" ");

        String expression = "T(xyz.ibudai.validate.core.util.StringUtils).isNotBlank(name)";
        System.out.println(ExpressionUtils.evaluateBoolean(alex, expression));
    }
}

3. 功能实现

因此,我们即可利用该特性在注解中添加参数 triggered 用于声明 EL 表达式,修改代码为如下:

@Repeatable(NotBlankGroup.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface NotBlank {

    int group() default 0;

    String message() default "";

    String triggered() default "true";

}

同样在规则校验器添加 EL 表达式解析模块,解析结果若为否则退出。

public class NotBlankChecker {
    public void check(Object obj, Field field, Integer group) throws Exception {
        // 若不存在注解则返回
        NotBlank annotation = this.filter(field, group);
        if (Objects.isNull(annotation)) {
            return;
        }
        // 过滤是否触发校验规则
        String triggered = annotation.triggered();
        if (StringUtils.isBlank(triggered) || !ExpressionUtils.evaluateBoolean(obj, triggered)) {
            return;
        }

        // 反射获取对象属性值
        Object o = field.get(obj);
        if (Objects.isNull(o) || StringUtils.isBlank(o.toString())) {
            // 为空根据 message 抛出异常
            String msg = annotation.message();
            if (StringUtils.isBlank(msg)) {
                msg = "The field of {" + field.getName() + "} can't be blank";
            }
            throw new IllegalArgumentException(msg);
        }
    }

    private NotBlank filter(Field field, int group) {
        // 省略,代码同上
    }
}

4. 示例演示

创建参数实体类并通过 triggered 编写表达式,如下述则标识只有当 id 值为 2 时才触发校验。

public class User {

    private Integer id;

    @NotBlank(triggered = "id =='123'")
    private String name;
}

编写对应的测试用例,执行后可以发现 validate(user1) 并不会触发空校验。

public void demo5() {
    User user1 = new User(1, "Alex");
    User user2 = new User(2, "Beth");

    FieldValidate.validate(user1);
    FieldValidate.validate(user2);
}

四、拦截切面

1. 基本介绍

在上面的上面的示例中在执行规则验证时通过 FieldValidate 手动执行,如若在 Spring 项目中则可通过切面方式从而更便捷的实现。

在项目工程中引入 Spring AOP 依赖,配置如下:

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

定义注解 @Validate,注意其作用对象为 PARAMETER 即方法参数。

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Validate {

    int group() default 0;

}

2. 切面实现

新建切面类 ValidateAspect,实现并不复杂这里就不展开介绍。

@Aspect
@Component
public class ValidateAspect {

    /**
     * 定义切点拦截 Validate 注解
     */
    @Pointcut("@annotation(validate) && @annotation(xyz.ibudai.validate.core.annotation.Validate) ")
    public void pointcut(Validate validate) {
    }

    /**
     * 定义切面处理逻辑
     */
    @Around(value = "pointcut(validate)", argNames = "joinPoint, validate")
    public Object around(ProceedingJoinPoint joinPoint, Validate validate) {
        try {
            Object[] args = joinPoint.getArgs();
            MethodSignature signature = (MethodSignature) joinPoint.getSignature();
            // Get target method
            Method method = signature.getMethod();
            Parameter[] parameters = method.getParameters();
            for (int i = 0; i < parameters.length; i++) {
                Parameter param = parameters[i];
                // 方法参数若未标识注解则跳过
                if (!param.isAnnotationPresent(Validate.class)) {
                    continue;
                }

                // 执行校验
                Validate annotation = param.getAnnotation(Validate.class);
                FieldValidate.validate(args[i], annotation.group());
            }

            return joinPoint.proceed(args);
        } catch (Throwable e) {
            throw new RuntimeException(e);
        }
    }
}

3. 示例演示

通过上述定义注解以及切面,在 Spring 工程中即可在方法中通过 @Validate 注解标识即可,无需手动调用。

@GetMapping("demo")
public void demo(@Validate User user) {
    // do something

}

文中涉及完整工程以上传 GitHub仓库直达


文章作者: 烽火戏诸诸诸侯
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 烽火戏诸诸诸侯 !
  目录