WhatAKitty Daily

A Programmer's Daily Record

Validator内国际化未生效的解决

WhatAKitty   阅读次数loading...

背景

笔者在最近应用国际化校验的时候,碰到一个奇怪的问题:国际化始终不能生效,消息返回的仍旧是模板消息。

相关源码

Java Bean:

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
@Data
public class DemoParam {

@NotNull(message = "{validator.demo.name.not-null}")
private String name;

@NotNull(
groups = DemoParamValidateGroup1.class,
message = "{validator.demo.title.not-blank}"
)
@NotEmpty(
groups = DemoParamValidateGroup1.class,
message = "{validator.demo.title.not-blank}"
)
@Length(
min = 1,
max = 64,
groups = DemoParamValidateGroup1.class,
message = "{validator.demo.title.illegal-length-1-64}"
)
private String title;

/**
* first validation group
*/
public interface DemoParamValidateGroup1 {}

}

DemoApi:

1
2
3
4
@PostMapping("/param1")
public Object param1(@Validated @RequestBody DemoParam demoParam) {
return Result.getSuccResult(demoParam);
}

ValidatorConfig:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
public class ValidatorConfig {

private final MessageSource messageSource;

public ValidatorConfig(MessageSource messageSource) {
this.messageSource = messageSource;
}

@Bean
public Validator validator() {
LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean();
validator.setValidationMessageSource(messageSource);
return validator;
}

}

DemoApiTest:

1
2
3
4
5
6
7
8
9
10
11
@Test
public void test_param1_default() throws Exception {
DemoParam demoParam = new DemoParam();

mockMvc.perform(
post("/api/demo/param1")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(JSON.toJSONString(demoParam)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code", is(DemoResultCode.BAD_REQUEST.getCode())));
}

定位问题

由于笔者并没有在以前看过springvalidator源码;所以,打算从校验的执行入口处入手。

请求是POST形式,而在类RequestResponseBodyMethodProcessorresolveArgument方法内,会对注解有@RequestBody的参数做参数解析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {

parameter = parameter.nestedIfOptional();
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);
}

在上述的解析块内,① 处的代码是使用MessageHttpConvertersjson字符串转化为目标实例;② 处的代码通过创建的WebDataBinder获取校验后的结果,通过结果判断是否校验通过。而我们需要的错误信息构建肯定在validateIfApplicable(binder, parameter);语句内。

1
2
3
4
5
6
7
8
9
10
11
12
protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
Annotation[] annotations = parameter.getParameterAnnotations();
for (Annotation ann : annotations) {
Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) { ③
Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints});
binder.validate(validationHints); ④
break;
}
}
}

③ 处在校验参数的时候,会校验参数的注解是否有注解,如果注解为@Validated或者注解以Valid开头,则校验该参数,如 ④ 处的代码;binder是类DataBinder的实例,校验的逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 Boot框架内,校验框架的实现交由Hibernate Validator实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public final <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) {
Contracts.assertNotNull( object, MESSAGES.validatedObjectMustNotBeNull() );
sanityCheckGroups( groups );

ValidationContext<T> validationContext = getValidationContextBuilder().forValidate( object );

if ( !validationContext.getRootBeanMetaData().hasConstraints() ) {
return Collections.emptySet();
}

ValidationOrder validationOrder = determineGroupValidationOrder( groups );
ValueContext<?, Object> valueContext = ValueContext.getLocalExecutionContext(
validatorScopedContext.getParameterNameProvider(),
object,
validationContext.getRootBeanMetaData(),
PathImpl.createRootPath()
);

return validateInContext( validationContext, valueContext, validationOrder ); ⑦
}

在 ⑦ 处,通过校验和值的上下文校验具体的内容;之后在ConstraintTree类内做具体的校验,其中的层次调用就不在本篇内描述。

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
protected final <T, V> Set<ConstraintViolation<T>> validateSingleConstraint(ValidationContext<T> executionContext,
ValueContext<?, ?> valueContext,
ConstraintValidatorContextImpl constraintValidatorContext,
ConstraintValidator<A, V> validator) {
boolean isValid;
try {
@SuppressWarnings("unchecked")
V validatedValue = (V) valueContext.getCurrentValidatedValue();
isValid = validator.isValid( validatedValue, constraintValidatorContext );
}
catch (RuntimeException e) {
if ( e instanceof ConstraintDeclarationException ) {
throw e;
}
throw LOG.getExceptionDuringIsValidCallException( e );
}
if ( !isValid ) {
//We do not add these violations yet, since we don't know how they are
//going to influence the final boolean evaluation
return executionContext.createConstraintViolations( ⑧
valueContext, constraintValidatorContext
);
}
return Collections.emptySet();
}

在 ⑧ 处,可以看到在这里创建错误信息的实例:

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
public ConstraintViolation<T> createConstraintViolation(ValueContext<?, ?> localContext, ConstraintViolationCreationContext constraintViolationCreationContext, ConstraintDescriptor<?> descriptor) {
String messageTemplate = constraintViolationCreationContext.getMessage(); ⑨
String interpolatedMessage = interpolate( ⑩
messageTemplate,
localContext.getCurrentValidatedValue(),
descriptor,
constraintViolationCreationContext.getMessageParameters(),
constraintViolationCreationContext.getExpressionVariables()
);
// at this point we make a copy of the path to avoid side effects
Path path = PathImpl.createCopy( constraintViolationCreationContext.getPath() );
Object dynamicPayload = constraintViolationCreationContext.getDynamicPayload();

switch ( validationOperation ) {
case PARAMETER_VALIDATION:
return ConstraintViolationImpl.forParameterValidation(
messageTemplate,
constraintViolationCreationContext.getMessageParameters(),
constraintViolationCreationContext.getExpressionVariables(),
interpolatedMessage,
getRootBeanClass(),
getRootBean(),
localContext.getCurrentBean(),
localContext.getCurrentValidatedValue(),
path,
descriptor,
localContext.getElementType(),
executableParameters,
dynamicPayload
);
case RETURN_VALUE_VALIDATION:
return ConstraintViolationImpl.forReturnValueValidation(
messageTemplate,
constraintViolationCreationContext.getMessageParameters(),
constraintViolationCreationContext.getExpressionVariables(),
interpolatedMessage,
getRootBeanClass(),
getRootBean(),
localContext.getCurrentBean(),
localContext.getCurrentValidatedValue(),
path,
descriptor,
localContext.getElementType(),
executableReturnValue,
dynamicPayload
);
default:
return ConstraintViolationImpl.forBeanValidation(
messageTemplate,
constraintViolationCreationContext.getMessageParameters(),
constraintViolationCreationContext.getExpressionVariables(),
interpolatedMessage,
getRootBeanClass(),
getRootBean(),
localContext.getCurrentBean(),
localContext.getCurrentValidatedValue(),
path,
descriptor,
localContext.getElementType(),
dynamicPayload
);
}
}

在 ⑨ 处,获取原消息的消息模板,即:{validator.demo.name.not-null},之后通过interpolate方法,将模板消息替换为解析后的字符串。

一层层递归:ValidationContext::interpolate -> AbstractMessageInterpolator::interpolate -> AbstractMessageInterpolator::interpolateMessage

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
private String interpolateMessage(String message, Context context, Locale locale) throws MessageDescriptorFormatException {
// if the message does not contain any message parameter, we can ignore the next steps and just return
// the unescaped message. It avoids storing the message in the cache and a cache lookup.
if ( message.indexOf( '{' ) < 0 ) {
return replaceEscapedLiterals( message );
}

String resolvedMessage = null;

// either retrieve message from cache, or if message is not yet there or caching is disabled,
// perform message resolution algorithm (step 1)
if ( cachingEnabled ) {
resolvedMessage = resolvedMessages.computeIfAbsent( new LocalizedMessage( message, locale ), lm -> resolveMessage( message, locale ) );
}
else {
resolvedMessage = resolveMessage( message, locale );
}

// there's no need for steps 2-3 unless there's `{param}`/`${expr}` in the message
if ( resolvedMessage.indexOf( '{' ) > -1 ) {
// resolve parameter expressions (step 2)
resolvedMessage = interpolateExpression(
new TokenIterator( getParameterTokens( resolvedMessage, tokenizedParameterMessages, InterpolationTermType.PARAMETER ) ),
context,
locale
);

// resolve EL expressions (step 3)
resolvedMessage = interpolateExpression(
new TokenIterator( getParameterTokens( resolvedMessage, tokenizedELMessages, InterpolationTermType.EL ) ),
context,
locale
);
}

// last but not least we have to take care of escaped literals
resolvedMessage = replaceEscapedLiterals( resolvedMessage );

return resolvedMessage;
}

通过 resolveMessage( message, locale ) 方法,会真正将消息转化:

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
private String resolveMessage(String message, Locale locale) {
String resolvedMessage = message;

ResourceBundle userResourceBundle = userResourceBundleLocator
.getResourceBundle( locale );

ResourceBundle constraintContributorResourceBundle = contributorResourceBundleLocator
.getResourceBundle( locale );

ResourceBundle defaultResourceBundle = defaultResourceBundleLocator
.getResourceBundle( locale );

String userBundleResolvedMessage;
boolean evaluatedDefaultBundleOnce = false;
do {
// search the user bundle recursive (step 1.1)
userBundleResolvedMessage = interpolateBundleMessage(
resolvedMessage, userResourceBundle, locale, true
);

// search the constraint contributor bundle recursive (only if the user did not define a message)
if ( !hasReplacementTakenPlace( userBundleResolvedMessage, resolvedMessage ) ) {
userBundleResolvedMessage = interpolateBundleMessage(
resolvedMessage, constraintContributorResourceBundle, locale, true
);
}

// exit condition - we have at least tried to validate against the default bundle and there was no
// further replacements
if ( evaluatedDefaultBundleOnce && !hasReplacementTakenPlace( userBundleResolvedMessage, resolvedMessage ) ) {
break;
}

// search the default bundle non recursive (step 1.2)
resolvedMessage = interpolateBundleMessage(
userBundleResolvedMessage,
defaultResourceBundle,
locale,
false
);
evaluatedDefaultBundleOnce = true;
} while ( true );

return resolvedMessage;
}

ResourceBundle userResourceBundle = userResourceBundleLocator.getResourceBundle( locale ); 获取过程中,并没有获取到messagesbundle,也就是说,上文设置validator.setValidationMessageSource(messageSource);并没有生效。

解析问题

上文,笔者通过一步步定位了解到:validator设置的messageSource并没有生效。那么接下来,就需要探查下这个失效的原因。

ValidatorConfig内的Validator未执行?

在笔者自定义的Validator注入Bean的方法内增加一个断点。然后重新启动应用,应用初始化过程顺利在断点处停留。那么,未执行的判断可以pass。

LocalValidatorFactoryBean的初始化过程未成功设置国际化?

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
public void afterPropertiesSet() {
Configuration<?> configuration;
if (this.providerClass != null) {
ProviderSpecificBootstrap bootstrap = Validation.byProvider(this.providerClass);
if (this.validationProviderResolver != null) {
bootstrap = bootstrap.providerResolver(this.validationProviderResolver);
}
configuration = bootstrap.configure();
}
else {
GenericBootstrap bootstrap = Validation.byDefaultProvider();
if (this.validationProviderResolver != null) {
bootstrap = bootstrap.providerResolver(this.validationProviderResolver);
}
configuration = bootstrap.configure();
}

// Try Hibernate Validator 5.2's externalClassLoader(ClassLoader) method
if (this.applicationContext != null) {
try {
Method eclMethod = configuration.getClass().getMethod("externalClassLoader", ClassLoader.class);
ReflectionUtils.invokeMethod(eclMethod, configuration, this.applicationContext.getClassLoader());
}
catch (NoSuchMethodException ex) {
// Ignore - no Hibernate Validator 5.2+ or similar provider
}
}

MessageInterpolator targetInterpolator = this.messageInterpolator; ①
if (targetInterpolator == null) {
targetInterpolator = configuration.getDefaultMessageInterpolator();
}
configuration.messageInterpolator(new LocaleContextMessageInterpolator(targetInterpolator)); ②

if (this.traversableResolver != null) {
configuration.traversableResolver(this.traversableResolver);
}

ConstraintValidatorFactory targetConstraintValidatorFactory = this.constraintValidatorFactory;
if (targetConstraintValidatorFactory == null && this.applicationContext != null) {
targetConstraintValidatorFactory =
new SpringConstraintValidatorFactory(this.applicationContext.getAutowireCapableBeanFactory());
}
if (targetConstraintValidatorFactory != null) {
configuration.constraintValidatorFactory(targetConstraintValidatorFactory);
}

if (this.parameterNameDiscoverer != null) {
configureParameterNameProvider(this.parameterNameDiscoverer, configuration);
}

if (this.mappingLocations != null) {
for (Resource location : this.mappingLocations) {
try {
configuration.addMapping(location.getInputStream());
}
catch (IOException ex) {
throw new IllegalStateException("Cannot read mapping resource: " + location);
}
}
}

this.validationPropertyMap.forEach(configuration::addProperty);

// Allow for custom post-processing before we actually build the ValidatorFactory.
postProcessConfiguration(configuration);

this.validatorFactory = configuration.buildValidatorFactory(); ③
setTargetValidator(this.validatorFactory.getValidator());
}

解释下国际化消息如何设置到validator工厂的逻辑:
在 ① 处将国际化消息解析拦截器赋值给了 targetInterpolator 变量;而这个变量最终传递给了configuration,如 ③ 处。最后,在 ③ 处使用configurationbuildValidatorFactory方法构建validator的工厂。

笔者在validator的工厂类LocalValidatorFactoryBean初始化hook内设置了断点:然后启动应用,应用在执行了Validator的注入后,成功执行了LocalValidatorFactoryBean的初始化方法afterPropertiesSet;但是笔者在这里发现,这个初始化执行了两次。恰恰,通过this.messageInterpolator这个变量,笔者在第一次初始化的时候查看到用户定义的messageResource已经加载,如下图:

messagesource

图片上的第一个红框是已成功加载的messagesource;而第二个红框是未加载的形式;在第二次初始化的时候,笔者在userResourceBundle未看到笔者定义的messagesource值,跟第二个红框即未加载的形式是一样的。

很好,成功定位到具体的问题:DataBinder使用的validator实例并不是笔者定义的实例,这也就是为什么国际化始终无法生效的原因。

解决问题

定位到问题所在,就该思考如何去解决这个问题。

按理来说,Spring Boot在用户自定义Validator后,会覆盖它自身的校验器,实际情况按照笔者定位的问题,这种覆盖情况并没有发生。

在这里提一句,Spring Boot集成校验器或者其他一些框架等等都是通过Configuration机制来实现(这个可以看笔者之前写的一篇文章:Spring-Bean解析分析过程)。来找找Validator的自动化配置类:

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
@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
@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;
}

}

可以在 ① 处看到,这个就是Spring Boot自身默认的校验器的一个初始化注入方法。并且,可以看到,在这里没有注入messageSource

而这个方法上有标识@ConditionalOnMissingBean(Validator.class)注解,也就是说,如果已经存在Validator类,那么久不会执行Spring Boot自身校验器的初始化流程;这个就奇怪了,之前笔者自定义的Validator在注入后,并没有使得这个初始化失效。笔者尝试在这个方法上加了断点,启动应用后,笔者定义的ValidatorSpring Boot自身的Validator都执行了初始化过程。

这个时候,笔者的内心真的是崩溃的,难不成Spring BootConditional机制失效了???

突然想到,ConditionalOnMissingBean是根据类来判断的,那么会不会存在两个Validator类?然后对比了一下,发现了一个巨坑无比的事情:

笔者引入的全限定名:org.springframework.validation.Validator
Spring Boot支持的全限定名:javax.validation.Validator

难怪一致无法成功覆盖默认配置。

而为什么类全限定名不一样,而仍旧可以返回LocalValidatorFactoryBean类的实例呢?因为,LocalValidatorFactoryBean类的父类SpringValidatorAdapter实现了javax.validation.Validator接口以及SmartValidator接口;而SmartValidator接口继承了org.springframework.validation.Validator接口。所以,对LocalValidatorFactoryBean类的实例来说,都可以兼容。

这个也就是为什么笔者在执行校验的时候,校验器直接返回消息模板而不是解析后的消息的原因所在。

总结

一句话,引入类的时候,以后还是要仔细点。