Spring Boot整合 hibernate validator

985 阅读9分钟

零、使用场景

日常开发中,难免需要对参数进行一些参数正确性的校验,比如字段非空,字段长度限制,邮箱格式验证等等,可能普通操作就是写一些字段校验的代码去做处理判断,不同的地方可能会重复编写(不停搬砖),而这些校验出现在业务代码中,让我们的业务代码显得臃肿,并且不方便维护。

Hibernate Validator 框架刚好解决了这些问题,可以很优雅的方式实现参数的校验,让业务代码和校验逻辑 分开,不再编写重复的校验逻辑,从此在参数校验上不用花费太多时间。

一、Hibernate Validator 简介

Hibernate Validator是 Bean Validation 的参考实现 。 Hibernate Validator 提供了 JSR 303 规范中所有内置 constraint 的实现,除此之外还有一些附加的 constraint。

Bean Validation 为 JavaBean 验证定义了相应的元数据模型和API。缺省的元数据是 Java Annotations,通过使用 XML 可以对原有的元数据信息进行覆盖和扩展。Bean Validation 是一个运行时的数据验证框架,在验证之后验证的错误信息会被马上返回。

二、常用注解

1、常用注解如下

验证注解验证的数据类型说明
@AssertFalseBoolean,boolean验证注解的元素值是falsejavax.validation.constraints
@AssertTrueBoolean,boolean验证注解的元素值是truejavax.validation.constraints
@NotNull任意类型验证注解的元素值不是nulljavax.validation.constraints
@Null任意类型验证注解的元素值是nulljavax.validation.constraints
@Min(value=值)BigDecimal,BigInteger, byte,short, int, long,等任何Number或CharSequence(存储的是数字)子类型验证注解的元素值大于等于@Min指定的value值javax.validation.constraints
@Max(value=值)和@Min要求一样验证注解的元素值小于等于@Max指定的value值javax.validation.constraints
@DecimalMin(value=值)和@Min要求一样验证注解的元素值大于等于@ DecimalMin指定的value值javax.validation.constraints
@DecimalMax(value=值)和@Min要求一样验证注解的元素值小于等于@ DecimalMax指定的value值javax.validation.constraints
@Digits(integer=整数位数, fraction=小数位数)和@Min要求一样验证注解的元素值的整数位数和小数位数上限javax.validation.constraints
@Size(min=下限, max=上限)字符串、Collection、Map、数组等验证注解的元素值的在min和max(包含)指定区间之内,如字符长度、集合大小javax.validation.constraints
@NotBlankCharSequence子类型验证注解的元素值不为空(不为null、去除首位空格后长度为0),不同于@NotEmpty,@NotBlank只应用于字符串且在比较时会去除字符串的首位空格javax.validation.constraints,org.hibernate.validator.constraints
@Length(min=下限, max=上限)CharSequence子类型验证注解的元素值长度在min和max区间内org.hibernate.validator.constraints
@NotEmptyCharSequence子类型、Collection、Map、数组验证注解的元素值不为null且不为空(字符串长度不为0、集合大小不为0)javax.validation.constraints,org.hibernate.validator.constraints
@Range(min=最小值, max=最大值)BigDecimal,BigInteger,CharSequence, byte, short, int, long等原子类型和包装类型验证注解的元素值在最小值和最大值之间org.hibernate.validator.constraints
@Email(regexp=正则表达式,flag=标志的模式)CharSequence子类型(如String)验证注解的元素值是Email,也可以通过regexp和flag指定自定义的email格式javax.validation.constraints,org.hibernate.validator.constraints
@Pattern(regexp=正则表达式,flag=标志的模式)String,任何CharSequence的子类型验证注解的元素值与指定的正则表达式匹配javax.validation.constraints
@Valid任何非原子类型指定递归验证关联的对象如用户对象中有个地址对象属性,如果想在验证用户对象时一起验证地址对象的话,在地址对象上加@Valid注解即可级联验证javax.validation.constraints

1、对于基本数据类型,使用@Max、@Min等注解时,如果被注解元素为null,则该元素会被赋默认值,如int的默认值为0,boolean的默认值为false;

2、对于包装类型,使用@Max、@Min等注解时,如果被注解元素为null,则被注解元素就为null,因此当被注解元素不能为null时,则必须配合@NotNull等非空注解使用

3、对于字符类型和集合类型: @Length、@Size 仅判断被注解元素的长度/大小是否符合要求,不判断被注解元素是否为空,因此要保证被注解元素为空时必须与@NotNull等非空注解配合使用

2、依赖包及版本关系

以上注解来源于两个jar包,一个是org.hibernate.hibernate-validator包,一个是javax.validation.validation-api,如下图所示,其中org.hibernate.hibernate-validator依赖于javax.validation.validation-api,可以看到,org.hibernate.hibernate-validator包下拥有自己的实现,如@Length、@Range、以及被标注为过时的@Email等。

image-20220316195738508

可以看到,两个依赖包都有一些共同的注解,在6.x.x的版本中,@Email、@NotBlank、@NotEmpty注解已经被标记为过时,因此不存在不知道引入那个版本的问题,而在5.x.x的版本中,这三个注解都未过时,因此必须引入org.hibernate.hibernate-validator下的注解,否则会报错

javax.validation.validation-api 3.0.0版本开始,包路径由javax.validation.constraints变为jakarta.validation.constraints.NotNull,如@NotNull注解的引用路径从javax.validation.constraints.NotNull 变为jakarta.validation.constraints.NotNull。具体原因可以搜索Jakarta EE,了解世事沧桑。

三、使用

1、包引入

  1. 可以直接引入org.hibernate.hibernate-validator包,需要指定具体版本

     <dependency>
         <groupId>org.hibernate</groupId>
         <artifactId>hibernate-validator</artifactId>
         <version>6.0.0.Final</version>
     </dependency>
    
  2. 如果是spring boot项目,则推荐如下方式:通过引入spring-boot-starter-validation引入org.hibernate.hibernate-validator,无需指定版本,且由spring boot来保证版本间的兼容性。

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

2、基础使用

1、创建参数校验类

新建实体类:User

 import java.util.List;
 import javax.validation.constraints.Max;
 import javax.validation.constraints.Min;
 // 如果org.hibernate.hibernate-validator的版本为5.x.x,则这里必须引入该包下的NotBlank和NotEmpty,否则会报错
 import javax.validation.constraints.NotBlank;
 import javax.validation.constraints.NotEmpty;
 import javax.validation.constraints.NotNull;
 ​
 import lombok.Data;
 import org.hibernate.validator.constraints.Length;
 import org.hibernate.validator.constraints.Range;
 ​
 @Data
 public class User {
 ​
     @Max(value = 10, message = "最大不能超过10")
     private Integer i1;
     @Min(value = 20, message = "最小不能小于20")
     private Integer i2;
     @NotNull(message = "不能为null")
     @Min(value = 30, message = "最小不能小于30")
     @Max(value = 30, message = "最大不能超过30")
     private Integer i3;
     @Range(min = 40, max = 40, message = "值必须等于40")
     private int i4;
 ​
     @NotNull(message = "不能为null")
     private String s1;
     @NotBlank(message = "不能为空")
     private String s2;
     @NotEmpty(message = "不能为空")
     private String s3;
     @Length(min = 2, max = 5, message = "范围为2~5")
     private String s4;
 ​
     @NotNull(message = "不能null")
     private List<String> l1;
     @NotEmpty(message = "不能为空")
     private List<String> l2;
 }

2、 Controller层验证

直接在参数前加上注解@Valid即可

 @RestController
 public class ValidController {
 ​
     @GetMapping("valid1")
     public ResultBack user1(@Valid User user, BindingResult result) {
         if (result.hasErrors()) {
             List<ObjectError> allErrors = result.getAllErrors();
             StringJoiner error = new StringJoiner(",");
             result.getFieldErrors().forEach(data -> error.add(data.getDefaultMessage()));
             return ResultBack.fail(error.toString());
         }
         System.out.println(user);
         return ResultBack.success(user);
     }
 ​
 }

3、异常处理方式一

这里在接口参数中通过@Valid注解来标识该实体类需求被校验,并通过紧跟在@Valid注解参数后面的BindingResult对象接受错误信息,然后在方法中进行判断、封装处理,返回错误信息。

优点:关于参数校验的逻辑只有错误处理的一个if块代码,没有其他多余的校验逻辑,简洁明了。

缺点:对于每个需求校验的接口,都需要添加BindingResult参数,而且要保证参数位置紧跟在@Valid参数后面,另外每个接口都有一样的错误处理逻辑,重复编码,不方便维护。

4、异常处理方式二(后文默认的异常处理方式)

通过全局异常的方式进行处理:

上面的代码中,如果不加BindingResult,则程序会抛出一个异常:org.springframework.validation.BindException,其实是org.springframework.validation.MethodArgumentNotValidExceptionMethodArgumentNotValidException继承于BindException。因此,这里可以用全局异常处理的方式,进行统一处理,就不用每个校验接口都加BindingResult参数和错误处理逻辑了。

完整的代码如下:

 @RestControllerAdvice
 public class GlobalException {
     
     /**
      *  Service层校验抛出的异常:ConstraintViolationException
      */
     @ExceptionHandler(ConstraintViolationException.class)
     @ResponseStatus(HttpStatus.OK)
     public ResultBack handleConstraintViolationException(ConstraintViolationException ex) {
         BasicLogUtil.info("ConstraintViolationException");
 ​
         return new ResultBack(ResultStatus.FAILURE, ex.getMessage());
     }
 ​
     @ExceptionHandler({BindException.class})
     @ResponseStatus(HttpStatus.OK)
     public ResultBack handleBindException(BindException ex) {
         BasicLogUtil.info("BindException");
 ​
         StringBuilder errorMsg = new StringBuilder();
         // 获取所有字段验证出错的信息
         List<FieldError> allErrors = ex.getFieldErrors();
         allErrors.forEach(fieldError -> errorMsg.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append("; "));
         return new ResultBack(ResultStatus.FAILURE, errorMsg.toString());
     }
 ​
     @ExceptionHandler({MethodArgumentNotValidException.class})
     @ResponseStatus(HttpStatus.OK)
     public ResultBack handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
         BasicLogUtil.info("MethodArgumentNotValidException");
 ​
         StringBuilder errorMsg = new StringBuilder();
         // 获取所有字段验证出错的信息
         List<FieldError> allErrors = ex.getFieldErrors();
         allErrors.forEach(fieldError -> errorMsg.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append("; "));
         return new ResultBack(ResultStatus.FAILURE, errorMsg.toString());
     }
 ​
 }

这里对两个异常分别做了处理,可以看到,处理方式都是一样的,只是第一行打印输出的内容不同,从而判断出这里到底抛出的是那个异常。有兴趣的同学可以试一下,只有注释掉handleMethodArgumentNotValidException方法时,控制台才打印BindException,从此可以推断这里抛出的异常是MethodArgumentNotValidException

5、Service层验证

只需在service接口上添加注解即可,service接口的实现类不用添加,具体添加方式为:

  • service接口添加@Validated注解
  • 具体方法要校验的参数添加@Valid注解
 @Validated
 public interface TestService {
     Object test(@Valid TestBean testBean);
 }

此种方式中接口的实现类,无需任何注解

 @Service
 public class TestServiceImpl implements TestService {
     @Override
     public Object test(TestBean testBean) {
         return testBean;
     }
 }

也可以将这些注解统一放到service的实现类中实现,不过由于override的关系,接口中的test方法参数也必须使用@Valid注解,如下:

 public interface TestService {
     // 由于继承重写该方法,故此处必须和重写的方法参数保持一致
     Object test(@Valid TestBean testBean);
 }
 ​
 @Service
 @Validated
 public class TestServiceImpl implements TestService {
     @Override
     public Object test(@Valid TestBean testBean) {
         return testBean;
     }
 }

总结:

  1. Controller里的验证直接添加@Valid注解就行

  2. Service里的验证说了那么多,其实就是两个层面的注解:

    service接口/实现类层面上的@Validated注解

    方法参数层面上校验参数的@Valid注解

具备这两个层面的注解就行,可以随意组合

3、进阶(分组处理)

1. 测试Bean类

 @Data
 public class TestBeanWithGroup {
 ​
     /**
      * 主键
      */
     @NotNull(groups = {GroupUpt.class}, message = "修改时主键不能为null")
     private Integer id;
 ​
     /**
      * 编号
      */
     @NotNull(groups = {GroupAdd.class}, message = "新增时编号不能为null")
     private String numberId;
 ​
     /**
      * 姓名
      */
     @NotNull(groups = {GroupAdd.class}, message = "新增时姓名不能为null")
     private String name;
 ​
     /**
      * 修改人
      */
     @NotNull(groups = {GroupAdd.class, GroupUpt.class}, message = "操作人不能为null")
     private String modifier;
 ​
     @NotNull(message = "测试非组操作属性")
     private String noGroup;
 ​
 ​
     public interface GroupAdd extends Default{};
     public interface GroupUpt extends Default{};
 }

上面最后的noGroup属性未指明任何分组,故是默认的分组Default,而分组GroupAdd和GroupUpt都继承了Default,故当校验这两个分组时也会校验noGroup属性,若只想单纯校验分组的属性,可以取消其继承关系。

2. controller层验证

直接在参数前加上注解@Validated,并指明校验的分组。

 @RequestMapping("/testGroup")
 public Object testWithGroup(@Validated(TestBeanWithGroup.GroupUpt.class) @RequestBody TestBeanWithGroup testBean) {
     System.out.println(testBean);
     return testBean;
 }

3. service层验证

同上面不使用分组时一样,两个层面,不过由于使用了分组还需多一个层面:

  • service接口/实现类层面上的@Validated注解
  • 方法参数层面上校验参数的@Valid注解
  • 方法层面上的@Validated注解,并指定分组

两个层次(接口和实现类)共同拥有这三个层面的注解即可。

若只有前两个层面的注解,即相当于第三个层面指定的分组为Default分组,故只会验证Default分组(默认分组)的属性。

 // 校验全部在接口中实现
 @Validated
 public interface TestGroupService {
     // 指明该校验的分组
     @Validated(TestBeanWithGroup.GroupAdd.class)
     Object test(@Valid TestBeanWithGroup testBeanWithGroup);
 }
 ​
 ​
 ​
 // 实现类中指明校验的分组
 @Validated
 public interface TestGroupService {
     Object test(@Valid TestBeanWithGroup testBeanWithGroup);
 }
 ​
 @Service
 public class TestGroupServiceImpl implements TestGroupService {
     @Override
     @Validated(TestBeanWithGroup.GroupAdd.class)
     public Object test(TestBeanWithGroup testBeanWithGroup) {
         return testBeanWithGroup;
     }
 }