Laravel 11 之後,例外處理集中寫在 bootstrap/app.php:
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (LinkNotFoundException $e, Request $request) {
return response()->json(['message' => $e->getMessage()], 404);
});
})
而驗證失敗這件事,Laravel 完全不用自己處理——FormRequest(Day06 提過)驗證沒過,框架內建的 ValidationException 會自動被轉成 422 JSON,格式框架已經幫你決定好了,一行程式碼都不用寫。
@RestControllerAdvice 全域攔截Spring 對應的機制是寫一個標了 @RestControllerAdvice 的類別,裡面每個 @ExceptionHandler 方法負責攔截一種例外類型,效果類似 Laravel 的 withExceptions()——寫一次,所有 Controller 拋出的例外都會被攔到,不用每支 API 各自 try/catch:
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult().getFieldErrors().stream()
.map(err -> err.getField() + ":" + err.getDefaultMessage())
.toList();
return ResponseEntity.badRequest().body(new ErrorResponse("驗證失敗", errors));
}
@ExceptionHandler(LinkNotFoundException.class)
ResponseEntity<ErrorResponse> handleNotFound(LinkNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(ex.getMessage(), List.of()));
}
}
record ErrorResponse(String message, List<String> errors) {}
還記得 Day06 那個沒整理過的原始錯誤回應嗎?
{
"timestamp": "2026-09-20T10:00:00.000+00:00",
"status": 400,
"errors": [
{
"field": "url",
"defaultMessage": "url 不能為空"
}
]
}
加上 GlobalExceptionHandler 之後,同一個驗證失敗,回應變成:
{
"message": "驗證失敗",
"errors": ["url:url 不能為空"]
}
差別看起來不大,但意義不小——現在所有的錯誤,不管是驗證失敗還是查無資料,都長同一個形狀(message + errors),呼叫端只要學一種錯誤格式,不用針對每種錯誤類型各自解析。
Optional 換成明確的例外Day09 的 show() 方法用 Optional 處理查無資料:
@GetMapping("/{code}")
ResponseEntity<LinkResponse> show(@PathVariable String code) {
return linkService.findByCode(code)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
這樣寫沒有錯,但「查無資料」這件事只有一行的 404,沒辦法帶上更明確的錯誤訊息(例如「找不到短碼 abc123」)。想要更明確的訊息,可以改成自訂例外,讓 Service 層直接拋出:
class LinkNotFoundException extends RuntimeException {
LinkNotFoundException(String code) {
super("找不到短碼:" + code);
}
}
// LinkService
LinkResponse findByCodeOrThrow(String code) {
return linkRepository.findByCode(code)
.map(link -> new LinkResponse(link.getCode(), "https://short.ly/" + link.getCode()))
.orElseThrow(() -> new LinkNotFoundException(code));
}
// Controller
@GetMapping("/{code}")
ResponseEntity<LinkResponse> show(@PathVariable String code) {
return ResponseEntity.ok(linkService.findByCodeOrThrow(code));
}
LinkNotFoundException 丟出去之後,會被前面寫好的 GlobalExceptionHandler 攔截、轉成統一格式的 404 回應。這跟 Laravel 自訂例外 + render() 客製回應是同一種模式:Service 層(或任何地方)只管拋出語意明確的例外,不用管「這個例外最後要變成什麼樣的 HTTP 回應」,那是 Handler 的責任,兩邊職責清楚分開。
Optional 跟丟例外這兩種寫法不衝突,是互補的:單純「有沒有找到」用 Optional 處理最簡潔(Day09 的寫法沒有錯);需要在錯誤訊息裡帶更多上下文、或錯誤發生在呼叫鏈更深的地方(不是直接在 Controller),丟自訂例外讓 GlobalExceptionHandler 統一處理會更合適。