如何为每个 bean 验证调用指定不同的语言环境

How to specify different locales for each bean validation call

我正在一个平台上工作,该平台运行 Spring 批处理作业,负责从第三方应用程序检索一组 object,执行 bean 验证和 returns任何违反约束的行为都会反馈给第三方应用程序供用户纠正(没有违反约束的项目会被转换并传递给另一个应用程序)。现在,我们使用由 Spring Boot 配置的 Validator,这在英语中运行良好。

我们正在扩展哪些用户可以访问第三方应用程序,现在需要以适合创建 object 的用户的语言提供约束验证。我有办法查找特定 object 所需的 language/locale,但我缺少的是如何告诉 Validator Set<ConstraintViolation<T>> 中消息的语言环境由 validate(<T> object) 方法返回。此外,可能同时有多个作业 运行,每个作业都验证自己的类型 object 并需要以不同的语言报告违规行为。理想情况下,有一个 validate(<T> object, Locale locale) 方法会很好,但 Validator 接口中不存在该方法。

我的第一个想法是编写自定义 MessageInterpolator,并在每次验证之前设置适当的 Locale(请参阅下面的 ValueMessageInterpolatorDemoJobConfig),但事实并非如此thread-safe,所以我们最终可能会收到错误语言的消息。

我还考虑过是否可以使用 LocaleResolver 界面来提供帮助,但我没有看到不会出现与 MessageInterpolator 相同问题的解决方案。

根据我目前所确定的,似乎我唯一的解决方案是:

  1. 为需要一个的每个批次 job/step 实例化单独的 Validators 和 MessageInterpolators 并使用已经介绍的方法。由于循环通过这些 objects,这种方法似乎相当低效。
  2. 创建一个包含 collection 个 Validator 的服务 bean,一个用于每个需要的 Locale。然后每个批次 job/step 可以引用这个新服务,并且该服务将负责委托给适当的 Validator。验证器可以像这样设置,并将所需的验证器数量限制为我们支持的语言数量。
javax.validation.Validator caFRValidator = Validation.byProvider(HibernateValidator.class).configure().localeResolver(context -> {return Locale.CANADA_FRENCH;}).buildValidatorFactory().getValidator();
javax.validation.Validator usValidator = Validation.byProvider(HibernateValidator.class).configure().localeResolver(context -> {return Locale.US;}).buildValidatorFactory().getValidator();
javax.validation.Validator germanValidator = Validation.byProvider(HibernateValidator.class).configure().localeResolver(context -> {return Locale.GERMANY;}).buildValidatorFactory().getValidator();
  1. 与其直接调用 Validator,不如创建一个只接受 object 进行验证的微服务,然后通过 Accept-Language header。虽然我可能只拥有一个 Validator bean,但这似乎不必要地复杂。

是否有其他方法可以用来解决这个问题?

我们目前正在使用 2.5.3 spring-boot-starter-parent pom 来管理依赖项,并且可能会在我们需要实施这些更改时更新到最新的 2.6.x 版本。

ValueMessageInterpolator.java

public class ValueMessageInterpolator implements MessageInterpolator {

    private final MessageInterpolator interpolator;
    private Locale currentLocale;
    
    public ValueMessageInterpolator(MessageInterpolator interp) {
        this.interpolator = interp;
        this.currentLocale = Locale.getDefault();
    }
    
    public void setLocale(Locale locale) {
        this.currentLocale = locale;
    }
    
    @Override
    public String interpolate(String messageTemplate, Context context) {
        return interpolator.interpolate(messageTemplate, context, currentLocale);
    }

    @Override
    public String interpolate(String messageTemplate, Context context, Locale locale) {
        return interpolator.interpolate(messageTemplate, context, locale);
    }

}

ToBeValidated.java

public class ToBeValidated {
    @NotBlank
    private final String value;

    private final Locale locale;
    
    // Other boilerplate code removed
}

DemoJobConfig.java

@Configuration
@EnableBatchProcessing
public class DemoJobConfig extends DefaultBatchConfigurer {

    @Bean
    public ValueMessageInterpolator buildInterpolator() {
        return new ValueMessageInterpolator(Validation.byDefaultProvider().configure().getDefaultMessageInterpolator());
    }

    @Bean
    public javax.validation.Validator buildValidator(ValueMessageInterpolator valueInterp) {
        return Validation.byDefaultProvider().configure().messageInterpolator(valueInterp).buildValidatorFactory().getValidator();
    }

    @Bean
    public Job configureJob(JobBuilderFactory jobFactory, Step demoStep) {
        return jobFactory.get("demoJob").start(demoStep).build();
    }

    @Bean
    public Step configureStep(StepBuilderFactory stepFactory, javax.validation.Validator constValidator, ValueMessageInterpolator interpolator) {

        ItemReader<ToBeValidated> reader = 
                new ListItemReader<ToBeValidated>(Arrays.asList(
                        new ToBeValidated("values1", Locale.US),            // (No errors)
                        new ToBeValidated("", Locale.US),                   // value: must not be blank
                        new ToBeValidated("", Locale.CANADA),               // value: must not be blank
                        new ToBeValidated("value3", Locale.CANADA_FRENCH),  // (No errors)  
                        new ToBeValidated("", Locale.FRANCE),               // value: ne doit pas être vide
                        new ToBeValidated("", Locale.GERMANY)               // value: kann nicht leer sein
                        ));

        Validator<ToBeValidated> springValidator = new Validator<ToBeValidated>() {
            @Override
            public void validate(ToBeValidated value) throws ValidationException {
                interpolator.setLocale(value.getLocale());
                String errors = constValidator.validate(value).stream().map(v -> v.getPropertyPath().toString() +": "+v.getMessage()).collect(Collectors.joining(","));
                if(errors != null && !errors.isEmpty()) {
                    throw new ValidationException(errors);
                }
            }
        };

        ItemProcessor<ToBeValidated, ToBeValidated> processor = new ValidatingItemProcessor<ToBeValidated>(springValidator);

        ItemWriter<ToBeValidated> writer =  new ItemWriter<ToBeValidated>() {
            @Override
            public void write(List<? extends ToBeValidated> items) throws Exception {
                items.forEach(System.out::println);
            }
        };
        
        SkipListener<ToBeValidated, ToBeValidated> skipListener = new SkipListener<ToBeValidated, ToBeValidated>() {
            @Override
            public void onSkipInRead(Throwable t) {}

            @Override
            public void onSkipInWrite(ToBeValidated item, Throwable t) {}

            @Override
            public void onSkipInProcess(ToBeValidated item, Throwable t) {
                System.out.println("Skipped ["+item.toString()+"] for reason(s) ["+t.getMessage()+"]");
            }
        };

        return stepFactory.get("demoStep")
                .<ToBeValidated, ToBeValidated>chunk(2)
                .reader(reader)
                .processor(processor)
                .writer(writer)
                .faultTolerant()
                .skip(ValidationException.class)
                .skipLimit(10)
                .listener(skipListener)
                .build();
    }

    @Override
    public PlatformTransactionManager getTransactionManager() {
        return new ResourcelessTransactionManager();
    }
}

Spring Boot 中的 ValidationAutoConfiguration 创建了一个 LocalValidatorFactoryBean,其中,在 afterPropertiesSet() 方法中配置了一个 LocaleContextMessageInterpolator

因此,支持此要求所需的唯一更改是 LocaleContextHolder.setLocale(Locale locale)ItemProcessor 中的验证调用之前添加。 LocalContextHolder 保留一个 ThreadLocal<LocaleContext>,它允许每个线程 (job/step) 保留它自己的当前正在使用的 Locale 版本。

        Validator<ToBeValidated> springValidator = new Validator<ToBeValidated>() {
            @Override
            public void validate(ToBeValidated value) throws ValidationException {
                LocaleContextHolder.setLocale(value.getLocale());
                String errors = constValidator.validate(value).stream().map(v -> v.getPropertyPath().toString() +": "+v.getMessage()).collect(Collectors.joining(","));
                if(errors != null && !errors.isEmpty()) {
                    throw new ValidationException(errors);
                }
            }
        };