Spring Boot - 數(shù)據(jù)校驗

[TOC]

簡介

后端編程中,通常對于前端傳遞過來的數(shù)據(jù),我們都需要進行校驗,確保數(shù)據(jù)正確且安全。

最直接的方法當然是在 Controller 相應(yīng)方法內(nèi)對數(shù)據(jù)進行手動校驗,但是,由于很多校驗都具備相似性,因此這種做法稍顯冗余。

因此,相關(guān)的校驗規(guī)范就應(yīng)運而生。比如:

  • JSR-303:它是一項 Bean Validation 校驗標準,規(guī)定了一些校驗規(guī)范,比如@Null,@NotNull,@Pattern,相關(guān)注解都位于javax.validation.constraints包下。需要注意的是,JSR-303 只提供校驗規(guī)范,不提供實現(xiàn)。

JSR-303 是 Bean Validation 1.0 版本,隨著越來越多的新規(guī)范并入,它的版本也一直在更新,比如,JSR-349 就是 Bean Validation 1.1 版本,而當前最新的版本為 JSR-380,也即 Bean Validation 2.0 版本...

由于 JSR-303 只提供規(guī)范,因此其實現(xiàn)需要其他庫進行提供。當前使用最廣泛的 Bean Validation 實現(xiàn)庫為:hibernate-validator

hibernate-validator 是對 JSR-303 的實現(xiàn),同時它也增添了其他一些校驗注解,比如,@URL,@Length,@Ranger等。

而在 Spring 中,其也提供了相應(yīng)的 Bean Validation 實現(xiàn):Java Bean Validation
Spring Validation 主要是對 hibernate-validator 進行了二次封裝,并在 SpringMVC 中添加了自動校驗,以及將校驗信息封裝進特定類中等功能。

本文主要介紹下在 Spring Boot 中進行數(shù)據(jù)校驗(Bean Validation)。

依賴添加

Spring Boot 中進行數(shù)據(jù)校驗需要添加起步依賴:spring-boot-starter-validation,如下所示:

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

:在spring-boot-starter-web舊版本中,其內(nèi)置了spring-boot-starter-validation,但是 Spring Boot 官方似乎認為并不是很多應(yīng)用會使用數(shù)據(jù)校驗功能,因此對其進行了移除。具體請參考:issue#19550。

基本使用

數(shù)據(jù)校驗最基本的操作就是使用相關(guān)注解對一個 Java Bean 內(nèi)的相關(guān)字段進行約束,然后前端傳遞上來的數(shù)據(jù)會首先組裝為相應(yīng)的 Java Bean 對象,該對象會被移交到一個Validator,讓其檢查對象字段(即數(shù)據(jù))是否滿足約束,如果不滿足的話,則會通過如拋出異常等方式通知系統(tǒng)。

具體的使用步驟如下所示:

  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類中的相應(yīng)字段進行了約束。
    各注解對應(yīng)的約束內(nèi)容請參考后文。

  2. 在 Controller 相應(yīng)接口方法中,使用@Valid/@Validated等注解開啟數(shù)據(jù)校驗功能:

    @RestController
    @RequestMapping("validate")
    public class ValidationController {
    
        @PostMapping("/user")
        public String addUser(@Validated @RequestBody User user){
            return "add user successfully! " + user;
        }
    }
    
  3. 如果數(shù)據(jù)校驗不通過,就會拋出一個MethodArgumentNotValidException異常。默認情況下,Spring 會將該異常及其信息以錯誤碼 400 進行下發(fā)。我們可以通過自定義一個全局異常捕獲器攔截該異常,提取出數(shù)據(jù)校驗出錯信息,進行展示:

    @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());
        }
    }
    

以上,就完成了一個基礎(chǔ)的數(shù)據(jù)校驗功能。

此時我們進行如下訪問:

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

可以看到,結(jié)果符合預(yù)期。

:上述代碼如果數(shù)據(jù)校驗不通過,就會拋出MethodArgumentNotValidException,其實是因為我們在為參數(shù)注解了@RequestBody,此時HttpMessageConverter會負責轉(zhuǎn)換過程,當遇到數(shù)據(jù)校驗失敗時,就會拋出MethodArgumentNotValidException
而如果去除@RequestBody注解,默認就會由@ModelAttribute負責數(shù)據(jù)綁定和校驗,如果此時校驗失敗,則會拋出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());
    }
}

此時,請求上述代碼,結(jié)果如下:

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

上面是對復雜數(shù)據(jù)(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;
    }
}

可以看到,對于簡單數(shù)據(jù)類型,我們將約束注解直接注解到相應(yīng)參數(shù)上,然后在Controller類上使用@Validated注解,啟動數(shù)據(jù)校驗。

對于這種數(shù)據(jù)校驗方式,當校驗失敗時,會拋出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 相關(guān)注解

  • 下面主要介紹下 JSR 中一些常用的相關(guān)約束注解,如下所示:

    注解 釋義 可被注解元素類型
    @NotNull 被注解的元素不能為null 所有類型
    @NotBlank 被注解的元素不能為null,且至少包含一個非空白字符 支持CharSequence
    @NotEmpty 被注解的元素不能為null,且不能為空(即不能為空集合) 支持CharSequence、Collection、Map、Array
    @Min(value) 被注解的元素值必須大于或等于@Min指定的值 支持BigDecimalBigInteger,以及byte、short等基本數(shù)值類型及其他們相應(yīng)的包裝類型
    @Max(value) 被注解的元素值必須小于或等于@Max指定的值 支持BigDecimalBigInteger,以及byte、short等基本數(shù)值類型及其他們相應(yīng)的包裝類型
    @Size(max=, min=) 被注解的元素大小必須在指定的范圍內(nèi) CharSequence、Collection、Map、Array以及null
    null元素會被認為是有效值
    @Pattern 被注解的元素必須符合指定的正則匹配 CharSequence
    null類型元素會被認為是有效值
    @AssertTrue 被注解的元素值必須為true 支持boolean、Boolean類型
    @AssertFalse 被注解的元素值必須為false 支持booleanBoolean類型

    更多 JSR 相關(guān)注解內(nèi)容,請參考:javax.validation.constraints

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

    注解 釋義 可被注解元素類型
    @Length(min=,max=) 被注解的字符串長度必須在指定范圍內(nèi) 字符串
    @Range(min=,max=) 被注解的元素必須在指定范圍內(nèi) 數(shù)值類型或者數(shù)值字符串類型
    @URL 被注解的字符串匹配 URL 字符串

    更多 hibernate-validator 相關(guān)注解內(nèi)容,請參考:org.hibernate.validator.constraints

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

    注解 釋義 可被注解元素類型
    @Validated 開啟數(shù)據(jù)校驗功能,支持分組校驗 任何非原子類型

    更多 Spring Bean Validation 相關(guān)注解內(nèi)容,請參考:org.springframework.validation.annotation

    @Validated注解是@Valid注解的一個變種實現(xiàn),它們都主要用于啟動數(shù)據(jù)校驗功能,而不同之處大致有以下幾方面:

    • @Valid是屬于 JSR 規(guī)范,其位于包javax內(nèi);而@Validated是屬于 Spring Bean Validation,其位于包org.springframework.validation內(nèi)。

    • @Valid支持嵌套校驗(就是一個 Bean 內(nèi)嵌套另一個 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 內(nèi)使用@Valid注解,而啟動數(shù)據(jù)校驗(即 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\": \"男\(zhòng)"}}"
      [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. 首先創(chuàng)建兩個分組接口:
      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進行數(shù)據(jù)校驗,ValidationGroup2只對password進行NotNull約束,因此,只要我們發(fā)送的數(shù)據(jù)滿足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)
      

      :分組校驗的一個問題就是,對于未指定分組的其他校驗,直接忽略,通常這并不是我們想要的結(jié)果。對于未指定分組的校驗,我們通常期望的是,無論使用哪種分組校驗,這些未指定的分組校驗均生效。
      實際上,未指定分組的校驗都歸類為 默認分組(Default,且分組支持繼承,子類分組可完全繼承父類分組的約束校驗,因此,只需讓我們的自定義分組繼承默認分組,即可完成分組校驗以及默認分組生效,代碼如下:

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

    綜上,一個比較推薦的使用方式就是:啟動校驗(即 Controller 層)時使用@Validated注解,嵌套校驗時使用@Valid注解,這樣,就能同時使用分組校驗和嵌套校驗功能。

自定義Validator

前文講過,數(shù)據(jù)校驗功能是由Validator負責開啟并校驗的,在 SpringMVC 中,如果檢測到 Bean Validation(比如,Hibernate Validator)存在于classpath路徑上時,就會默認全局注冊了一個ValidatorLocalValidatorFactoryBean,它會驅(qū)動@Valid@Validated開啟數(shù)據(jù)校驗。

LocalValidatorFactoryBean同時實現(xiàn)了javax.validation.ValidatorFactory、javax.validation.Validatororg.springframework.validation.Validator三個接口,所以如果需要手動調(diào)用數(shù)據(jù)校驗邏輯,可以通過 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;
    }
    

上述獲取的是系統(tǒng)默認的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 設(shè)置一個局部Validator,如下所示:

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

自定義約束注解

如果現(xiàn)存的約束注解無法滿足我們的需求,那么我們可以通過自定義約束注解,來定制我們的數(shù)據(jù)校驗邏輯。

在 Spring 中,自定義約束注解主要就是定義一個約束注解及其對應(yīng)的Validator,兩者通過@Constraint關(guān)聯(lián)到一起。
默認情況下,全局校驗器LocalValidatorFactoryBean會配置一個SpringConstraintValidatorFactory實例,SpringConstraintValidatorFactory實現(xiàn)了接口ConstraintValidatorFactory,因此它會在遇到自定義約束注解的時候,就會自動實例化@Constraint指定的關(guān)聯(lián)Validator,從而完成數(shù)據(jù)校驗過程。

詳細過程可參考如下示例:

例子:假設(shè)我們想自定義一個約束注解,用于對手機號進行校驗,要求滿足手機號碼的格式為:+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)關(guān)聯(lián)到一起。

  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注解,獲取得到其對應(yīng)的Valiator,然后通過 Spring 創(chuàng)建該Validator實例,進行數(shù)據(jù)校驗。利用這種機制,可以使得我們的自定義Validator享受到其他 Java Bean 一樣的依賴注入功能。

    請求上述代碼,結(jié)果如下:

    $ 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 包含+、=&等特殊符號時,會被進行轉(zhuǎn)義,比如,+會被轉(zhuǎn)義為空格,這樣后端接收的數(shù)據(jù)格式就永遠是錯誤的,因此,發(fā)送數(shù)據(jù)前,應(yīng)先對數(shù)據(jù)進行編碼,所以上述curl命令使用--data-urlencode對數(shù)據(jù)進行編碼,以確保特殊字符能成功發(fā)送。

其他

  • 除了對 Controller 層添加數(shù)據(jù)校驗外,還可以為 Spring 其他組件添加數(shù)據(jù)校驗功能,只需結(jié)合@Validated@Valid這兩個注解。
    比如,對 Serivce 層添加數(shù)據(jù)校驗功能,如下所示:
    @Service
    @Validated
    class ValidatingService{
    
        void validateInput(@Valid Input input){
          // do something
        }
    }
    

參考

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容