在项目中如何自定义字段校验注解,扩展javax的validation-api?

347 阅读4分钟

概述

如本篇文章标题所述,本篇文章要给大家介绍如何扩展 javax 的 validation-api。为什么要扩展它原有的注解呢,那肯定是因为原有的注解无法满足业务需求嘛。

准备工作

首先我们需要导入几个依赖包:

<dependencies>
    <dependency>
        <groupId>javax.validation</groupId>
        <artifactId>validation-api</artifactId>
        <version>2.0.1.Final</version>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.20</version>
    </dependency>

    <dependency>
        <groupId>org.hibernate.validator</groupId>
        <artifactId>hibernate-validator</artifactId>
        <version>6.2.5.Final</version>
    </dependency>
</dependencies>

@RangeIn注解

在某次需求开发中,我突然发现 Hibernate 的 validator 库的 @Range 注解太局限了,只能限定最小值和最大值,而不能限定枚举值。如下所示:

@Range(min = 0, max = 10)
private Integer amount;

这样的写法表示 amount 字段只能最小为 0,最大为 10。如果我想让这个字段的值限定在某个集合中那就不行了。于是我想到了开发自定义注解,比如命名为 @RangeIn

@RangeIn 注解:

package org.codeart;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ ElementType.FIELD, ElementType.TYPE_USE })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = { RangeInValidator.class }) // 指定校验器
public @interface RangeIn {

    long[] value() default {};

    // 默认的错误消息
    String message() default "value must range in the given array";
    
    // 分组
    Class<?>[] groups() default {};
    
    // 负载信息
    Class<? extends Payload>[] payload() default {};
}

long[] 类型的 value 属性表示字段的值限定在某个数组之中。这个注解的顶部使用了 @Constraint 注解修饰,内部 validatedBy 属性指定了由那个类来处理这个注解。注意它是一个数组,说明可以指定多个校验器。

元注解 @Target 的参数为 ElementType.FIELD, ElementType.TYPE_USE 表示它可以修饰成员变量以及泛型的类型使用(区别于泛型类型参数,泛型的类型参数是声明泛型,类型使用是使用泛型)。

实现校验器类

下一步需要声明校验器 RangeInValidator 类,这是官方固定写法不要问我为什么。

RangeInValidator 校验器(注解解析器)定义如下:

package org.codeart;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.Arrays;

public class RangeInValidator implements ConstraintValidator<RangeIn, Number> {

    private long[] values;

    @Override
    public void initialize(RangeIn annotation) {
        this.values = annotation.value();
    }

    @Override
    public boolean isValid(Number number, ConstraintValidatorContext context) {
        long fieldVal = number.longValue();
        for (long value : values) {
            if (value == fieldVal) {
                return true;
            }
        }
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate(getMessage()).addConstraintViolation();
        return false;
    }

    private String getMessage() {
        return "field value must range in the given array: " + Arrays.toString(values);
    }
}

这个校验器类的成员属性中有 long[] 类型的 value 字段,用来接收注解的 value 字段的值。这个校验器类实现了 ConstraintValidator 接口,需要实现内部的抽象方法 isValid。这个方法的返回值表示是否校验通过。

通过重写 initialize 方法,我们拿到注解对象。因为在声明类的时候指定了 ConstraintValidator 的泛型是 <RangeIn, Number>,所以我们可以直接设置方法的参数是 RangIn 类型,从而获取注解的 value 的字段值赋值给成员变量。

isValid 中,因为设置了接口泛型第二个类型使用(type use)参数是 Number 类型,所以 isValid 第一个参数可以直接设置为 Number 类型,从而获取需要校验的字段的值,然后再进行校验。

成员变量 value 已经赋值,然后现在开始遍历这个数组,在数组中查找是否存在需要校验字段的值。假如存在那么就返回 true,否则返回 false

最后调用 ConstraintValidatorContext#disableDefaultConstraintViolation 方法表示禁用默认消息,使用自定义的返回错误消息。

context.buildConstraintViolationWithTemplate(getMessage()).addConstraintViolation(); 这一段表示生成自定义的错误消息模板,最后添加到 ConstraintValidatorContext 内部的集合中去,这个由实现类实现。

测试一下

在运行之前先声明一个实体类 Person

package org.codeart.pojo;

import lombok.Data;
import org.codeart.validator.RangeIn;

import java.util.List;

@Data
public class Person {

    private String name;

    @RangeIn({10, 20, 50})
    private Integer age;

    private List<@RangeIn({8, 88, 888}) Integer> luckNumbers;
}

Person 的字段添加了我们自定义的字段校验注解。

运行测试一下:

package org.codeart;

import org.hibernate.validator.messageinterpolation.ParameterMessageInterpolator;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;

public class Main {

    public static void main(String[] args) {
        // 创建校验器工厂类
        ValidatorFactory factory = Validation.byDefaultProvider()
                                             .configure()
                                             .messageInterpolator(new ParameterMessageInterpolator())
                                             .buildValidatorFactory();
        Validator validator = factory.getValidator();

        Person person = new Person();
        person.setAge(15);
        person.setName("Trump");
        List<Integer> luckNumbers = new ArrayList<>();
        luckNumbers.add(999);
        luckNumbers.add(666);
        person.setLuckNumbers(luckNumbers);

        // 打印校验信息
        Set<ConstraintViolation<Person>> violations = validator.validate(person);
        for (ConstraintViolation<Person> violation : violations) {
            System.out.println(violation.getMessage());
        }
    }
}

结果如下:

image.png

因为 Personage 字段设置为了 15 所以不在限定值集合中。luckNumbers 添加了 999 和 666,也不在限定集合中,所以也触发了校验失败。

注意小坑

在导入依赖的时候,假如你没有导入 hibernate-validator,会提示一个错误,没有服务提供者。因为 javax.constraint 是一个规范只声明了接口和注解,但是并没有实现,所以需要实现的库。

image.png

结合SpringBoot使用

当然最后肯定要将注解应用到 SpringBoot 的 Restful 接口中去,现在来定义一个接口来接收一个请求体参数并校验:

@RestController
public class TestController {
    
    @PostMapping("/person")
    public String addPerson(@Valid @RequestBody Person person) {
        System.out.println(person);
        return "ok";
    }

}

使用 ApiPost 简单测试一下:

image.png

果然报错了:

image.png

被校验住了。