基于 SpringValidation 的参数校验较佳实践

1,792 阅读11分钟

对于 WEB 项目中的 参数校验 个人经验中一直也没一套完整的解决方案,每次遇到都是接口上硬编码调用,大概就是“最朴实”的做法,传入校验的值、名字并异常做返回,下面是一段伪代码:

// xxxController
public ResultContext<XxxVo> xxx(XxxVo xxxVo) {
    ParamCheckUtils.checkObjectNotNull(xxxVo, "xxx 不能为空");
    ParamCheckUtils.checkStringParam("id 过长", xxxVo.getUrid(), 32, true);
    ParamCheckUtils.checkIntegerParam("version 必须为整型", xxxVo.getVersion(), 10, true);
    // do something
}

于是每个 Controller 方法开头那 n 行都是这些,重复代码充斥了大部分的代码排面。当接口数量不多时,倒也这样子过去了,别说还有种挺灵活的感觉。但真的面对几十个接口,几百个字段时,我是真的笑不出来。

到后来偶然了解到了 Spring Validation,于是展开了一番学习,像是打开了新世界,不但解决了重复代码问题,甚至比以前更灵活。下面分享一些自己的经验:

一些概念+入门

在 Java 平台,其实前辈们早就定义了一套标准 JSR-303JSR-349 是它的升级版本),专门用于规范对 Java Bean 的校验,但不提供具体的实现。类似 java.sql.Driver 组织提供标准,各个数据库厂商提供实现。常见的实现是 hibernate-validator

假设当前有 POJO

public class PersonForm {
    private String name;
    private int age;
}

JSR-303 允许使用者在字段属性上添加规定的注解,来约束这些属性的值

public class PersonForm {
    @NotNull
    @Size(max=64)
    private String name;

    @Min(0)
    private int age;
}

当调用 JSR-303 标准的校验器校验此类实例,会按照在类字段上定义的约束执行。

public static void main(String[] args) {
    ParamC param = new ParamC();
    // 创建一个默认的校验器
    Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
    Set<ConstraintViolation<ParamC>> violations = validator.validate(param);
    for (ConstraintViolation<ParamC> constraintViolation : violations) {
        // 字段路径
        Path propertyPath = constraintViolation.getPropertyPath();
        // 失败信息
        String message = constraintViolation.getMessage();
    }
}

关于 validator 实例,这里又涉及到 SPI 的知识点,就不展开。简单来说,默认查找类路径下面:META-INF/services/javax.validation.spi.ValidationProvider 文件中提供的实现类完整路径。如果你引入了 hibernate-validator 的依赖,此文件内容为:

org.hibernate.validator.HibernateValidator

JSR-303 标准提供了如下注解:

注解描述
@Null被注释的元素必须为 null
@NotNull被注释的元素必须不为 null
@AssertTrue被注释的元素必须为 true
@AssertFalse被注释的元素必须为 false
@Min(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@Max(value)被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMin(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@DecimalMax(value)被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@Size(max, min)被注释的元素的大小必须在指定的范围内
@Digits(integer, fraction)被注释的元素必须是一个数字,其值必须在可接受的范围内
@Past被注释的元素必须是一个过去的日期
@Future被注释的元素必须是一个将来的日期
@Pattern(value)被注释的元素必须符合指定的正则表达式

当然还可以进行扩展,hibernate-validator 在此基础上添加了一些

注解描述
@Email被注释的元素必须是电子邮箱地址
@Length被注释的字符串的大小必须在指定的范围内
@NotEmpty被注释的字符串的必须非空
@Range被注释的元素必须在合适的范围内

除此之外,还可以自定义约束和校验器(后续讲)。接着,讲讲如何与 Spring 结合进行使用。

Spring 提供了对 Bean 验证 API 的全面支持,支持由使用者自定义 validator,也可以使用默认的校验器,如下

<bean id="validator" class="org.springframework.validation.beanvalidation.LocalValidatorFactoryBean"/>

默认去类路径下找实现了 JSR-303 标准的校验器(上文提到的 SPI 方式)。可以理解为 LocalValidatorFactoryBean 这个类是 Spring 和 hibernate-validator 之间的桥梁。

如果你用的是 SpringBoot 项目,直接跳过 pom、和 xml 修改两步,直接加上注解就可以使用。假如你是传统的 SpringMVC 项目,需要先把对应的校验器依赖进来,如下:

<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.1.6.Final</version>
</dependency>
<!-- 无需再单独依赖 Bean Validation API 包,该包中已依赖-->

如果你的项目没有打开注解驱动,那也是不生效的,所以请保证此配置已被打开,如下:

<mvc:annotation-driven/>

做完这两步,接下来再加一些注解就搞定了,如下:

// 需要校验的实体类
// import org.hibernate.validator.constraints.NotBlank;
// import org.hibernate.validator.constraints.Length;
@Data
public class Param {
    // 该属性不能为空,且长度为 2-10 个字符之间
    @NotBlank
    @Length(min=2, max=10)
    String name;
}

// 某 Controller 方法
// import org.springframework.validation.BindingResult;
// import org.springframework.validation.annotation.Validated;
@RequestMapping(value = "/test", method = RequestMethod.POST)
public String test(@Validated Param param, BindingResult bindingResult) {
    System.out.println(bindingResult.getAllErrors());
    return "success";
}

假设现在前端上传的 name 字段为空或者是长度不规范,此时 SpringMVC 会识别出 @Validated 注解,并结合 hibernate.validator 框架或者是 JRS 中定义的在字段上面添加的注解,对上传的数据进行校验,最后将结果封装在 BindingResult 对象实例中。此时使用者就能拿到详细的错误信息,来进行一些后续的操作。

这里请注意,一次请求可能存在多个字段同时错误。hibernate.validator 默认采用 完整校验,会把所有的错误字段都校验出来

List<FieldError> errors = bindingResult.getFieldErrors();

如果你只想 随意(对顺序无所谓) 获取一个错误,并不用将每个错误都展示出去,那么可以这么获取:

FieldError error = bindingResult.getFieldError();
// 错误的字段名
String fieldName = error.getField();
// 在注解上输入的自定义错误文字
String errMsg = error.getDefaultMessage();
System.out.println(String.format("字段名:%s,错误信息:%s", fieldName, errMsg));

当然,假如你想一遇到错误就立即返回,hibernate.validator 还提供了 Fail fast mode 也就是快速失败模式。

Validator failFastValidator = Validation
            .byProvider(HibernateValidator.class)
            .configure().failFast(true)
            .buildValidatorFactory().getValidator()

注意一点,快速失败模式 并不保证约束被校验的顺序,假如对字段顺序有一定的要求,请跳到后面对 按序校验 的章节,以下是 hibernate-validator 官网的引用:链接

There is no guarantee in which order the constraints are evaluated

这时候,虽说已不用挨个手动调用进行校验,但是返回的校验结果还是要每个方法写一遍,依旧很费力。好在 SpringMVC 还给我们提供了另外一种错误结果交互方式 异常

结合 SpringMVC 全局异常处理

只要将 Controller 方法中的 BindingResult 参数去掉,SpringMVC 就会以默认抛出 org.springframework.validation.BindException 异常来通知使用者校验失败。

有了异常,很自然地联想到 SpringMVC 中对统一异常的处理,下面是一段示例代码:

@Slf4j
@ControllerAdvice
public class ExceptionAdvice {
    /**
     * 与 @Validated 注解搭配使用,对 Controller 接口参数的校验异常
     * 进行统一处理返回
     */
    @ResponseBody
    @ExceptionHandler(BindException.class)
    public String handleBindException(BindException ex) {
        BindingResult result = ex.getBindingResult();
        if (result.hasErrors()) {
            FieldError fieldError = result.getFieldError();
            if (fieldError == null) {
                log.error("fieldError 实例为 null");
                return OpenApiBaseResponse.gatewayFail(UserTips.PARAM_BINDING_ERROR);
            }
            String fieldName = fieldError.getField();
            String defaultErrMsg = fieldError.getDefaultMessage();

            if (fieldError.isBindingFailure()) {
                return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.PARAM_ERROR, fieldName + " " + UserTips.PARAM_BINDING_ERROR);
            }

            if (NotBlank.class.getSimpleName().equals(fieldError.getCode())) {
                return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.PARAM_ERROR, fieldName + " 必填");
            }

            if (NotNull.class.getSimpleName().equals(fieldError.getCode())) {
                return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.PARAM_ERROR, fieldName + " 必填");
            }

            if (Length.class.getSimpleName().equals(fieldError.getCode())) {
                return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.PARAM_ERROR, fieldName + " " + defaultErrMsg);
            }

            return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.PARAM_ERROR, defaultErrMsg);
        }
        // 其他绑定异常
        return OpenApiBaseResponse.gatewayFail(UserTips.PARAM_BINDING_ERROR);
    }
}

注:OpenApiBaseResponse 该类是自己定义的统一返回信息载体,实战请选择自定义返回

此时,只需要在接受参数的 POJO 类的属性加几个注解,做到了 全自动参数校验。除此之外,利用了全局的异常处理,对参数校验失败结果响应做了统一的处理。回顾,与之前相比已是一个质变。

还没完,之前的方案虽说是最笨的,却有个好处,非常的灵活。假设现在有这样的情况,你要检验的对象并不是从 HTTP 方式中进入的,而是 service 间的方法调用,那新的方式就不能适用。又或许你在 web 层方法中需要先用 Map、String 来接受,后续再进行转 Bean 操作,新的方式也不适用。

所以下面要讲的是,添加 手动校验工具类,并与之前的 SpringMVC 统一异常处理相结合,来解决这些场景中的问题。

手动校验

SpringMVC 之所以能校验 bean 实例,底层也是调用了 JSR 标准提供的 API,而真正的逻辑在 hibernate-validator 中。

结合上面默认注入的 LocalValidatorFactoryBean我们可以获取 bean name 为 validator 的 SpringBean 来拿到真正的校验器。或者是自己手动创建一个,如下:

注: 如果是手动创建,建议与注入 Spring 中的校验器的配置保持一致,当系统存在两个不同行为的校验器,会导致相同的实例在自动、手动校验后,结果却不一致的情况

import org.hibernate.validator.HibernateValidator;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;

// 手动校验器
public class ValidationUtil {
    private static Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

    public static <T> void validate(T obj) throws BindException {
        Set<ConstraintViolation<T>> constraintViolations = validator.validate(obj);

        if (constraintViolations.size() > 0) {
            BeanPropertyBindingResult result = new BeanPropertyBindingResult(obj, "");

            Iterator<ConstraintViolation<T>> iterator = constraintViolations.iterator();
            while (iterator.hasNext()) {
                ConstraintViolation<T> cv = iterator.next();

                String field = cv.getPropertyPath().toString();
                String defaultMessage = cv.getMessage();
                String annotationName = cv.getConstraintDescriptor().
                        getAnnotation().annotationType().getSimpleName();

                result.addError(new FieldError("", field, null, false, new String[]{annotationName}, null, defaultMessage));
            }

            throw new BindException(result);
        }
    }

    public static <T> void throwBindException(T obj, String field, boolean bindingFailure, String message) throws BindException {
        BeanPropertyBindingResult result = new BeanPropertyBindingResult(obj, "");
        result.addError(new FieldError("", field, null, bindingFailure, new String[]{}, null, message));
        throw new BindException(result);
    }
}

为了能被统一异常处理捕捉,ValidationUtil#validate 方法模仿 Spring 行为,自己构建了 BindException 异常。,而 ValidationUtil#throwBindException 方法则是为了,那些没有调用 validate 方法而又是参数校验失败情况而创建。

再来看之前的场景。web 入口方法并不是一个 Bean 类型,假如放在 body 域中且有多种类型。如此也无妨,现在只需在解析成 bean 实例之后,再手动调用一次 validate 方法,也能得到与自动校验相同的结果

 public OpenApiBaseResponse<?> xxx(HttpServletRequest request) throws BindException {
     String ori = super.readAsString(request);
    OpenApiBaseRequest<R> reqInstance = null;
    try {
        // 忽略 clazz 的获取逻辑
        reqInstance = JSON.parseObject(ori, new TypeReference<OpenApiBaseRequest<R>>(clazz){});
    }
    catch (Exception e) {
        log.error("解析 JSON 请求异常", e);
        return OpenApiBaseResponse.gatewayFail(OpenApiResultCodeEnum.PARAM_ERROR);
    }
    // 
    ValidationUtil.validate(reqInstance);
    // 
 }

嵌套校验

假如请求报文存在嵌套(json 对象中套 json 对象),那后台的 pojo 类也存在嵌套。 若想内外对象一起校验,形成递归式,那必须在内层对象属性上加 javax.validation.Valid 注解,否则默认不校验 T 类型对象。 如下:

public class OpenApiBaseRequest<T extends OpenApiBizContent> {
    @Valid
    private T bizContent;
}

分组校验

类似管理后台,新增和更新接口两者的 pojo 对象往往很相似,两接口若是公用 pojo,分组校验 就派上用场了。首先定义两个 标识接口 AddUpdate,并在各个约束注解上加 groups 属性,指定对应校验组别:

public class TestVo {
    interface Add {}
    interface Update {}
    @NotBlank(groups = Update.class, message = "更新时 id 必填")
    private String id;
    @NotBlank(groups = Add.class, message = "新增时 code 必填")
    private String code;
}

controller 入口处,则根据不同入口,传入不同的分组校验标识接口

@RequestMapping(value = "/add", method = RequestMethod.POST)
public String add(@Validated(value=TestVo.Add.class) TestVo param) {
    return "success";
}
@RequestMapping(value = "/update", method = RequestMethod.POST)
public String update(@Validated(value=TestVo.Update.class) TestVo param) {
    return "success";
}

自定义校验器:特殊字符、可枚举值校验器

JSR 标准留的自定义校验器接口,提供了巨大的扩展性,基于此我们可以实现很多系统内部自定义的约束,分享在工作中常用的两个例子:

特殊字符

仿照已存在的注解(如:javax.validation.constraints.NotNull),自定义注解,同时使用 @Constraint 关联对应的校验器

@Target( {FIELD})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {SpecialCharacterValidator.class})
public @interface SpecialCharacter {
    String regexp() default "";
    String message() default "值中含有回车符、换行符或制表符";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

创建自定义检验器,实现 JSR 标准定义的 javax.validation.ConstraintValidator 接口

public class SpecialCharacterValidator implements ConstraintValidator<SpecialCharacter, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value != null && !value.equals("")) {
            // 回车符、制表符、换行符
            if (value.contains("\r") || value.contains("\n") || value.contains("\t") || value.contains("\r\n")) {
                return false;
            }
        }
        return true;
    }
    @Override
    public void initialize(SpecialCharacter constraintAnnotation) {

    }
}

可枚举值校验器

前端上传的某个字段是否处于后端要求的范围内,以前你会怎么实现呢?是不是想想就很复杂?现在有了自定义约束,轻松拿下。

相比于前者,这个稍微难些。注解内多了一个 target 属性,用于接受枚举值的类类型,只有拿到类型,才能调用其中方法。

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = {EnumCheckValidator.class})
public @interface EnumCheck {
    Class<?> target();

    String regexp() default "";
    String message() default "值必须在枚举值内中选填";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

同样地需要创建对应的校验器,这里复写了 initialize 初始化方法,给内部 targetClass 赋了值

public class EnumCheckValidator implements ConstraintValidator<EnumCheck, String> {
    Class<?> targetClass;

    @Override
    public void initialize(EnumCheck constraintAnnotation) {
        targetClass = constraintAnnotation.target();
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (!targetClass.isEnum()) {
            return false;
        }
        // 枚举类的成员
        Object[] enumInstances = targetClass.getEnumConstants();
        // 枚举类的所有 value 值集合
        Set<String> values = new HashSet<>();
        try {
            for (Object enumInstance : enumInstances) {
                Method getValue = targetClass.getMethod("getValue");
                Object valueObj = getValue.invoke(enumInstance, null);
                values.add(valueObj.toString());
            }
        }
        catch (Exception e) {
            return false;
        }
        return values.contains(value);
    }
}

几个点需要注意:

  • EnumCheckValidator 与字段进行一对一绑定,每个字段都有自己的校验器,不存在并发问题
  • 定义枚举时,需要有 getValue 方法,建议用实现接口方式进行约定,如下
// 约定所有枚举都要实现该接口
public interface LabelAndValue<T> extends Serializable {
    T getValue();
    String getLabel();
}

public enum YesOrNoEnum implements LabelAndValue<String> {
    YES("1", "是"),
    NO("0", "否"),;
    private String value;
    private String label;
    YesOrNoEnum(String value, String label) {
        this.value = value;
        this.label = label;
    }
    @Override
    public String getValue() {
        return value;
    }
    @Override
    public String getLabel() {
        return label;
    }
}
  • 效率问题

可以考虑在初始化时,做一层缓存,并不是每次都需要进行反射调用

按顺校验

说实话自己在实践中并没有遇到过,字段校验对顺序有严格要求的场景。不过我们测试人员确实发现某个接口同时存在多个字段错误时,重启前后 会出现提示不一致情况。

JSR 标准当然也想到了,做法与分组校验类似,多加一个顺序标识接口。定义标识接口,加 @GroupSequence 注解,参数内指定校验的顺序,其中 Default.class 是不加 groups 的属性,如下面的 c 属性

@Data
public class TestVo2 {
    interface A {}
    interface B {}
    @GroupSequence({ Default.class, A.class, B.class })
    interface CheckOrder1 { }

    @NotBlank(groups = TestVo2.A.class, message = "a 错误")
    private String a;

    @NotBlank(groups = TestVo2.B.class, message = "b 错误")
    private String b;

    @NotBlank(message = "c 错误")
    private String c;
}

调用方传入 CheckOrder1 标识接口进行分组校验

@RequestMapping(value = "/test3", method = RequestMethod.POST)
public String test3(@Validated(value=TestVo2.CheckOrder1.class) TestVo2 param) {
    return "success";
}

到这里文章已经结束了。但还有一点是没有讲到的,字段的联合校验,不知大家有没有什么好的方法分享?