これは、なにをしたくて書いたもの?
Spring Boot(Spring Framework)を使って、Bean Validationのメッセージを変更したりするのをどうやるのかをよく覚えて
いなかったので、確認してみることに。
結局、Bean Validationの復習的な感じになりましたけど。
せっかくなので、自分でValidatorを作り、対応するメッセージをプロパティファイルに定義して組み込むことをやってみたいと
思います。
あと、プラスで既存メッセージの上書きも。
Bean Validationで自作のValidatorを作成する
これについては、Hibernate Validatorを見るのがよいでしょう。
バリデーションで使うアノテーションを作成して、
対応するValidatorを作成します。
エラーメッセージに関しては、ValidationMessages.propertiesというファイルに組み込むことになっています。
メッセージを解決するルールは、こちらに書かれています。
デフォルトではValidationMessages.properties(これはBean Validationの利用者が作成する)からメッセージを取得し、
なければorg.hibernate.validator.ValidationMessagesというデフォルトのファイルからメッセージを取得します。
この動きは、SpringでBean Validationを使う上でも押さえておくと良いでしょう。
Spring FrameworkとBean Validation
一方で、Spring Framework側はそれほどBean Validationについては詳しく書いていません。
LocalValidatorFactoryBeanやSpringの提供するValidator、MethodValidationPostProcessor、DataBinderなどが
Spring固有の話として触れられています。
Spring Bootに至っては、ほとんど記述がありません。クラスパス上にBean Validationの実装がある場合に、自動で有効に
なります、くらいですね。
Spring Frameworkで使うBean Validationに、自作のメッセージファイルを組み込む
で、Spring Bootで自分で用意したメッセージ用のプロパティファイルを組み込むには?という方法で調べると、
だいたいLocalValidatorFactoryBeanに対して、ReloadableResourceBundleMessageSourceをMessageSourceとして
組み込むような方法が見つかると思います。
Custom Validation MessageSource in Spring Boot | Baeldung
Spring FrameworkのバリデーションではBean Validationでのメッセージ解決の際にMessageSourceおよびMessageCodesResolverを
使うようになっているようです。
Core / Validation, Data Binding, and Type Conversion / Resolving Codes to Error Messages
つまり、Spring Bootの場合はmessages.propertiesにMessageCodesResolverのルールに従って記述できることになります。
MessageCodesResolverのデフォルトの実装はこちら。
DefaultMessageCodesResolver (Spring Framework 5.3.6 API)
messages.propertiesで解決できなかった場合は、Bean Validationでのメッセージ解決の仕組みにフォールバックするようです。
その場合は、結局のところ内部で動くのはBean Validationなので、ValidationMessages.propertiesファイルが存在すれば
そちらを利用する挙動になります。
MessageSourceを渡した場合は、意味としてはValidationMessages.propertiesではなく別のファイルから読み込むように
Bean Validationをカスタマイズしたこととほぼ同じのようです。
ソースコードとしては、userResourceBundleLocatorをSpring(というかアプリケーション側)で作って渡すかどうか
(指定しなかった場合は、デフォルトのValidationMessages.propertiesを探そうとする)ということになります。
Bean定義でこういうコードを書いた場合は、
localValidatorFactoryBean.setValidationMessageSource(messageSource);
Spring側でResourceBundleLocatorのインスタンスを作って、Hibernate Validatorに渡してValidationMessages.propertiesの
代わりに使う、という感じです。
もっとも、Springが提供するMessageSourceの実装には良さもありますし、そもそもSpringのバリデーションの仕組みでメッセージ解決に
使われるのはMessageSourceなので、こちらに従う方が素直なのかもしれません。
ReloadableResourceBundleMessageSource (Spring Framework 5.3.6 API)
ResourceBundleMessageSource (Spring Framework 5.3.6 API)
なお、Hibernate Validatorが使うメッセージは次の3種類があります。
ValidationMessages.propertiesContributorValidationMessages.propertiesorg.hibernate.validator.ValidationMessages
メッセージの解決は、ValidationMessages.properties → ContributorValidationMessages.properties →
org.hibernate.validator.ValidationMessagesの順に行われます。
※ソースコードを見ていると、ContributorValidationMessages.propertiesが使われるのには条件があるみたいですけどね
このため、ValidationMessages.propertiesにデフォルトで定義されたメッセージ(org.hibernate.validator.ValidationMessagesで
定義されたメッセージ)と同じキーを用意すると、そのメッセージを上書きすることができることになります。
デフォルトのメッセージが定義されているのは、こちら。
とまあ、説明はこれくらいにして、実際に試してみましょう。
環境
今回の環境は、こちらです。
$ java --version openjdk 11.0.11 2021-04-20 OpenJDK Runtime Environment (build 11.0.11+9-Ubuntu-0ubuntu2.20.04) OpenJDK 64-Bit Server VM (build 11.0.11+9-Ubuntu-0ubuntu2.20.04, mixed mode, sharing) $ mvn --version Apache Maven 3.8.1 (05c21c65bdfed0f71a2f2ada8b84da59348c4c5d) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 11.0.11, vendor: Ubuntu, runtime: /usr/lib/jvm/java-11-openjdk-amd64 Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "5.4.0-73-generic", arch: "amd64", family: "unix"
準備
Spring Bootプロジェクトを作成します。依存関係は、validationのみを含めました。
$ curl -s https://start.spring.io/starter.tgz \ -d bootVersion=2.4.5 \ -d javaVersion=11 \ -d name=my-validator-and-message \ -d groupId=org.littlewings \ -d artifactId=my-validator-and-message \ -d version=0.0.1-SNAPSHOT \ -d packageName=org.littlewings.spring.beanvalidation \ -d dependencies=validation \ -d baseDir=my-validator-and-message | tar zxvf - $ cd my-validator-and-message $ find src -name '*.java' | xargs rm
Spring Bootのバージョンは2.4.5で、最初から入っているソースコードは削除。
Mavenの依存関係などは、こちらです。
<properties> <java.version>11</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>
自作のValidatorを作成する
まずは、自分でValidatorを作成します。お題としては、アノテーションで指定した値のどれかであること、というルールに
しましょう。
こんな感じで作成。
src/main/java/org/littlewings/spring/beanvalidation/Select.java
package org.littlewings.spring.beanvalidation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Repeatable; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import javax.validation.Constraint; import javax.validation.Payload; @Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE, ElementType.TYPE_USE}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = SelectValidator.class) @Documented @Repeatable(Select.List.class) public @interface Select { String message() default "{org.littlewings.spring.beanvalidation.Select.message}"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; String[] value(); @Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented @interface List { Select[] value(); } }
メッセージは、設定ファイルを参照するようにしています。
Validator側。
src/main/java/org/littlewings/spring/beanvalidation/SelectValidator.java
package org.littlewings.spring.beanvalidation; import java.util.Arrays; import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; public class SelectValidator implements ConstraintValidator<Select, String> { Select select; @Override public void initialize(Select select) { this.select = select; } @Override public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) { return Arrays.asList(select.value()).contains(value); } }
メッセージファイルについては、いろいろ変えながら試していくので後で載せます。
@SpringBootApplicationを付与したクラス
動作確認は、テストコードで行うことにしました。
とはいえ、@SpringBootApplicationアノテーションが付与されたクラスは必要になるので、作っておきます。
src/main/java/org/littlewings/spring/beanvalidation/App.java
package org.littlewings.spring.beanvalidation; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class App { }
テストコード
まずは、バリデーションを行う対象のクラスを用意します。
src/test/java/org/littlewings/spring/beanvalidation/Person.java
package org.littlewings.spring.beanvalidation; import javax.validation.constraints.Min; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotNull; public class Person { @NotEmpty String firstName; @NotNull @Select({"磯野", "フグ田"}) String lastName; @Min(1) int age; // getter/setterは省略 }
標準のアノテーションも利用。
自分で作った@SelectアノテーションはlastNameに付与して、「磯野」と「フグ田」のみOKとするようにしています。
テストコードは、こちら。
src/test/java/org/littlewings/spring/beanvalidation/ValidationTest.java
package org.littlewings.spring.beanvalidation; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.Validator; import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest public class ValidationTest { @Autowired Validator validator; @Test public void valid() { Person katsuo = new Person(); katsuo.setLastName("磯野"); katsuo.setFirstName("カツオ"); katsuo.setAge(11); BeanPropertyBindingResult errors1 = new BeanPropertyBindingResult(katsuo, "bean"); validator.validate(katsuo, errors1); assertThat(errors1.hasErrors()).isFalse(); Person masuo = new Person(); masuo.setLastName("フグ田"); masuo.setFirstName("マスオ"); masuo.setAge(28); BeanPropertyBindingResult errors2 = new BeanPropertyBindingResult(masuo, "bean"); validator.validate(masuo, errors2); assertThat(errors2.hasErrors()).isFalse(); } @Test public void notValid() { Person taro = new Person(); taro.setFirstName("太郎"); taro.setAge(-1); BeanPropertyBindingResult errors1 = new BeanPropertyBindingResult(taro, "bean"); validator.validate(taro, errors1); assertThat(errors1.hasErrors()).isTrue(); assertThat(errors1.getFieldErrors().stream().map(f -> f.getField() + " : " + f.getDefaultMessage()).collect(Collectors.toList())) .hasSize(3) .containsOnly( // エラー時のメッセージ ); Person suzuki = new Person(); suzuki.setLastName("鈴木"); suzuki.setAge(-1); BeanPropertyBindingResult errors2 = new BeanPropertyBindingResult(suzuki, "bean"); validator.validate(suzuki, errors2); assertThat(errors2.hasErrors()).isTrue(); assertThat(errors2.getFieldErrors().stream().map(f -> f.getField() + " : " + f.getDefaultMessage()).collect(Collectors.toList())) .hasSize(3) .containsOnly( // エラー時のメッセージ ); } }
バリデーションには、SpringのValidatorと
@Autowired
Validator validator;
BeanPropertyBindingResultを使うことにしました。
Person katsuo = new Person(); katsuo.setLastName("磯野"); katsuo.setFirstName("カツオ"); katsuo.setAge(11); BeanPropertyBindingResult errors1 = new BeanPropertyBindingResult(katsuo, "bean"); validator.validate(katsuo, errors1); assertThat(errors1.hasErrors()).isFalse();
バリデーションがOKとなる方はいいでしょう。ここからは、NGとなる方のメッセージ定義を中心に見ていきます。
メッセージ定義のあれこれ
ValidationMessages.propertiesを使う
まずは、ValidationMessages.propertiesを用意しましょう。
src/main/resources/ValidationMessages.properties
org.littlewings.spring.beanvalidation.Select.message={value} のいずれかから選択してください
こちらを配置した時は、エラーメッセージを含めたテスト結果は、このようになります。
@Test public void notValid() { Person taro = new Person(); taro.setFirstName("太郎"); taro.setAge(-1); BeanPropertyBindingResult errors1 = new BeanPropertyBindingResult(taro, "bean"); validator.validate(taro, errors1); assertThat(errors1.hasErrors()).isTrue(); assertThat(errors1.getFieldErrors().stream().map(f -> f.getField() + " : " + f.getDefaultMessage()).collect(Collectors.toList())) .hasSize(3) .containsOnly( "lastName : [磯野, フグ田] のいずれかから選択してください", "lastName : null は許可されていません", "age : 1 以上の値にしてください" ); Person suzuki = new Person(); suzuki.setLastName("鈴木"); suzuki.setAge(-1); BeanPropertyBindingResult errors2 = new BeanPropertyBindingResult(suzuki, "bean"); validator.validate(suzuki, errors2); assertThat(errors2.hasErrors()).isTrue(); assertThat(errors2.getFieldErrors().stream().map(f -> f.getField() + " : " + f.getDefaultMessage()).collect(Collectors.toList())) .hasSize(3) .containsOnly( "lastName : [磯野, フグ田] のいずれかから選択してください", "firstName : 空要素は許可されていません", "age : 1 以上の値にしてください" ); }
他の2つのものは、標準のアノテーションでのメッセージですね。
このファイルの内容です。
ではここで、標準のアノテーションに対応するメッセージも定義してみます。@NotNullと@Minに対して定義した形です。
@NotEmptyは、特に変えません。
src/main/resources/ValidationMessages.properties
org.littlewings.spring.beanvalidation.Select.message={value} のいずれかから選択してください
javax.validation.constraints.NotNull.message = null はダメです
javax.validation.constraints.Min.message = {value} 以上でお願いします
こうすると、メッセージはそれぞれこのように変化します。
assertThat(errors1.hasErrors()).isTrue();
assertThat(errors1.getFieldErrors().stream().map(f -> f.getField() + " : " + f.getDefaultMessage()).collect(Collectors.toList()))
.hasSize(3)
.containsOnly(
"lastName : [磯野, フグ田] のいずれかから選択してください",
//"lastName : null は許可されていません",
"lastName : null はダメです",
//"age : 1 以上の値にしてください"
"age : 1 以上でお願いします"
);
assertThat(errors2.hasErrors()).isTrue();
assertThat(errors2.getFieldErrors().stream().map(f -> f.getField() + " : " + f.getDefaultMessage()).collect(Collectors.toList()))
.hasSize(3)
.containsOnly(
"lastName : [磯野, フグ田] のいずれかから選択してください",
"firstName : 空要素は許可されていません",
//"age : 1 以上の値にしてください"
"age : 1 以上でお願いします"
);
@NotNullと@Minは定義したメッセージに変更され、@NotEmptyはそのままですね。
つまり、必要なものだけ上書きできます、と。また、最初の確認結果から、独自にメッセージファイルを用意したからといって
デフォルトの内容を塗りつぶすというわけでもないようです。
LocalValidatorFactoryBeanとMessageSourceを使う
次は、LocalValidatorFactoryBeanとMessageSourceを使ってみましょう。
まず、メッセージファイルはValidationMessages.propertiesからリネームしておきます。
$ mv src/main/resources/ValidationMessages.properties src/main/resources/my-validation-messages.properties
この時点で、テストコードは自前のバリデーションのメッセージ、変更した標準アノテーションのメッセージがわからなくなり、
テストは失敗します。
リネーム後のファイル。
src/main/resources/my-validation-messages.properties
org.littlewings.spring.beanvalidation.Select.message={value} のいずれかから選択してください
javax.validation.constraints.NotNull.message = null はダメです
javax.validation.constraints.Min.message = {value} 以上でお願いします
そして、このファイルを使うようにReloadableResourceBundleMessageSourceをMessageSourceとして設定した
LocalValidatorFactoryBeanをBean定義。
src/main/java/org/littlewings/spring/beanvalidation/ValidatorConfig.java
package org.littlewings.spring.beanvalidation; import java.io.IOException; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.support.ReloadableResourceBundleMessageSource; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; @Configuration public class ValidatorConfig { @Bean public LocalValidatorFactoryBean localValidatorFactoryBean() throws IOException { LocalValidatorFactoryBean localValidatorFactoryBean = new LocalValidatorFactoryBean(); ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); messageSource.setBasename("classpath:my-validation-messages"); messageSource.setDefaultEncoding("UTF-8"); localValidatorFactoryBean.setValidationMessageSource(messageSource); return localValidatorFactoryBean; } }
これで、同じく以下のメッセージを期待するテストがパスするようになります。
assertThat(errors1.hasErrors()).isTrue();
assertThat(errors1.getFieldErrors().stream().map(f -> f.getField() + " : " + f.getDefaultMessage()).collect(Collectors.toList()))
.hasSize(3)
.containsOnly(
"lastName : [磯野, フグ田] のいずれかから選択してください",
//"lastName : null は許可されていません",
"lastName : null はダメです",
//"age : 1 以上の値にしてください"
"age : 1 以上でお願いします"
);
assertThat(errors2.hasErrors()).isTrue();
assertThat(errors2.getFieldErrors().stream().map(f -> f.getField() + " : " + f.getDefaultMessage()).collect(Collectors.toList()))
.hasSize(3)
.containsOnly(
"lastName : [磯野, フグ田] のいずれかから選択してください",
"firstName : 空要素は許可されていません",
//"age : 1 以上の値にしてください"
"age : 1 以上でお願いします"
);
標準アノテーションに対応するメッセージをコメントアウトすると
src/main/resources/my-validation-messages.properties
org.littlewings.spring.beanvalidation.Select.message={value} のいずれかから選択してください
#javax.validation.constraints.NotNull.message = null はダメです
#javax.validation.constraints.Min.message = {value} 以上でお願いします
当然といえば当然ですが、標準アノテーションに対するメッセージは元に戻ります。
assertThat(errors1.hasErrors()).isTrue();
assertThat(errors1.getFieldErrors().stream().map(f -> f.getField() + " : " + f.getDefaultMessage()).collect(Collectors.toList()))
.hasSize(3)
.containsOnly(
"lastName : [磯野, フグ田] のいずれかから選択してください",
"lastName : null は許可されていません",
//"lastName : null はダメです",
"age : 1 以上の値にしてください"
//"age : 1 以上でお願いします"
);
assertThat(errors2.hasErrors()).isTrue();
assertThat(errors2.getFieldErrors().stream().map(f -> f.getField() + " : " + f.getDefaultMessage()).collect(Collectors.toList()))
.hasSize(3)
.containsOnly(
"lastName : [磯野, フグ田] のいずれかから選択してください",
"firstName : 空要素は許可されていません",
"age : 1 以上の値にしてください"
//"age : 1 以上でお願いします"
);
あと、国際化(ResourceBundle)を気にしないのなら、こんな感じでもよいのでは?と思ったりもするのですが。
src/main/java/org/littlewings/spring/beanvalidation/ValidatorConfig.java
package org.littlewings.spring.beanvalidation; import java.io.IOException; import org.springframework.beans.factory.config.PropertiesFactoryBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.support.ReloadableResourceBundleMessageSource; import org.springframework.context.support.StaticMessageSource; import org.springframework.core.io.ClassPathResource; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; @Configuration public class ValidatorConfig { @Bean public LocalValidatorFactoryBean localValidatorFactoryBean() throws IOException { LocalValidatorFactoryBean localValidatorFactoryBean = new LocalValidatorFactoryBean(); PropertiesFactoryBean propertiesFactoryBean = new PropertiesFactoryBean(); propertiesFactoryBean.setLocation(new ClassPathResource("my-validation-messages.properties")); propertiesFactoryBean.setFileEncoding("UTF-8"); propertiesFactoryBean.afterPropertiesSet(); StaticMessageSource messageSource = new StaticMessageSource(); messageSource.setCommonMessages(propertiesFactoryBean.getObject()); /* ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); messageSource.setBasename("classpath:my-validation-messages"); messageSource.setDefaultEncoding("UTF-8"); */ localValidatorFactoryBean.setValidationMessageSource(messageSource); return localValidatorFactoryBean; } }
StaticMessageSourceはテストで使うくらいの想定で捉えた方が良さそうですが、
StaticMessageSource (Spring Framework 5.3.6 API)
こちらのコードの場合、実質使っているのはここにあるPropertiesだけなんですよね。
まあ、実際使うなら…となるとValidationMessages.propertiesを使うかLocalValidatorFactoryBeanと
ReloadableResourceBundleMessageSourceを使ってBean定義するという感じでしょうね。
まとめ
Spring BootとBean Validationを使って、自分で作ったValidatorとメッセージファイルの組み込み方を確認してみました。
大半の内容はBean Validationの話な気はしますが、忘れていたことも多かったので再確認の意味でもやっておいて
良かったかなと思います。