【Spring Boot】第24課-使用 Controller Advice 處理例外與 query string


https://unsplash.com/photos/LmyPLbbUWhA

在業務邏輯中,若遇到不合理的情況,我們可透過拋出例外(exception)的方式,回傳 4 或 5 系列的 HTTP 狀態碼給前端。然而例外雖可攜帶錯誤訊息,但不適合回傳複雜的資料。

另外,controller 接收查詢字串(query string)後,可能需要處理成適合的形式才能使用。例如轉換成日期物件。

本文將介紹 ExceptionHandler 與 InitBinder,針對上述兩個問題做優化。最後整合到 ControllerAdvice 元件,將它們的效果以 controller 為單位來套用。

本文的練習用專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch24-start

一、範例專案簡介

在專案中有兩種類型的資料,即 Product 與 User,如下:

其中 Product 的建構子呼叫了一個 util 方法,將「yyyy-MM-dd」格式的字串轉換為日期物件。

這兩種資料都有對應的 controller,並且已事先準備好測試資料。

且 controller 中各有兩個 API,分別用來刪除與查詢資料。


二、捕捉 API 層級的例外

(一)動機

在範例專案中,DELETE 方法的 API 是透過 request body 接收一至數個 id,藉此刪除指定的資料。但若包含不存在資料的 id,則會拋出筆者自訂的例外,HTTP 狀態碼為 422。

由於後端未提供其他資訊,因此前端頂多得到狀態碼和錯誤訊息。若想做稍微複雜的處理(例如在畫面上顯示哪些資料或 id 有問題),那麼讓前端程式解析訊息文字這種做法本身也比較 workaround,不是很好。

Spring Boot 拋出例外後預設的 response
Spring Boot 拋出例外後預設的 response

接著從另一個角度思考,同一支 API 是有可能發生不同例外卻有相同狀態碼的情形。如此前端會無法分辨出錯的原因。

若想同時解決這兩個問題,則方式是在例外發生時,讓後端回傳 response body,提供有結構的資料。

(二)使用 Exception Handler

由於我們想回傳有結構的資料,因此設計了叫做 ExceptionResponse 的類別,做為 response body。其 type 欄位代表發生例外的原因;info 欄位代表額外要提供的資料。

接著透過在 controller 宣告方法,冠上「@ExceptionHandler」的標記(annotation),我們可藉此捕捉該 controller 所拋出的指定例外。

以下的範例是捕捉 OperateAbsentItemsException。只要在該標記中設定好例外類別,並在方法參數中接收即可。

在業務邏輯,已經將需要的資料包裝在例外中一起拋出。在此只要交由專門的 ExceptionHandler 方法接收,隨後處理成想要的 response 就好。且 OperateAbsentItemsException 所冠上的「@ResponseStatus」標記也沒有必要留著了。

使用 Postman 測試,讀者可發現後端不但回傳 422 狀態碼,也有自訂的 respose body。

發生 HTTP 422 狀態時攜帶自訂的 response
例外發生後的自訂 response

三、使用 Controller Advice 達到全局處理

在上一節,筆者針對 ProductController 做例外處理。為了避免在 UserController 寫出重複的程式,這節讓我們利用 ControllerAdvice 進行套用。

以下的範例是設計一個 ControllerAdvice 類別,並設定這裡的實作將會套用在 ProductController 與 UserController。

在「@RestControllerAdvice」標記中,讀者可使用三種陣列參數,來設定要套用的 controller。

  • assignableTypes:直接指定 controller。
  • basePackages:package 下的所有 controller。
  • basePackageClasses:controller 所隸屬的父 package,其下所有的 controller。

接著我們將剛剛的 ExceptionHandler 方法搬移到至此,便能讓這些 controller 都生效。

附帶一提,ControllerAdvice 本身是一種 Spring 元件,因此能注入(autowired)其他元件。若讀者開發的專案對於例外處理有特殊的做法(例如將 log 存在資料庫),那麼此處也是呼叫邏輯的好地方。


四、查詢字串的前處理

(一)動機

在範例專案中,GET 方法的 API 會接收查詢字串(query string) 作為查詢資料的條件。

ProductController 接收 createdFrom、createdTo 與 name 三 種。

UserController 接收 name 與 email 兩種。

並且還呼叫了 util 方法,將字串去除空白、轉為小寫或轉為 Date 物件。

上述處理 query string 的過程,在 code 中顯得冗長、不優雅。

(二)使用 Init Binder 與 Property Editor

本節將在 ControllerAdvice 統一對 query string 進行事前處理,才讓 controller 接收。以 ProductController 的日期為例,請宣告如下的方法:

該方法冠上了「@InitBinder」標記,並指定要處理「createdFrom」與「createdTo」兩個 query string。此外還接收了「WebDataBinder」參數,我們可用它來註冊「PropertyEditor」物件,讓這個 editor 處理 query string。

類別中還建立了 CustomDateEditor 物件,它就是一種 PropertyEditor。我們在建構子給予 SimpleDateFormat 物件,代表要處理的日期格式。

接著在 InitBinder 方法中呼叫 registerCustomEditor 方法,將 editor 註冊上去。第一個參數是想將 query string 轉換成的類別;第二個則是 editor。

如此一來,ProductController 的參數便能直接以 Date 來接收。

這邊的 CustomDateEditor 是由 Spring 提供的。其他還有 StringTrimmerEditor 等,讀者可自行探索。


五、自行實作 Property Editor

我們可以打造自己的 PropertyEditor。再次以 controller 中的 GET 方法為例,上述的 StringTrimmerEditor 只能去除頭尾空白,但沒有內建的 editor 能夠轉為小寫。

以下的範例是實作一個具有這兩種功能的 editor。

這個 ToSearchTextEditor 類別繼承了 PropertyEditorSupport。它能夠將 query string 去除頭尾空白,並轉為小寫,成為適合用來查詢的形式。

繼承後會用到兩個重要的方法。首先是覆寫 setAsText 方法,它的 text 字串參數代表的是 query string 的值,讀者可對其進行處理。最後再呼叫 setValue 方法「寫回去」,讓 controller 接收到我們想要的值。

完成 editor 的實作後,配置到 ControllerAdvice 中,並指定 query string 的名稱,便能生效了。

如此一來,ProductController 與 UserController 的程式碼也能簡化。

本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch24-fin

上一篇:【Spring Boot】第23課-利用 Swagger 產生 API 文件

下一篇:【Spring Boot】第25課-定時任務排程

留言