昨天我們在進行功能測試的時候出現了意外的問題,也在文末點出了問題的原因。
因為與 Exception 的處理有關,今天決定花一點時間來整理先前認識 Exception 的筆記,好銜接明天的內容:)!
講 Exception 之前,先複習一下族譜 XD
圖片來源:oreilly - PURE Java™ 2
在 Java 的異常處理機制中,Throwable
是所有 Error
和 Exception
的父類別(如上圖)。只有 Throwable
類別(或其子類)的實例才能被 JVM 拋出,或者被 Java 的 throw
語句拋出。理解 Throwable
是掌握 Java 異常處理的關鍵第一步!
Error 及其子類別是系統拋出的問題,代表 Java 運行環境出現問題,這些問題通常是無法事先處理的。例如:
OutOfMemoryError
: 記憶體不足錯誤。當 JVM 沒有足夠的記憶體來為物件分配空間,並且垃圾回收器也無法釋放出更多記憶體時拋出。StackOverflowError
: 堆疊溢位錯誤。通常發生在遞迴呼叫沒有終止條件,導致方法調用堆疊深度超過限制時。Exception 及其子類別則是開發人員可以預先處理的問題,又分為兩大類:
1. 編譯時異常 (Checked Exception)
非 RuntimeException,也就是在編譯時就會被編譯器檢查到,提醒開發者這裡存在可預見的問題,此時就會強制要求開發人員進行處理。常見的例子有:
IOException
: 處理輸入/輸出操作時可能發生的問題,如讀取檔案失敗。SQLException
: 與資料庫互動時可能發生的錯誤。2. 運行時異常 (Unchecked Exception)
即 RuntimeException,編譯時尚未發現的異常,通常是在運行時由於程式邏輯錯誤導致的異常,因此不會強制要求開發人員預先處理,常見例子有:
NullPointerException
: 當試圖在一個值為 null 的物件參考上呼叫方法或存取屬性時拋出。ArrayIndexOutOfBoundsException
: 試圖存取陣列時,使用了無效的索引。IllegalArgumentException
: 傳遞給方法的參數不合法或不適當。按照上面的邏輯聽起來,可能會覺得 MethodArgumentNotValidException
屬於 RuntimeException
,實則不然。到 Java 官方文件 中可以看到,MethodArgumentNotValidException
事實上繼承於 BindException
,而 BindException
是 Exception
的子類別,也就是說他實際上是一個 Checked Exception 。
那為什麼我們並沒有在編譯時就收到錯誤?
那是因為,在 Controller 上使用 @Valid 驗證參數時,Spring 已經將整個捕捉和處理的過程封裝了,開發人員通常不需要自己捕捉、處理此類錯誤。
取而代之,Spring 的 DispatcherServlet 捕捉到這個異常後,會啟動異常處理機制(Exception Handling Mechanism),去尋找 @ControllerAdvice
或 @RestControllerAdvice
中有沒有定義此類異常的 @ExceptionHandler
。
我們在先前的流程中並沒有特別處理 Exception,後續的過程就會像我們在 log 中看到的一樣,轉發至/error 路徑時被Spring Security攔截並回傳了 403 Forbidden。
在 Java 中,處理例外最基礎的方式就是 try-catch (捕捉處理) 與 throw (拋出傳遞)。throw 用於在偵測到錯誤時發出信號,而 try-catch 則是用來接收這個信號並進行處理。
例如在 Checked Exception 發生時,我們就被強制撰寫 try-catch 或 throw Excption 來讓程式碼通過編譯。或在預見可能出現 RuntimeException 時,透過 try-catch 捕捉並處理錯誤,如同我們在註冊用戶的 API 端點中撰寫的對應內容:
...
@PostMapping
public ResponseEntity<?> registerUser(@Valid @RequestBody UserRegistrationRequest request) {
try {
authService.registerUser(request);
return ResponseEntity.status(HttpStatus.CREATED).build();
} catch (IllegalStateException e) {
// 手動捕捉信箱已存在的例外
return ResponseEntity.badRequest().body(e.getMessage());
}
}
這麼做雖然很直觀,但有幾個缺點:
MethodArgumentNotValidException
,請求根本還沒到達Controller就被框架攔截,無從捕捉。為了解決上述問題,Spring 提供了全域例外處理 (Global Exception Handling) 機制。
@RestControllerAdvice
是 Spring MVC 框架提供的核心功能之一,這個 Annotation 用來修飾類別,類別中用來處理特定 Exception 的方法則以 @ExceptionHandler 進行修飾。
@RestControllerAdvice
等同於 @ControllerAdvice
+ @ResponseBody
,除了可搭配@ExceptionHandler
來捕捉特定 Exception 外,錯誤的回傳皆預設轉換成 JSON/XML 回應,非常適合 RESTful API,也確保錯誤回應的格式清晰且一致。
為什麼說使用 RestControllerAdvice + @ExceptionHandler
可以解決 try-catch 的問題?因為我們可以透過建立一個 GlobalExceptionHandler
的類別集中處理來自各個 Controller 的 Exception,以明天實際修復 MethodArgumentNotValidException
的例子來說,可能就會像下面這個樣子:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
// ... 將錯誤整理成 Map ...
return ResponseEntity.badRequest().body(errors);
}
}
如此一來每當 Exception 發生時,由 Spring 為我們掃描有無對應的 @ExceptionHandler
方法,不需要撰寫過多的 try-catch,也可以處理被框架封裝的例外捕捉,並且將 Exception 集中管理,也大大提升了未來維護的效率。
今天我們複習了 Exception 的概念,也學習到了如何以 @RestControllerAdvice
更方便的管理 Exception。雖然明天會以上述的方式改寫當前 try-catch 的內容,但其實兩者並非相斥,而是相輔相成的解決方法,如果篇幅允許,屆時再來舉個合併使用的案例!