404 Not Found

404 Not Found


nginx

データ検証

データ検証はAPIの第一防衛線です。無効な入力はビジネスロジックに到達する前に拒否されるべきで, 早ければ早いほどよい。

1. 学ぶ内容


2. API開発者の実話

(1) ペインポイント:汚いデータがデータベースに保存される

AliceはOrderFlowデータベースに奇妙なデータを発見しました。注文数量が-5, メールアドレスが"abc", 商品価格が0です。Bobは, ユーザーがAPI経由でマイナスの在庫を持つ商品を送信し, レポート統計に異常が発生したと報告しました。Aliceはこれまでサービス層に大量のif-else検証コードを書いていましたが, 冗長で見落としがちでした。

(2) Bean Validationによる解決策

アノテーションで検証ルールを宣言し, Springが自動的に検証をトリガーします。

JAVA
public record CreateOrderRequest(
    @NotNull Long productId,
    @Min(1) @Max(100) Integer quantity,
    @Email String customerEmail
) {}

無効なリクエストはコントローラに到達する前に拒否されます。

(3) 成果

Aliceはすべての手動検証コードをBean Validationに置き換え, コントローラのコード量を40%削減し, 検証ルールの見落としをなくしました。データベースに汚いデータはもうありません。


3. Bean Validationアノテーション体系

(1) 検証の実行フロー

100%
graph TD
    A["クライアントリクエスト<br/>@RequestBody"] --> B{"@Valid<br/>トリガーされた?"}
    B -->|はい| C["Hibernate Validator<br/>制約をチェック"]
    C --> D{"すべて有効?"}
    D -->|はい| E["コントローラメソッド<br/>実行"]
    D -->|いいえ| F["MethodArgumentNotValidException<br/>400 Bad Request"]
    B -->|いいえ| G["検証スキップ<br/>汚いデータの可能性"]

(3) 検証トリガー方法

アノテーション 用途 配置場所
@Valid カスケード検証のトリガー (ネストされたオブジェクトを含む) メソッドパラメータ, フィールド
@Validated グループ検証をサポート クラス, メソッドパラメータ
@Validated(Group.class) 検証グループの指定 メソッドパラメータ

(1) ▶ サンプル:コントローラパラメータの検証

JAVA
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    @PostMapping
    public ResponseEntity<Order> createOrder(
            @Valid @RequestBody CreateOrderRequest request) {
        // 検証に失敗するとMethodArgumentNotValidExceptionがスローされ
        // この行には到達しない
        Order order = orderService.createOrder(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(order);
    }
}

public record CreateOrderRequest(
    @NotNull(message = "商品IDは必須です")
    Long productId,

    @Min(value = 1, message = "数量は1以上である必要があります")
    @Max(value = 100, message = "数量は100を超えることはできません")
    Integer quantity,

    @Email(message = "メールアドレスの形式が不正です")
    String customerEmail
) {}

出力:

TEXT
// 実行成功

4. 一般的な制約アノテーション

(1) 制約アノテーションのクイックリファレンス

アノテーション 適用型 説明
@NotNull すべての型 null不可 @NotNull Long id
@NotBlank String 空/null/スペースのみ不可 @NotBlank String name
@NotEmpty String/Collection 空/null不可 @NotEmpty List<String> tags
@Size String/Collection 長さ/サイズの範囲 @Size(min=2, max=100)
@Min / @Max 数値型 値の範囲 @Min(0) @Max(99999)
@Positive 数値型 正の数 @Positive BigDecimal price
@Email String メール形式 @Email String email
@Pattern String 正規表現マッチ @Pattern(regexp="^[A-Z]")
@Past / @Future 日付型 過去/未来 @Past LocalDate birthDate

(1) ▶ サンプル:Product DTO検証

JAVA
public record CreateProductRequest(
    @NotBlank(message = "商品名は必須です")
    @Size(min = 2, max = 200, message = "名前は2〜200文字である必要があります")
    String name,

    @NotNull(message = "価格は必須です")
    @Positive(message = "価格は正の数である必要があります")
    @DecimalMin(value = "0.01", message = "価格は0.01以上である必要があります")
    BigDecimal price,

    @NotNull(message = "在庫数は必須です")
    @Min(value = 0, message = "在庫数はマイナスにできません")
    Integer stock,

    @Email(message = "サプライヤーメールアドレスが不正です")
    String supplierEmail,

    @Pattern(regexp = "^[A-Z]{3}-\\d{4}$", message = "SKU形式:XXX-0000")
    String sku
) {}

出力:

TEXT
// 実行成功
アノテーション比較 null "" " " "abc"
@NotNull
@NotBlank
@NotEmpty
🔥 よくある間違い: String型の検証では@NotNullではなく@NotBlankを使用してください。空文字列も通常は無効だからです。


5. カスタムバリデータ

(1) 実装手順

  1. 制約アノテーションの定義
  2. ConstraintValidator<A, T>の実装
  3. DTOフィールドで使用

(1) ▶ サンプル:カスタム@ValidOrderQuantityバリデータ

JAVA
// ステップ1:制約アノテーションの定義
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderQuantityValidator.class)
public @interface ValidOrderQuantity {
    String message() default "注文数量が商品在庫制限を超えています";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// ステップ2:ConstraintValidatorの実装
public class OrderQuantityValidator
        implements ConstraintValidator<ValidOrderQuantity, Integer> {

    private static final int MAX_QUANTITY_PER_ITEM = 100;

    @Override
    public boolean isValid(Integer quantity, ConstraintValidatorContext context) {
        if (quantity == null) {
            return true; // nullチェックは@NotNullに任せる
        }
        return quantity >= 1 && quantity <= MAX_QUANTITY_PER_ITEM;
    }
}

// ステップ3:DTOで使用
public record CreateOrderRequest(
    @NotNull Long productId,
    @ValidOrderQuantity Integer quantity
) {}

出力:

TEXT
// 実行成功

6. グループ検証

(1) シナリオ別の検証ルールの分類

「作成」と「更新」のシナリオでは通常, 異なる検証ルールが必要です。

シナリオ 商品ID 名前 価格
作成 自動生成, 不要 必須 必須
更新 必須 (変更対象を示すため) オプション オプション

(1) ▶ サンプル:グループ検証

JAVA
// グループインターフェースの定義
public interface Create {}
public interface Update {}

// グループ対応検証付きDTO
public record ProductRequest(
    @Null(groups = Create.class, message = "作成時にIDはnullである必要があります")
    @NotNull(groups = Update.class, message = "更新時にIDは必須です")
    Long id,

    @NotBlank(groups = Create.class, message = "作成時に名前は必須です")
    @Size(min = 2, max = 200)
    String name,

    @NotNull(groups = Create.class, message = "作成時に価格は必須です")
    @Positive BigDecimal price
) {}

出力:

TEXT
// 実行成功
JAVA
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {

    @PostMapping
    public ResponseEntity<Product> create(
            @Validated(Create.class) @RequestBody ProductRequest request) {
        // Createグループの検証のみが適用される
        // ...
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }

    @PutMapping("/{id}")
    public ResponseEntity<Product> update(
            @PathVariable Long id,
            @Validated(Update.class) @RequestBody ProductRequest request) {
        // Updateグループの検証のみが適用される
        // ...
        return ResponseEntity.ok().build();
    }
}

(2) ▶ サンプル:ネストされた検証

JAVA
public record CreateOrderRequest(
    @NotNull Long productId,
    @ValidOrderQuantity Integer quantity,
    @Valid @NotNull ShippingAddress shippingAddress
) {}

public record ShippingAddress(
    @NotBlank String street,
    @NotBlank String city,
    @NotBlank String zipCode,
    @Pattern(regexp = "^[A-Z]{2}$") String country
) {}

出力:

TEXT
// 実行成功
📌 重要ポイント: ネストされたオブジェクトには@Validを付ける必要があります。付けないとネストされたフィールドの検証が有効になりません。@Validatedはネストされたカスケード検証をサポートしていません。


7. 検証エラーレスポンスのフォーマット

(1) エラーレスポンス形式の標準化

JAVA
@RestControllerAdvice
public class ValidationExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidation(
            MethodArgumentNotValidException ex) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("timestamp", Instant.now());
        body.put("status", HttpStatus.BAD_REQUEST.value());

        List<Map<String, String>> errors = ex.getBindingResult()
            .getFieldErrors().stream()
            .map(fe -> Map.of(
                "field", fe.getField(),
                "message", fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "",
                "rejectedValue", fe.getRejectedValue() != null ? fe.getRejectedValue().toString() : "null"
            ))
            .toList();
        body.put("errors", errors);
        return ResponseEntity.badRequest().body(body);
    }
}
💻 出力:

JSON
{
  "timestamp": "2024-01-15T10:00:00Z",
  "status": 400,
  "errors": [
    {"field": "quantity", "message": "数量は1以上である必要があります", "rejectedValue": "0"},
    {"field": "customerEmail", "message": "メールアドレスの形式が不正です", "rejectedValue": "abc"}
  ]
}

8. 総合サンプル:OrderFlowの完全な検証システム

JAVA
// 検証グループ
package com.orderflow.validation;
public interface Create {}
public interface Update {}

// カスタムバリデータ:@ValidShippingAddress
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ShippingAddressValidator.class)
public @interface ValidShippingAddress {
    String message() default "配送先住所が不正です";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class ShippingAddressValidator
        implements ConstraintValidator<ValidShippingAddress, String> {
    @Override
    public boolean isValid(String address, ConstraintValidatorContext ctx) {
        if (address == null || address.isBlank()) return false;
        return address.length() >= 10 && address.length() <= 500;
    }
}

// DTO群
public record CreateOrderRequest(
    @NotNull(groups = Create.class) Long productId,
    @Min(value = 1, message = "数量は1以上である必要があります")
    @Max(value = 100, message = "数量は100を超えることはできません")
    Integer quantity,
    @Email String customerEmail,
    @ValidShippingAddress String shippingAddress
) {}

public record CreateProductRequest(
    @Null(groups = Create.class) @NotNull(groups = Update.class) Long id,
    @NotBlank(groups = Create.class) @Size(min = 2, max = 200) String name,
    @NotNull(groups = Create.class) @Positive BigDecimal price,
    @Min(0) Integer stock,
    @Pattern(regexp = "^[A-Z]{3}-\\d{4}$", message = "SKU:XXX-0000") String sku
) {}

// コントローラ
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
    @PostMapping
    public ResponseEntity<Void> create(
            @Validated(Create.class) @RequestBody CreateOrderRequest req) {
        // 検証通過, ビジネスロジックに進む
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}

❓ よくある質問

Q @Validと@Validatedの違いは何ですか?
A @ValidはJSR-380標準のアノテーションで, ネストされたカスケード検証をサポートします。@ValidatedはSpring拡張アノテーションで, グループ検証をサポートします。グループ分けには@Validated, ネストには@Validを使用し, 両方を組み合わせて使うこともできます。
Q 検証に失敗した場合, 何が返されますか?
A デフォルトでは, Spring Bootは400 Bad RequestステータスコードとJSONエラーメッセージを返します。@RestControllerAdviceを使ってフォーマットをカスタマイズできます。このレッスンで標準フォーマットのソリューションを提供しています。
Q @NotBlank, @NotEmpty, @NotNullのどれを選ぶべきですか?
A Stringには@NotBlankを使用 (null, 空文字, スペースのみの文字列を許可しない)。Collectionには@NotEmptyを使用 (nullと空のコレクションを許可しない)。その他の型には@NotNullを使用してください。
Q グループ検証とデフォルト検証を同時に有効にできますか?
A デフォルトでは, 特定のグループを指定すると"Default"グループは無効になります。両方を有効にしたい場合は, カスタムグループに"Default"を継承させてください。public interface Create extends Default {}
Q カスタムバリデータにSpring Beanをインジェクションできますか?
A はい。ConstraintValidatorはSpringコンテナで管理されるため, isValidメソッド内で@Autowiredを使ってBeanをインジェクションできます (例:一意性検証のためにデータベースを照会するなど)。
Q エラーメッセージの国際化はどうすればよいですか?
A resources/ValidationMessages.propertiesにメッセージキーを定義してください。例:order.quantity.invalid=注文数量は{min}から{max}の間である必要があります。多言語ファイルもサポートされています。

📖 まとめ


📝 練習問題

  1. 基本問題 (難易度 ⭐):OrderFlowのCreateOrderRequestCreateProductRequestにBean Validationアノテーションを追加し, 無効な入力を検証して400エラーを返すようにしてください。

  2. 応用問題 (難易度 ⭐⭐):グループ検証を実装してください。Create操作ではnamepriceが必須, Update操作ではidが必須でnamepriceはオプション。カスタム@ValidShippingAddressバリデータを実装してください。

  3. チャレンジ問題 (難易度 ⭐⭐⭐):@UniqueProductSkuバリデータを作成し, ProductRepositoryをインジェクションしてSKUの既存チェックを行い, データベースレベルの一意性検証を実装してください。バリデータとビジネス層の責任の境界について考察してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%