1. 程式人生 > >Spring Validation最佳實踐及其實現原理,引數校驗沒那麼簡單!

Spring Validation最佳實踐及其實現原理,引數校驗沒那麼簡單!

之前也寫過一篇關於`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 { } } ``` > 巢狀校驗可以結合分組校驗一起使用。還有就是`巢狀集合校驗`會對集合裡面的每一項都進行校驗,例