Spring(十二)-Validation校验
参考文献
- Spring Boot 实现各种参数校验,写得太好了,建议收藏!
- Hibernate Validator 8.0.0.Final - Jakarta Bean Validation Reference Implementation: Reference Guide
Spring校验使用场景
- Spring常规校验(
Validator
) - Spring数据绑定(
DataBinder
) - Spring Web参数绑定(
WebDataBinder
) Spring Web MVC
/Spring WebFlux
处理方法参数校验
Validator
接口设计
-
接口职责
- Spring内部校验器接口,通过编程的方式校验目标对象
-
核心方法
support(Class)
: 校验目标类能否校验;validate(Object,Errors)
: 校验目标对象,并将校验失败的内容输出至Errors
对象
-
配套组件
-
错误收集器:
org.springframework.validation.Errors
-
Validator
工具类:org.springframework.validation.ValidationUtils
-
Errors
接口设计
-
接口职责
- 数据绑定和校验错误收集接口,与 Java Bean 和其属性有强关联性
-
核心方法
-
reject
方法(重载):收集错误文案 -
rejectValue
方法(重载):收集对象字段中的错误文案
-
-
配套组件
- Java Bean 错误描述:
org.springframework.validation.ObjectError
- Java Bean 属性错误描述:
org.springframework.validation.FieldError
- Java Bean 错误描述:
Errors
文案来源
- Errors 文案生成步骤
- 选择
Errors
实现(如:org.springframework.validation.BeanPropertyBindingResult
) - 调用
reject
或rejectValue
方法 - 获取
Errors
对象中ObjectError
或FieldError
- 将
ObjectError
或FieldError
中的code
和args
,关联MessageSource
实现(如:ResourceBundleMessageSource
)
- 选择
自定义 Validator
- 实现
org.springframework.validation.Validator
接口- 实现
supports
方法 - 实现
validate
方法- 通过
Errors
对象收集错误ObjectError
:对象(Bean)错误:FieldError
:对象(Bean)属性(Property)错误
- 通过
ObjectError
和FieldError
关联MessageSource
实现获取最终文案
- 通过
- 实现
Validator
的救赎
Bean Validation
与Validator
适配- 核心组件 -
org.springframework.validation.beanvalidation.LocalValidatorFactoryBean
- 依赖
Bean Validation
-JSR-303 or JSR-349 provider
- Bean 方法参数校验 -
org.springframework.validation.beanvalidation.MethodValidationPostProcessor
- 核心组件 -
Hibernate Validator
注解
注解名 | 支持类型 | 作用 |
---|---|---|
@Valid |
被注解的元素是一个对象,需要检查此对象的所有字段 | |
@AssertTrue |
Boolean , boolean |
被注解的元素必须为true |
@AsserFalse |
Boolean , boolean |
被注解的元素必须为false |
@Min(value) |
BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; |
被注解的元素必须是一个数字,其值必须大于等于指定的最小值 |
@Max(value) |
BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; |
被注解的元素必须是一个数字,其值必须小于等于指定的最大值 |
@DecimalMin(value) |
BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; |
被注解的元素必须是一个数字,其值必须小于等于指定的最大值 |
@DecimalMax(value) |
BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; |
被注解的元素必须是一个数字,其值必须小于等于指定的最大值 |
@Size(max,min) |
被注解的元素的大小必须在指定的范围内 | |
@Digits(integer,fraction) |
BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; |
被注解的元素必须是一个数字,其值必须在可接受的范围内 |
@Past |
java.util.Date ,java.util.Calendar , java.time.Instant , java.time.LocalDate , java.time.LocalDateTime , java.time.LocalTime , java.time.MonthDay , java.time.OffsetDateTime , java.time.OffsetTime , java.time.Year , java.time.YearMonth , java.time.ZonedDateTime , java.time.chrono.HijrahDate , java.time.chrono.JapaneseDate , java.time.chrono.MinguoDate , java.time.chrono.ThaiBuddhistDate ; |
被注解的元素必须是一个过去的日期 |
@PastOrPresent |
java.util.Date ,java.util.Calendar , java.time.Instant , java.time.LocalDate , java.time.LocalDateTime , java.time.LocalTime , java.time.MonthDay , java.time.OffsetDateTime , java.time.OffsetTime , java.time.Year , java.time.YearMonth , java.time.ZonedDateTime , java.time.chrono.HijrahDate , java.time.chrono.JapaneseDate , java.time.chrono.MinguoDate , java.time.chrono.ThaiBuddhistDate ; |
|
@Future |
java.util.Date , java.util.Calendar , java.time.Instant , java.time.LocalDate , java.time.LocalDateTime , java.time.LocalTime , java.time.MonthDay , java.time.OffsetDateTime , java.time.OffsetTime , java.time.Year , java.time.YearMonth , java.time.ZonedDateTime , java.time.chrono.HijrahDate , java.time.chrono.JapaneseDate , java.time.chrono.MinguoDate , java.time.chrono.ThaiBuddhistDate ; |
被注解的元素必须是一个将来的日期 |
@FutureOrPresent |
java.util.Date , java.util.Calendar , java.time.Instant , java.time.LocalDate , java.time.LocalDateTime , java.time.LocalTime , java.time.MonthDay , java.time.OffsetDateTime , java.time.OffsetTime , java.time.Year , java.time.YearMonth , java.time.ZonedDateTime , java.time.chrono.HijrahDate , java.time.chrono.JapaneseDate , java.time.chrono.MinguoDate , java.time.chrono.ThaiBuddhistDate ; |
检查标注的日期是现在还是将来 |
@Pattern(value) |
被注解的元素必须符合指定的正则表达式 | |
@Email |
被注解的元素必须是电子邮箱地址 | |
@Length(min=,max=) |
CharSequence |
被注解的字符串的大小必须在指定的范围内 |
@Range(min=,max=) |
BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; |
被注解的元素必须在可接受的范围内 |
@Null |
Any type | 被注解的元素必须为null |
@NotNull |
Any type | 被注解的元素必须不为null |
@NotEmpty |
CharSequence , Collection , Map and arrays |
被注解的元素必须非空 |
@NotBlank |
CharSequence |
被注解的元素必须非空 |
@URL(protocol=,host=,port=,regexp=,flags=) |
被注解的元素必须是一个有效的URL | |
@CreditCardNumber |
被注解的字符串必须通过Luhn校验算法,银行卡,信用卡等号码一般都用Luhn计算合法性 | |
@ScriptAssert(lang=,script=,alias=) |
要有Java Scripting API | |
@SafeHtml(whitelistType,additionTags=) |
classpath中要有jsoup包 | |
@Negative |
BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; |
负数 |
@NegativeOrZero |
BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; |
负数和0 |
@Positive |
BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; |
正数 |
@PositiveOrZero |
BigDecimal, BigInteger, CharSequence ,byte, short, int, long and the respective wrappers of the primitive types; |
正数和0 |
@DurationMax(days=, hours=, minutes=, seconds=, millis=, nanos=, inclusive=) |
java.time.Duration 前段参数传入参数以PT 开头,如PT1H30M 表示1小时30分钟的Duration值 |
|
@DurationMin(days=, hours=, minutes=, seconds=, millis=, nanos=, inclusive=) |
java.time.Duration |
@NotNull
,@NotEmpty
,@NotBlank
三个注解的区别@NotNull
:任何对象的value不能为null.@NotEmpty
:集合对象元素不为0,即集合不为空,也可以用于字符串不为null.@NotBlank
: 只能用于字符串不为null,并且字符串trim()
以后length要大于0.
实战
RequestBody
参数校验
POST
,PUT
请求一般会使用RequestBody
传递参数,这种情况下,后端使用DTO对象进行接收.只要给DTO对象加上@Validated
注解就能实现自动参数校验.- 如果校验失败,会抛出
MethodArgumentNotValidException
异常,Spring
默认会将其转为400(Bad Request
)请求.
1 | package com.holelin.sundry.vo.request; |
1 | package com.holelin.sundry.controller; |
RequestParam/PathVariable
参数校验
- GET请求一般会使用
RequestParam/PathVariable
传参.如果参数比较多(比如超过6个),还是推荐使用DTO对象接收. - 否则,推荐将一个个参数平铺到方法入参中.在这种情况下,必须在
Controller
类上标注@Validated
注解,并在入参上声明约束注解(如@Min
等).如果校验失败,会抛出ConstraintViolationException
异常.
1 | package com.holelin.sundry.controller; |
统一异常处理
- 如果校验失败,会抛出
MethodArgumentNotValidException
或者ConstraintViolationException
异常.在实际项目开发中,通常会用统一异常处理来返回一个更友好的提示.
1 | package com.holelin.gb.advice; |
分组校验
- 在实际项目中,可能多个方法需要使用同一个
DTO
类来接收参数,而不同方法的校验规则很可能是不一样的.这个时候,简单地在DTO
类的字段上加约束注解无法解决这个问题.因此,Spring-Validation
支持了分组校验的功能,专门用来解决这类问题. - 使用同一个
DTO
类来校验不同场景的参数校验,常见的如新增和更新.
1 | package com.holelin.sundry.vo.request; |
1 |
|
嵌套校验
DTO
类里面的字段都是基本数据类型和String
类型.但是实际场景中,有可能某个字段也是一个对象,这种情况先,可以使用嵌套校验.- 需要注意的是,此时
DTO
类的对应字段必须标记@Valid
注解. - 嵌套校验可以结合分组校验一起使用.还有就是嵌套集合校验会对集合里面的每一项都进行校验,例如
List<Job>
字段会对这个list里面的每一个Job对象都进行校验
1 |
|
源码
1 | 入口:org.springframework.web.method.support.InvocableHandlerMethod#invokeForRequest |
1 | private CascadingMetaDataBuilder getCascadingMetaData(JavaBeanAnnotatedElement annotatedElement, |
集合校验
- 如果请求体直接传递了json数组给后台,并希望对数组中的每一项都进行参数校验.此时,如果我们直接使用
java.util.Collection
下的list或者set来接收数据,参数校验并不会生效!我们可以使用自定义list集合来接收参数:包装List类型,并声明@Valid
注解 @Delegate
注解受lombok
版本限制,1.18.6以上版本可支持.如果校验不通过,会抛出NotReadablePropertyException
,同样可以使用统一异常进行处理.
1 | public class ValidationList<E> implements List<E> { |
1 |
|
自定义校验
-
业务需求总是比框架提供的这些简单校验要复杂的多,我们可以自定义校验来满足我们的需求.
-
自定义
Spring Validation
非常简单,假设我们自定义加密id
(由数字或者a-f
的字母组成,32-256长度)校验,主要分为两步:-
自定义约束注解
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public EncryptId {
// 默认错误消息
String message() default "加密id格式错误";
// 分组
Class<?>[] groups() default {};
// 负载
Class<? extends Payload>[] payload() default {};
} -
实现
ConstraintValidator
接口编写约束校验器
1
2
3
4
5
6
7
8
9
10
11
12
13
14public class EncryptIdValidator implements ConstraintValidator<EncryptId, String> {
private static final Pattern PATTERN = Pattern.compile("^[a-f\\d]{32,256}$");
public boolean isValid(String value, ConstraintValidatorContext context) {
// 不为null才进行校验
if (value != null) {
Matcher matcher = PATTERN.matcher(value);
return matcher.find();
}
return true;
}
} -
校验枚举类
1 | import javax.validation.Constraint; |
1 | import org.apache.commons.lang3.StringUtils; |
编程式校验
- 在某些情况下,我们可能希望以编程方式调用验证.这个时候可以注入
javax.validation.Validator
对象,然后再调用其api.
1 |
|
快速失败(Fail Fast
)
- Spring Validation默认会校验完所有字段,然后才抛出异常.可以通过一些简单的配置,开启Fali Fast模式,一旦校验失败就立即返回.
1 |
|
@Valid
和@Validated
区别
区别 | @Valid |
@Validated |
---|---|---|
提供者 | JSR-303规范 | Spring |
是否支持分组 | 不支持 | 支持 |
标注位置 | ElementType.METHOD, ElementType.FIELD, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE |
ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER |
嵌套校验 | 支持 | 不支持 |
实现原理
-
在
Spring-MVC
中,RequestResponseBodyMethodProcessor
是用于解析@RequestBody
标注的参数以及处理@ResponseBody
标注方法的返回值的.显然,执行参数校验的逻辑肯定就在解析参数的方法resolveArgument()
中:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31/**
* Throws MethodArgumentNotValidException if validation fails.
* @throws HttpMessageNotReadableException if {@link RequestBody#required()}
* is {@code true} and there is no body content or if there is no suitable
* converter to read the content with.
*/
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory)throws Exception {
parameter = parameter.nestedIfOptional();
// 将请求封装到对应的DTO对象中
Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
String name = Conventions.getVariableNameForParameter(parameter);
if (binderFactory != null) {
WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
if (arg != null) {
// 执行数据校验
validateIfApplicable(binder, parameter);
if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
}
}
if (mavContainer != null) {
mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
}
}
return adaptArgumentIfNecessary(arg, parameter);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* Validate the binding target if applicable.
* <p>The default implementation checks for {@code @javax.validation.Valid},
* Spring's {@link org.springframework.validation.annotation.Validated},
* and custom annotations whose name starts with "Valid".
* @param binder the DataBinder to be used
* @param parameter the method parameter descriptor
* @since 4.1.5
* @see #isBindExceptionRequired
*/
protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
// 获取参数注解,比如@RequestBody、@Valid、@Validated
Annotation[] annotations = parameter.getParameterAnnotations();
for (Annotation ann : annotations) {
Object[] validationHints = ValidationAnnotationUtils.determineValidationHints(ann);
if (validationHints != null) {
binder.validate(validationHints);
break;
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27/**
* Determine any validation hints by the given annotation.
* <p>This implementation checks for {@code @javax.validation.Valid},
* Spring's {@link org.springframework.validation.annotation.Validated},
* and custom annotations whose name starts with "Valid".
* @param ann the annotation (potentially a validation annotation)
* @return the validation hints to apply (possibly an empty array),
* or {@code null} if this annotation does not trigger any validation
*/
public static Object[] determineValidationHints(Annotation ann) {
Class<? extends Annotation> annotationType = ann.annotationType();
String annotationName = annotationType.getName();
if ("javax.validation.Valid".equals(annotationName)) {
return EMPTY_OBJECT_ARRAY;
}
Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
if (validatedAnn != null) {
Object hints = validatedAnn.value();
return convertValidationHints(hints);
}
if (annotationType.getSimpleName().startsWith("Valid")) {
Object hints = AnnotationUtils.getValue(ann);
return convertValidationHints(hints);
}
return null;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22/**
* Invoke the specified Validators, if any, with the given validation hints.
* <p>Note: Validation hints may get ignored by the actual target Validator.
* @param validationHints one or more hint objects to be passed to a {@link SmartValidator}
* @since 3.1
* @see #setValidator(Validator)
* @see SmartValidator#validate(Object, Errors, Object...)
*/
public void validate(Object... validationHints) {
Object target = getTarget();
Assert.state(target != null, "No target to validate");
BindingResult bindingResult = getBindingResult();
// Call each validator with the same binding result
for (Validator validator : getValidators()) {
if (!ObjectUtils.isEmpty(validationHints) && validator instanceof SmartValidator) {
((SmartValidator) validator).validate(target, bindingResult, validationHints);
}
else if (validator != null) {
validator.validate(target, bindingResult);
}
}
}
方法级别的参数校验实现原理
-
上面提到的将参数一个个平铺到方法参数中,然后在每个参数前面声明约束注解的校验方式,就是方法级别的参数校验.
实际上,这种方式可用于任何
Spring Bean
的方法上,比如Controller/Service
等.其底层实现原理就是AOP,具体来说是通过MethodValidationPostProcessor
动态注册AOP切面,然后使用MethodValidationInterceptor
对切点方法织入增强.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor
implements InitializingBean {
private Class<? extends Annotation> validatedAnnotationType = Validated.class;
private Validator validator;
// ...略
public void afterPropertiesSet() {
//为所有@Validated标注的Bean创建切面
Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
// 创建Advisor进行增强
this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
}
/**
* Create AOP advice for method validation purposes, to be applied
* with a pointcut for the specified 'validated' annotation.
* @param validator the JSR-303 Validator to delegate to
* @return the interceptor to use (typically, but not necessarily,
* a {@link MethodValidationInterceptor} or subclass thereof)
* @since 4.2
*/
protected Advice createMethodValidationAdvice( { Validator validator)
return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor());
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86public class MethodValidationInterceptor implements MethodInterceptor {
private final Validator validator;
public Object invoke(MethodInvocation invocation) throws Throwable {
// Avoid Validator invocation on FactoryBean.getObjectType/isSingleton
// 无需增强的方法,直接跳过
if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
return invocation.proceed();
}
// 获取分组信息
Class<?>[] groups = determineValidationGroups(invocation);
// Standard Bean Validation 1.1 API
ExecutableValidator execVal = this.validator.forExecutables();
Method methodToValidate = invocation.getMethod();
Set<ConstraintViolation<Object>> result;
Object target = invocation.getThis();
Assert.state(target != null, "Target must not be null");
try {
result = execVal.validateParameters(target, methodToValidate, invocation.getArguments(), groups);
}
catch (IllegalArgumentException ex) {
// Probably a generic type mismatch between interface and impl as reported in SPR-12237 / HV-1011
// Let's try to find the bridged method on the implementation class...
methodToValidate = BridgeMethodResolver.findBridgedMethod(
ClassUtils.getMostSpecificMethod(invocation.getMethod(), target.getClass()));
result = execVal.validateParameters(target, methodToValidate, invocation.getArguments(), groups);
}
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
}
Object returnValue = invocation.proceed();
result = execVal.validateReturnValue(target, methodToValidate, returnValue, groups);
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
}
return returnValue;
}
private boolean isFactoryBeanMetadataMethod(Method method) {
Class<?> clazz = method.getDeclaringClass();
// Call from interface-based proxy handle, allowing for an efficient check?
if (clazz.isInterface()) {
return ((clazz == FactoryBean.class || clazz == SmartFactoryBean.class) &&
!method.getName().equals("getObject"));
}
// Call from CGLIB proxy handle, potentially implementing a FactoryBean method?
Class<?> factoryBeanType = null;
if (SmartFactoryBean.class.isAssignableFrom(clazz)) {
factoryBeanType = SmartFactoryBean.class;
}
else if (FactoryBean.class.isAssignableFrom(clazz)) {
factoryBeanType = FactoryBean.class;
}
return (factoryBeanType != null && !method.getName().equals("getObject") &&
ClassUtils.hasMethod(factoryBeanType, method));
}
/**
* Determine the validation groups to validate against for the given method invocation.
* <p>Default are the validation groups as specified in the {@link Validated} annotation
* on the containing target class of the method.
* @param invocation the current MethodInvocation
* @return the applicable validation groups as a Class array
*/
protected Class<?>[] determineValidationGroups(MethodInvocation invocation) {
Validated validatedAnn = AnnotationUtils.findAnnotation(invocation.getMethod(), Validated.class);
if (validatedAnn == null) {
Object target = invocation.getThis();
Assert.state(target != null, "Target must not be null");
validatedAnn = AnnotationUtils.findAnnotation(target.getClass(), Validated.class);
}
return (validatedAnn != null ? validatedAnn.value() : new Class<?>[0]);
}
}
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 HoleLin's Blog!