iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Software Development

從 Laravel 到 Spring Boot:30 天打造縮網址服務系列 第 10 篇

Day10 - 例外處理與統一錯誤回應 vs Laravel Exception Handler

  • 分享至 

  • xImage
  •  

Laravel 怎麼做全域例外處理

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,格式框架已經幫你決定好了,一行程式碼都不用寫。

Spring Boot:@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 的欠款

還記得 Day06 那個沒整理過的原始錯誤回應嗎?

{
    "timestamp": "2026-09-20T10:00:00.000+00:00",
    "status": 400,
    "errors": [
        {
            "field": "url",
            "defaultMessage": "url 不能為空"
        }
    ]
}

加上 GlobalExceptionHandler 之後,同一個驗證失敗,回應變成:

{
    "message": "驗證失敗",
    "errors": ["url:url 不能為空"]
}

差別看起來不大,但意義不小——現在所有的錯誤,不管是驗證失敗還是查無資料,都長同一個形狀(message + errors),呼叫端只要學一種錯誤格式,不用針對每種錯誤類型各自解析。

自訂例外:把 Day09 的 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 統一處理會更合適。


上一篇
Day9 - 層架構與 DTO/Mapper(縮網址核心 API 雛形)
下一篇
Day11 - 設定管理 application.yml/Profile vs Laravel .env/config
系列文
從 Laravel 到 Spring Boot:30 天打造縮網址服務 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言