本篇要點
JDK1.8、SpringBoot2.3.4release
- 說明后端參數校驗的必要性。
- 介紹如何使用validator進行參數校驗。
- 介紹@Valid和@Validated的區別。
- 介紹如何自定義約束注解。
- 關於Bean Validation的前世今生,建議閱讀文章: 不吹不擂,第一篇就能提升你對Bean Validation數據校驗的認知,介紹十分詳細。
后端參數校驗的必要性
在開發中,從表現層到持久化層,數據校驗都是一項邏輯差不多,但容易出錯的任務,
前端框架往往會采取一些檢查參數的手段,比如校驗並提示信息,那么,既然前端已經存在校驗手段,后端的校驗是否還有必要,是否多余了呢?
並不是,正常情況下,參數確實會經過前端校驗傳向后端,但如果后端不做校驗,一旦通過特殊手段越過前端的檢測,系統就會出現安全漏洞。
不使用Validator的參數處理邏輯
既然是參數校驗,很簡單呀,用幾個if/else
直接搞定:
@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.
如果依照下圖的架構,對每個層級都進行類似的校驗,未免過於冗雜。
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擴展源。
SpringBoot自動配置ValidationAutoConfiguration
Hibernate Validator
是 Jakarta Bean Validation
的參考實現。
在SpringBoot中,只要類路徑上存在JSR-303的實現,如Hibernate Validator
,就會自動開啟Bean Validation驗證功能,這里我們只要引入spring-boot-starter-validation
的依賴,就能完成所需。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
目的其實是為了引入如下依賴:
<!-- Unified EL 獲取動態表達式-->
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>jakarta.el</artifactId>
<version>3.0.3</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.1.5.Final</version>
<scope>compile</scope>
</dependency>
SpringBoot對BeanValidation的支持的自動裝配定義在org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration
類中,提供了默認的LocalValidatorFactoryBean
和支持方法級別的攔截器MethodValidationPostProcessor
。
@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優雅處理
默認已經引入相關依賴。
為實體類定義約束注解
/**
* 實體類字段加上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層做方法參數校驗時功能相近,具體區別可以往后面看。
@RestController
public class ValidateController {
@PostMapping("/person")
public Map<String, Object> validatePerson(@Validated @RequestBody Person person, BindingResult result) {
Map<String, Object> map = new HashMap<>();
// 如果有參數校驗失敗,會將錯誤信息封裝成對象組裝在BindingResult里
if (result.hasErrors()) {
List<String> 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工具發送請求。
POST http://localhost:8081/person
Content-Type: application/json
{
"name": "天喬巴夏",
"age": 10
}
響應信息如下:
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 錯誤值 -> 天喬巴夏 原因 -> 個數必須在6和12之間",
"錯誤字段 -> age 錯誤值 -> 10 原因 -> 最小不能小於20"
]
}
Response code: 200; Time: 393ms; Content length: 92 bytes
Validator + 全局異常處理
在接口方法中利用BindingResult處理校驗數據過程中的信息是一個可行方案,但在接口眾多的情況下,就顯得有些冗余,我們可以利用全局異常處理,捕捉拋出的MethodArgumentNotValidException
異常,並進行相應的處理。
定義全局異常處理
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* If the bean validation is failed, it will trigger a MethodArgumentNotValidException.
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpStatus status) {
BindingResult result = ex.getBindingResult();
Map<String, Object> map = new HashMap<>();
List<String> 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);
}
}
定義接口
@RestController
public class ValidateController {
@PostMapping("/person")
public Map<String, Object> validatePerson(@Valid @RequestBody Person person) {
Map<String, Object> map = new HashMap<>();
map.put("msg", "success");
System.out.println(person);
return map;
}
}
@Validated精確校驗到參數字段
有時候,我們只想校驗某個參數字段,並不想校驗整個pojo對象,我們可以利用@Validated精確校驗到某個字段。
定義接口
@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請求,偽造不合法信息
GET http://localhost:8081/0/hyh
Content-Type: application/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
修改狀態。
捕獲異常,處理結果
@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】
@Valid
是標准JSR-303規范的標記型注解,用來標記驗證屬性和方法返回值,進行級聯和遞歸校驗。@Validated
:是Spring提供的注解,是標准JSR-303
的一個變種(補充),提供了一個分組功能,可以在入參驗證時,根據不同的分組采用不同的驗證機制。- 在
Controller
中校驗方法參數時,使用@Valid和@Validated並無特殊差異(若不需要分組校驗的話)。 @Validated
注解可以用於類級別,用於支持Spring進行方法級別的參數校驗。@Valid
可以用在屬性級別約束,用來表示級聯校驗。@Validated
只能用在類、方法和參數上,而@Valid
可用於方法、字段、構造器和參數上。
如何自定義注解
Jakarta Bean Validation API
定義了一套標准約束注解,如@NotNull,@Size等,但是這些內置的約束注解難免會不能滿足我們的需求,這時我們就可以自定義注解,創建自定義注解需要三步:
- 創建一個constraint annotation。
- 實現一個validator。
- 定義一個default error message。
創建一個constraint annotation
/**
* 自定義注解
* @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<? extends Payload>[] payload() default {};
CaseMode value();
@Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE})
@Retention(RUNTIME)
@Documented
@interface List {
CheckCase[] value();
}
}
實現一個validator
/**
* 實現ConstraintValidator
*
* @author Summerday
*/
public class CheckCaseValidator implements ConstraintValidator<CheckCase, String> {
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
文件中定義:
CheckCase.message=Case mode must be {value}.
這樣,自定義的注解就完成了,如果感興趣可以自行測試一下,在某個字段上加上注解:@CheckCase(value = CaseMode.UPPER)
。
源碼下載
本文內容均為對優秀博客及官方文檔總結而得,原文地址均已在文中參考閱讀處標注。最后,文中的代碼樣例已經全部上傳至Gitee:https://gitee.com/tqbx/springboot-samples-learn,另有其他SpringBoot的整合哦。