Spring Boot - 数据校验

[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)
      

      :分组校验的一个问题就是,对于未指定分组的其他校验,直接忽略,通常这并不是我们想要的结果。对于未指定分组的校验,我们通常期望的是,无论使用哪种分组校验,这些未指定的分组校验均生效。
      实际上,未指定分组的校验都归类为 默认分组(Default,且分组支持继承,子类分组可完全继承父类分组的约束校验,因此,只需让我们的自定义分组继承默认分组,即可完成分组校验以及默认分组生效,代码如下:

      public interface ValidationGroup1 extends Default {}
      public interface ValidationGroup2 extends Default {}
      

    综上,一个比较推荐的使用方式就是:启动校验(即 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
        }
    }
    

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345