Validator内国际化未生效的解决

2,581 阅读5分钟

背景

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

相关源码

Java Bean:

@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:

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

ValidatorConfig:

@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:

@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的参数做参数解析。

@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);语句内。

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的实例,校验的逻辑如下:

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实现:

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类内做具体的校验,其中的层次调用就不在本篇内描述。

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();
}

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

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

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 ) 方法,会真正将消息转化:

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的初始化过程未成功设置国际化?

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的自动化配置类:

@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类的实例来说,都可以兼容。

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

总结

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