之前也寫過一篇關于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,則需要手動引入依賴:
<dependency>
<groupId>org.hibernategroupId>
<artifactId>hibernate-validatorartifactId>
<version>6.0.1.Finalversion>
dependency>
對于web服務來說,為防止非法參數對業務造成影響,在Controller層一定要做參數校驗的!大部分情況下,請求參數分為如下兩種形式:
- POST、PUT請求,使用requestBody傳遞參數;
- 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字段上聲明約束注解
@Data
publicclassUserDTO{
privateLonguserId;
@NotNull
@Length(min=2,max=10)
privateStringuserName;
@NotNull
@Length(min=6,max=20)
privateStringaccount;
@NotNull
@Length(min=6,max=20)
privateStringpassword;
}
在方法參數上聲明校驗注解
@PostMapping("/save")
publicResultsaveUser(@RequestBody@ValidatedUserDTOuserDTO){
//校驗通過,才會執行業務邏輯處理
returnResult.ok();
}
這種情況下,使用@Valid和@Validated都可以。
requestParam/PathVariable參數校驗
GET請求一般會使用requestParam/PathVariable傳參。如果參數比較多(比如超過6個),還是推薦使用DTO對象接收。
否則,推薦將一個個參數平鋪到方法入參中。在這種情況下,必須在Controller類上標注@Validated注解,并在入參上聲明約束注解(如@Min等)。如果校驗失敗,會拋出ConstraintViolationException異常。
代碼示例如下:
@RequestMapping("/api/user")
@RestController
@Validated
publicclassUserController{
//路徑變量
@GetMapping("{userId}")
publicResultdetail(@PathVariable("userId")@Min(10000000000000000L)LonguserId){
//校驗通過,才會執行業務邏輯處理
UserDTOuserDTO=newUserDTO();
userDTO.setUserId(userId);
userDTO.setAccount("11111111111111111");
userDTO.setUserName("xixi");
userDTO.setAccount("11111111111111111");
returnResult.ok(userDTO);
}
//查詢參數
@GetMapping("getByAccount")
publicResultgetByAccount(@Length(min=6,max=20)@NotNullStringaccount){
//校驗通過,才會執行業務邏輯處理
UserDTOuserDTO=newUserDTO();
userDTO.setUserId(10000000000000003L);
userDTO.setAccount(account);
userDTO.setUserName("xixi");
userDTO.setAccount("11111111111111111");
returnResult.ok(userDTO);
}
}
統一異常處理
前面說過,如果校驗失敗,會拋出MethodArgumentNotValidException或者ConstraintViolationException異常。在實際項目開發中,通常會用統一異常處理來返回一個更友好的提示。
比如我們系統要求無論發送什么異常,http的狀態碼必須返回200,由業務碼去區分系統的異常情況。
@RestControllerAdvice
publicclassCommonExceptionHandler{
@ExceptionHandler({MethodArgumentNotValidException.class})
@ResponseStatus(HttpStatus.OK)
@ResponseBody
publicResulthandleMethodArgumentNotValidException(MethodArgumentNotValidExceptionex){
BindingResultbindingResult=ex.getBindingResult();
StringBuildersb=newStringBuilder("校驗失敗:");
for(FieldErrorfieldError:bindingResult.getFieldErrors()){
sb.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(",");
}
Stringmsg=sb.toString();
returnResult.fail(BusinessCode.參數校驗失敗,msg);
}
@ExceptionHandler({ConstraintViolationException.class})
@ResponseStatus(HttpStatus.OK)
@ResponseBody
publicResulthandleConstraintViolationException(ConstraintViolationExceptionex){
returnResult.fail(BusinessCode.參數校驗失敗,ex.getMessage());
}
}
進階使用
分組校驗
在實際項目中,可能多個方法需要使用同一個DTO類來接收參數,而不同方法的校驗規則很可能是不一樣的。這個時候,簡單地在DTO類的字段上加約束注解無法解決這個問題。因此,spring-validation支持了分組校驗的功能,專門用來解決這類問題。
還是上面的例子,比如保存User的時候,UserId是可空的,但是更新User的時候,UserId的值必須>=10000000000000000L;其它字段的校驗規則在兩種情況下一樣。這個時候使用分組校驗的代碼示例如下:
約束注解上聲明適用的分組信息groups
@Data
publicclassUserDTO{
@Min(value=10000000000000000L,groups=Update.class)
privateLonguserId;
@NotNull(groups={Save.class,Update.class})
@Length(min=2,max=10,groups={Save.class,Update.class})
privateStringuserName;
@NotNull(groups={Save.class,Update.class})
@Length(min=6,max=20,groups={Save.class,Update.class})
privateStringaccount;
@NotNull(groups={Save.class,Update.class})
@Length(min=6,max=20,groups={Save.class,Update.class})
privateStringpassword;
/**
*保存的時候校驗分組
*/
publicinterfaceSave{
}
/**
*更新的時候校驗分組
*/
publicinterfaceUpdate{
}
}
@Validated注解上指定校驗分組
@PostMapping("/save")
publicResultsaveUser(@RequestBody@Validated(UserDTO.Save.class)UserDTOuserDTO){
//校驗通過,才會執行業務邏輯處理
returnResult.ok();
}
@PostMapping("/update")
publicResultupdateUser(@RequestBody@Validated(UserDTO.Update.class)UserDTOuserDTO){
//校驗通過,才會執行業務邏輯處理
returnResult.ok();
}
嵌套校驗
前面的示例中,DTO類里面的字段都是基本數據類型和String類型。但是實際場景中,有可能某個字段也是一個對象,這種情況先,可以使用嵌套校驗。
比如,上面保存User信息的時候同時還帶有Job信息。需要注意的是,此時DTO類的對應字段必須標記@Valid注解。
@Data
publicclassUserDTO{
@Min(value=10000000000000000L,groups=Update.class)
privateLonguserId;
@NotNull(groups={Save.class,Update.class})
@Length(min=2,max=10,groups={Save.class,Update.class})
privateStringuserName;
@NotNull(groups={Save.class,Update.class})
@Length(min=6,max=20,groups={Save.class,Update.class})
privateStringaccount;
@NotNull(groups={Save.class,Update.class})
@Length(min=6,max=20,groups={Save.class,Update.class})
privateStringpassword;
@NotNull(groups={Save.class,Update.class})
@Valid
privateJobjob;
@Data
publicstaticclassJob{
@Min(value=1,groups=Update.class)
privateLongjobId;
@NotNull(groups={Save.class,Update.class})
@Length(min=2,max=10,groups={Save.class,Update.class})
privateStringjobName;
@NotNull(groups={Save.class,Update.class})
@Length(min=2,max=10,groups={Save.class,Update.class})
privateStringposition;
}
/**
*保存的時候校驗分組
*/
publicinterfaceSave{
}
/**
*更新的時候校驗分組
*/
publicinterfaceUpdate{
}
}
嵌套校驗可以結合分組校驗一起使用。還有就是嵌套集合校驗會對集合里面的每一項都進行校驗,例如List
字段會對這個list里面的每一個Job對象都進行校驗
集合校驗
如果請求體直接傳遞了json數組給后臺,并希望對數組中的每一項都進行參數校驗。此時,如果我們直接使用java.util.Collection下的list或者set來接收數據,參數校驗并不會生效!我們可以使用自定義list集合來接收參數:
包裝List類型,并聲明@Valid注解
publicclassValidationList<E>implementsList<E>{
@Delegate//@Delegate是lombok注解
@Valid//一定要加@Valid注解
publicListlist=newArrayList<>();
//一定要記得重寫toString方法
@Override
publicStringtoString(){
returnlist.toString();
}
}
@Delegate注解受lombok版本限制,1.18.6以上版本可支持。如果校驗不通過,會拋出NotReadablePropertyException,同樣可以使用統一異常進行處理。
比如,我們需要一次性保存多個User對象,Controller層的方法可以這么寫:
@PostMapping("/saveList")
publicResultsaveList(@RequestBody@Validated(UserDTO.Save.class)ValidationListuserList) {
//校驗通過,才會執行業務邏輯處理
returnResult.ok();
}
自定義校驗
業務需求總是比框架提供的這些簡單校驗要復雜的多,我們可以自定義校驗來滿足我們的需求。
自定義spring validation非常簡單,假設我們自定義加密id(由數字或者a-f的字母組成,32-256長度)校驗,主要分為兩步:
自定義約束注解
@Target({METHOD,FIELD,ANNOTATION_TYPE,CONSTRUCTOR,PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy={EncryptIdValidator.class})
public@interfaceEncryptId{
//默認錯誤消息
Stringmessage()default"加密id格式錯誤";
//分組
Class>[]groups()default{};
//負載
Class?extends?Payload>[]payload()default{};
}
實現ConstraintValidator接口編寫約束校驗器
publicclassEncryptIdValidatorimplementsConstraintValidator<EncryptId,String>{
privatestaticfinalPatternPATTERN=Pattern.compile("^[a-f\d]{32,256}$");
@Override
publicbooleanisValid(Stringvalue,ConstraintValidatorContextcontext){
//不為null才進行校驗
if(value!=null){
Matchermatcher=PATTERN.matcher(value);
returnmatcher.find();
}
returntrue;
}
}
這樣我們就可以使用@EncryptId進行參數校驗了!
編程式校驗
上面的示例都是基于注解來實現自動校驗的,在某些情況下,我們可能希望以編程方式調用驗證。這個時候可以注入javax.validation.Validator對象,然后再調用其api。
@Autowired
privatejavax.validation.ValidatorglobalValidator;
//編程式校驗
@PostMapping("/saveWithCodingValidate")
publicResultsaveWithCodingValidate(@RequestBodyUserDTOuserDTO){
Set>validate=globalValidator.validate(userDTO,UserDTO.Save.class);
//如果校驗通過,validate為空;否則,validate包含未校驗通過項
if(validate.isEmpty()){
//校驗通過,才會執行業務邏輯處理
}else{
for(ConstraintViolationuserDTOConstraintViolation:validate){
//校驗失敗,做其它邏輯
System.out.println(userDTOConstraintViolation);
}
}
returnResult.ok();
}
快速失敗(Fail Fast)
Spring Validation默認會校驗完所有字段,然后才拋出異常。可以通過一些簡單的配置,開啟Fali Fast模式,一旦校驗失敗就立即返回。
@Bean
publicValidatorvalidator(){
ValidatorFactoryvalidatorFactory=Validation.byProvider(HibernateValidator.class)
.configure()
//快速失敗模式
.failFast(true)
.buildValidatorFactory();
returnvalidatorFactory.getValidator();
}
@Valid和@Validated區別

實現原理
requestBody參數校驗實現原理
在spring-mvc中,RequestResponseBodyMethodProcessor是用于解析@RequestBody標注的參數以及處理@ResponseBody標注方法的返回值的。顯然,執行參數校驗的邏輯肯定就在解析參數的方法resolveArgument()中:
publicclassRequestResponseBodyMethodProcessorextendsAbstractMessageConverterMethodProcessor{
@Override
publicObjectresolveArgument(MethodParameterparameter,@NullableModelAndViewContainermavContainer,
NativeWebRequestwebRequest,@NullableWebDataBinderFactorybinderFactory)throwsException{
parameter=parameter.nestedIfOptional();
//將請求數據封裝到DTO對象中
Objectarg=readWithMessageConverters(webRequest,parameter,parameter.getNestedGenericParameterType());
Stringname=Conventions.getVariableNameForParameter(parameter);
if(binderFactory!=null){
WebDataBinderbinder=binderFactory.createBinder(webRequest,arg,name);
if(arg!=null){
//執行數據校驗
validateIfApplicable(binder,parameter);
if(binder.getBindingResult().hasErrors()&&isBindExceptionRequired(binder,parameter)){
thrownewMethodArgumentNotValidException(parameter,binder.getBindingResult());
}
}
if(mavContainer!=null){
mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX+name,binder.getBindingResult());
}
}
returnadaptArgumentIfNecessary(arg,parameter);
}
}
可以看到,resolveArgument()調用了validateIfApplicable()進行參數校驗。
protectedvoidvalidateIfApplicable(WebDataBinderbinder,MethodParameterparameter){
//獲取參數注解,比如@RequestBody、@Valid、@Validated
Annotation[]annotations=parameter.getParameterAnnotations();
for(Annotationann:annotations){
//先嘗試獲取@Validated注解
ValidatedvalidatedAnn=AnnotationUtils.getAnnotation(ann,Validated.class);
//如果直接標注了@Validated,那么直接開啟校驗。
//如果沒有,那么判斷參數前是否有Valid起頭的注解。
if(validatedAnn!=null||ann.annotationType().getSimpleName().startsWith("Valid")){
Objecthints=(validatedAnn!=null?validatedAnn.value():AnnotationUtils.getValue(ann));
Object[]validationHints=(hintsinstanceofObject[]?(Object[])hints:newObject[]{hints});
//執行校驗
binder.validate(validationHints);
break;
}
}
}
看到這里,大家應該能明白為什么這種場景下@Validated、@Valid兩個注解可以混用。我們接下來繼續看WebDataBinder.validate()實現。
@Override
publicvoidvalidate(Objecttarget,Errorserrors,Object...validationHints){
if(this.targetValidator!=null){
processConstraintViolations(
//此處調用HibernateValidator執行真正的校驗
this.targetValidator.validate(target,asValidationGroups(validationHints)),errors);
}
}
最終發現底層最終還是調用了Hibernate Validator進行真正的校驗處理。
方法級別的參數校驗實現原理
上面提到的將參數一個個平鋪到方法參數中,然后在每個參數前面聲明約束注解的校驗方式,就是方法級別的參數校驗。
實際上,這種方式可用于任何Spring Bean的方法上,比如Controller/Service等。其底層實現原理就是AOP,具體來說是通過MethodValidationPostProcessor動態注冊AOP切面,然后使用MethodValidationInterceptor對切點方法織入增強。
publicclassMethodValidationPostProcessorextendsAbstractBeanFactoryAwareAdvisingPostProcessorimplementsInitializingBean{
@Override
publicvoidafterPropertiesSet(){
//為所有`@Validated`標注的Bean創建切面
Pointcutpointcut=newAnnotationMatchingPointcut(this.validatedAnnotationType,true);
//創建Advisor進行增強
this.advisor=newDefaultPointcutAdvisor(pointcut,createMethodValidationAdvice(this.validator));
}
//創建Advice,本質就是一個方法攔截器
protectedAdvicecreateMethodValidationAdvice(@NullableValidatorvalidator){
return(validator!=null?newMethodValidationInterceptor(validator):newMethodValidationInterceptor());
}
}
接著看一下MethodValidationInterceptor:
publicclassMethodValidationInterceptorimplementsMethodInterceptor{
@Override
publicObjectinvoke(MethodInvocationinvocation)throwsThrowable{
//無需增強的方法,直接跳過
if(isFactoryBeanMetadataMethod(invocation.getMethod())){
returninvocation.proceed();
}
//獲取分組信息
Class>[]groups=determineValidationGroups(invocation);
ExecutableValidatorexecVal=this.validator.forExecutables();
MethodmethodToValidate=invocation.getMethod();
Set>result;
try{
//方法入參校驗,最終還是委托給HibernateValidator來校驗
result=execVal.validateParameters(
invocation.getThis(),methodToValidate,invocation.getArguments(),groups);
}
catch(IllegalArgumentExceptionex){
...
}
//有異常直接拋出
if(!result.isEmpty()){
thrownewConstraintViolationException(result);
}
//真正的方法調用
ObjectreturnValue=invocation.proceed();
//對返回值做校驗,最終還是委托給HibernateValidator來校驗
result=execVal.validateReturnValue(invocation.getThis(),methodToValidate,returnValue,groups);
//有異常直接拋出
if(!result.isEmpty()){
thrownewConstraintViolationException(result);
}
returnreturnValue;
}
}
實際上,不管是requestBody參數校驗還是方法級別的校驗,最終都是調用Hibernate Validator執行校驗,Spring Validation只是做了一層封裝。
-
參數
+關注
關注
11文章
1865瀏覽量
32847 -
spring
+關注
關注
0文章
340瀏覽量
14867 -
Boot
+關注
關注
0文章
153瀏覽量
36521
原文標題:【先收藏】Spring Boot 實現各種參數校驗,寫得太好了!
文章出處:【微信號:AndroidPush,微信公眾號:Android編程精選】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
Spring Boot如何實現異步任務
Spring Boot Starter需要些什么

Spring Boot從零入門1 詳述
「Spring認證」什么是Spring GraphQL?

Spring Boot特有的實踐
強大的Spring Boot 3.0要來了
Spring Boot Web相關的基礎知識
簡述Spring Boot數據校驗
Spring Boot應用中如何做好參數校驗?
Spring Boot應用中如何做好參數校驗?2
Spring Boot如何優雅實現數據加密存儲、模糊匹配和脫敏

Spring Boot Actuator快速入門
Spring Boot啟動 Eureka流程

Spring Boot的啟動原理

評論