【Spring Boot】Bean Validationでフォームの入力チェックを実装する

Java

こんにちは、かつコーチです。

フォームの入力チェックを、Controllerの中でif文を並べて書いていませんか。

その方法だと、チェック項目が増えるたびにControllerが肥大化し、同じ検証ロジックを別の画面でも書き直すはめになります。

Spring BootにはBean Validationという、アノテーションを付けるだけで入力チェックを宣言的に書ける仕組みが標準で用意されています。

この記事では、Bean Validationの基本的な使い方と、エラーメッセージの出し方まで実装しながら解説します。

Bean Validationとは何か

アノテーションで検証ルールを宣言する仕組み

Bean Validationとは、Javaの標準仕様(Jakarta Bean Validation)で、クラスのフィールドにアノテーションを付けることで入力値の検証ルールを宣言できる仕組みです。

Spring Bootではspring-boot-starter-validationを依存関係に追加するだけで、この仕組みをそのまま使えます。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Controllerに検証ロジックをベタ書きする必要がなくなり、「何を検証しているか」がクラス定義を見るだけで一目瞭然になります。

なぜControllerに検証ロジックを書いてはいけないのか

Controllerは本来、リクエストを受け取ってレスポンスを組み立てる役割に徹するべきです。

検証ロジックをControllerに直書きすると、同じ検証ルールを別のControllerでも使いたくなったときに、コピペが発生します。

コピペされたコードは片方だけ修正されて仕様がズレる、というバグの温床になりやすいです。

Bean Validationを使えば、検証ルールをリクエストを受け取るクラス(DTO)側に持たせられるので、この問題を根本から避けられます。

実装手順

手順1:検証ルールをDTOに付ける

まず、フォームの入力を受け取るクラスにアノテーションを付けます。

public class UserRegisterRequest {

    @NotBlank(message = "名前は必須です")
    @Size(max = 50, message = "名前は50文字以内で入力してください")
    private String name;

    @NotBlank(message = "メールアドレスは必須です")
    @Email(message = "メールアドレスの形式が正しくありません")
    private String email;

    @NotNull(message = "年齢は必須です")
    @Min(value = 0, message = "年齢は0以上で入力してください")
    @Max(value = 150, message = "年齢の値が不正です")
    private Integer age;

    // getter・setterは省略
}

@NotBlankは空文字とnullとホワイトスペースのみの文字列を弾き、@NotNullはnullのみを弾きます。

この違いを意識せずに使うと、意図通りに検証できないので注意してください。

手順2:Controllerで@Validを付けて検証を有効にする

DTOに付けたルールは、Controller側で@Validを付けないと動作しません。

@RestController
@RequestMapping("/api/users")
public class UserController {

    @PostMapping
    public ResponseEntity<?> register(@Valid @RequestBody UserRegisterRequest request,
                                       BindingResult result) {
        if (result.hasErrors()) {
            Map<String, String> errors = new HashMap<>();
            result.getFieldErrors().forEach(error ->
                    errors.put(error.getField(), error.getDefaultMessage()));
            return ResponseEntity.badRequest().body(errors);
        }
        // 検証を通過した場合の処理
        return ResponseEntity.ok("登録に成功しました");
    }
}

BindingResultを引数に追加すると、検証エラーを例外にせず自前でハンドリングできます。

つまずきやすい設定・注意点

BindingResultを引数に取る場合、必ず@Validの直後に置く必要があります

間の位置に別の引数を挟むと、Springが正しく紐付けられずに実行時エラーになります。

// ❌ NG:@Validと@RequestBodyの間に別の引数を挟んでいる
public ResponseEntity<?> register(@Valid @RequestBody UserRegisterRequest request,
                                   HttpServletRequest httpRequest,
                                   BindingResult result) { ... }

よくあるつまずきポイント・エラー対処

BindingResultを使わない場合は例外で処理する

BindingResultを引数に取らない場合、検証に失敗するとMethodArgumentNotValidExceptionが投げられます。

筆者が最初にこの仕様を知らず、BindingResultを付け忘れて500エラーを返してしまい、フロントエンド側から「エラーメッセージが空で返ってくる」と指摘を受けたことがあります。

原因は、例外が未処理のままExceptionHandlerも用意していなかったことでした。

@ControllerAdviceで例外をキャッチし、レスポンス形式を統一するのが実務での定石です。

@ControllerAdvice
public class ValidationExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
                errors.put(error.getField(), error.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors);
    }
}

BindingResultで自前ハンドリングするか、例外を@ControllerAdviceで一元処理するかは、プロジェクト全体でどちらかに統一しておくと保守しやすくなります。

応用・一歩先の使い方

ネストしたオブジェクトを検証する

DTOの中に別のオブジェクトを持つ場合、@Validをフィールドにも付けないと、ネストした先のルールが検証されません。

public class OrderRequest {

    @Valid
    @NotNull
    private ShippingAddress shippingAddress;
}

この@Validを付け忘れる漏れは非常に多いので、ネストしたDTOを作った際は必ず確認してください。

独自のバリデーションルールを作る

標準アノテーションでは表現できない検証(パスワード確認と一致するか、など)は、独自のアノテーションを作れます。

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchesValidator.class)
public @interface PasswordMatches {
    String message() default "パスワードが一致しません";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

ConstraintValidatorインターフェースを実装したクラスと組み合わせることで、標準アノテーションと同じ感覚で使えるようになります。

まとめ

この記事のポイント

  • Bean Validationはアノテーションで検証ルールを宣言できる標準仕様
  • @ValidをControllerに付けないと検証は動作しない
  • BindingResultで自前ハンドリングするか、@ControllerAdviceで例外を一元処理するかを統一する
  • ネストしたDTOには@Validの付け忘れに注意する

次に読むべき記事

DTOに検証ルールを持たせる考え方をさらに深掘りし、Entityとの使い分けを次回解説します。

→ 次の記事:DTOとEntityを使い分ける設計の考え方

タグ: #SpringBoot #中級者向け #バリデーション

タイトルとURLをコピーしました