美文网首页
Spring Boot - 数据校验

Spring Boot - 数据校验

作者: Whyn | 来源:发表于2020-08-28 02:27 被阅读0次

    [TOC]

    简介

    后端编程中,通常对于前端传递过来的数据,我们都需要进行校验,确保数据正确且安全。

    最直接的方法当然是在 Controller 相应方法内对数据进行手动校验,但是,由于很多校验都具备相似性,因此这种做法稍显冗余。

    因此,相关的校验规范就应运而生。比如:

    • JSR-303:它是一项 Bean Validation 校验标准,规定了一些校验规范,比如@Null@NotNull@Pattern,相关注解都位于javax.validation.constraints包下。需要注意的是,JSR-303 只提供校验规范,不提供实现。

    JSR-303 是 Bean Validation 1.0 版本,随着越来越多的新规范并入,它的版本也一直在更新,比如,JSR-349 就是 Bean Validation 1.1 版本,而当前最新的版本为 JSR-380,也即 Bean Validation 2.0 版本...

    由于 JSR-303 只提供规范,因此其实现需要其他库进行提供。当前使用最广泛的 Bean Validation 实现库为:hibernate-validator

    hibernate-validator 是对 JSR-303 的实现,同时它也增添了其他一些校验注解,比如,@URL@Length@Ranger等。

    而在 Spring 中,其也提供了相应的 Bean Validation 实现:Java Bean Validation
    Spring Validation 主要是对 hibernate-validator 进行了二次封装,并在 SpringMVC 中添加了自动校验,以及将校验信息封装进特定类中等功能。

    本文主要介绍下在 Spring Boot 中进行数据校验(Bean Validation)。

    依赖添加

    Spring Boot 中进行数据校验需要添加起步依赖:spring-boot-starter-validation,如下所示:

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

    :在spring-boot-starter-web旧版本中,其内置了spring-boot-starter-validation,但是 Spring Boot 官方似乎认为并不是很多应用会使用数据校验功能,因此对其进行了移除。具体请参考:issue#19550

    基本使用

    数据校验最基本的操作就是使用相关注解对一个 Java Bean 内的相关字段进行约束,然后前端传递上来的数据会首先组装为相应的 Java Bean 对象,该对象会被移交到一个Validator,让其检查对象字段(即数据)是否满足约束,如果不满足的话,则会通过如抛出异常等方式通知系统。

    具体的使用步骤如下所示:

    1. 首先定义一个需要校验的 Java Bean 类:

      @Data
      public class User {
          private int id;
      
          @NotBlank(message = "用户名不能为空")
          private String name;
      
          @NotNull(message = "请输入密码")
          @Length(min = 6, max = 10, message = "密码为 6 到 10 位")
          private String password;
      
          @Email
          private String email;
      }
      

      上述代码中,我们使用@NotBlank@NotNull@Length@Email等注解对User类中的相应字段进行了约束。
      各注解对应的约束内容请参考后文。

    2. 在 Controller 相应接口方法中,使用@Valid/@Validated等注解开启数据校验功能:

      @RestController
      @RequestMapping("validate")
      public class ValidationController {
      
          @PostMapping("/user")
          public String addUser(@Validated @RequestBody User user){
              return "add user successfully! " + user;
          }
      }
      
    3. 如果数据校验不通过,就会抛出一个MethodArgumentNotValidException异常。默认情况下,Spring 会将该异常及其信息以错误码 400 进行下发。我们可以通过自定义一个全局异常捕获器拦截该异常,提取出数据校验出错信息,进行展示:

      @RestControllerAdvice
      public class GlobalExceptionHandler {
      
          @ExceptionHandler(MethodArgumentNotValidException.class)
          @ResponseStatus(HttpStatus.BAD_REQUEST)
          public String handlerMethodArgumentNotValidException(MethodArgumentNotValidException e) {
              return e.getBindingResult().getFieldErrors()
                      .stream()
                      .map(fieldError -> {
                          return String.format("[%s: %s]\n", fieldError.getField(), fieldError.getDefaultMessage());
                      }).collect(Collectors.joining());
          }
      }
      

    以上,就完成了一个基础的数据校验功能。

    此时我们进行如下访问:

    $ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"password\": \"123456\"}"
    [name: 用户名不能为空]
    
    $ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"name\": \"Whyn\",\"password\": \"12345\"}"
    [password: 密码为 6 到 10 位]
    
    $ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"name\": \"Whyn\",\"password\": \"123456\"}"
    add user successfully! User(id=0, name=Whyn, password=123456, email=null)
    

    可以看到,结果符合预期。

    :上述代码如果数据校验不通过,就会抛出MethodArgumentNotValidException,其实是因为我们在为参数注解了@RequestBody,此时HttpMessageConverter会负责转换过程,当遇到数据校验失败时,就会抛出MethodArgumentNotValidException
    而如果去除@RequestBody注解,默认就会由@ModelAttribute负责数据绑定和校验,如果此时校验失败,则会抛出BindException(更多详情,可参考:issue#14790),因此,为了程序更加健壮,最好为我们的全局异常处理器增加BindException异常捕获。如下所示:

    @RestControllerAdvice
    public class GlobalExceptionHandler {
    
        ...
        @ExceptionHandler(BindException.class)
        @ResponseStatus(HttpStatus.BAD_REQUEST)
        public String handleBindException(BindException e){
            return e.getBindingResult().getFieldErrors()
                    .stream()
                    .map(fieldError -> {
                        return String.format("[%s: %s]\n", fieldError.getField(), fieldError.getDefaultMessage());
                    }).collect(Collectors.joining());
        }
    }
    

    此时,请求上述代码,结果如下:

    $ curl http://localhost:8080/validate/user -X POST
    [name: 用户名不能为空]
    [password: 请输入密码]
    
    $ curl http://localhost:8080/validate/user -X POST --data "name=Whyn"
    [password: 请输入密码]
    
    $ curl http://localhost:8080/validate/user -X POST --data "name=Whyn" --data "password=123456"
    add user successfully! User(id=0, name=Whyn, password=123456, email=null, phoneNo=null)
    

    上面是对复杂数据(Java Bean)的校验使用方式,而如果前端传递的是简单基本类型(比如String)或者是对路径变量(Path Variable)进行校验,可使用如下方式:

    @RestController
    @RequestMapping("validate")
    @Validated
    public class ValidationController {
    
        @GetMapping("/user/{id}")
        public String getUser(@PathVariable("id") @Min(10) int id) {
            return "User id is " + id;
        }
    
        @PutMapping("/user")
        public String updateUser(@RequestParam("name") @NotBlank String name,
                                 @RequestParam("email") @Email String email) {
            User user = new User();
            user.setName(name);
            user.setEmail(email);
            return "update user done: " + user;
        }
    }
    

    可以看到,对于简单数据类型,我们将约束注解直接注解到相应参数上,然后在Controller类上使用@Validated注解,启动数据校验。

    对于这种数据校验方式,当校验失败时,会抛出ConstraintViolationException,而不是我们上面对 Java Bean 校验失败抛出的MethodArgumentNotValidException异常,因此,可以为我们的全局异常处理器捕获该异常,进行处理。如下所示:

    @RestControllerAdvice
    public class GlobalExceptionHandler {
        ...
        @ExceptionHandler(ConstraintViolationException.class)
        @ResponseStatus(HttpStatus.BAD_REQUEST)
        public String handleConstraintViolationException(ConstraintViolationException e) {
            return e.getConstraintViolations()
                    .stream()
                    .map(constraintViolation -> {
                        return String.format("[%s: %s]\n",
                                constraintViolation.getPropertyPath().toString(),
                                constraintViolation.getMessage());
                    }).collect(Collectors.joining());
        }
    }
    

    请求上述代码,如下所示:

    $ curl -X GET http://localhost:8080/validate/user/1
    [getUser.id: must be greater than or equal to 10]
    
    $ curl -X GET http://localhost:8080/validate/user/10
    User id is 10
    
    $ curl http://localhost:8080/validate/user -X PUT --data "name=" --data "email=10"
    [updateUser.name: must not be blank]
    [updateUser.email: must be a well-formed email address]
    
    $ curl http://localhost:8080/validate/user -X PUT --data "name=Whyn" --data "email=10@qq.com"
    update user done: User(id=0, name=Whyn, password=null, email=10@qq.com, extraInfo=null)
    

    Bean Validation 相关注解

    • 下面主要介绍下 JSR 中一些常用的相关约束注解,如下所示:

      注解 释义 可被注解元素类型
      @NotNull 被注解的元素不能为null 所有类型
      @NotBlank 被注解的元素不能为null,且至少包含一个非空白字符 支持CharSequence
      @NotEmpty 被注解的元素不能为null,且不能为空(即不能为空集合) 支持CharSequenceCollectionMapArray
      @Min(value) 被注解的元素值必须大于或等于@Min指定的值 支持BigDecimalBigInteger,以及byteshort等基本数值类型及其他们相应的包装类型
      @Max(value) 被注解的元素值必须小于或等于@Max指定的值 支持BigDecimalBigInteger,以及byteshort等基本数值类型及其他们相应的包装类型
      @Size(max=, min=) 被注解的元素大小必须在指定的范围内 CharSequenceCollectionMapArray以及null
      null元素会被认为是有效值
      @Pattern 被注解的元素必须符合指定的正则匹配 CharSequence
      null类型元素会被认为是有效值
      @AssertTrue 被注解的元素值必须为true 支持booleanBoolean类型
      @AssertFalse 被注解的元素值必须为false 支持booleanBoolean类型

      更多 JSR 相关注解内容,请参考:javax.validation.constraints

    • 下面介绍下 hibernate-validator 的一些常用特有注解:

      注解 释义 可被注解元素类型
      @Length(min=,max=) 被注解的字符串长度必须在指定范围内 字符串
      @Range(min=,max=) 被注解的元素必须在指定范围内 数值类型或者数值字符串类型
      @URL 被注解的字符串匹配 URL 字符串

      更多 hibernate-validator 相关注解内容,请参考:org.hibernate.validator.constraints

    • 下面介绍下 Spring Bean Validation 的一些常用特有注解:

      注解 释义 可被注解元素类型
      @Validated 开启数据校验功能,支持分组校验 任何非原子类型

      更多 Spring Bean Validation 相关注解内容,请参考:org.springframework.validation.annotation

      @Validated注解是@Valid注解的一个变种实现,它们都主要用于启动数据校验功能,而不同之处大致有以下几方面:

      • @Valid是属于 JSR 规范,其位于包javax内;而@Validated是属于 Spring Bean Validation,其位于包org.springframework.validation内。

      • @Valid支持嵌套校验(就是一个 Bean 内嵌套另一个 Bean),而@Validated不支持。如下所示:

        @Data
        public class User {
            ...
            @Valid // 嵌套校验
            private ExtraInfo extraInfo;
        
        
            @Data
            public static class ExtraInfo {
                @Pattern(regexp = "\\b(male|female)\\b", message = "male or female")
                @NotBlank(message = "性别不能为空")
                private String sex;
        
                @Min(0)
                @Max(130)
                private int age;
        
            }
        }
        

        :嵌套校验只需要求嵌套 Bean 内使用@Valid注解,而启动数据校验(即 Controller 层)使用@Valid或者@Validated都可以。

        请求上述代码,如下所示:

        $ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"name\": \"Whyn\",\"password\": \"123456\",\"extraInfo\": {\"sex\": \"男\"}}"
        [extraInfo.sex: male or female]
        
        $ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"name\": \"Whyn\",\"password\": \"123456\",\"extraInfo\": {\"sex\": \"male\"}}"
        add user successfully! User(id=0, name=Whyn, password=123456, email=null, extraInfo=User.ExtraInfo(sex=male, age=0))
        
      • @Validated支持分组校验功能,而@Valid不支持。启动分组校验步骤如下所示:

        1. 首先创建两个分组接口:
        public interface ValidationGroup1 {}
        public interface ValidationGroup2 {}
        
        1. 在实体类中添加分组信息:
        @Data
        public class User {
            private int id;
        
            // 隶属分组 1
            @NotBlank(message = "用户名不能为空", groups = ValidationGroup1.class)
            private String name;
        
            // 隶属分组 1 和 2
            @NotNull(message = "请输入密码", groups = {ValidationGroup1.class, ValidationGroup2.class})
            // 不进行分组
            @Length(min = 6, max = 10, message = "密码为 6 到 10 位")
            private String password;
        
            // 不进行分组
            @Email
            private String email;
        }
        
        1. 使用@Validated指定分组:
        @RestController
        @RequestMapping("validate")
        public class ValidationController {
        
            @PostMapping("/user")
            public String addUser(@Validated(ValidationGroup2.class) @RequestBody User user){
                return "add user successfully! " + user;
            }
        }
        

        上述代码我们指定使用分组ValidationGroup2进行数据校验,ValidationGroup2只对password进行NotNull约束,因此,只要我们发送的数据满足password不为null,就可以通过校验,如下所示:

        $ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"name\": \"Whyn\"}"
        [password: 请输入密码]
        
        $ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"name\": \"Whyn\",\"password\": \"\"}"
        add user successfully! User(id=0, name=Whyn, password=, email=null)
        

        综上,一个比较好的使用方式就是:启动校验(即 Controller 层)时使用@Validated注解,嵌套校验时使用@Valid注解,这样,就能同时使用分组校验和嵌套校验功能。

    自定义Validator

    前文讲过,数据校验功能是由Validator负责开启并校验的,在 SpringMVC 中,如果检测到 Bean Validation(比如,Hibernate Validator)存在于classpath路径上时,就会默认全局注册了一个ValidatorLocalValidatorFactoryBean,它会驱动@Valid@Validated开启数据校验。

    LocalValidatorFactoryBean同时实现了javax.validation.ValidatorFactoryjavax.validation.Validatororg.springframework.validation.Validator三个接口,所以如果需要手动调用数据校验逻辑,可以通过 IOC 容器获取到这些接口的实例。如下所示:

    • 获取javax.validation.Validator接口实例:
      import javax.validation.Validator;
      
      @Service
      public class MyService {
      
          @Autowired
          private Validator validator;
      }
      
    • 获取org.springframework.validation.Validator接口实例:
      import org.springframework.validation.Validator;
      
      @Service
      public class MyService {
      
          @Autowired
          private Validator validator;
      }
      

    上述获取的是系统默认的Validator,而如果我们想注入一个自定义Validator,有如下几种方法:

    • 注入自定义Validator到 Spring IOC 容器:

      import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
      
      @Configuration
      public class AppConfig {
      
          @Bean
          public LocalValidatorFactoryBean validator() {
              return new LocalValidatorFactoryBean();
          }
      }
      
    • 为 SpringMVC 配置一个全局Validator

      @Configuration
      @EnableWebMvc
      public class WebConfig implements WebMvcConfigurer {
      
          @Override
          public Validator getValidator() {
              // ...
          }
      }
      

      也可以为单独一个 Controller 设置一个局部Validator,如下所示:

      @Controller
      public class MyController {
      
          @InitBinder
          protected void initBinder(WebDataBinder binder) {
              binder.addValidators(new FooValidator());
          }
      }
      

    自定义约束注解

    如果现存的约束注解无法满足我们的需求,那么我们可以通过自定义约束注解,来定制我们的数据校验逻辑。

    在 Spring 中,自定义约束注解主要就是定义一个约束注解及其对应的Validator,两者通过@Constraint关联到一起。
    默认情况下,全局校验器LocalValidatorFactoryBean会配置一个SpringConstraintValidatorFactory实例,SpringConstraintValidatorFactory实现了接口ConstraintValidatorFactory,因此它会在遇到自定义约束注解的时候,就会自动实例化@Constraint指定的关联Validator,从而完成数据校验过程。

    详细过程可参考如下示例:

    例子:假设我们想自定义一个约束注解,用于对手机号进行校验,要求满足手机号码的格式为:+86 13699328716,即以+86开头,然后中间一个或多个空格,后面是有效的手机号码。

    自定义约束注解的步骤如下所示:

    1. 自定义一个约束注解:

      @Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
      @Retention(RetentionPolicy.RUNTIME)
      @Constraint(validatedBy = PhoneNoConstraintValidator.class)
      public @interface PhoneNoConstraint {
          String message() default "手机号码格式错误";
      
          Class<?>[] groups() default {};
      
          Class<? extends Payload>[] payload() default {};
      }
      

      这里通过注解@Constraint将自定义注解PhoneNoConstraintPhoneNoConstraintValidator(即一个自定义Validator)关联到一起。

    2. 自定义一个Validator

      public class PhoneNoConstraintValidator implements ConstraintValidator<PhoneNoConstraint, String> {
          @Override
          public boolean isValid(String value, ConstraintValidatorContext context) {
              String regex = "\\+86\\s+\\d{11}";
              Pattern pattern = Pattern.compile(regex);
              Matcher matcher = pattern.matcher(value);
              return matcher.matches();
          }
      }
      
    3. 使用自定义约束注解:

      @RestController
      @RequestMapping("validate")
      @Validated
      public class ValidationController {
      
          @PostMapping("/user/{id}")
          public String addPhoneNo(@PathVariable("id") int id,
                                   @RequestParam("phoneNo")
                                   @NotBlank(message = "手机号不能为空")
                                   @PhoneNoConstraint(message = "手机号必须以 +86 开头")
                                           String phoneNo) {
              return id + " => add phoneNo done: " + phoneNo;
      
          }
      }
      

      当程序运行时,遇到自定义约束注解@PhoneNoConstraint时,SpringConstraintValidatorFactory就会通过@PhoneNoConstraint上的@Constraint注解,获取得到其对应的Valiator,然后通过 Spring 创建该Validator实例,进行数据校验。利用这种机制,可以使得我们的自定义Validator享受到其他 Java Bean 一样的依赖注入功能。

      请求上述代码,结果如下:

      $ curl localhost:8080/validate/user/1 -X POST --data-urlencode "phoneNo=13699328716"
      [addPhoneNo.phoneNo: 手机号必须以 +86 开头]
      
      $ curl localhost:8080/validate/user/1 -X POST --data-urlencode "phoneNo=+86 13699328716"
      1 => add phoneNo done: +86 13699328716
      

      :如果 URL 包含+=&等特殊符号时,会被进行转义,比如,+会被转义为空格,这样后端接收的数据格式就永远是错误的,因此,发送数据前,应先对数据进行编码,所以上述curl命令使用--data-urlencode对数据进行编码,以确保特殊字符能成功发送。

    其他

    • 除了对 Controller 层添加数据校验外,还可以为 Spring 其他组件添加数据校验功能,只需结合@Validated@Valid这两个注解。
      比如,对 Serivce 层添加数据校验功能,如下所示:
      @Service
      @Validated
      class ValidatingService{
      
          void validateInput(@Valid Input input){
            // do something
          }
      }
      

    参考

    相关文章

      网友评论

          本文标题:Spring Boot - 数据校验

          本文链接:https://www.haomeiwen.com/subject/biuqsktx.html