之前也写过一篇关于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文章
1832浏览量
32197 -
spring
+关注
关注
0文章
340浏览量
14340 -
Boot
+关注
关注
0文章
149浏览量
35825
原文标题:【先收藏】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的启动原理

评论