Issue
I'm working on a platform that runs Spring Batch jobs which are responsible for retrieving a group of objects from a third party application, performs bean validations and returns any constraint violations back up to the third party application for the user to then correct (items without violations get transformed and passed to another application). Right now, we use the Validator
configured by Spring Boot and this all works great in English.
We're expanding which users have access to the third party application and now need to provide the constraint validations in a language appropriate to the user who created the object. I have a means to lookup the language/locale needed for a particular object, but what I'm missing is how to tell the Validator
the locale of the messages in the Set<ConstraintViolation<T>>
returned by the validate(<T> object)
method. Furthermore, there might be multiple jobs running at the same time, each validating their own type of object and needing the violations reported back in a different language. Ideally, it would be nice to have a validate(<T> object, Locale locale)
method, but that doesn't exist in the Validator
interface.
My first thought was to write a custom MessageInterpolator
, and set the appropriate Locale
prior to each validation (see ValueMessageInterpolator
and DemoJobConfig
below) however it's not thread-safe, so it's possible we could end up with with messages in the wrong language.
I also considered if there was a way to use the LocaleResolver
interface to assist instead, but I'm not seeing a solution that wouldn't have the same issues as the MessageInterpolator
.
Based on what I've determined so far, it seems like my only solutions are:
- Instantiate separate
Validator
s andMessageInterpolator
s for each batch job/step that needs one and use the approach already presented. This approach seems rather inefficient because of the cycling through these objects. - Create a service bean that contains a collection of
Validator
s, one for each Locale needed. Each batch job/step could then reference this new service and the service would be responsible for delegating to the appropriateValidator
. The validators could be setup something like this and would limit the number of validators needed to the number of languages we support.
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();
- Instead of calling a
Validator
directly, create a microservice that would just accept objects for validation and then pass in the requisiteLocale
via the Accept-Language header. While I might get away with only having oneValidator
bean, this seems unnecessarily complex.
Are there alternative approaches that could be used to solve this problem?
We are currently using the 2.5.3 spring-boot-starter-parent
pom to manage dependencies and would likely update to the most recent 2.6.x release by the time we need to implement these changes.
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();
}
}
Solution
The ValidationAutoConfiguration
from Spring Boot creates a LocalValidatorFactoryBean
, where, in the afterPropertiesSet()
method a LocaleContextMessageInterpolator
is configured.
So, the only change needed to support this requirement is a LocaleContextHolder.setLocale(Locale locale)
added prior to the validation call in the ItemProcessor
. The LocalContextHolder
keeps a ThreadLocal<LocaleContext>
which allows each thread (job/step) to keep it's own version of the current Locale
being used.
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);
}
}
};
Answered By - Erik Volkman