1. 程式人生 > >SpringBoot中BeanValidation資料校驗與優雅處理詳解

SpringBoot中BeanValidation資料校驗與優雅處理詳解

[toc] ## 本篇要點 > JDK1.8、SpringBoot2.3.4release - 說明後端引數校驗的必要性。 - 介紹**如何使用validator進行引數校驗**。 - 介紹@Valid和@Validated的區別。 - 介紹**如何自定義約束註解**。 - 關於Bean Validation的前世今生,建議閱讀文章:[ 不吹不擂,第一篇就能提升你對Bean Validation資料校驗的認知](https://www.yourbatman.cn/x2y/55d56c0b.html),介紹十分詳細。 ## 後端引數校驗的必要性 在開發中,從表現層到持久化層,資料校驗都是一項邏輯差不多,但容易出錯的任務, 前端框架往往會採取一些檢查引數的手段,比如校驗並提示資訊,那麼,既然前端已經存在校驗手段,後端的校驗是否還有必要,是否多餘了呢? 並不是,正常情況下,引數確實會經過前端校驗傳向後端,但**如果後端不做校驗,一旦通過特殊手段越過前端的檢測,系統就會出現安全漏洞。** ## 不使用Validator的引數處理邏輯 既然是引數校驗,很簡單呀,用幾個`if/else`直接搞定: ```java @PostMapping("/form") public String form(@RequestBody Person person) { if (person.getName() == null) { return "姓名不能為null"; } if (person.getName().length() < 6 || person.getName().length() > 12) { return "姓名長度必須在6 - 12之間"; } if (person.getAge() == null) { return "年齡不能為null"; } if (person.getAge() < 20) { return "年齡最小需要20"; } // service .. return "註冊成功!"; } ``` 寫法乾脆,但`if/else`太多,過於臃腫,更何況這只是區區一個介面的兩個引數而已,要是需要更多引數校驗,甚至更多方法都需要這要的校驗,這程式碼量可想而知。於是,這種做法顯然是不可取的,我們可以利用下面這種更加優雅的引數處理方式。 ## Validator框架提供的便利 >
**Validating data** is a common task that occurs throughout all application layers, from the presentation to the persistence layer. **Often the same validation** logic is implemented in each layer which is time consuming and error-prone. 如果依照下圖的架構,對每個層級都進行類似的校驗,未免過於冗雜。 ![](https://img2020.cnblogs.com/blog/1771072/202011/1771072-20201116130324684-639762727.png) >
Jakarta Bean Validation 2.0 - **defines a metadata model and API for entity and method validation**. The default metadata source are **annotations**, with the ability to override and extend the meta-data through the use of XML. > > The API is not tied to a specific application tier nor programming model. It is specifically not tied to either web or persistence tier, and is available for both server-side application programming, as well as rich client Swing application developers. `Jakarta Bean Validation2.0`定義了一個元資料模型,為實體和方法提供了資料驗證的API,預設將註解作為源,可以通過XML擴充套件源。 ![](https://img2020.cnblogs.com/blog/1771072/202011/1771072-20201116130330887-563455677.png) ## SpringBoot自動配置ValidationAutoConfiguration `Hibernate Validator`是` Jakarta Bean Validation`的參考實現。 在SpringBoot中,只要類路徑上存在JSR-303的實現,如`Hibernate Validator`,就會自動開啟Bean Validation驗證功能,這裡我們只要引入`spring-boot-starter-validation`的依賴,就能完成所需。 ```xml org.springframework.boot
spring-boot-starter-validation
``` 目的其實是為了引入如下依賴: ```xml org.glassfish jakarta.el 3.0.3 compile org.hibernate.validator hibernate-validator 6.1.5.Final compile ``` SpringBoot對BeanValidation的支援的自動裝配定義在`org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration`類中,提供了預設的`LocalValidatorFactoryBean`和支援方法級別的攔截器`MethodValidationPostProcessor`。 ```java @Configuration(proxyBeanMethods = false) @ConditionalOnClass(ExecutableValidator.class) @ConditionalOnResource(resources = "classpath:META-INF/services/javax.validation.spi.ValidationProvider") @Import(PrimaryDefaultValidatorPostProcessor.class) public class ValidationAutoConfiguration { @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) @ConditionalOnMissingBean(Validator.class) public static LocalValidatorFactoryBean defaultValidator() { //ValidatorFactory LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean(); MessageInterpolatorFactory interpolatorFactory = new MessageInterpolatorFactory(); factoryBean.setMessageInterpolator(interpolatorFactory.getObject()); return factoryBean; } // 支援Aop,MethodValidationInterceptor方法級別的攔截器 @Bean @ConditionalOnMissingBean public static MethodValidationPostProcessor methodValidationPostProcessor(Environment environment, @Lazy Validator validator) { MethodValidationPostProcessor processor = new MethodValidationPostProcessor(); boolean proxyTargetClass = environment.getProperty("spring.aop.proxy-target-class", Boolean.class, true); processor.setProxyTargetClass(proxyTargetClass); // factory.getValidator(); 通過factoryBean獲取了Validator例項,並設定 processor.setValidator(validator); return processor; } } ``` ## Validator+BindingResult優雅處理 > 預設已經引入相關依賴。 ### 為實體類定義約束註解 ```java /** * 實體類欄位加上javax.validation.constraints定義的註解 * @author Summerday */ @Data @ToString public class Person { private Integer id; @NotNull @Size(min = 6,max = 12) private String name; @NotNull @Min(20) private Integer age; } ``` ### 使用@Valid或@Validated註解 @Valid和@Validated在Controller層做方法引數校驗時功能相近,具體區別可以往後面看。 ```java @RestController public class ValidateController { @PostMapping("/person") public Map validatePerson(@Validated @RequestBody Person person, BindingResult result) { Map map = new HashMap<>(); // 如果有引數校驗失敗,會將錯誤資訊封裝成物件組裝在BindingResult裡 if (result.hasErrors()) { List res = new ArrayList<>(); result.getFieldErrors().forEach(error -> { String field = error.getField(); Object value = error.getRejectedValue(); String msg = error.getDefaultMessage(); res.add(String.format("錯誤欄位 -> %s 錯誤值 -> %s 原因 -> %s", field, value, msg)); }); map.put("msg", res); return map; } map.put("msg", "success"); System.out.println(person); return map; } } ``` ### 傳送Post請求,偽造不合法資料 這裡使用IDEA提供的HTTP Client工具傳送請求。 ```json POST http://localhost:8081/person Content-Type: application/json { "name": "hyh", "age": 10 } ``` 響應資訊如下: ```json HTTP/1.1 200 Content-Type: application/json Transfer-Encoding: chunked Date: Sat, 14 Nov 2020 15:58:17 GMT Keep-Alive: timeout=60 Connection: keep-alive { "msg": [ "錯誤欄位 -> name 錯誤值 -> hyh 原因 -> 個數必須在6和12之間", "錯誤欄位 -> age 錯誤值 -> 10 原因 -> 最小不能小於20" ] } Response code: 200; Time: 393ms; Content length: 92 bytes ``` ## Validator + 全域性異常處理 在介面方法中利用BindingResult處理校驗資料過程中的資訊是一個可行方案,但在介面眾多的情況下,就顯得有些冗餘,我們可以利用全域性異常處理,捕捉丟擲的`MethodArgumentNotValidException`異常,並進行相應的處理。 ### 定義全域性異常處理 ```java @RestControllerAdvice public class GlobalExceptionHandler { /** * If the bean validation is failed, it will trigger a MethodArgumentNotValidException. */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMethodArgumentNotValid( MethodArgumentNotValidException ex, HttpStatus status) { BindingResult result = ex.getBindingResult(); Map map = new HashMap<>(); List list = new LinkedList<>(); result.getFieldErrors().forEach(error -> { String field = error.getField(); Object value = error.getRejectedValue(); String msg = error.getDefaultMessage(); list.add(String.format("錯誤欄位 -> %s 錯誤值 -> %s 原因 -> %s", field, value, msg)); }); map.put("msg", list); return new ResponseEntity<>(map, status); } } ``` ### 定義介面 ```java @RestController public class ValidateController { @PostMapping("/person") public Map validatePerson(@Valid @RequestBody Person person) { Map map = new HashMap<>(); map.put("msg", "success"); System.out.println(person); return map; } } ``` ## @Validated精確校驗到引數欄位 有時候,我們只想校驗某個引數欄位,並不想校驗整個pojo物件,我們可以利用@Validated精確校驗到某個欄位。 ### 定義介面 ```java @RestController @Validated public class OnlyParamsController { @GetMapping("/{id}/{name}") public String test(@PathVariable("id") @Min(1) Long id, @PathVariable("name") @Size(min = 5, max = 10) String name) { return "success"; } } ``` ### 傳送GET請求,偽造不合法資訊 ```json GET http://localhost:8081/0/hyh Content-Type: application/json ``` 未作任何處理,響應結果如下: ```json { "timestamp": "2020-11-15T15:23:29.734+00:00", "status": 500, "error": "Internal Server Error", "trace": "javax.validation.ConstraintViolationException: test.id: 最小不能小於1, test.name: 個數必須在5和10之間...省略", "message": "test.id: 最小不能小於1, test.name: 個數必須在5和10之間", "path": "/0/hyh" } ``` 可以看到,校驗已經生效,但狀態和響應錯誤資訊不太正確,我們可以通過捕獲`ConstraintViolationException`修改狀態。 ### 捕獲異常,處理結果 ```java @ControllerAdvice public class CustomGlobalExceptionHandler extends ResponseEntityExceptionHandler { private static final Logger log = LoggerFactory.getLogger(CustomGlobalExceptionHandler.class); /** * If the @Validated is failed, it will trigger a ConstraintViolationException */ @ExceptionHandler(ConstraintViolationException.class) public void constraintViolationException(ConstraintViolationException ex, HttpServletResponse response) throws IOException { ex.getConstraintViolations().forEach(x -> { String message = x.getMessage(); Path propertyPath = x.getPropertyPath(); Object invalidValue = x.getInvalidValue(); log.error("錯誤欄位 -> {} 錯誤值 -> {} 原因 -> {}", propertyPath, invalidValue, message); }); response.sendError(HttpStatus.BAD_REQUEST.value()); } } ``` ## @Validated和@Valid的不同 參考:[@Validated和@Valid的區別?教你使用它完成Controller引數校驗(含級聯屬性校驗)以及原理分析【享學Spring】](https://blog.csdn.net/f641385712/article/details/97621783) - `@Valid`是標準JSR-303規範的標記型註解,用來標記驗證屬性和方法返回值,進行級聯和遞迴校驗。 - `@Validated`:是Spring提供的註解,是標準`JSR-303`的一個變種(補充),提供了一個分組功能,可以在入參驗證時,根據不同的分組採用不同的驗證機制。 - 在`Controller`中校驗方法引數時,使用@Valid和@Validated並無特殊差異(若不需要分組校驗的話)。 - `@Validated`註解可以用於類級別,用於支援Spring進行方法級別的引數校驗。`@Valid`可以用在屬性級別約束,用來表示**級聯校驗**。 - `@Validated`只能用在類、方法和引數上,而`@Valid`可用於方法、**欄位、構造器**和引數上。 ## 如何自定義註解 `Jakarta Bean Validation API`定義了一套標準約束註解,如@NotNull,@Size等,但是這些內建的約束註解難免會不能滿足我們的需求,這時我們就可以自定義註解,建立自定義註解需要三步: 1. 建立一個constraint annotation。 2. 實現一個validator。 3. 定義一個default error message。 ### 建立一個constraint annotation ```java /** * 自定義註解 * @author Summerday */ @Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE, TYPE_USE}) @Retention(RUNTIME) @Constraint(validatedBy = CheckCaseValidator.class) //需要定義CheckCaseValidator @Documented @Repeatable(CheckCase.List.class) public @interface CheckCase { String message() default "{CheckCase.message}"; Class[] groups() default {}; Class[] payload() default {}; CaseMode value(); @Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE}) @Retention(RUNTIME) @Documented @interface List { CheckCase[] value(); } } ``` ### 實現一個validator ```java /** * 實現ConstraintValidator * * @author Summerday */ public class CheckCaseValidator implements ConstraintValidator { private CaseMode caseMode; /** * 初始化獲取註解中的值 */ @Override public void initialize(CheckCase constraintAnnotation) { this.caseMode = constraintAnnotation.value(); } /** * 校驗 */ @Override public boolean isValid(String object, ConstraintValidatorContext constraintContext) { if (object == null) { return true; } boolean isValid; if (caseMode == CaseMode.UPPER) { isValid = object.equals(object.toUpperCase()); } else { isValid = object.equals(object.toLowerCase()); } if (!isValid) { // 如果定義了message值,就用定義的,沒有則去 // ValidationMessages.properties中找CheckCase.message的值 if(constraintContext.getDefaultConstraintMessageTemplate().isEmpty()){ constraintContext.disableDefaultConstraintViolation(); constraintContext.buildConstraintViolationWithTemplate( "{CheckCase.message}" ).addConstraintViolation(); } } return isValid; } } ``` ### 定義一個default error message 在`ValidationMessages.properties`檔案中定義: ```properties CheckCase.message=Case mode must be {value}. ``` 這樣,自定義的註解就完成了,如果感興趣可以自行測試一下,在某個欄位上加上註解:`@CheckCase(value = CaseMode.UPPER)`。 ## 原始碼下載 本文內容均為對優秀部落格及官方文件總結而得,原文地址均已在文中參考閱讀處標註。最後,文中的程式碼樣例已經全部上傳至Gitee:[https://gitee.com/tqbx/springboot-samples-learn](https://gitee.com/tqbx/springboot-samples-learn),另有其他SpringBoot的整合哦。 ## 參考閱讀 - [javax.validation.constraints](https://docs.jboss.org/hibernate/beanvalidation/spec/2.0/api/javax/validation/constraints/package-summary.html) - [SpringFramework:JavaBean Validation](https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#validation-beanvalidation) - [SpringBoot官方:Validation](https://docs.spring.io/spring-boot/docs/2.4.0/reference/html/spring-boot-features.html#boot-features-validation) - [SpringBoot寫後端介面,看這一篇就夠了!](https://segmentfault.com/a/1190000024467109) - [SpringBoot如何優雅的校驗引數](https://segmentfault.com/a/1190000021473727) - [Spring Boot 2.x基礎教程:JSR-303實現請求引數校驗](http://blog.didispace.com/spring-boot-learning-21-2-3/) - [ 不吹不擂,第一篇就能提升你對Bean Validation資料校驗的認知](https://www.yourbatman.cn/x2y/55d56c