アノテーションを定義する方法が面倒な時

Spring Boot でのバリデーション実装に関しては昨日の記事に書いた。 今回は @NotBlank@Size など標準で用意されているアノテーションだけではまかないきれないようなバリデーションを行いたい時にどうするかについて書く。

まず、独自のバリデーションを行いたい場合は自分でアノテーションクラスを作成して Form クラスの対象フィールドに定義するといった方法がある。 これに関しては「Spring バリデーション 独自」などで検索すればいくらでも出てくるのでここでは言及しない。 アノテーションクラスを定義すればアノテーションを付与するだけでどこにでも使えるようになるので便利なのだが、私は以下の理由で後述の方法をとることにした:

尚、自分でガリガリ書けば (Spring Validation を使用しなければ) どのようなバリデーションも思いのままだが、ここではそういうことではなくあくまで BindingResult の対象のフィールドにエラーが入った状態で Thymeleaf テンプレートが表示され、対象のフィールドに th:errors="(Form フィールド名)" でエラー表示がされるといったように組み込みのバリデーションと同じ流れで表示されるようにしたい。

BindingResult.rejectValue()

最初 BindingResult.addError() というメソッドを見つけたので何度か試していたのだが、全くうまくいかないので諦めて検索したら正しくは BindingResult.rejectValue() というメソッドを使用するのだという情報を見つけた。 このメソッドは rejectValue({Form フィールド名}, {messages.properties キー}) のようにして任意のタイミングで messages.properties (設定を変更していない場合は ValidationMessages.properties) に定義されているバリデーションエラーメッセージを対象のフィールドに対し設定することができる。 また、バリデーションエラーにプレースホルダがある場合 (例えば「{0} には日付の形式で入力してください。」のような) に rejectValue({Form フィールド名}, {messages.properties キー}, {プレースホルダに渡したい引数の配列}, {デフォルトメッセージ}) のようにして引数を渡すこともできる。

実装例

ここでは例として「同一日付に対する記事は登録できない (但し更新時は自分自身を対象外とする)」というバリデーション実装を行う。 messaegs.properties に以下リソースが定義されているものとする:

validation.date-format={0} は日付形式で入力してください。
validation.date-already-registered=その日付の記事は既に登録されています。

コントローラ側に組み込みのバリデーションを実施した後今回の独自バリデーションを以下のように実装する:

/**
 * 記事を追加する.
 *
 * @param model モデル
 * @param form PostForm
 * @param result BindingResult
 * @param id postId
 * @return template 名
 */
@PostMapping("/posts/add", "/posts/{id}")
fun savePost(model: Model, @Validated @ModelAttribute("form") form: PostForm, result: BindingResult,
        @PathVariable("id") id: Int?): String {
    model.addAttribute("tags", tagService.findAll())

    // 組み込みバリデーションエラーに引っかかった場合
    if (result.hasErrors()) {
        return "/posts/add"
    }

    // 日付の形式が間違っている場合はエラー
    val date: LocalDate
    try {
        date = LocalDate.parse(form.date)
    } catch (e: DateTimeParseException) {
        e.printStackTrace()
        result.rejectValue("date", "validation.date-format", arrayOf("日付"), "")
        return "/posts/add"
    }

    // 既に登録されている年月日の場合はエラー (但し更新時は自分自身を対象にしない)
    val post2 = postService.findByDate(date)
    if (post2 != null && (id == null || id != post2.id)) {
        result.rejectValue("date", "validation.date-already-registered")
        return "/posts/add"
    }

    TODO("保存処理")
    return "redirect:/posts"
}

これで同一日付で記事を登録しようとした時に「その日付の記事は既に登録されています。」といったエラーメッセージが表示される。