Spring BootJava 21Spring Framework深入淺出

Spring Boot 04:從 JSON 到 Service,讀懂驗證與錯誤回應

··閱讀約 7 分鐘
回到 Spring Boot 系列導讀

API 回傳 400,Controller 的斷點卻完全沒停住,不一定是 mapping 壞掉。JSON 可能解析失敗,或 @Valid 已經在呼叫方法之前拒絕資料。這一課把「請求進得來」與「業務接受這個輸入」拆開觀察。

text
POST /api/tickets
Content-Type: application/json

{"title":"Learn Boot"}

DispatcherServlet → JSON conversion → @Valid
                  → Controller → Service → Store
                  ← status + headers + JSON

先定義輸入,不讓資料庫類別決定 HTTP 格式

java
// CreateTicket.java
package com.keeponfirst.bootlab;

import jakarta.validation.constraints.NotBlank;

public record CreateTicket(@NotBlank String title) {}

CreateTicket 是輸入 DTO,只有 title。@NotBlank 拒絕 null、空字串與空白文字。HTTP 輸入、業務物件與資料庫模型可以不同;即使本例的 Ticket 也很小,仍把輸入分開,避免將來新增內部欄位就不小心變成 API 可寫欄位。

java
@PostMapping("/tickets")
public ResponseEntity<Ticket> create(@Valid @RequestBody CreateTicket request) {
    Ticket ticket = service.createBatch(List.of(request.title())).getFirst();
    return ResponseEntity.created(URI.create("/api/tickets/" + ticket.id()))
        .body(ticket);
}

這是 TicketController 的方法片段,完整 import 與類別在下載檔。@RequestBody 要求把本文轉成 CreateTicket,@Valid 再要求驗證。接著 Controller 呼叫 Service,把成功結果轉成 201 與 Location。Location 指到新工單,讓呼叫端可以接著 GET;這是本例自行定義的契約,不是每個 POST 都會自動如此。

錯誤格式由程式明確決定

java
// ApiErrors.java
package com.keeponfirst.bootlab;

import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class ApiErrors {
    @ExceptionHandler(MissingTicket.class)
    public ResponseEntity<Map<String, String>> missing(MissingTicket error) {
        return ResponseEntity.status(404).body(Map.of("code", "TICKET_NOT_FOUND"));
    }

    @ExceptionHandler(BadTitle.class)
    public ResponseEntity<Map<String, String>> badTitle(BadTitle error) {
        return ResponseEntity.status(422).body(Map.of("code", "INVALID_TITLE"));
    }

    @ExceptionHandler({MethodArgumentNotValidException.class,
                       HttpMessageNotReadableException.class})
    public ResponseEntity<Map<String, String>> badRequest(Exception error) {
        return ResponseEntity.badRequest().body(Map.of("code", "INVALID_REQUEST"));
    }
}

本例選擇簡單的 {"code":"..."}。缺工單是 404;無法解析或欄位驗證失敗是 400;Service 拒絕標題長度是 422。它們都是設計選擇,不能把 HTTP status 全部改成 200 再要求前端猜 body。Spring 也支援 ProblemDetail;是否採用,要看既有客戶端契約與一致的例外處理設計。

輸入status觀察點
合法 title201id、trim 後 title、Location
title 只有空白400INVALID_REQUEST、沒有寫入
JSON 只有左大括號400INVALID_REQUEST
不存在的 id404TICKET_NOT_FOUND
81 個 ASCII 字元的 title422INVALID_TITLE、沒有寫入
固定案例的預期回應

動手:一個輸入只改一件事

bash
curl -i -X POST http://127.0.0.1:8080/api/tickets \
  -H 'Content-Type: application/json' -d '{"title":" Learn Boot "}'
curl -i -X POST http://127.0.0.1:8080/api/tickets \
  -H 'Content-Type: application/json' -d '{"title":" "}'
curl -i -X POST http://127.0.0.1:8080/api/tickets \
  -H 'Content-Type: application/json' -d '{'

預測這三次會在哪一層停止。第一個輸入會到 Service,再由它 trim 後寫入;第二個在 Bean Validation 被拒絕;第三個在 JSON 轉換就失敗。保留 status、header 與 body,不只截一張「有收到資料」的畫面。標題長度目前用 Java String.length 計算 UTF-16 code unit,不能直接當成所有語言的可見字數。

為什麼有些拒絕不會經過 ApiErrors?

本範例沒有 Spring Security。正式應用的 Security filter 可能在 MVC 前回傳 401 或 403;這不是把 @ExceptionHandler 加進去就一定能攔到的範圍。CORS 也不等於登入驗證。將來加入安全控制時,要再列未登入、權限不足、session 到期與 CSRF 案例,不能靠移除 filter 讓測試變綠。

答案與工作對照:CXF 換成 MVC,哪些要重比?

即使呼叫同一個 Service,參數解析、日期格式、null 欄位、Content-Type 與錯誤格式都可能改變。升級比對時,固定請求只是起點,還要核對呼叫端實際依賴的回應契約。這份練習的價值,是你能說出差異發生在哪一層,而不是把所有 HTTP 問題都丟回業務程式。

下載本系列完整工單 API 範例(ZIP)下一課:05:資料庫與交易回滾

參考資料