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に書いてありますので、キャッシュして使い回しても大丈夫なはず・・・です。
僕はあまりスレッドセーフ関連は詳しくないので、ご自分でも十分に調査の上、利用してください。
参考資料
槙さんのスライド
コントローラークラスの作成
@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