CLOVER🍀

That was when it all began.

Spring Boot × Bean Validationで、自作Validator+メッセージファイルを組み込む

これは、なにをしたくて書いたもの?

Spring Boot(Spring Framework)を使って、Bean Validationのメッセージを変更したりするのをどうやるのかをよく覚えて
いなかったので、確認してみることに。

結局、Bean Validationの復習的な感じになりましたけど。

せっかくなので、自分でValidatorを作り、対応するメッセージをプロパティファイルに定義して組み込むことをやってみたいと
思います。
あと、プラスで既存メッセージの上書きも。

Bean Validationで自作のValidatorを作成する

これについては、Hibernate Validatorを見るのがよいでしょう。

Creating a simple constraint

バリデーションで使うアノテーションを作成して、

The constraint annotation

対応するValidatorを作成します。

The constraint validator

エラーメッセージに関しては、ValidationMessages.propertiesというファイルに組み込むことになっています。

The error message

メッセージを解決するルールは、こちらに書かれています。

Default message interpolation

デフォルトではValidationMessages.properties(これはBean Validationの利用者が作成する)からメッセージを取得し、
なければorg.hibernate.validator.ValidationMessagesというデフォルトのファイルからメッセージを取得します。

この動きは、SpringでBean Validationを使う上でも押さえておくと良いでしょう。

Spring FrameworkとBean Validation

一方で、Spring Framework側はそれほどBean Validationについては詳しく書いていません。

Java Bean Validation

LocalValidatorFactoryBeanやSpringの提供するValidatorMethodValidationPostProcessorDataBinderなどが
Spring固有の話として触れられています。

Spring Bootに至っては、ほとんど記述がありません。クラスパス上にBean Validationの実装がある場合に、自動で有効に
なります、くらいですね。

Validation

Spring Frameworkで使うBean Validationに、自作のメッセージファイルを組み込む

で、Spring Bootで自分で用意したメッセージ用のプロパティファイルを組み込むには?という方法で調べると、
だいたいLocalValidatorFactoryBeanに対して、ReloadableResourceBundleMessageSourceMessageSourceとして
組み込むような方法が見つかると思います。

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.propertiesMessageCodesResolverのルールに従って記述できることになります。

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を探そうとする)ということになります。

https://github.com/hibernate/hibernate-validator/blob/6.1.7.Final/engine/src/main/java/org/hibernate/validator/messageinterpolation/AbstractMessageInterpolator.java#L281-L287

Bean定義でこういうコードを書いた場合は、

        localValidatorFactoryBean.setValidationMessageSource(messageSource);

Spring側でResourceBundleLocatorインスタンスを作って、Hibernate Validatorに渡してValidationMessages.properties
代わりに使う、という感じです。

https://github.com/spring-projects/spring-framework/blob/v5.3.6/spring-context/src/main/java/org/springframework/validation/beanvalidation/LocalValidatorFactoryBean.java#L460-L462

もっとも、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

https://github.com/hibernate/hibernate-validator/blob/6.1.7.Final/engine/src/main/java/org/hibernate/validator/messageinterpolation/AbstractMessageInterpolator.java#L77-L92

メッセージの解決は、ValidationMessages.propertiesContributorValidationMessages.properties
org.hibernate.validator.ValidationMessagesの順に行われます。
ソースコードを見ていると、ContributorValidationMessages.propertiesが使われるのには条件があるみたいですけどね

https://github.com/hibernate/hibernate-validator/blob/6.1.7.Final/engine/src/main/java/org/hibernate/validator/messageinterpolation/AbstractMessageInterpolator.java#L455-L479

このため、ValidationMessages.propertiesにデフォルトで定義されたメッセージ(org.hibernate.validator.ValidationMessages
定義されたメッセージ)と同じキーを用意すると、そのメッセージを上書きすることができることになります。

デフォルトのメッセージが定義されているのは、こちら。

https://github.com/hibernate/hibernate-validator/tree/6.1.7.Final/engine/src/main/resources/org/hibernate/validator

とまあ、説明はこれくらいにして、実際に試してみましょう。

環境

今回の環境は、こちらです。

$ 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つのものは、標準のアノテーションでのメッセージですね。

このファイルの内容です。

https://github.com/hibernate/hibernate-validator/blob/6.1.7.Final/engine/src/main/resources/org/hibernate/validator/ValidationMessages_ja.properties

ではここで、標準のアノテーションに対応するメッセージも定義してみます。@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を使う

次は、LocalValidatorFactoryBeanMessageSourceを使ってみましょう。

まず、メッセージファイルは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} 以上でお願いします

そして、このファイルを使うようにReloadableResourceBundleMessageSourceMessageSourceとして設定した
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だけなんですよね。

https://github.com/spring-projects/spring-framework/blob/v5.3.6/spring-context/src/main/java/org/springframework/context/support/AbstractMessageSource.java#L71

まあ、実際使うなら…となるとValidationMessages.propertiesを使うかLocalValidatorFactoryBean
ReloadableResourceBundleMessageSourceを使ってBean定義するという感じでしょうね。

まとめ

Spring BootとBean Validationを使って、自分で作ったValidatorとメッセージファイルの組み込み方を確認してみました。

大半の内容はBean Validationの話な気はしますが、忘れていたことも多かったので再確認の意味でもやっておいて
良かったかなと思います。