怠惰系エンジニアのメモ帳

勉強した内容をメモしていきます。

BeanValidationメモ_その2

前回に引き続き、Bean Validationについてのメモ。
今回はカスタムバリデーションについて。

記載内容に間違いなどがありましたら、指摘していただけると幸いです。

カスタムバリデーション

Bean Validationは、ユーザが独自にバリデーターを作成することが可能。
カスタムバリデーターの作成方法は2つある。

1つ目は、既存のアノテーションを組み合わせて作成する方法。
2つ目は、バリデータクラスを自作する方法。

どちらにも共通するのは、独自の制約アノテーションを作成する点。
今回は2つ目の バリデータクラスを自作する 方法のみを記載します。

作成手順

基本的には以下の順番でカスタムバリデーターを作成していくことになると思います。

  1. 独自の制約アノテーションを作成する。
  2. それに対応するバリデーターを作成する。

バリデーターに「どの制約アノテーションと関連があるか」を指定する必要があるので、 先に制約アノテーションを作成します。

作成する内容

今回は商品(Goods)の価格(price)が、指定された範囲内であるかをチェックするカスタムバリデーションを例に挙げて説明していきます。

@AllArgsConstructor
@Getter
public class Goods {
    private final String name;  // 商品名
    private final Integer price; // 価格
}

制約アノテーションを作成する

まずは制約アノテーションを作成します。
通常のアノテーション定義に、Bean Validationとのお約束を加えたものになります。 とりあえず、コードをドン。

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.CONSTRUCTOR;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {})
public @interface Price {

    String message() default "";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    int min() default 1_000;
    int max() default 5_000;

    @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
    @Retention(RUNTIME)
    @Documented
    @interface List {
        Price[] value();
    }
}

上から順に何をやっているのか説明。

  • @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
    • このアノテーションが注釈できる場所を定義している。
    • 今回の場合は、メソッド、フィールド、アノテーション、コンストラクタ、パラメータに注釈可能。
    • Bean Validationに限らず、アノテーションを作成する場合には注釈しておくのがマスト。
  • @Retention(RUNTIME)
    • アノテーションの情報をどこまで保持するかを指定している。
    • デフォルトだとclassファイルへの変換時にアノテーションの情報は消えるが、RUNTIME なので、アノテーションの情報はVMにロードされる。
    • Bean Validatioに限らず、アノテーションを作成する場合には注釈しておくのがマスト。
    • Bean Validationを利用する場合、アノテーションから情報を取得することになるので @Retention(RUNTIME) でないとダメ。
  • @Documented
  • @Constraint(validatedBy = {})
    • Bean Validationによるチェックを行う場合に必須なアノテーション
    • validatedBy 属性に、ConstraintValidator を実装したクラスを列挙する。(実際にバリデーションを行うクラスになる)
    • バリデータクラスよりも、アノテーションを先に作成することになるはずなので、作成時は空にしておく。
    • バリデータクラスの作成後に、validatedBy に検査クラスを列挙する。
  • public @interface Price
  • String message() default "";
    • Bean Validationの制約アノテーションには必須な属性。
    • バリデーションの結果、エラーとなった場合のエラーメッセージを設定する。
    • default に指定する値は "{javax.validation.constraints.Size.message}" といったようにpropertiesファイルに記載してあるキーを渡すこともできる。
  • Class<?>[] groups() default {};
    • Bean Validationのアノテーションには必須な属性。
    • バリデーションを、ケースバイケースで実行できるようにするためのもの。
    • Bean Validationは制約アノテーションを注釈しないとチェックできないが、特定下においてはチェックをしたくない場合がある。
    • 例えば「画面Aからのリクエストはチェックして、画面Bからのリクエストはチェックしない」みたいな。
    • default は、必ず空({})でないとダメ。(らしい)
  • Class<? extends Payload>[] payload() default {};
    • Bean Validationのアノテーションには必須な属性。
    • バリデーションの結果エラーとなった場合に、任意項目を付与する属性。
    • 例えばエラーの重要度とか。
    • default は、必ず空({})でないとダメ。(らしい)
  • int min() default 1_000;
  • int max() default 5_000;
  • @interface List { Price[] value(); }
    • Bean Validationのアノテーションには必須な属性。
    • 同じ制約を異なる条件で複数定義したい場合があるので、それに対応させる必要がある。
    • というのも、アノテーションの仕様として、同じアノテーションを同一箇所に複数注釈することは禁止されている。(どっかで、最近複数定義も可能になった、みたいなのを見た気がする)
    • 例えば、500円〜1000円 の間であることを検証したい場合と 1000円 ~ 2000円 の間であることを検証したい場合など。

バリデーターを作成する

次に実際にバリデーションを行う、バリデーターを作成する。
Bean Validationのバリデーターは、javax.validation.ConstraintValidator<A, T>インターフェースを実装する。

今回は、PriceValidator という名前でバリデーターを作成。

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class PriceValidator implements ConstraintValidator<Price, Goods> {

    private int min;
    private int max;

    @Override
    public void initialize(Price annotation) {
        this.min = annotation.min();
        this.max = annotation.max();
    }

    @Override
    public boolean isValid(Goods goods, ConstraintValidatorContext context) {

        if (goods == null) {
            return false;
        }

        int price = goods.getPrice();
        return price >= min && price <= max;
    }

}

上から順に説明。

  • public class PriceValidator implements ConstraintValidator<Price, Goods> {
    • ConstraintValidator をimplementsする。
    • BeanValidatorを使用する場合には、このConstraintValidatorを実装しなければならない。
    • ジェネリクスの左側には、制約アノテーションを指定する。(今回は Price
    • 右側には、バリデーション対象となるクラスを指定する。(今回は Goods
  • public void initialize(Price annotation) {}
    • バリデーションを行う際の初期処理。
    • 例えば、アノテーションからパラメータを受け取ったりする。
  • public boolean isValid(Goods goods, ConstraintValidatorContext context) {
    • 実際にバリデーションを行うためのメソッド。
    • false を返すことで、検証エラーと判定される。
    • ConstraintValidatorContext はバリデータに関する操作を提供しているインターフェース。
    • 例えば、エラーメッセージを直接指定したい場合などに使用する。
    • 正直なところ、ConstraintValidatorContext はよくわかってない…。

最後に、制約アノテーション PricevalidatedBy 属性にバリデーターを紐付けます。

@Constraint(validatedBy = {PriceValidator.class})
public @interface Price {

これでカスタムバリデーターの完成。
ちなみに validatedBy 属性にはバリデーターを複数列挙でき、検査対象の型によって適切なバリデーターが選択されます。

例えば PriceValidatorForString implements ConstraintValidator<Price, String>PriceValidatorForInteger implements ConstraintValidator<Price, Integer> などが定義されており、

@Constraint(validatedBy = {PriceValidator.class, PriceValidatorForString.class, PriceValidatorForInteger.class})
public @interface Price {

とすれば StringInteger 型に対してもバリデーションを行うことができます。

@Price  // PriceValidator が動く
Goods goods;

@Price  // PriceValidatorForString が動く
String stringField;

@Price  // PriceValidatorForInteger が動く
Integer intField;

もし対応するバリデーターが無ければ、UnexpectedTypeExceptionがスローされます。

@Price  // 対応するバリデーターがないので、UnexpectedTypeExceptionがスロー
Double doubleField;

使ってみる

public class PriceValidatorTest {

    private class TestBean {
        @Price(message = "価格ダメ!!")
        public Goods goods;
    }

    @Test
    public void test_priceValidation() {
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        Validator validator = validatorFactory.getValidator();

        TestBean testBean = new TestBean();
        testBean.goods = new Goods("はじめてのBeanValidation", 10_000);

        Set<ConstraintViolation<TestBean>> result = validator.validate(testBean);

        assertThat(result.size(), is(1));
        result.stream().map(ConstraintViolation::getMessage).forEach(System.out::println);
    }
    
}

結果

価格ダメ!!

カスタムバリデーションが動いていることを確認できました。

まとめ

  • カスタムバリデータを作成する場合は、まず制約アノテーションを作成する。
  • 次に ConstraintValidator<A, T> を実装したバリデータークラスを作成。
  • 最後に制約アノテーションとバリデーターを紐付けて完成。

気になったこととか

Hibernate Validatorはメッセージに制約アノテーションの属性を埋め込んでいますが、 自分でカスタマイズできるのか気になりました。

価格は {mix}円以上、{max}以下にしてください。

ただ、そうなるとメッセージの再利用が出来なくなりそうなので考えどころ…。

あとは、payload の利用方法とかがイマイチ分かってないので、今後の課題というところ。