分组校验
场景描述
在实际开发中经常会遇到这种情况:添加用户时,id是由后端生成的,不需要校验id是否为空,但是修改用户时就需要校验id是否为空。如果在接收参数的User实体类的id属性上添加NotNull
,显然无法实现。这时候就可以定义分组,在需要校验id的时候校验,不需要的时候不校验。
定义分组
校验的分组通过接口的形式定义。
代码准备
/**
* @author lihz
* @date 2023/2/18
*/
@RestController
@RequestMapping("/group/validation/")
public class GroupValidationController {
@PostMapping("/insert")
public String testInsert(@RequestBody @Validated({UserValidGroup.Insert.class}) UserInfo userInfo) {
System.out.println(userInfo);
return "OK";
}
@PostMapping("/update")
public String testUpdate(@RequestBody @Validated({UserValidGroup.Update.class}) UserInfo userInfo) {
System.out.println(userInfo);
return "OK";
}
@PostMapping("/delete")
public String testDelete(@RequestBody @Validated({UserValidGroup.Delete.class}) UserInfo userInfo) {
System.out.println(userInfo);
return "OK";
}
}
@Data
class UserInfo {
@Min(value = 1, message = "ID不能小于1", groups = {UserValidGroup.Delete.class, UserValidGroup.Update.class})
private int id;
@NotBlank(message = "用户名不能为空", groups = {UserValidGroup.Update.class, UserValidGroup.Insert.class})
private String username;
@NotBlank(message = "密码不能为空", groups = {UserValidGroup.Update.class, UserValidGroup.Insert.class})
@Length(min = 8, max = 20, message = "密码长度在8-20之间", groups = {UserValidGroup.Update.class, UserValidGroup.Insert.class})
private String password;
}
class UserValidGroup {
public interface Insert {
}
public interface Update {
}
public interface Delete {
}
@GroupSequence({Insert.class, Update.class, Delete.class})
public interface All {
}
}
数据准备
insert测试
{
"id": null,
"username": "demon",
"password": "123456"
}
输出:
{
"code": 1,
"msg": "Validation failed for argument [0] in public java.lang.String com.jurassic.cloud.project.controller.GroupValidationController.testInsert(com.jurassic.cloud.project.controller.UserInfo): [Field error in object 'userInfo' on field 'password': rejected value [12345]; codes [Length.userInfo.password,Length.password,Length.java.lang.String,Length]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [userInfo.password,password]; arguments []; default message [password],20,8]; default message [密码长度在8-20之间]] ",
"data": null
}
注意:没有校验 id 属性。
update测试
{
"id": null,
"username": "demon",
"password": "123456"
}
输出:
{
"code": 1,
"msg": "Validation failed for argument [0] in public java.lang.String com.jurassic.cloud.project.controller.GroupValidationController.testUpdate(com.jurassic.cloud.project.controller.UserInfo) with 2 errors: [Field error in object 'userInfo' on field 'id': rejected value [0]; codes [Min.userInfo.id,Min.id,Min.int,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [userInfo.id,id]; arguments []; default message [id],1]; default message [ID不能小于1]] [Field error in object 'userInfo' on field 'password': rejected value [123456]; codes [Length.userInfo.password,Length.password,Length.java.lang.String,Length]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [userInfo.password,password]; arguments []; default message [password],20,8]; default message [密码长度在8-20之间]] ",
"data": null
}
注意:校验了 id 属性。
delete测试
{
"id": null,
"username": "demon",
"password": "123456"
}
输出:
{
"code": 1,
"msg": "Validation failed for argument [0] in public java.lang.String com.jurassic.cloud.project.controller.GroupValidationController.testDelete(com.jurassic.cloud.project.controller.UserInfo): [Field error in object 'userInfo' on field 'id': rejected value [0]; codes [Min.userInfo.id,Min.id,Min.int,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [userInfo.id,id]; arguments []; default message [id],1]; default message [ID不能小于1]] ",
"data": null
}
注意:仅验证 id 属性。
总结
如果未指定分组,则是Default
组,不属于Default
组的属性不会验证。
指定了分组,则仅验证指定的分组涉及的约束。
分组的高级特性
见其他JSR 380文档。
i18n
在进行约束声明时,会指定message
属性,用于设置约束校验失败之后的提示,如果需要支持多语言,则不能得到期望的结果。
不指定message属性
如果不指定message,则会采用框架的默认值,会提供主流的语言,
框架解析message默认值的相关逻辑在 org.hibernate.validator.internal.engine.ValidationContext
中
private String interpolate(String messageTemplate,
Object validatedValue,
ConstraintDescriptor<?> descriptor,
Map<String, Object> messageParameters,
Map<String, Object> expressionVariables) {
MessageInterpolatorContext context = new MessageInterpolatorContext(
descriptor,
validatedValue,
getRootBeanClass(),
messageParameters,
expressionVariables
);
try {
//使用 MessageInterpolator 解析
return validatorScopedContext.getMessageInterpolator().interpolate(
messageTemplate,
context
);
}
catch (ValidationException ve) {
throw ve;
}
catch (Exception e) {
throw LOG.getExceptionOccurredDuringMessageInterpolationException( e );
}
}
在约束定义时,会设置message
的默认值,是个消息插值。例如:@NotNull
,{javax.validation.constraints.NotNull.message}
,定义了消息参数,在Resource Bundle
:ValidationMessages
中作为Key获取 获取属性值。
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(List.class)
@Documented
@Constraint(validatedBy = { })
public @interface NotNull {
String message() default "{javax.validation.constraints.NotNull.message}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@interface List {
NotNull[] value();
}
}
ValidationMessages.properties
:
javax.validation.constraints.NotBlank.message = must not be blank
javax.validation.constraints.NotEmpty.message = must not be empty
javax.validation.constraints.NotNull.message = must not be null
javax.validation.constraints.Null.message = must be null
AbstractMessageInterpolator
消息解析主要是通过MessageInterpolator
的实现类,默认都继承AbstractMessageInterpolator
。 此类默认会根据JVM的Locale来获取对应的i18n消息(源码中的 defaultLocale = Locale.getDefault()
),不能根据request传递来的Locale来显示对应的消息。
缺陷
只能显示JVM的locale对应的消息。
自定义MessageInterpolator
自定义一个 MessageInterpolator
实现并改写其第一个interpolate
方法,可以根据request传递的locale进行动态显示。
@Configuration
public class I18nConstrainValidator {
@Bean
public Validator validator() {
return Validation.byDefaultProvider().configure().messageInterpolator(new ParameterMessageInterpolator() {
@Override
public String interpolate(String message, Context context) {
return interpolate(message, context, Locale.getDefault());
}
@Override
public String interpolate(String message, Context context, Locale locale) {
// 获取当前请求所指定的语言对应的Locale
Locale requestLocale = I18nUtil.getLocaleFromCurrentRequest();
if (null == requestLocale) {
requestLocale = locale;
}
return super.interpolate(message, context, requestLocale);
}
}).buildValidatorFactory().getValidator();
}
}
缺陷
当request指定了一种框架中不存在的语种时无法得到准确的对应语种的消息而是得到JVM Locale
对应的消息。
语种不存在时会获取Locale.getDefault()
对应的Locale
ResourceBundleMessageInterpolator
@Slf4j
@Configuration
public class ConstrainValidatorConfig {
@Value("${spring.messages.basename}")
private String[] baseNames;
@Bean
public Validator validator() {
return Validation.byDefaultProvider().configure().messageInterpolator(new RequesLocaleAwareMessageInterpolator(
// 提供AggregateResourceBundleLocator使得除了用框架提供的Validation ConstrainViolation
// Message外,还可以用自己指定的或覆盖框架提供的。
new AggregateResourceBundleLocator(Arrays.asList(baseNames))
)).buildValidatorFactory().getValidator();
}
/**
* 自定义ResourceBundleMessageInterpolator的若干方法,使得可根据request指定的语言返回对应语种的Validation
* ConstrainViolation Message
*/
public static class RequesLocaleAwareMessageInterpolator extends ResourceBundleMessageInterpolator {
public RequesLocaleAwareMessageInterpolator(ResourceBundleLocator userResourceBundleLocator) {
super(userResourceBundleLocator);
}
@Override
public String interpolate(String message, Context context) {
return interpolate(message, context, Locale.getDefault());
}
@Override
public String interpolate(String message, Context context, Locale locale) {
// 获取当前请求所指定的语言对应的Locale
Locale requestLocale = LocaleContextHolder.getLocale();
log.debug("locale for javax.validation.Validator resolved: {}", requestLocale);
if (null == requestLocale) {
requestLocale = locale;
}
return super.interpolate(message, context, requestLocale);
}
}
}
若注解的message
未指定——即用的是框架默认值(如 {javax.validation.constraints.Size.message}
),则对于框架未提供的i18n语种(如 zh_CHS),在你自己项目的i18n文件里补充相应值即可(如在messages_zh_CHS.properties
文件里增加 javax.validation.constraints.Size.message = 长度不能超过{max}
);
若注解的message不用默认值,而是自己指定message,如 message=“{custom.constraints.Size.message.name}
” ,则在你自己项目的i18n文件里补充相应值即可(如 custom.constraints.Size.message.name = 姓名的长度不能超过{max}
)。此时,注解的message仍支持EL表达式。实际使用中推荐用此方案,因为这种方案不仅支持EL表达式、i18n消息、还支持返回可直接弹窗显示给用户的i18n字段名。
设置的properties文件,可以不叫
ValidationMessages
,可以是任何文件名。
设置语言的方式
1、设置header: Accept-Language
,基于 AcceptHeaderLocaleResolver
实现
2、设置session / cookie:基于 CookieLocaleResolver
,SessionLocaleResolver
实现。自定义参数的名称,需要用到LocaleChangeInterceptor
(需要启用,默认参数名为locale
),用于监控哪个属性(可自定义)切换语言。
public static final String LOCALE_SESSION_ATTRIBUTE_NAME = SessionLocaleResolver.class.getName() + ".LOCALE";
public static final String LOCALE_REQUEST_ATTRIBUTE_NAME = CookieLocaleResolver.class.getName() + ".LOCALE";
CookieLocaleResolver
,会把locale放到cookie中,cookieName:org.springframework.web.servlet.i18n.CookieLocaleResolver.LOCALE
3、固定locale。FixedLocaleResolver
。
spring.mvc.locale=zh_CN
//或者
spring:
web:
locale: zh_CN
locale-resolver: fixed
spring.web.locale-resolver
优先级比spring.mvc.locale-resolver
高一些。
spring.web.locale
、spring.mvc.locale
这两个配置属性,假如存在,就会成为AcceptHeaderLocaleResolver
的默认的Locale 区域对象。 并在请求响应的请求头中没有Accept-Language
这个属性时,成为AcceptHeaderLocaleResolver
返回的Locale 区域对象。
Spring实现原理
Spring会在启动时通过AOP
对使用@Validated
或@Valid
的类或其子类的对象生成一个代理对象。在代理对象中调用目标hanlder
方法前后会分别进行参数、返回值的JSR校验。
MethodValidationPostProcessor
切面创建的相关逻辑在MethodValidationPostProcessor
。
//org.springframework.validation.beanvalidation
public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor
implements InitializingBean {
private Class<? extends Annotation> validatedAnnotationType = Validated.class;
@Nullable
private Validator validator;
public void setValidatedAnnotationType(Class<? extends Annotation> validatedAnnotationType) {
Assert.notNull(validatedAnnotationType, "'validatedAnnotationType' must not be null");
this.validatedAnnotationType = validatedAnnotationType;
}
public void setValidator(Validator validator) {
// Unwrap to the native Validator with forExecutables support
if (validator instanceof LocalValidatorFactoryBean) {
this.validator = ((LocalValidatorFactoryBean) validator).getValidator();
}
else if (validator instanceof SpringValidatorAdapter) {
this.validator = validator.unwrap(Validator.class);
}
else {
this.validator = validator;
}
}
public void setValidatorFactory(ValidatorFactory validatorFactory) {
this.validator = validatorFactory.getValidator();
}
//此方法在bean自身初始化时会创建一个DefaultPointcutAdvisor用于向符合条件的对象添加进行方法验证的AOP advise
@Override
public void afterPropertiesSet() {
Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
}
//如果有
protected Advice createMethodValidationAdvice(@Nullable Validator validator) {
return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor());
}
}
MethodValidationPostProcessor
实现了接口BeanPostProcessor
定义的方法postProcessAfterInitialization
(从父类AbstractAdvisingBeanPostProcessor
继承),该方法会检查每个bean的创建(在该bean初始化之后),如果检测到该bean符合条件,会向其增加上述AOP advise
。
MethodValidationPostProcessor
是被ValidationAutoConfiguration
自动配置到IoC容器的。
ValidationAutoConfiguration
package org.springframework.boot.autoconfigure.validation;
@Configuration
@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() {
LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();
MessageInterpolatorFactory interpolatorFactory = new MessageInterpolatorFactory();
factoryBean.setMessageInterpolator(interpolatorFactory.getObject());
return factoryBean;
}
// 向容器注册一个 bean MethodValidationPostProcessor
@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);
processor.setValidator(validator);
return processor;
}
}
MethodValidationInterceptor
切面中进行参数验证、返回值验证的相关逻辑在MethodValidationInterceptor
。
@Override
@SuppressWarnings("unchecked")
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;
try {
result = execVal.validateParameters(
invocation.getThis(), 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(), invocation.getThis().getClass()));
result = execVal.validateParameters(
invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
}
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
}
Object returnValue = invocation.proceed();
result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups);
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
}
return returnValue;
}
附录
Spring MVC localeResolver
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
/**
* Cookie方式
*
* @return
*/
@Bean
public LocaleResolver localeResolver() {
return new CookieLocaleResolver();
}
/**
* 切换语言按钮URL?language=zh_CN,切换后将语言信息存入cookie;
*
* @return
*/
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor lci = new LocaleChangeInterceptor();
//不设置,默认为locale。
lci.setParamName("language");
return lci;
}
}