SpringFrameworkでアノテーションによるカスタムバリデーションを実装し、メッセージを出力するまで

こんにちわ, 久しぶりにSpringとわちゃわちゃしているくらたです.
仕事では半年くらい使っていなかったもので.

表題の通り以下の内容についてメモしました.

1. アノテーションの作成
2. アノテーションからバリデーション処理を移譲されるバリデータクラスを作成
3. カスタムメッセージの表示

## はじめに
### 背景
データベースに保存されているマスターデータとの整合性をチェックしたいです.

### 実現したいこと
会議室予約フォームを実装する.
会議室予約フォームには, 利用する会議室をラジオボタンで選択, 利用日時と利用人数を入力する. 
会議室は複数存在し, それぞれ収容人数が決まっている.
収容人数以上の人数を入力されたら入力エラーとする.

## 実装
### バリデーションの仕様
入力された会議室IDをもとに会議室の情報を取得する.
会議室の情報から収容人数を取得し, 入力された利用人数が収容人数以下かどうかを判定.
不正な値だった場合は, 収容人数のフォームコントロールに対してバリデーションメッセージを表示させる.

※ 収容人数の整合性のみチェックするものなので, 存在しない会議室IDが指定されているなどのことは考慮しない.

### 1. アノテーションの作成.

収容人数についてのバリデーションなので, `SeatingCapacityValid`と命名しました.
バリデーションの仕様からして, 少なくとも会議室IDと収容人数のフィールド名はアノテーションの引数で受け取る必要があります.
それぞれメソッドとして宣言する必要があります.

```SeatingCapacityValid.java
@Documented
@Constraint(validatedBy = {SeatingCapacityValidator.class}) // バリデーションロジックを実装したクラスを指定.
@Target(ElementType.TYPE) // フォームの複数の属性に関するバリデーションなので, クラスに付与できるよう指定.
@Retention(RetentionPolicy.RUNTIME)
public @interface SeatingCapacityValid{
    // デフォルトで表示するメッセージのキーを指定.
    String message() default "{com.example.demo.form.validator.SeatingCapacityValid.message}";
    String roomIdProperty(); // 入力された会議室IDが格納されるプロパティの名前を指定を受け取る.
    String numberOfUsersProperty(); // 入力された利用人数が格納されるプロパティの名前を指定を受け取る.

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

    @Target({ElementType.TYPE})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface List {
        SeatingCapacityValid[] value();
    }
}
```

### 2. アノテーションからバリデーション処理を移譲されるバリデータクラスを作成.
`ConstraintValidator`インターフェースを実装します.
型引数の一つ目はアノテーション, アノテーションが付与された対象（この場合はFormクラスですが汎用性のためあえてObject型）を指定します.

```SeatingCapacityValidator.java
public class SeatingCapacityValidator implements ConstraintValidator<SeatingCapacityValid, Object> {

    @Autowired
    private RoomRepository roomRepository;
    
    private String roomIdProperty;
    private String timesProperty;
    private String message;
    
    @Override
    public void initialize(CommodityTimesValid constraintAnnotation) {
        roomIdProperty = constraintAnnotation.roomIdProperty();
        numberOfUsersProperty = constraintAnnotation.numberOfUsersProperty();
        message = constraintAnnotation.message();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {        
        BeanWrapper beanWrapper = new BeanWrapperImpl(value);
        Integer roomId = (Integer)beanWrapper.getPropertyValue(roomIdProperty);
        Integer numberOfUsers = (Integer) beanWrapper.getPropertyValue(numberOfUsersProperty);

        if (roomId == null || numberOfUsers == null) {
            return true;
        }

        Optional<Room> mayBeRoom = roomRepository.findOne(roomId);

        return mayBeRoom.map(room -> {
            Integer capacity = room.getCapacity();
            if (capacity >= numberOfUsers) {
                return true;
            // TODO メッセージをカスタマイズ
            context
                    .buildConstraintViolationWithTemplate(message)
                    .addPropertyNode(numberOfUsersProperty)
                    .addConstraintViolation();
            return false;
        }).orElse(true);
    }
}

```

### 3. カスタムメッセージの表示
`SeatingCapacityValidator.java`のTODOと表記した部分にメッセージ表示ロジックを記載します.
プロパティファイルには, `{0}には{1}人以下の人数を指定してください`と定義し, {1}には部屋ごとの収容人数を表示することを目標にします.

```SeatingCapacityValidator.java
public class SeatingCapacityValidator implements ConstraintValidator<SeatingCapacityValid, Object> {
    // 中略
    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        // 中略
        return mayBeRoom.map(room -> {
            Integer capacity = room.getCapacity();
            if (capacity >= numberOfUsers) {
                return true;
            }
            HibernateConstraintValidatorContext hibernateContext =
                    context.unwrap(HibernateConstraintValidatorContext.class);

            hibernateContext.disableDefaultConstraintViolation();
            hibernateContext
                    .addMessageParameter("1", capacity) // ここで部屋ごとの人数を{1}にセット.
                    .buildConstraintViolationWithTemplate(message)
                    .addPropertyNode(numberOfUsersProperty)
                    .addConstraintViolation();
            return false;
        }).orElse(true);
    }
}
```

## メモ
アノテーションを使用したバリデーションでは以下のことに気をつければ良さそう.
###1. `@Target`の引数で指定する定数.
アノテーションはどこに付与できるのか. 
単一フィールドの検証なのか, 相関チェックなのか.
本当にアノテーションにする必要があるのかとか.

今回, 画面からの入力値をドメイン層に置くようなオブジェクトにバインディングしようと思いました.
なのでバインディング時に評価されるようにアノテーションを作成した.

###2. `@Constraint`の引数で指定するクラス.
バリデーションのロジックはどのクラスに委譲するのか. 

###3. アノテーションで宣言するメソッド, アノテーションの引数.
バリデーションで必要な情報を必ずアノテーションから受け取れるようにすること.

###4. アノテーション引数以外の情報をメッセージに表示する方法.
メッセージテンプレートのプレースホルダ(`{0}`とか`{1}`)は, アノテーションから取得できる情報で置換される.
`{0}`はフィールド名で, `{1}`以降はアノテーションの引数.

上記以外のものを設定するには, `ConstraintValidatorContext#unwrap(HibernateConstraintValidatorContext.class)`メソッドでHibernateConstraintValidatorContextクラスに変換してから, 表示する文字列を設定する.

| 使用するメソッドの名前       | プレースホルダの記法 |
|:-----------------|:------------------|
| addMessageParameter(String s, Object o)             | {s}  |
| addExpressionVariable(String s, Object o)           | ${s} |

## さいごに
前提条件について書けていない部分が多いので, コードの全容を公開するタイミングで整理しようかなと思います.