検証エラーをまとめて返す
このレッスンでは、検証エラーを1件で打ち切らずにまとめて返す設計と、その受け皿である BindingResult を理解します。
1件ずつ返す API は使いにくいものです
入力フォームに5つの項目があり、そのうち3つが誤っているとします。サーバーが最初の1件だけを返すと、利用者は直して送信し、また次のエラーを見せられ、また直す、という往復を3回することになります。まとめて返せば1回で済みます。エラーを集約するかどうかは、単なる実装の都合ではなく利用者の体験そのものです。
Spring はすべての制約を評価します
Bean Validation の Validator は、最初の違反で止まりません。対象オブジェクトのすべての制約を評価し、違反の集合を返します。@Valid を付けた引数の直後に BindingResult 型の引数を置くと、その集合を自分で受け取れます。
Java
@PostMapping("/users")
public ResponseEntity<?> create(@Valid @RequestBody UserRequest req,
BindingResult result) {
if (result.hasErrors()) {
List<String> messages = result.getFieldErrors().stream()
.map(e -> e.getField() + "=" + e.getDefaultMessage())
.toList();
return ResponseEntity.badRequest().body(messages);
}
// 正常系
}ここで重要なのは、BindingResult を引数に置いた瞬間、Spring は例外を投げなくなるという点です。検証に失敗しても制御はメソッドの中へ入り、続きは自分で書くことになります。逆に BindingResult を置かなければ MethodArgumentNotValidException が飛び、既定のエラー応答になります。どちらを選ぶかで責任の所在が変わります。
| 書き方 | 違反時の振る舞い |
|---|---|
@Valid だけ | 例外が投げられ、既定の 400 応答になる |
@Valid と BindingResult | メソッドに入り、自分で応答を組み立てる |
どのフィールドが悪かったのかを返します
エラーの本文で最も価値のある情報は「どの項目が」「なぜ」駄目だったのかです。FieldError は getField() でフィールド名、getDefaultMessage() でメッセージ、getRejectedValue() で拒否された値を返します。これを使うと、次のような応答が組めます。
JSON
{"errors":[{"field":"name","reason":"required"},{"field":"age","reason":"range"}]}クライアントはこの field を見て、対応する入力欄の横にメッセージを出せます。逆に文章を1本返すだけの設計では、どの欄が悪いのかを画面側で推測することになり、うまくいきません。
順序を決めておきます
Validator が返す違反の集合には、決まった順序がありません。応答の順が呼び出しごとに変わるとテストが安定しないので、フィールドの定義順やアルファベット順など、自分で並びを決めて整えるのが実務的です。
演習で書くもの
演習では、複数の検証を全部評価して結果を集め、フィールド名の付いた形で連結して返します。区切りにはイコールとカンマを使い、name=required,age=range のような文字列を組み立てます。エラーが1件も無ければ ok を返します。Spring では BindingResult から取り出して整形する部分にあたる処理を、ここでは検証そのものを含めて自分で書きます。前のレッスンとの違いは、最初の1件で return せず、最後まで集める点です。
要件
- 最初の違反で打ち切らず、すべての違反を集めてから連結すること
- 並びは name、age、email の順に固定し、区切りはカンマとすること
- name の必須と長さは排他とし、email の必須と形式も排他とすること。違反が無ければ ok を返すこと
入出力例
validateAll("taro", 20, "taro@example.com") → "ok"
validateAll("", 200, "bad") → "name=required,age=range,email=format"
validateAll("taro", 200, "taro@example.com") → "age=range"
validateAll("abcdefghijklmnopqrstu", 20, "") → "name=length,email=required"
validateAll("", -5, "") → "name=required,age=range,email=required"
validateAll("taro", 0, "a@b@c") → "email=format"