Spring Validation最佳實踐及其實現原理,引數校驗沒那麼簡單!
阿新 • • 發佈:2020-08-03
之前也寫過一篇關於`Spring Validation`使用的文章,不過自我感覺還是浮於表面,本次打算徹底搞懂`Spring Validation`。本文會詳細介紹`Spring Validation`各種場景下的最佳實踐及其實現原理,死磕到底!
專案原始碼:[spring-validation](https://github.com/chentianming11/spring-validation)
## 簡單使用
`Java API`規範(`JSR303`)定義了`Bean`校驗的標準`validation-api`,但沒有提供實現。`hibernate validation`是對這個規範的實現,並增加了校驗註解如`@Email`、`@Length`等。`Spring Validation`是對`hibernate validation`的二次封裝,用於支援`spring mvc`引數自動校驗。接下來,我們以`spring-boot`專案為例,介紹`Spring Validation`的使用。
### 引入依賴
如果`spring-boot`版本小於`2.3.x`,`spring-boot-starter-web`會自動傳入`hibernate-validator`依賴。如果`spring-boot`版本大於`2.3.x`,則需要手動引入依賴:
```xml
```
對於`web`服務來說,為防止非法引數對業務造成影響,在`Controller`層一定要做引數校驗的!大部分情況下,請求引數分為如下兩種形式:
1. `POST`、`PUT`請求,使用`requestBody`傳遞引數;
2. `GET`請求,使用`requestParam/PathVariable`傳遞引數。
下面我們簡單介紹下`requestBody`和`requestParam/PathVariable`的引數校驗實戰!
### `requestBody`引數校驗
`POST`、`PUT`請求一般會使用`requestBody`傳遞引數,這種情況下,後端使用**DTO物件**進行接收。**只要給DTO物件加上`@Validated`註解就能實現自動引數校驗**。比如,有一個儲存`User`的介面,要求`userName`長度是`2-10`,`account`和`password`欄位長度是`6-20`。如果校驗失敗,會丟擲`MethodArgumentNotValidException`異常,`Spring`預設會將其轉為`400(Bad Request)`請求。
> **DTO表示資料傳輸物件(Data Transfer Object),用於伺服器和客戶端之間互動傳輸使用的**。在spring-web專案中可以表示用於接收請求引數的`Bean`物件。
- **在`DTO`欄位上宣告約束註解**
```java
@Data
public class UserDTO {
private Long userId;
@NotNull
@Length(min = 2, max = 10)
private String userName;
@NotNull
@Length(min = 6, max = 20)
private String account;
@NotNull
@Length(min = 6, max = 20)
private String password;
}
```
- **在方法引數上宣告校驗註解**
```java
@PostMapping("/save")
public Result saveUser(@RequestBody @Validated UserDTO userDTO) {
// 校驗通過,才會執行業務邏輯處理
return Result.ok();
}
```
> 這種情況下,**使用`@Valid`和`@Validated`都可以**。
### `requestParam/PathVariable`引數校驗
`GET`請求一般會使用`requestParam/PathVariable`傳參。如果引數比較多(比如超過6個),還是推薦使用`DTO`物件接收。否則,推薦將一個個引數平鋪到方法入參中。在這種情況下,**必須在`Controller`類上標註`@Validated`註解,並在入參上宣告約束註解(如`@Min`等)**。如果校驗失敗,會丟擲`ConstraintViolationException`異常。程式碼示例如下:
```java
@RequestMapping("/api/user")
@RestController
@Validated
public class UserController {
// 路徑變數
@GetMapping("{userId}")
public Result detail(@PathVariable("userId") @Min(10000000000000000L) Long userId) {
// 校驗通過,才會執行業務邏輯處理
UserDTO userDTO = new UserDTO();
userDTO.setUserId(userId);
userDTO.setAccount("11111111111111111");
userDTO.setUserName("xixi");
userDTO.setAccount("11111111111111111");
return Result.ok(userDTO);
}
// 查詢引數
@GetMapping("getByAccount")
public Result getByAccount(@Length(min = 6, max = 20) @NotNull String account) {
// 校驗通過,才會執行業務邏輯處理
UserDTO userDTO = new UserDTO();
userDTO.setUserId(10000000000000003L);
userDTO.setAccount(account);
userDTO.setUserName("xixi");
userDTO.setAccount("11111111111111111");
return Result.ok(userDTO);
}
}
```
### 統一異常處理
前面說過,如果校驗失敗,會丟擲`MethodArgumentNotValidException`或者`ConstraintViolationException`異常。在實際專案開發中,通常會用**統一異常處理**來返回一個更友好的提示。比如我們系統要求無論傳送什麼異常,`http`的狀態碼必須返回`200`,由業務碼去區分系統的異常情況。
```java
@RestControllerAdvice
public class CommonExceptionHandler {
@ExceptionHandler({MethodArgumentNotValidException.class})
@ResponseStatus(HttpStatus.OK)
@ResponseBody
public Result handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
BindingResult bindingResult = ex.getBindingResult();
StringBuilder sb = new StringBuilder("校驗失敗:");
for (FieldError fieldError : bindingResult.getFieldErrors()) {
sb.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(", ");
}
String msg = sb.toString();
return Result.fail(BusinessCode.引數校驗失敗, msg);
}
@ExceptionHandler({ConstraintViolationException.class})
@ResponseStatus(HttpStatus.OK)
@ResponseBody
public Result handleConstraintViolationException(ConstraintViolationException ex) {
return Result.fail(BusinessCode.引數校驗失敗, ex.getMessage());
}
}
```
## 進階使用
### 分組校驗
在實際專案中,可能多個方法需要使用同一個`DTO`類來接收引數,而不同方法的校驗規則很可能是不一樣的。這個時候,簡單地在`DTO`類的欄位上加約束註解無法解決這個問題。因此,`spring-validation`支援了**分組校驗**的功能,專門用來解決這類問題。還是上面的例子,比如儲存`User`的時候,`UserId`是可空的,但是更新`User`的時候,`UserId`的值必須`>=10000000000000000L`;其它欄位的校驗規則在兩種情況下一樣。這個時候使用**分組校驗**的程式碼示例如下:
- **約束註解上宣告適用的分組資訊`groups`**
```java
@Data
public class UserDTO {
@Min(value = 10000000000000000L, groups = Update.class)
private Long userId;
@NotNull(groups = {Save.class, Update.class})
@Length(min = 2, max = 10, groups = {Save.class, Update.class})
private String userName;
@NotNull(groups = {Save.class, Update.class})
@Length(min = 6, max = 20, groups = {Save.class, Update.class})
private String account;
@NotNull(groups = {Save.class, Update.class})
@Length(min = 6, max = 20, groups = {Save.class, Update.class})
private String password;
/**
* 儲存的時候校驗分組
*/
public interface Save {
}
/**
* 更新的時候校驗分組
*/
public interface Update {
}
}
```
- **`@Validated`註解上指定校驗分組**
```java
@PostMapping("/save")
public Result saveUser(@RequestBody @Validated(UserDTO.Save.class) UserDTO userDTO) {
// 校驗通過,才會執行業務邏輯處理
return Result.ok();
}
@PostMapping("/update")
public Result updateUser(@RequestBody @Validated(UserDTO.Update.class) UserDTO userDTO) {
// 校驗通過,才會執行業務邏輯處理
return Result.ok();
}
```
### 巢狀校驗
前面的示例中,`DTO`類裡面的欄位都是`基本資料型別`和`String`型別。但是實際場景中,有可能某個欄位也是一個物件,這種情況先,可以使用`巢狀校驗`。比如,上面儲存`User`資訊的時候同時還帶有`Job`資訊。需要注意的是,**此時`DTO`類的對應欄位必須標記`@Valid`註解**。
```java
@Data
public class UserDTO {
@Min(value = 10000000000000000L, groups = Update.class)
private Long userId;
@NotNull(groups = {Save.class, Update.class})
@Length(min = 2, max = 10, groups = {Save.class, Update.class})
private String userName;
@NotNull(groups = {Save.class, Update.class})
@Length(min = 6, max = 20, groups = {Save.class, Update.class})
private String account;
@NotNull(groups = {Save.class, Update.class})
@Length(min = 6, max = 20, groups = {Save.class, Update.class})
private String password;
@NotNull(groups = {Save.class, Update.class})
@Valid
private Job job;
@Data
public static class Job {
@Min(value = 1, groups = Update.class)
private Long jobId;
@NotNull(groups = {Save.class, Update.class})
@Length(min = 2, max = 10, groups = {Save.class, Update.class})
private String jobName;
@NotNull(groups = {Save.class, Update.class})
@Length(min = 2, max = 10, groups = {Save.class, Update.class})
private String position;
}
/**
* 儲存的時候校驗分組
*/
public interface Save {
}
/**
* 更新的時候校驗分組
*/
public interface Update {
}
}
```
> 巢狀校驗可以結合分組校驗一起使用。還有就是`巢狀集合校驗`會對集合裡面的每一項都進行校驗,例