JRS-303校验最佳实践
JRS 303 Bean Validation 最佳实践

JSR-303是什么

JSR全称:Java Specification Requests。

JSR-303 是 Java EE 6 中的一项子规范,叫做 Bean Validation,官方参考实现是hibernate Validator。

Bean Validation 为 JavaBean 验证定义了相应的元数据模型和 API。缺省的元数据是 Java Annotations,通过使用 XML 可以对原有的元数据信息进行覆盖和扩展。在应用程序中,通过使用 Bean Validation 或是你自己定义的 constraint,例如 @NotNull, @Max, @ZipCode , 就可以确保数据模型(JavaBean)的正确性。constraint 可以附加到字段,getter 方法,类或者接口上面。对于一些特定的需求,用户可以很容易的开发定制化的 constraint。Bean Validation 是一个运行时的数据验证框架,在验证之后验证的错误信息会被马上返回。

Bean Validation

Bean Validation 中内置的 一些constraint

其本质是将一些常用的校验规则内置

注解 含义
@Null 被注释的元素必须为null
@NotNull 被注释的元素必须不为null
@NotEmpty 被注释的元素必须不为null且不为空字符串
@NotBlank 被注释的元素至少包含一个有效字符
@AssertTrue 被注释的元素必须为true
@AssertFalse 被注释的元素必须为false
@Min(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@Max(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMin(value) 与@Min作用相同,主要针对高精度的BigDecimal类型
@DecimalMax(value) 与@Max作用相同,主要针对高精度的BigDecimal类型
@Size(max,min) 被注释的元素的大小必须再指定的范围内
@digits(integer,fraction) 被注释的元素必须是一个数字,其值必须在可接受的范围内
@Past 被注释的元素必须是一个过去的日期
@Future 被注释的元素必须是一个将来的日期
@Pattern(value) 被注释的元素必须符合指定的正则表达式

最佳实践

实践介绍

本实践不涉及数据库链接

  1. 用户实体

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    package com.imxushuai.jsr303.entity;

    import lombok.Data;

    @Data
    public class User {

    /**
    * ID
    */
    private String id;

    /**
    * 姓名
    */
    private String name;

    /**
    * 年龄
    */
    private Integer age;

    /**
    * 住址
    */
    private String address;

    }
  2. 使用一个简单的对象作为通用接口返回对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    package com.imxushuai.jsr303.entity;

    import lombok.Data;

    @Data
    public class CustomerResult {

    private int code;
    private String msg;
    private Object data;

    public static CustomerResult result(int code, String msg) {
    CustomerResult customerResult = new CustomerResult();
    customerResult.setCode(code);
    customerResult.setMsg(msg);
    customerResult.setData(null);

    return customerResult;
    }

    public static CustomerResult result(int code, String msg, Object data) {
    CustomerResult customerResult = new CustomerResult();
    customerResult.setCode(code);
    customerResult.setMsg(msg);
    customerResult.setData(data);

    return customerResult;
    }

    }
  3. Jsr303TestController,Controller中提供两个方法,一个新增方法,一个保存方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    package com.imxushuai.jsr303.controller;

    import com.imxushuai.jsr303.entity.CustomerResult;
    import com.imxushuai.jsr303.entity.User;
    import com.imxushuai.jsr303.service.UserService;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;

    @RestController
    @RequestMapping("api/user")
    public class Jsr303TestController {

    @Autowired
    private UserService userService;

    @PostMapping("save")
    public ResponseEntity<CustomerResult> save(@RequestBody User user) {
    // 保存数据
    userService.save(user);

    return ResponseEntity.ok().;
    }

    @PostMapping("update")
    public ResponseEntity<CustomerResult> update(@RequestBody User user) {
    // 更新数据
    userService.update(user);

    return ResponseEntity.ok().build();
    }

    }
  4. UserService,仅声明方法并打印了日志,不做具体的业务操作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    package com.imxushuai.jsr303.service;

    import com.imxushuai.jsr303.entity.User;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Service;

    @Slf4j
    @Service
    public class UserService {
    public void save(User user) {
    log.info("用户新增成功.....");
    }

    public void update(User user) {
    log.info("用户更新成功.....");
    }
    }
  5. pom.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.8.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.imxushuai</groupId>
    <artifactId>jsr-303-demo</artifactId>
    <version>1.0-SNAPSHOT</version>
    <name>jsr-303-demo</name>
    <description>JSR-303校验</description>
    <properties>
    <java.version>1.8</java.version>
    </properties>
    <dependencies>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

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

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

    <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.12.0</version>
    </dependency>

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

    <build>
    <plugins>
    <plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
    </plugins>
    </build>

    </project>

原始的校验方法

  1. 如新增的时候,我们需要校验用户名称和年龄不为空时,必须进行编码来完成校验工作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @PostMapping("save")
    public ResponseEntity<CustomerResult> save(@RequestBody User user) {
    if (StringUtils.isBlank(user.getName())) {
    return ResponseEntity.ok().body(CustomerResult.result(400, "用户姓名不能为空!"));
    }
    if (user.getAge() == null || user.getAge() < 0) {
    return ResponseEntity.ok().body(CustomerResult.result(400, "用户年龄不能为空且不能小于0!"));
    }
    // 保存数据
    userService.save(user);

    return ResponseEntity.ok(CustomerResult.result(200, "新增成功"));
    }
  2. 测试API

    当用户名为空时,正确返回了提示信息

想想看,如果更新的时候也需要判断用户名为空的情况不允许保存,是否同样的判断有需要写一遍,这样会大大的增加了工作量。

下面让我们来看看使用 Bean Validation来完成校验

基本使用

  1. 根据需求在实体类上添加需要的注解标注其字段需要进行校验的规则,如:

    用户名:不能为空

    年龄:不能为空且需要大于0

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /**
    * 姓名
    */
    @NotBlank
    private String name;

    /**
    * 年龄
    */
    @NotNull
    @Min(value = 0)
    private Integer age;

    添加注解后的实体类字段

    @NotBlank:字段值至少有一个有效字符

    @NotNull:字段值不能为空

    @Min:字段最小值为0即age字段的值必须大于等于0

    注意:添加的注解的包位于:javax.validation.constraints

  2. 在API的参数接收处加上@Valid注解,表明该参数需要进行参数校验并且注释掉刚才编写的校验代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @PostMapping("save")
    public ResponseEntity<CustomerResult> save(@Valid @RequestBody User user) {
    /*if (StringUtils.isBlank(user.getName())) {
    return ResponseEntity.ok().body(CustomerResult.result(400, "用户姓名不能为空!"));
    }
    if (user.getAge() == null || user.getAge() < 0) {
    return ResponseEntity.ok().body(CustomerResult.result(400, "用户年龄不能为空且不能小于0!"));
    }*/
    // 保存数据
    userService.save(user);

    return ResponseEntity.ok(CustomerResult.result(200, "新增成功"));
    }
  3. 重启项目进行测试

    返回值中可以看到提示消息:不能为空。

    但明显可以看出这样的返回格式有点过于难以辨别,是否能够自定义返回消息以及返回的格式呢?

  4. 自定义返回的消息和格式

    • 自定义消息

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      /**
      * 姓名
      */
      @NotBlank(message = "用户姓名不能为空")
      private String name;

      /**
      * 年龄
      */
      @NotNull(message = "用户年龄不能为空")
      @Min(value = 0, message = "用户年龄必须大于等于0")
      private Integer age;
    • 自定义格式

      可以在Controller的参数中声明BindResult类型的参数,此参数可以接收到参数校验的结果,我们可以从此参数中获取到校验的结果并处理后返回

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      @PostMapping("save")
      public ResponseEntity<CustomerResult> save(@Valid @RequestBody User user, BindingResult result) {
      /*if (StringUtils.isBlank(user.getName())) {
      return ResponseEntity.ok().body(CustomerResult.result(400, "用户姓名不能为空!"));
      }
      if (user.getAge() == null || user.getAge() < 0) {
      return ResponseEntity.ok().body(CustomerResult.result(400, "用户年龄不能为空且不能小于0!"));
      }*/
      if (result.hasErrors()) {
      Map<String, String> map = new HashMap<>();
      // 获取错误的校验结果
      result.getFieldErrors().forEach((item) -> {
      // 获取发生错误时的message
      String message = item.getDefaultMessage();
      // 获取发生错误的字段
      String field = item.getField();
      map.put(field, message);
      });
      return ResponseEntity.ok(CustomerResult.result(400, "新增失败", map));
      }
      // 保存数据
      userService.save(user);

      return ResponseEntity.ok(CustomerResult.result(200, "新增成功"));
      }
  5. 重启服务,测试API

    可以看到返回的结果很理想,错误的字段是name,提示信息为用户姓名不能为空

    年龄字段我就不测试了。

统一异常处理

上面讲了基本的校验使用,还是会发现,其实我们还是在API中进行了一系列的编码, 还是没有做到代码的复用性,下面我们通过统一的异常处理来完成校验失败的结果统一返回。

  1. 定义统一异常处理类

    创建的类主要需要使用@RestControllerAdvice进行标注

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    package com.imxushuai.jsr303.exception;

    import com.imxushuai.jsr303.entity.CustomerResult;
    import org.springframework.http.ResponseEntity;
    import org.springframework.validation.BindingResult;
    import org.springframework.web.bind.MethodArgumentNotValidException;
    import org.springframework.web.bind.annotation.ExceptionHandler;
    import org.springframework.web.bind.annotation.RestControllerAdvice;

    import java.util.HashMap;
    import java.util.Map;

    /**
    * 统一异常处理
    */
    @RestControllerAdvice
    public class Jsr303ExceptionControllerAdvice {

    /**
    * MethodArgumentNotValidException异常统一处理
    * 仅处理MethodArgumentNotValidException异常
    *
    * @param exception MethodArgumentNotValidException
    * @return 异常返回
    */
    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public ResponseEntity<CustomerResult> handleValidException(MethodArgumentNotValidException exception) {
    Map<String, String> map = new HashMap<>();
    BindingResult bindingResult = exception.getBindingResult();
    bindingResult.getFieldErrors().forEach(fieldError -> {
    String message = fieldError.getDefaultMessage();
    String field = fieldError.getField();
    map.put(field, message);
    });

    return ResponseEntity.ok().body(CustomerResult.result(400, "参数异常", map));
    }

    /**
    * 通用异常处理
    *
    * @param throwable 异常
    * @return 异常返回
    */
    @ExceptionHandler(value = Throwable.class)
    public ResponseEntity<CustomerResult> handleException(Throwable throwable) {
    return ResponseEntity.ok().body(CustomerResult.result(500, "未知异常"));
    }

    }
  2. 注释掉在controller中的代码并且删除参数中接收到BindResult参数

  3. 重启服务进行测试

    测试依然有效

    我们再测试一下更新API

    注意:测试之前需要在update的参数上使用@Valid注解进行标注,否则参数校验不会生效

    依然生效。

分组校验

上面已经基本完成了参数校验的常规使用,下面介绍一种进阶用法。场景如下:

新增时,ID参数为空,名称不为空,年龄不为空且大于等于0

更新时,ID参数不为空,名称不为空,年龄不为空且大于等于0

这种情况,在上面的代码中是无法去判断ID参数的。

这个时候我们就可以使用分组校验来完成想要的效果。

  1. 首先需要声明两个类,用来表示新增组和更新组。(只作接口类声明,不需要编码)

    • AddGroup

      1
      2
      3
      4
      package com.imxushuai.jsr303.valid;

      public interface AddGroup {
      }
    • UpdateGroup

      1
      2
      3
      4
      package com.imxushuai.jsr303.valid;

      public interface UpdateGroup {
      }
  2. 在实体类中对参数进行分组

    使用注解中的groups参数用来表示当前注解适用于哪些分组

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    package com.imxushuai.jsr303.entity;

    import com.imxushuai.jsr303.valid.AddGroup;
    import com.imxushuai.jsr303.valid.UpdateGroup;
    import lombok.Data;

    import javax.validation.constraints.Min;
    import javax.validation.constraints.NotBlank;
    import javax.validation.constraints.NotNull;
    import javax.validation.constraints.Null;

    @Data
    public class User {

    /**
    * ID
    */
    @Null(message = "新增时, ID必须为空", groups = {AddGroup.class})
    @NotNull(message = "更新时, ID不能为空", groups = {UpdateGroup.class})
    private String id;

    /**
    * 姓名
    */
    @NotBlank(message = "用户姓名不能为空", groups = {AddGroup.class, UpdateGroup.class})
    private String name;

    /**
    * 年龄
    */
    @NotNull(message = "用户年龄不能为空", groups = {AddGroup.class, UpdateGroup.class})
    @Min(value = 0, message = "用户年龄必须大于等于0", groups = {AddGroup.class, UpdateGroup.class})
    private Integer age;

    /**
    * 住址
    */
    private String address;

    }
  3. 在controller的API参数上使用该分组来表示当前API适用于哪个分组

    之前我们使用的是@Valid注解,要使用分组校验的特性需要更换注解,使用@Validated并在注解的参数中标注其使用的校验分组,如:@Validated({AddGroup.class})

    update的API则使用@Validated({UpdateGroup.class})

  4. 重启服务进行测试

    新增时传入了ID参数,返回的结果和理想的一致。更新的情况,我就不测试了。

注意:加了校验分组后,没有校验分组的字段将不会参与声明了分组校验的API参数校验

自定义校验

上面讲了分组校验的办法,接下来介绍另一种情况,比如用户有一个属性:性别,其值只能为0或者1,0是男性,1是女性,2是未知;需要校验传递的参数必须为0,1,2三个值中的一种,这时使用内置的校验注解已经没有办法满足这个需求了。

这时就可以使用到自定义注解来完成这项操作。

  1. 在用户实体类中新增性别字段

    1
    2
    3
    4
    /**
    * 性别,0:男性;1:女性;2:未知
    */
    private Integer gender;
  2. 创建一个自定义注解

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    package com.imxushuai.jsr303.valid;

    import javax.validation.Constraint;
    import java.lang.annotation.Documented;
    import java.lang.annotation.Retention;
    import java.lang.annotation.Target;

    import static java.lang.annotation.ElementType.*;
    import static java.lang.annotation.RetentionPolicy.RUNTIME;

    @Documented
    @Constraint(validatedBy = {ListValueConstraintValidator.class})
    @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
    @Retention(RUNTIME)
    public @interface ListValue {

    String message() default "必须提交指定的值";

    Class[] groups() default {};

    Class[] payload() default {};

    int[] values() default {};

    }

    类中最关键的点在于类上的@Constraint(validatedBy = {ListValueConstraintValidator.class})注解,这里声明了该注解的校验器类为:ListValueConstraintValidator.class,所以我们还需要创建一个校验器类,该类用于编码完成校验逻辑。

    注解的参数:

    • message:校验失败的返回值
    • groups:分组校验
    • values:自定义的字段,用于存放字段可能的值的列表,比如性别:{1, 2, 3}
  3. 编写校验器类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    package com.imxushuai.jsr303.valid;

    import javax.validation.ConstraintValidator;
    import javax.validation.ConstraintValidatorContext;
    import java.util.HashSet;
    import java.util.Set;

    public class ListValueConstraintValidator implements ConstraintValidator<ListValue, Integer> {

    private Set<Integer> set = new HashSet<>();

    @Override
    public void initialize(ListValue constraintAnnotation) {
    int[] values = constraintAnnotation.values();

    for (int value : values) {
    set.add(value);
    }

    }

    @Override
    public boolean isValid(Integer value, ConstraintValidatorContext constraintValidatorContext) {
    // 校验值是否是
    return set.contains(value);
    }
    }
  4. 在实体类的性别字段上添加注解

    1
    2
    3
    4
    5
    /**
    * 性别,0:男性;1:女性;2:未知
    */
    @ListValue(values = {1, 2, 3})
    private Integer gender;
  5. 重启服务,测试API

    异常校验按照预期返回。

注意:自定义的校验注解,同名注解可以有多个,主要用于多类型的参数校验。

比如:A系统的男性女性使用的数字1,2;而B系统的男性女性使用的是字符串的1,2;此情况就可以使用再声明一个同名注解,其values的值是字符串类型,然后编写参数校验器即可完成。

代码获取

完整的代码获取:👉JSR-303-Demo👈

此项目为我个人的Demo合集,本文章介绍的内容的目录如图:

文章作者: imxushuai
文章链接: https://www.imxushuai.com/2021/12/16/44.JRS-303校验最佳实践/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 imxushuai
支付宝打赏
微信打赏