[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)。
具體的使用步驟如下所示:
-
首先定義一個需要校驗的 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)容請參考后文。 -
在 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; } } -
如果數(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指定的值支持 BigDecimal、BigInteger,以及byte、short等基本數(shù)值類型及其他們相應(yīng)的包裝類型@Max(value)被注解的元素值必須小于或等于 @Max指定的值支持 BigDecimal、BigInteger,以及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支持 boolean、Boolean類型更多 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不支持。啟動分組校驗步驟如下所示:- 首先創(chuàng)建兩個分組接口:
public interface ValidationGroup1 {} public interface ValidationGroup2 {}- 在實體類中添加分組信息:
@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; }- 使用
@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路徑上時,就會默認全局注冊了一個Validator:LocalValidatorFactoryBean,它會驅(qū)動@Valid和@Validated開啟數(shù)據(jù)校驗。
LocalValidatorFactoryBean同時實現(xiàn)了javax.validation.ValidatorFactory、javax.validation.Validator和org.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開頭,然后中間一個或多個空格,后面是有效的手機號碼。
自定義約束注解的步驟如下所示:
-
自定義一個約束注解:
@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將自定義注解PhoneNoConstraint與PhoneNoConstraintValidator(即一個自定義Validator)關(guān)聯(lián)到一起。 -
自定義一個
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(); } } -
使用自定義約束注解:
@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 } }