これは、なにをしたくて書いたもの?
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.properties
ContributorValidationMessages.properties
org.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の話な気はしますが、忘れていたことも多かったので再確認の意味でもやっておいて
良かったかなと思います。