基于Spring和Hibernate Validation的全局参数统一校验,Spring Boot 参数校验

作者: Seayon阿阳 分类: Java,后端开发 发布时间: 2021-07-02 17:16

本文需要读者有一定的基于 Spring 的 Java Web项目开发经验,对于 Spring MVC 使用有一定的基本了解,知道 HTTP 协议的常用几种参数传递方式。

原因介绍

在 Java 后端项目的开发中,经常要对前端或外界传入的参数进行完整性校验。

我个人的宗旨一直都是:

不要相信任何外界的输入,不要相信前端做的校验就一定完美。

在早期的开发中,我们可能会直接进行手动判断。如下:

/**
* @param name
*/
@GetMapping("/validate_by_manual")
public void validateByManual(@RequestParam String name) {
  if (name == null || name.length() == 0) {
    // 抛出异常或返回报错等
    throw new RuntimeException("参数 姓名 不能为空");
  }
}

这种校验在代码量较少的情况下灵活快速,但在大项目开发中可能会有点力不从心。

  • 校验无法很好的复用,不同的人写出来的校验可能不一致。
  • 代码的 Controller 层充斥着过多逻辑
  • 校验耦合在业务代码中

在 Spring MVC 中我们可以利用 Spring 框架实现的 Hibernate Validator 实现的校验机制优雅的解决这个问题。

Hibernate Validator 介绍

快速上手体验

  1. 在一个 Spring Boot 项目中引入 hibernate-validator 的依赖

 org.hibernate.validator
 hibernate-validator
 6.2.0.Final

  1. 创建一个接收参数的类 UserVo
@Getter
@Setter
public class UserVo {

        // 参数不能为空限制  
    @NotNull
    private String name;

    // 最大值限制
    @Max(100)
    private int age;

    // 通过正则匹配校验来限制
    @Pattern(regexp = "女|男", message = "性别只能是男或女")
    private String sex;

}
  1. 在Spring 框架的Controller 层,使用UserVo对象来接收参数,在需要被校验的参数前面添加 @Validated 注解;

  2. 在 Controller 层的方法最后面添加一个 Errors 对象。

    Errors 类是 spring mvc 框架来支持的存储校验失败的信息,注意必须将 Errors 放到Controller 方法的最后一个参数才会生效,Spring 会自动将校验失败的信息放到 errors 对象中。

/**
* @param userVo 接受参数的对象
* @param errors 校验失败的结果
*/
@PostMapping("/simple_param_with_body")
public @ResponseBody
  MsgVo testWithBody(@RequestBody @Validated UserVo userVo, Errors errors) {
  if (errors.hasErrors()) {
    StringBuilder errMsg = new StringBuilder();
    errors.getAllErrors().forEach(objectError -> {
      errMsg.append(objectError.getDefaultMessage() + " ");
    });
    return new MsgVo("入参数据不正确:" + errMsg.toString(), -1);
  }
  return MsgVo.SUCCESS;
}

Errors 类中存储的信息非常丰富,里面是一个 List<ObjectError>,List中的每一个ObjectError囊括了本次校验失败的几乎所有细节,可以自行对其获取信息进行处理,上述代码进行简单拼接返回
Errors类的错误信息展示

另外,除了 Errros 这个类,使用 BindingResult 也可以,它是 Errors 的子类,也可以获得类似的效果。

这里的 defaultMessage 显示的是中文,是自动的根据系统语言的国际化支持。如果需要自定义报错信息,可以在 UserVo 对象的字段注解上修改

@NotNull(message = "姓名不能为空")
private String name;

@Max(value = 160, message = "年龄不能超过 160")
private int age;

不使用对象直接校验参数,平铺参数的校验

如果只是简单的一两个参数,不想创建新的类来接收参数,也可以直接在参数上面添加注解。

import com.saeyon.vo.MsgVo;
import com.saeyon.vo.UserVo;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Controller;
import org.springframework.validation.Errors;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;

@Controller
@Log4j2
@Validated
public class ValidatorTestController {
  /**
   *
   * @param name
   * @param age
   */
  @RequestMapping("/simple_param")
  public @ResponseBody
  MsgVo testParam(@Validated @NotNull String name, @Max(160) @Validated @NotNull @Min(1) Integer age) {
      return MsgVo.SUCCESS;
  }
}

这样写要求必须在类上面添加 @Validated 注解,并且不能把 BindResult 或 Errors 类放到最后一个参数,因为这种方法只支持用对象来接收参数的方式。如果这么写会报错类似如下:

java.lang.IllegalStateException: An Errors/BindingResult argument is expected to be declared immediately after the model attribute, the @RequestBody or the @RequestPart arguments to which they apply: public com.saeyon.vo.MsgVo com.saeyon.controller.ValidatorTestController.testParam(java.lang.String,java.lang.Integer,org.springframework.validation.Errors)
    at org.springframework.web.method.annotation.ErrorsMethodArgumentResolver.resolveArgument(ErrorsMethodArgumentResolver.java:69) ~[spring-web-5.3.8.jar:5.3.8]

那么这时候怎么获取校验失败的信息呢,答案是Spring 框架会抛出 javax.validation.ConstraintViolationException 异常,我们可以自定义全局异常捕获器来处理。

import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.view.json.MappingJackson2JsonView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/** 
 * @BelongProjecet my-springboot-example
 * @BelongPackage com.saeyon.controller
 * @Date: 2021/6/27 7:29 下午
 * @Version V1.0
 * @Description:
 */
 @ControllerAdvice
 @EnableWebMvc
class DefaultExceptionHandler implements HandlerExceptionResolver {


    @Override
    public @ResponseBody
    ModelAndView resolveException(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) {
        MappingJackson2JsonView view = new MappingJackson2JsonView();
        ModelAndView modelAndView = new ModelAndView();
        modelAndView.setView(view);
        if (e instanceof ConstraintViolationException) {
            modelAndView.addObject("code", -999);
            modelAndView.addObject("msg", "请求的参数校验失败:" + e.getMessage());
            return modelAndView;
        }
        modelAndView.addObject("code", -999);
        modelAndView.addObject("msg", e.getMessage());
        return modelAndView;
    }
}

另外对于前述基于对象接收参数的校验,如果不在方法最后添加 Errors 类,则会抛出 MethodArgumentNotValidException,也可以在全局异常里面添加处理

  @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})//有些校验失败会抛出 BindException,在这里一起处理了
    public @ResponseBody
    ModelAndView handleMethodArgumentNotValidException(Exception e) {
        BindingResult bindingResult = null;
        if (e instanceof MethodArgumentNotValidException) {
            bindingResult = ((MethodArgumentNotValidException) e).getBindingResult();
        }
        if (e instanceof BindException) {
            bindingResult = ((BindException) e).getBindingResult();
        }
        MappingJackson2JsonView view = new MappingJackson2JsonView();
        ModelAndView modelAndView = new ModelAndView();
        modelAndView.setView(view);
        modelAndView.addObject("code", -31);
        modelAndView.addObject("msg", "入参数据校验失败:" + e.getMessage());
        modelAndView.addObject("originInput", bindingResult.getTarget());
        return modelAndView;
    }

脱离 Spring 主动校验,手动校验

如果脱离 Spring 的 Controller 层,我们想在代码中需要的地方(比如 Service 层)手动校验,也是可以的。

如果你使用了 Spring,可以直接 @Autowired SmartValidator smartValidator; 使用这个对象来校验

BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(userVo, "reqRouteMessage");
smartValidator.validate(userVo, bindingResult);
if (bindingResult.hasErrors()) {
  // TODO ...
}

框架中已经提供了非常多的常用的注解,以下转载自 https://www.huaweicloud.com/articles/91f923912b79560fa22ff2475f298779.html

@AssertTrue 用于boolean字段,该字段只能为true
@AssertFalse 该字段的值只能为false
@CreditCardNumber 对信用卡号进行一个大致的验证
@DecimalMax 只能小于或等于该值
@DecimalMin 只能大于或等于该值
@Digits(integer=,fraction=) 检查是否是一种数字的整数、分数,小数位数的数字
@Email 检查是否是一个有效的email地址
@Future 检查该字段的日期是否是属于将来的日期
@Length(min=,max=) 检查所属的字段的长度是否在min和max之间,只能用于字符串
@Max 该字段的值只能小于或等于该值
@Min 该字段的值只能大于或等于该值
@NotNull 不能为null
@NotBlank 不能为空,检查时会将空格忽略
@NotEmpty 不能为空,这里的空是指空字符串
@Null 检查该字段为空
@Past 检查该字段的日期是在过去
@Pattern(regex=,flag=) 被注释的元素必须符合指定的正则表达式
@Range(min=,max=,message=) 被注释的元素必须在合适的范围内
@Size(min=, max=) 检查该字段的size是否在min和max之间,可以是字符串、数组、集合、Map等
@URL(protocol=,host,port) 检查是否是一个有效的URL,如果提供了protocol,host等,则该URL还需满足提供的条件
@Valid 该注解主要用于字段为一个包含其他对象的集合或map或数组的字段,或该字段直接为一个其他对象的引用,这样在检查当前对象的同时也会检查该字段所引用的对象

在第一次使用某种注解前,建议对注解进行测试,有些注解的实际效果可能跟预想的有所差距,一定要验证。另外有些注解只能用于特定类型的字段上,如果使用错误,将会到运行时才能发现错误。比如 @AssertTrue 只能用于 boolean 类型的字段,如果用于 String 字段,则会报错:

javax.validation.UnexpectedTypeException: HV000030: No validator could be found for constraint 'javax.validation.constraints.AssertTrue' validating type 'java.lang.String'. Check configuration for 'name'

自定义注解校验

常见的注解只能供基本使用,虽然有@Pattern 注解,利用正则来处理已经非常灵活,但正则性能可能不佳,并且较长的正则也可能不好理解。幸运的是我们还可以自定义注解类实现自己的校验逻辑。

单一参数的自定义基本校验

创建一个自定义注解

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import javax.validation.Constraint;
import javax.validation.Payload;
import javax.validation.constraints.NotBlank;

@Documented
@Constraint(validatedBy = {AgeCheckValidator.class})
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface AgeCheck {

    String message() default "年龄参数不合法";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

}

使用 @Constraint(validatedBy = {AgeCheckValidator.class}) 指定校验方法的实现类,编写实现类并继承 ConstraintValidator<AgeCheck, Object>

isValid 方法的第一个参数是被注解的变量的实际的值,这里可以用直接指定为被注解的类型的值,但自定义的建议使用Object类型的然后强转。如果指定了类型而被和实际被注解的需要校验的类型不符,校验发生时会报上面提到过的 UnexpectedTypeException 的异常。如果使用 Object 类型的,我们可以自己尝试转换,然后处理,如果转换失败不进行错误抛出,避免注解被错误的添加但是直到运行时才能检验出来的问题。

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class AgeCheckValidator implements ConstraintValidator<AgeCheck, Object> {

    /**
     * 这里实现自己的校验逻辑
     *
     * @param object                     被注接的变量的原始值
     * @param constraintValidatorContext 约束校验上下文
     * @return
     */
    @Override
    public boolean isValid(Object object, ConstraintValidatorContext constraintValidatorContext) {
        if (object instanceof Integer) {
            Integer integer = (Integer) object;
            if (integer <= 18 || integer >= 160) {
                return false;
            }
        } else {
            return true;
        }
        return true;
    }

}

上述已经可以实现基本的自定义校验注解

进一步的,以上述@AgeCheck 为例,我们还可以给@AgeCheck 注解添加自定义的参数支持,自定义校验的范围,通过配置的方式来支持更加灵活的校验。

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;

@Documented
@Constraint(validatedBy = {AgeCheckValidator.class})
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface AgeCheck {

    int min() default 0;// 默认为 0

    int max() default 100;// 默认为 100

    String message() default "年龄参数不合法";// 默认的报错信息

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

}
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class AgeCheckValidator implements ConstraintValidator<AgeCheck, Object> {

    // 定义两个变量来接收和存储注解中的值
    private int min;

    private int max;

    // 在这个初始化方法中,将注解的值传递给当前对象的私有变量
    public void initialize(AgeCheck constraintAnnotation) {
        this.min = constraintAnnotation.min();
        this.max = constraintAnnotation.max();
    }

    /**
     * 这里实现自己的校验逻辑
     *
     * @param object                     被注解的变量的原始值
     * @param constraintValidatorContext 约束校验上下文
     * @return boolean 校验是否通过
     */
    @Override
    public boolean isValid(Object object, ConstraintValidatorContext constraintValidatorContext) {
        if (object instanceof Integer) {
            Integer integer = (Integer) object;
            if (integer <= this.min || integer >= max) {
                //屏蔽掉默认的报错信息
                constraintValidatorContext.disableDefaultConstraintViolation();
                //添加自定义的报错信息
                constraintValidatorContext.buildConstraintViolationWithTemplate(String.format("年龄限定在 %s 至 %s 岁之间", min, max)).addConstraintViolation();
                return false;
            }
        } else {
            return true;
        }
        return true;
    }

}

在类中使用

@AgeCheck(min = 16, max = 120)
private int age;

多参数关联的自定义校验

有时候对象中的两个参数是有关联性的,以上面的 UserVo 为例,假如规定传入参数中,男性年龄不得低于 22 岁,女性年龄不得低于 20 岁。

第一种方法,我们可以定义一个类级别的校验器,获取该类的全部参数,然后自定义校验器根据情况进行判断校验。

类级别的校验器

定义一个 @UserCheck 注解

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;

@Documented
@Constraint(validatedBy = {UserCheckValidator.class})
@Target({ElementType.TYPE}) // 限制只能添加在类上
@Retention(RetentionPolicy.RUNTIME)
public @interface UserCheck {

    String message() default "User 对象参数不合法";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

}

编写校验实现类

import com.saeyon.vo.UserVo;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

/**
 * @BelongProjecet my-springboot-example
 * @BelongPackage com.saeyon.sys.validator
 * @Author: Saeyon
 * @Date: 2021/7/1 2:24 上午
 * @Version V1.0
 * @Description:
 */
public class UserCheckValidator implements ConstraintValidator<UserCheck, Object> {
    @Override
    public boolean isValid(Object object, ConstraintValidatorContext constraintValidatorContext) {
        if (!(object instanceof UserVo)) {
            return true;
        }
        UserVo userVo = (UserVo) object;
        if (userVo == null) {
            return true;
        }
        int age = userVo.getAge();
        if ("男".equals(userVo.getSex())) {
            if (age < 22) {
                //屏蔽掉默认的报错信息
                constraintValidatorContext.disableDefaultConstraintViolation();
                //添加自定义的报错信息
                constraintValidatorContext.buildConstraintViolationWithTemplate(String.format("男性年龄不得低于 22 岁")).addConstraintViolation();
                return false;
            }
        }
        if ("女".equals(userVo.getSex())) {
            if (age < 20) {
                //屏蔽掉默认的报错信息
                constraintValidatorContext.disableDefaultConstraintViolation();
                //添加自定义的报错信息
                constraintValidatorContext.buildConstraintViolationWithTemplate(String.format("女性年龄不得低于 20 岁")).addConstraintViolation();
                return false;
            }
        }
        return true;
    }
}

注意上述依然使用了 Object 类型来接收参数的值然后强转,你也可以直接写成 UserVo,更加清晰,不过要注意这种情况下只能将该注解添加到 UserVo 类上,如果误添加到其他类上又对齐进行了校验,则会报错,即上面提到过的 UnexpectedTypeException异常。在实际开发中经常发生误添加注释并且直到运行时才发现的情况,所以个人在此建议还是使用 Object类型来接收参数,并对其进行类型判断。

javax.validation.UnexpectedTypeException: HV000030: No validator could be found for constraint 'com.saeyon.sys.validator.UserCheck' validating type 'com.saeyon.vo.OrderVo'. Check configuration for ''...

将该注解添加到UserVo 的类上面,其余校验参数配置和前面的普通校验一致,即可自动校验。

import com.saeyon.sys.validator.AgeCheck;
import com.saeyon.sys.validator.UserCheck;
import lombok.Getter;
import lombok.Setter;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

@Getter
@Setter
@UserCheck // 添加到这里
public class UserVo {

    @NotBlank
    @AgeCheck
    private String name;

    @AgeCheck(min = 16, max = 120)
    private int age;

    @Pattern(regexp = "女|男", message = "性别只能是男或女")
    private String sex;

}

这种校验非常清晰明了,在开发中比较推荐这么做。但还有一种情况,假设我们有两个字段总是进行关联字段,但这两个字段出现在不同的类中,且这些类之间没有继承关系(即我们不能利用里式替换法则来编写对父类的校验然后套用到所有子类上,不过或许你可以将这两个或多个字段提取出来作为父类,然后让其他类继承该类然后编写类级别的校验器,但假如这些字段在不同的类中又单独有独特的校验规则,这样子也会比较复杂),这里我们尝试一种只校验某个类中的某两个字段的值校验方式。

多字段联合校验

假如除了 UserVo 我们还有一个 VisitorVo 参观者接收类,他们有同样的sexage字段,且校验规则一样,同样要求男性年龄不得低于 22 岁,女性年龄不得低于 20 岁。

我们自定义一个 AgeCheckWithSex 类。

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;

import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Documented
@Constraint(validatedBy = {AgeCheckWithSexValidator.class})
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface AgeCheckWithSex {

    String ageFiledName();// age 字段的名称

    String sexFiledName();// sex 字段的名称

    String message() default "性别和年龄参数不合法";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

}

编写实现类

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.lang.reflect.Field;

public class AgeCheckWithSexValidator implements ConstraintValidator<AgeCheckWithSex, Object> {

    private String ageFieldName;

    private String sexFieldName;

    public void initialize(AgeCheckWithSex constraintAnnotation) {
        this.ageFieldName = constraintAnnotation.ageFiledName();
        this.sexFieldName = constraintAnnotation.sexFiledName();
    }

    /**
     * 这里实现自己的校验逻辑
     *
     * @param object                     被注接的变量的原始值
     * @param constraintValidatorContext 约束校验上下文
     * @return
     */
    @Override
    public boolean isValid(Object object, ConstraintValidatorContext constraintValidatorContext) {
        Field fieldAge = null;
        Field fieldSex = null;
        try {
            fieldAge = object.getClass().getDeclaredField(ageFieldName);
            fieldSex = object.getClass().getDeclaredField(sexFieldName);
            fieldSex.setAccessible(true);
            fieldAge.setAccessible(true);
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
            //屏蔽掉默认的报错信息
            constraintValidatorContext.disableDefaultConstraintViolation();
            //添加自定义的报错信息
            constraintValidatorContext.buildConstraintViolationWithTemplate(String.format("在类 %s 中未找到需要校验的字段,请检查", object.getClass().getCanonicalName())).addConstraintViolation();
            return false;
        }
        if (fieldAge != null && fieldSex != null) {
            try {
                int age = fieldAge.getInt(object);
                String sex = (String) fieldSex.get(object);
                if ("男".equals(sex)) {
                    if (age < 22) {
                        //屏蔽掉默认的报错信息
                        constraintValidatorContext.disableDefaultConstraintViolation();
                        //添加自定义的报错信息
                        constraintValidatorContext.buildConstraintViolationWithTemplate(String.format("男性年龄不得低于 22 岁")).addConstraintViolation();
                        return false;
                    }
                }
                if ("女".equals(sex)) {
                    if (age < 20) {
                        //屏蔽掉默认的报错信息
                        constraintValidatorContext.disableDefaultConstraintViolation();
                        //添加自定义的报错信息
                        constraintValidatorContext.buildConstraintViolationWithTemplate(String.format("女性年龄不得低于 20 岁")).addConstraintViolation();
                        return false;
                    }
                }
            } catch (IllegalAccessException e) {
                e.printStackTrace();
                return false;
            }
        }
        return true;
    }

}

细心的读者应该已经发现了,这里其实就是给类添加注解,然后通过反射来获取被注解的对象的值,只不过更加细致,按需获取,只取需要的值。

校验的分组

分组校验也是经常使用的技术,对于前述的 VisitorVo 类,假如有如下 Controller 接口

@PostMapping("/queryVisitor")
public @ResponseBody
  MsgVo queryVisitor(@RequestBody @Validated VisitorVo visitorVo) {
  return MsgVo.SUCCESS;
}

这是一个查询接口,在查询接口中的传入参数一般没有新增参数严格,我们允许很多字段为空的传入,此时如果新增一个同样的类,去掉相关的校验注解,显然显得臃肿,Hibernate Validator 框架中为我们提供了分组技术来支持按需校验。

定义两个分组接口在 VisitorVo 类中,并在字段上定义指定对应的组。(定义在任何地方均可,写在该类中仅是为了表述更清晰一点)。

import com.saeyon.sys.validator.AgeCheck;
import com.saeyon.sys.validator.AgeCheckWithSex;
import lombok.Getter;
import lombok.Setter;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;

@Getter
@Setter
@AgeCheckWithSex(ageFiledName = "age", sexFiledName = "sex", groups = {VisitorVo.Add.class})
public class VisitorVo {

    // 查询类分组
    public interface Query {

    }

    // 添加类分组
    public interface Add {

    }

    // 目的参观地址
    @NotBlank(message = "参观地址必须填写", groups = {Add.class})
    @NotNull
    private String address;

    // 参观者的年龄限制 18 岁以上 75 岁以下
    @AgeCheck(min = 18, max = 75, groups = {Add.class})
    private int age;

    @Pattern(regexp = "女|男", message = "性别只能是男或女", groups = {Add.class, Query.class})
    private String sex;

    // 参观者的昵称
    @NotBlank(message = "参观者昵称必须填写", groups = {Add.class})
    private String nickName;

}

然后在 Controller 层校验的地方指定校验组

@PostMapping("/addVisitor")
public @ResponseBody
  MsgVo addVisitor(@RequestBody @Validated({VisitorVo.Add.class,Default.class}) VisitorVo visitorVo) {
  return MsgVo.SUCCESS;
}

@PostMapping("/queryVisitor")
public @ResponseBody
  MsgVo queryVisitor(@RequestBody @Validated({VisitorVo.Query.class}) VisitorVo visitorVo) {
  return MsgVo.SUCCESS;
}

则框架只会根据匹配的组去校验。所有的被校验字段都默认属于 javax.validation.groups.Default 组,默认情况下,如果不给被校验字段添加分组,校验发起方也不指明分组,就是默认组对默认组的调用。

如果被校验字段只指定了非默认组的分组,则在该字段上不会触发对默认组的调用。

如果在校验方指定了组,则只会校验指定的组,不会校验默认组。

校验的嵌套

在一个对象中包含另一种对象是常见的组合方式,如果需要对其进行级联校验,在对象上面添加 @Valid 注解。

@Getter
@Setter
@UserCheck
public class OrderVo {

    @NotNull(message = "ID 不能为空")
    private Long id;

    private String name;

    private BigDecimal price;

    @NotNull(message = "总数量不能为空")
    private BigDecimal count;

    @Valid
    UserVo user;
}

不过这种方式只能校验

// 入参
{
    "name": "姓名",
    "id": 1,
    "price": 1.9,
    "count": 100,
    "user": {
        "name": "姓名",
        "age": 121,
        "sex": "男"
    }
}
// 校验响应
{
    "msg": "入参数据校验失败:Validation failed for argument [0] in public com.saeyon.vo.MsgVo com.saeyon.controller.ValidatorTestController.addOrder(com.saeyon.vo.OrderVo): [Field error in object 'orderVo' on field 'user.age': rejected value [121]; codes [AgeCheck.orderVo.user.age,AgeCheck.user.age,AgeCheck.age,AgeCheck.int,AgeCheck]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [orderVo.user.age,user.age]; arguments []; default message [user.age],120,16]; default message [年龄限定在 16 至 120 岁之间]] ",
    "originInput": {
        "id": 1,
        "name": "姓名",
        "price": 1.9,
        "count": 100,
        "user": {
            "name": "姓名",
            "age": 121,
            "sex": "男"
        }
    },
    "code": -31
}

假如我们传入的 user 整个对象为空,如下

{
    "name": "姓名",
    "id": 1,
    "price": 1.9,
    "count": 100
}

则对于 user 对象的内部字段校验不会发生,配合 @NotNull 注解可以避免

@NotNull(message = "用户对象不能为空")
@Valid
UserVo user;
// 入参
{
    "name": "姓名",
    "id": 1,
    "price": 1.9,
    "count": 100
}
// 校验响应
{
    "msg": "入参数据校验失败:Validation failed for argument [0] in public com.saeyon.vo.MsgVo com.saeyon.controller.ValidatorTestController.addOrder(com.saeyon.vo.OrderVo): [Field error in object 'orderVo' on field 'user': rejected value [null]; codes [NotNull.orderVo.user,NotNull.user,NotNull.com.saeyon.vo.UserVo,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [orderVo.user,user]; arguments []; default message [user]]; default message [用户对象不能为空]] ",
    "originInput": {
        "id": 1,
        "name": "姓名",
        "price": 1.9,
        "count": 100,
        "user": null
    },
    "code": -31
}

枚举校验的问题

@Valid@Validated两种注解的区别

在 Controller 层中,通常情况下,使用@Valid@Validated 效果是一样的。

原理是在 Spring Controller 层的参数解析链路中 org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor里面的resolveArgument方法实现了对于参数的校验:

   public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
        parameter = parameter.nestedIfOptional();
        Object arg = this.readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
        String name = Conventions.getVariableNameForParameter(parameter);
        if (binderFactory != null) {
            WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
            if (arg != null) {
                this.validateIfApplicable(binder, parameter);
                if (binder.getBindingResult().hasErrors() && this.isBindExceptionRequired(binder, parameter)) {
                    throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
                }
            }

            if (mavContainer != null) {
                mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
            }
        }

        return this.adaptArgumentIfNecessary(arg, parameter);
    }

继续跟进其中的 validateIfApplicable,再到 ValidationAnnotationUtils.determineValidationHints() 方法,在 org.springframework.validation.annotation.ValidationAnnotationUtils 这个类中可以看到这么一段代码

    @Nullable
    public static Object[] determineValidationHints(Annotation ann) {
        Class<? extends Annotation> annotationType = ann.annotationType();
        String annotationName = annotationType.getName();
        if ("javax.validation.Valid".equals(annotationName)) {
            return EMPTY_OBJECT_ARRAY;
        } else {
            Validated validatedAnn = (Validated)AnnotationUtils.getAnnotation(ann, Validated.class);
            if (validatedAnn != null) {
                Object hints = validatedAnn.value();
                return convertValidationHints(hints);
            } else if (annotationType.getSimpleName().startsWith("Valid")) {
                Object hints = AnnotationUtils.getValue(ann);
                return convertValidationHints(hints);
            } else {
                return null;
            }
        }
    }

看到这里发现对于 Validated.class类型的注解和类名以 “Valid” 开头的注解处理是一样的,恍然大悟。

不过为了区分,还是建议在 Controller 层中使用@Validated 注解,@Valid 注解建议使用在级联校验中。