【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,如下:

public class Product {
private String id;
private String name;
private Date createdTime;
public static Product of(String id, String name, String createdTime) {
var product = new Product();
product.id = id;
product.name = name;
product.createdTime = CommonUtil.toDate(createdTime);
return product;
}
// getter, setter...
}
view raw Product.java hosted with ❤ by GitHub
public class User {
private String id;
private String name;
private String email;
public static User of(String id, String name, String email) {
var user = new User();
user.id = id;
user.name = name;
user.email = email;
return user;
}
// getter, setter...
}
view raw User.java hosted with ❤ by GitHub

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

public class CommonUtil {
public static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
public static Date toDate(String dateStr) {
try {
return sdf.parse(dateStr);
} catch (ParseException e) {
throw new RuntimeException(e);
}
}
// 其餘略過
}

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

@RestController
@RequestMapping(value = "/products", produces = MediaType.APPLICATION_JSON_VALUE)
public class ProductController {
private final Map<String, Product> productDB = new LinkedHashMap<>();
@PostConstruct
private void initData() {
var products = List.of(
Product.of("B1", "Android (Java)", "2022-01-15"),
Product.of("B2", "Android (Kotlin)", "2022-05-15"),
Product.of("B3", "Data Structure (Java)", "2022-09-15"),
Product.of("B4", "Finance Management", "2022-07-15"),
Product.of("B5", "Human Resource Management", "2022-03-15")
);
products.forEach(x -> productDB.put(x.getId(), x));
}
// 其餘略過
}

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


二、捕捉 API 層級的例外

(一)動機

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

@DeleteMapping
public ResponseEntity<Void> deleteProducts(@RequestBody BatchDeleteRequest request) {
var itemIds = request.getIds();
var absentIds = itemIds.stream()
.filter(Predicate.not(productDB::containsKey))
.collect(Collectors.toList());
if (!absentIds.isEmpty()) {
throw new OperateAbsentItemsException(absentIds);
}
itemIds.forEach(productDB::remove);
return ResponseEntity.noContent().build();
}
public class BatchDeleteRequest {
private List<String> ids = List.of();
// getter, setter...
}
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public class OperateAbsentItemsException extends RuntimeException {
private final List<String> itemIds;
public OperateAbsentItemsException(List<String> itemIds) {
super("Following ids are non-existent: " + itemIds);
this.itemIds = itemIds;
}
public List<String> getItemIds() {
return itemIds;
}
}

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

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

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

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

(二)使用 Exception Handler

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

public class ExceptionResponse {
private BusinessExceptionType type;
private Map<String, Object> info = new HashMap<>();
public static ExceptionResponse of(BusinessExceptionType type, Map<String, Object> info) {
var res = new ExceptionResponse();
res.type = type;
res.info = info;
return res;
}
// getter, setter...
}
public enum BusinessExceptionType {
OPERATE_ABSENT_ITEM
}

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

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

public class ProductController {
private final Map<String, Product> productDB = new LinkedHashMap<>();
@DeleteMapping
public ResponseEntity<Void> deleteProducts(@RequestBody DeleteByIdRequest request) {
// 其餘略過
if (!absentIds.isEmpty()) {
throw new OperateAbsentItemsException(absentIds);
}
// 其餘略過
}
@ExceptionHandler(OperateAbsentItemsException.class)
public ResponseEntity<ExceptionResponse> handleOperateAbsentItem(OperateAbsentItemsException e) {
Map<String, Object> info = Map.of("itemIds", e.getItemIds());
var res = new ExceptionResponse();
res.setType(BusinessExceptionType.OPERATE_ABSENT_ITEM);
res.setInfo(info);
return ResponseEntity.unprocessableEntity().body(res);
}
// 其餘略過
}

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

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

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


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

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

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

@RestControllerAdvice(assignableTypes = {ProductController.class, UserController.class})
public class GeneralAdvice {
}

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

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

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

@RestControllerAdvice(assignableTypes = {ProductController.class, UserController.class})
public class GeneralAdvice {
@ExceptionHandler(OperateAbsentItemsException.class)
public ResponseEntity<ExceptionResponse> handleOperateAbsentItem(OperateAbsentItemsException e) {
// 其餘略過
}
}

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


四、查詢字串的前處理

(一)動機

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

ProductController 接收 createdFrom、createdTo 與 name 三 種。

@GetMapping
public ResponseEntity<List<Product>> getProducts(
@RequestParam(required = false) String createdFrom,
@RequestParam(required = false) String createdTo,
@RequestParam(required = false) String name) {
var stream = productDB.values().stream();
if (createdFrom != null) {
var from = CommonUtil.toDate(createdFrom);
stream = stream.filter(p -> p.getCreatedTime().after(from));
}
if (createdTo != null) {
var to = CommonUtil.toDate(createdTo);
stream = stream.filter(p -> p.getCreatedTime().before(to));
}
if (name != null) {
var n = CommonUtil.toSearchText(name);
stream = stream.filter(p -> p.getName().toLowerCase().contains(n));
}
var products = stream.collect(Collectors.toList());
return ResponseEntity.ok(products);
}

UserController 接收 name 與 email 兩種。

@GetMapping
public ResponseEntity<List<User>> getUsers(
@RequestParam(required = false) String name,
@RequestParam(required = false) String email) {
var stream = userDB.values().stream();
if (name != null) {
var n = CommonUtil.toSearchText(name);
stream = stream.filter(u -> u.getName().toLowerCase().contains(n));
}
if (email != null) {
var e = CommonUtil.toSearchText(email);
stream = stream.filter(u -> u.getEmail().equalsIgnoreCase(e));
}
var users = stream.collect(Collectors.toList());
return ResponseEntity.ok(users);
}

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

public class CommonUtil {
public static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
public static Date toDate(String dateStr) {
// 其餘略過
}
public static String toSearchText(String s) {
return Optional.ofNullable(s)
.map(String::trim)
.map(String::toLowerCase)
.orElse("");
}
}

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

(二)使用 Init Binder 與 Property Editor

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

public class GeneralAdvice {
private final CustomDateEditor customDateEditor = new CustomDateEditor(CommonUtil.sdf, true);
@InitBinder({"createdFrom", "createdTo"})
public void bindDate(WebDataBinder binder) {
binder.registerCustomEditor(Date.class, customDateEditor);
}
// 其餘略過
}

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

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

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

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

@GetMapping
public ResponseEntity<List<Product>> getProducts(
@RequestParam(required = false) Date createdFrom,
@RequestParam(required = false) Date createdTo,
@RequestParam(required = false) String name) {
var stream = productDB.values().stream();
if (createdFrom != null) {
stream = stream.filter(p -> p.getCreatedTime().after(createdFrom));
}
if (createdTo != null) {
stream = stream.filter(p -> p.getCreatedTime().before(createdTo));
}
// 其餘略過
}

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


五、自行實作 Property Editor

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

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

public class ToSearchTextEditor extends PropertyEditorSupport {
@Override
public void setAsText(String text) throws IllegalArgumentException {
var t = CommonUtil.toSearchText(text);
super.setValue(t);
}
}

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

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

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

public class GeneralAdvice {
private final ToSearchTextEditor toSearchTextEditor = new ToSearchTextEditor();
@InitBinder({"name", "email"})
public void bindSearchText(WebDataBinder binder) {
binder.registerCustomEditor(String.class, toSearchTextEditor);
}
// 其餘略過
}

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

@GetMapping
public ResponseEntity<List<Product>> getProducts(
@RequestParam(required = false) Date createdFrom,
@RequestParam(required = false) Date createdTo,
@RequestParam(required = false) String name) {
var stream = productDB.values().stream();
if (name != null) {
stream = stream.filter(p -> p.getName().toLowerCase().contains(name));
}
// 其餘略過
}
@GetMapping
public ResponseEntity<List<User>> getUsers(
@RequestParam(required = false) String name,
@RequestParam(required = false) String email) {
var stream = userDB.values().stream();
if (name != null) {
stream = stream.filter(u -> u.getName().toLowerCase().contains(name));
}
if (email != null) {
stream = stream.filter(u -> u.getEmail().equalsIgnoreCase(email));
}
// 其餘略過
}



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

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

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

留言