Bean Validationでクライアント側のロケールに合わせたメッセージを出力する

やりたいこと

通常のBean Validationでは、クライアント側ではなく、サーバー側のロケールのエラーメッセージになってしまいます。

なので、クライアント側のロケールに合わせて

$ curl -v -H "Accept: text/html" -H "Accept-Language: ja" http://localhost:8080/jjug-my-mvc-1.0-SNAPSHOT/api/employee/result?id=a

としたらValidationMessages_ja.propertiesのエラーメッセージになり、

$ curl -v -H "Accept: text/html" -H "Accept-Language: en" http://localhost:8080/jjug-my-mvc-1.0-SNAPSHOT/api/employee/result?id=a

としたらValidationMessages_en.propertiesのエラーメッセージになり、

$ curl -v -H "Accept: text/html" -H "Accept-Language: cs" http://localhost:8080/jjug-my-mvc-1.0-SNAPSHOT/api/employee/result?id=a

としたらValidationMessages.propertiesのエラーメッセージになるようにします。

独自ResourceBundleLocatorの作成

Bean Validation参照実装のHibernate Validator独自インタフェースです。

ResourceBundleLocatorは、Localが引数で戻り値がResourceBundleなメソッドを持っているので、この中でゴニョゴニョします。

package com.example.rest.validation;

import org.hibernate.validator.spi.resourceloading.ResourceBundleLocator;

import java.util.Locale;
import java.util.ResourceBundle;

class NoFallbackControlResourceBundleLocator implements ResourceBundleLocator {
    @Override
    public ResourceBundle getResourceBundle(Locale locale) {
        ResourceBundle.Control control =
                ResourceBundle.Control.getNoFallbackControl(
                        ResourceBundle.Control.FORMAT_DEFAULT);
        ResourceBundle bundle = ResourceBundle.getBundle("ValidationMessages", locale, control);
        return bundle;
    }
}

ResourceBundleは、指定されたロケールに合致したプロパティファイルが無い場合、デフォルトロケール(僕の環境だとja_JP)のプロパティファイルを選びます。

これは今回のやりたいことに合致しないので、「フォールバック制御」を行っています。

getNoFallbackControl()メソッドによって、ロケールに合致したプロパティファイルが無い場合、ロケール無しのプロパティファイルが選ばれるようになります。

参考資料

櫻庭さんの記事です。上記の内容は、この記事を読んで初めて知りました・・・。

Java技術最前線 - 「Java SE 6完全攻略」第54回 ResourceBundleの新機能 その2:ITpro

独自MessageInterpolatorの作成

MessageInterpolator自体は、Bean Validationで定義されているインタフェースです。

今回は、Hibernate Validatorが持っているMessageInterpolator実装クラスを継承して、メソッドをオーバーライドします。

package com.example.rest.validation;

import org.hibernate.validator.messageinterpolation.ResourceBundleMessageInterpolator;

import java.util.Locale;

class LocalizedMessageInterpolator extends ResourceBundleMessageInterpolator {

    private final Locale locale;

    LocalizedMessageInterpolator(Locale locale) {
        super(new NoFallbackControlResourceBundleLocator());
        this.locale = locale;
    }

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

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

コンストラクタでロケールを受け取り、1つ目のinterpolate()メソッド内で利用します。

2つ目のinterpolate()メソッドはどんな時に呼ばれるのかは、まだ不明です... (^^;

参考資料

Hibernate Validatorのドキュメント

Chapter 4. Interpolating constraint error messages

ローカライズしたValidatorの作成

package com.example.rest.validation;

import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.inject.spi.CDI;
import javax.servlet.http.HttpServletRequest;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

@ApplicationScoped
public class LocalizedValidator {

    private ConcurrentMap<Locale, Validator> validatorCache = new ConcurrentHashMap<>();

    public <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) {
        HttpServletRequest httpServletRequest = CDI.current().select(HttpServletRequest.class).get();
        Locale locale = httpServletRequest.getLocale();
        Validator validator = validatorCache.computeIfAbsent(locale, (keyLocale) ->
            Validation.byDefaultProvider()
                    .configure()
                    .messageInterpolator(new LocalizedMessageInterpolator(keyLocale))
                    .buildValidatorFactory()
                    .getValidator()
        );
        return validator.validate(object, groups);
    }
}

HttpServletRequestからクライアント側のロケールを取得し、LocalizedMessageInterpolatorに渡します。

Validatorの生成を毎回行なうのは効率的でないので、キャッシュしました。

ValidatorFactoryおよびValidatorはスレッドセーフとJavadocに書いてありますので、キャッシュして使い回しても大丈夫なはず・・・です。

僕はあまりスレッドセーフ関連は詳しくないので、ご自分でも十分に調査の上、利用してください。

参考資料

槙さんのスライド

そんなリザルトキャッシュで大丈夫か? #jjug

コントローラークラスの作成

@Path("employee")
@RequestScoped
@Produces(MediaType.TEXT_HTML)
public class EmployeeController {

    @Inject
    private LocalizedValidator validator;

    @GET
    @Path("result")
    public ThymeleafViewable result(@BeanParam EmployeeIdForm form) throws Exception {
        // バリデーション実行
        Set<ConstraintViolation<EmployeeIdForm>> violations = validator.validate(form);
        // エラーがあれば入力画面に戻る
        if (!violations.isEmpty()) {
            HashMap<String, Object> models = new HashMap<>();
            models.put("violations", violations);
            return new ThymeleafViewable("employee/index.html", models);
        }

先ほど作成したLocalizedValidatorをDIし、コントローラーメソッド内でバリデーションを実行します。

プロパティファイルの作成

日本語用 src/main/resources/ValidationMessages_ja.properties

employee.id.notblank=社員IDは必須入力です。
employee.id.pattern=社員IDは整数で入力してください。

英語用 src/main/resources/ValidationMessages_en.properties

employee.id.notblank=Employee ID must not be blank.
employee.id.pattern=Employee ID must be integer.

それ以外用(英語のメッセージの先頭に「(Def)」をつけています) src/main/resources/ValidationMessages.properties

employee.id.notblank=(Def)Employee ID must not be blank.
employee.id.pattern=(Def)Employee ID must be integer.

実行

$ curl -v -H "Accept: text/html" -H "Accept-Language: ja" http://localhost:8080/jjug-my-mvc-1.0-SNAPSHOT/api/employee/result?id=a
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /jjug-my-mvc-1.0-SNAPSHOT/api/employee/result?id=a HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.43.0
> Accept: text/html
> Accept-Language: ja
>
< HTTP/1.1 200 OK
< Server: Payara Micro #badassfish
< Set-Cookie: JSESSIONID=43dfaf692f43c4bcdd999837d7c9; Path=/jjug-my-mvc-1.0-SNAPSHOT; HttpOnly
< Content-Type: text/html
< Date: Thu, 09 Jun 2016 08:18:45 GMT
< Content-Length: 644
<
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
(中略)
<ul class="error">
    <li>社員IDは整数で入力してください。</li>

</ul>
(中略)
* Connection #0 to host localhost left intact
$ curl -v -H "Accept: text/html" -H "Accept-Language: en" http://localhost:8080/jjug-my-mvc-1.0-SNAPSHOT/api/employee/result?id=a
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /jjug-my-mvc-1.0-SNAPSHOT/api/employee/result?id=a HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.43.0
> Accept: text/html
> Accept-Language: en
>
< HTTP/1.1 200 OK
< Server: Payara Micro #badassfish
< Set-Cookie: JSESSIONID=43e9b73c50fe3be35f60bd734842; Path=/jjug-my-mvc-1.0-SNAPSHOT; HttpOnly
< Content-Type: text/html
< Date: Thu, 09 Jun 2016 08:19:26 GMT
< Content-Length: 616
<
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
(中略)
<ul class="error">
    <li>Employee ID must be integer.</li>

</ul>
(中略)
* Connection #0 to host localhost left intact
$ curl -v -H "Accept: text/html" -H "Accept-Language: cs" http://localhost:8080/jjug-my-mvc-1.0-SNAPSHOT/api/employee/result?id=a
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /jjug-my-mvc-1.0-SNAPSHOT/api/employee/result?id=a HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.43.0
> Accept: text/html
> Accept-Language: cs
>
< HTTP/1.1 200 OK
< Server: Payara Micro #badassfish
< Set-Cookie: JSESSIONID=43efe0e6f24b7d015771568feb84; Path=/jjug-my-mvc-1.0-SNAPSHOT; HttpOnly
< Content-Type: text/html
< Date: Thu, 09 Jun 2016 08:19:51 GMT
< Content-Length: 640
<
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
(中略)
<ul class="error">
    <li>(Def)Employee ID must be integer.</li>

</ul>
(中略)
* Connection #0 to host localhost left intact

今回のコード

https://github.com/MasatoshiTada/jjug-action-based-mvc/tree/master/jjug-validation

https://github.com/MasatoshiTada/jjug-action-based-mvc/tree/master/jjug-my-mvc