Java 系列-- JSR-303 表单验证

起因,使用 springboot 创建了一个web项目,借助 mybatis 写了一个创建新用户的接口, 此时对客户端传递过来参数,该如何进行校验,哪个字段必传,如果某字段为空,如何返回一个指定的字符串消息?这样,我找到了 JSR303 规范

1.0 JSR-303 是什么?

1.1 JSR303 官方文档

JSR-303 是一套用于对 JavaBean 进行验证得 Java API 规范 ,又叫做 bean validation 1.0。当然,截止到目前为止,bean validation 规范已经在这二十几年里更新到了 1.1(JSR-349), 2.0(JSR380),3.0 等版本,

还好不必担心本文内容已过时,因为升级后的版本,仅仅是添加了若干可使用的注解而已,核心内容都未变动。(关于历史版本文末有连接)

1.2 实现 JSR-303 规范

JSR-303 是一个规范,那么我应该如何去实现它?在 官方文档 中第一个例子 2.1.2 所描述:1.先创建一个符合规范的注解,2.然后将该注解类标记在实体类上即可

首先,创建一个注解 OrderNumber

  1. 符合 JSR-303 标准的注解类,必须有 3 个字段
  2. 字段 message 定义:返回的错误消息。用于当 JavaBean 验证失败后,返回该内容
  3. 字段 groups 定义:该元素指定与约束声明关联的处理组。比如设我们的昵称,我们可能会设定多个限制,只允许英文,长度在 10 个以内。以 yyds_开头…登等, 像这样有多重限制的规则可以定义在一个组内 NamesGroups,当然默认位空,该规则就是单独的。
  4. 方法 payload() 定义:有效负载元素,该元素指定与约束声明关联的有效负载。这一方法通常是指定该注解只能够添加到哪个字段上去。默认是为空的,可以添加在任何字段上
package com.acme.constraint;

/**
 * Mark a String as representing a well formed order number
 */
@Documented
@Constraint(validatedBy = OrderNumberValidator.class)
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
public @interface OrderNumber {
    String message() default "{com.acme.constraint.OrderNumber.message}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

创建好之后,在字段上使用,就是这么简单

public class UserEntity implements Serializable {
    @OrderNumber
    private String oneOrder;
}

1.3 设计 JSR303 的目的及想要达到的效果

目前看起来, 似乎这个规范非常简单,仅仅是定义了几个字段和方法而已,似乎没什么特别的,但是对于一个规范而言,其实要考虑的东西非常多,摘取部分官方描述(感兴趣的可以查阅官方文档):

验证数据是在整个应用程序(从表示层到持久性层)中发生的常见任务。通常,在每一层中都实现了相同的验证逻辑,事实证明这很耗时且容易出错。为了避免在每一层中重复这些验证,开发人员通常将验证逻辑直接捆绑到域模型中,从而将域类与验证代码(实际上是有关类本身的元数据)混杂在一起。

此 JSR 定义了用于 JavaBean 验证的元数据模型和 API。默认的元数据源是注释,能够通过使用 XML 验证描述符来覆盖和扩展元数据。

此 JSR 开发的验证 API 不适用于任何一个层或编程模型。它特别不依赖于 Web 层或持久性层,并且可用于服务器端应用程序编程以及富客户端 Swing 应用程序开发人员。此 API 被视为 JavaBeans 对象模型的一般扩展,因此有望用作其他规范中的核心组件。易用性和灵活性影响了该规范的设计

2.0 验证框架

2.1 列举实现了JSR-303 规范的框架。

前面已经介绍了 JSR-303 规范是什么以及如何实现它。

在实际开发工作中,如果靠我们自己去实现很麻烦,而且后续升级交接也很头疼,如果有现成的,实现了这个规范的框架就好了。

因此,我找到了若干通用框架,只需要引入坐标即可。

接下来列举最常见通用的两种情景:

2.2.1 如果是通过 spring boot 创建的 web 应用,而 spring boot (version<2.3)

框架内部已经默认实现了 JSR-303 规范,可以直接使用其现成的注解

<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>
</dependencies>

2.2.2 如果是通过 spring boot 创建的 web 应用,而 spring boot (version>=2.3)

由于 web 框架内部已经剥离了该实现,因此需要引入这个依赖包:

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

2.2.3 如果是非 Spring boot 应用,则需要引入以下依赖

<dependencies>
    <dependency>
        <groupId>javax.validation</groupId>
        <artifactId>validation-api</artifactId>
        <version>${api-version}</version>
    </dependency>
    <dependency>
        <groupId>org.hibernate</groupId>
        <artifactId>hibernate-validator</artifactId>
        <version>${hibernate-validator-version}</version>
    </dependency>
</dependencies>

2.4 如果使用的是 gradle 进行编译

compile group: 'org.springframework.boot', name: 'spring-boot-starter-validation', version: '2.2.6.RELEASE'

3.0 内嵌的注解有哪些?

Bean Validation 的内嵌的注解有以下这些,也意味着 引入的符合 JSR303 规范的框架 ,其内部也同样实现了这些注解。

注解 详细信息
@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) 被注释的元素必须符合指定的正则表达式

使用示例:

// 在字段上使用
public class UserEntity implements Serializable {

    @Null
    private Integer id;

    @NotNull
    private String userName;

    @Pattern(regexp = "^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{8,120}$", message = "密码最少为8位字母和数字组合")
    private String pwd;

    @NotBlank(message = "性别不能为空")
    @Min(value = 0,message = "性别不能既非男又非女")
    private String sex;

    @Max(value = 100,message = "不准超过65")
    private Integer age;

    @DecimalMin(value="200000", message = "工资不能低于每月2W")
    private Integer salary;

    @Past
    private Date pastDay;
}
// 在方法上使用
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

@Validated
public interface SystemUserService {
   // 注销用户
    void logOff(@Min(1) long userId,@NotNull @Size(min = 10, max = 200) String reasonForlogOff);
}

tips: 如果你想看看究竟有多少内部注解,一共也就十几个,那么可以在项目中通过注解跳入源码查看
Java 系列-- JSR-303 表单验证_第1张图片

4.0 验证请求内容

通常情况下,我们都在 controller 这验证请求参数,只需要添加 @Valid,非常简单:

import javax.validation.Valid;
import org.springframework.validation.BindingResult;
...

@RestController
public class UserController {

    @PostMapping("/users")
    ResponseEntity<String> addUser(@Valid @RequestBody User user, BindingResult bindingResult) {
        // BindingResult中存储校验结果
        // 如果有错误提示信息
        if (bindingResult.hasErrors()) {
            Map<String , String> map = new HashMap<>();
            bindingResult.getFieldErrors().forEach( (item) -> {
                String message = item.getDefaultMessage();
                String field = item.getField();
                map.put( field , message );
            } );
            // 返回提示信息
            return ResponseEntity.error(map.toString());
        }
        return ResponseEntity.ok("创建成功");
    }
}

4.1 分组校验

假设这样一种情况:当我们新建用户时,不需要传用户 ID,让数据库自增 ID, 但是修改用户时需要传递一个用户 ID,并且新增和修改用的都是同一个 实体 接收参数,此时的约束条件该如何写?

  1. 创建一个类,专用于区别是新增还是更新
public class Groups {
    public interface Add{}
    public interface  Update{}
}
  1. 在字段标注上添加groups. 新增时,约束条件@Null生效。更新时,约束条件@NotNull生效.
// 在字段上使用
public class UserEntity implements Serializable {

    @Null(message = "新增不需要指定id" , groups = Groups.Add.class)
    @NotNull(message = "修改需要指定id" , groups = Groups.Update.class)
    private Integer id;

    @NotNull
    private String userName;
}
  1. 在 controller 中将 @Valid改为 @Validated(Groups.Add.class). SR303 本身的 @Valid 并不支持分组校验,但是 Spring 提供了一个注解@Validated 支持分组校验
import javax.validation.Valid;
import org.springframework.validation.BindingResult;
...

@RestController
public class UserController {

    @PostMapping("/users")
    ResponseEntity<String> addUser(@Validated(Groups.Add.class) @RequestBody User user, BindingResult bindingResult) {

        //如果有错误提示信息
        if (bindingResult.hasErrors()) {
            Map<String , String> map = new HashMap<>();
            bindingResult.getFieldErrors().forEach( (item) -> {
                String message = item.getDefaultMessage();
                String field = item.getField();
                map.put( field , message );
            } );
            //返回提示信息
            return ResponseEntity.error(map.toString());
        }
        return ResponseEntity.ok("创建成功");
    }
}

此时,当发起请求时,传递了参数 id, 那么就会返回错误信息:新增不需要指定id

### 新增用户
POST http://localhost:9000/user/register
Content-Type: application/json

{
  "id": 1234,
  "phone": 13366668888,
  "pwd": "12345678abc"
}

4.2 嵌套校验

什么是嵌套校验,说白了就是一个实体上,其中一个字段是另一个实体,那么字这个字段上添加上注解@Valid即可

public class UserEntity implements Serializable {

    @Null(message = "新增不需要指定id" , groups = Groups.Add.class)
    @NotNull(message = "修改需要指定id" , groups = Groups.Update.class)
    private Integer id;

    /**
     * @Valid 重要
     */
    @Valid
    @NotNull
    private RoleEntity role;
}
class RoleEntity implements Serializable {
    @NotBlank(message = "角色名称不能为空")
    private String roleName;
}

需要注意的是,嵌套校验对于分组有严格校验,也就是说,在 controller 中使用@Validated时不应该再指定分组了

import javax.validation.Valid;
import org.springframework.validation.BindingResult;
...

@RestController
public class UserController {

    // @Validated 不能指定分组为 groups = Groups.Add.class,否则嵌套校验role会失效
    @PostMapping("/users")
    ResponseEntity<String> addUser(@Validated @RequestBody User user, BindingResult bindingResult) {

        //如果有错误提示信息
        if (bindingResult.hasErrors()) {
            Map<String , String> map = new HashMap<>();
            bindingResult.getFieldErrors().forEach( (item) -> {
                String message = item.getDefaultMessage();
                String field = item.getField();
                map.put( field , message );
            } );
            //返回提示信息
            return ResponseEntity.error(map.toString());
        }
        return ResponseEntity.ok("创建成功");
    }
}

4.3 自定义校验器

虽说在日常的开发中内置的约束注解已经够用了,但是仍然有些时候不能满足需求,需要自定义一些校验约束。

比如:有这样一个例子,性别字段只允许传入 1 和 2,否则校验失败。

三步走:

  1. 创建注解(接收约束数据)
  2. 实现自定义规则的约束类(定义约束规则)
  3. 调用测试(传入约束数据)

1.创建注解DefineRangeValidationValues,其中在类上额外指定的规则@NotNull也会生效.另外为了通用,创建一个 values 字段用来接收稍后传入的数据

@Documented
@Constraint(validatedBy = DefineRangeValidator.class)
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@NotNull(message = "不能为空")
public @interface DefineRangeValidationValues {
    String message() default "传入的值不在范围内";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    /**
     * @return 传入的值
     */
    int[] values() default {};
}
  1. 实现自定义约束规则类DefineRangeValidator
package com.mock.water.core.validation;

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

/**
 * $$ 第一个泛型是校验注解,第二个是参数类型
 *
 * @author [email protected]
 * @version 1.0.0
 * @date 2022/10/14 17:18
 **/
public class DefineRangeValidator implements ConstraintValidator<DefineRangeValidationValues, Integer> {
    /**
     * 存储枚举值
     */
    private Set<Integer> enumValues = new HashSet<>();

    /**
     * 初始化
     *
     * @param constraintAnnotation 约束注释
     */
    @Override
    public void initialize(DefineRangeValidationValues constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
        for (int value : constraintAnnotation.values()) {
            enumValues.add(value);
        }
    }
    /**
     * 是否有效
     *
     * @param value                      整数
     * @param constraintValidatorContext 约束验证器上下文
     * @return boolean
     */
    @Override
    public boolean isValid(Integer value, ConstraintValidatorContext constraintValidatorContext) {
        //判断是否包含这个值
        return enumValues.contains(value);
    }
}
  1. 测试一下
// 在字段上使用
public class UserEntity implements Serializable {
    @DefineRangeValidationValues(values = {1, 2}, message = "性别只能传入1或者2")
    private Integer sex;
}

5.0 接收校验结果

在上面示例中, 已经看到我们可以通过 validation 框架提供的类 BindingResult 来接受参数。

这种方式弊端很明显,每一个接口都需要在传递参数的位置写上声明,以及对校验结果进行处理。因此正确的做法是进行全局异常捕捉

6.0 全局异常捕捉

参数在校验失败的时候会抛出的MethodArgumentNotValidException或者BindException两种异常,可以在全局的异常处理器中捕捉到这两种异常,将提示信息或者自定义信息返回给客户端。

作者这里就不再详细的贴出其他的异常捕获了,仅仅贴一下参数校验的异常捕获

package com.mock.water.core.group.exception;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.BindException;
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;

/**
 * $$
 *
 * @author [email protected]
 * @version 1.0.0
 * @date 2022/10/14 16:45
 **/
@RestControllerAdvice
public class GlobalExceptionHandler {
    @Autowired
    private ObjectMapper objectMapper;
    /**
     * 参数校验异常步骤
     */
    @ExceptionHandler(value= {MethodArgumentNotValidException.class , BindException.class})
    public String onException(Exception e) throws JsonProcessingException {
        BindingResult bindingResult = null;
        if (e instanceof MethodArgumentNotValidException) {
            bindingResult = ((MethodArgumentNotValidException)e).getBindingResult();
        } else if (e instanceof BindException) {
            bindingResult = ((BindException)e).getBindingResult();
        }
        Map errorMap = new HashMap<>(16);
        bindingResult.getFieldErrors().forEach((fieldError)->
                errorMap.put(fieldError.getField(),fieldError.getDefaultMessage())
        );
        return objectMapper.writeValueAsString(errorMap);
    }
}

拓展阅读资料

  • JSR303 document
  • Bean Vaidation 历史
  • Jakarta Bean Validation 3.0
  • 凤凰架构-Bean Vaidation 3.0:JSR380
  • 参考文章:springboot 中使用 validationBean
  • 参考文章:整合 JSR303

------ 如果文章对你有用,感谢右上角 >>>点赞 | 收藏 <<<

你可能感兴趣的:(java,JavaBean,validation,springboot)