能代替try catch處理異常的優雅方式

前言

軟體開發過程中,不可避免的是需要處理各種異常,就我自己來說,至少有一半以上的時間都是在處理各種異常情況,所以程式碼中就會出現大量的

try {…} catch {…} finally {…}

程式碼塊,不僅有大量的冗餘程式碼,而且還影響程式碼的可讀性。

一、什麼是統一異常處理?

Spring在3。2版本增加了一個註解

@ControllerAdvice

,可以與

@ExceptionHandler

@InitBinder

@ModelAttribute

等註解註解配套使用。

不過跟異常處理相關的只有註解

@ExceptionHandler

,從字面上看,就是 異常處理器 的意思,其實際作用也是:若在某個Controller類定義一個異常處理方法,並在方法上新增該註解,那麼當出現指定的異常時,會執行該處理異常的方法,其可以使用springmvc提供的資料繫結,比如注入

HttpServletRequest

等,還可以接受一個當前丟擲的Throwable物件。

但是,這樣一來,就必須在每一個Controller類都定義一套這樣的異常處理方法,因為異常可以是各種各樣。這樣一來,就會造成大量的冗餘程式碼,而且若需要新增一種異常的處理邏輯,就必須修改所有Controller類了,很不優雅。也可以定義個類似

BaseController

的基類,這種做法雖然沒錯,但因為這樣的程式碼有一定的侵入性和耦合性,萬一已經繼承其他基類了呢。

那有沒有一種方案,既不需要跟Controller耦合,也可以將定義的 異常處理器 應用到所有控制器呢?所以註解

@ControllerAdvice

出現了,簡單的說,該註解可以把異常處理器應用到所有控制器,而不是單個控制器。藉助該註解,我們可以實現:在獨立的某個地方,比如單獨一個類,定義一套對各種異常的處理機制,然後在類的簽名加上註解

@ControllerAdvice

,統一對 不同階段的、不同異常 進行處理。這就是統一異常處理的原理

注意到上面對異常按階段進行分類,大體可以分成:進入Controller前的異常 和 Service 層異常,具體可以參考下圖:

能代替try catch處理異常的優雅方式

二、統一異常處理實戰

在定義統一異常處理類之前,先來介紹一下如何優雅的判定異常情況並拋異常。

用 Assert(斷言) 替換

throw exception

@Testpublic void test1() { 。。。 User user = userDao。selectById(userId); Assert。notNull(user, “使用者不存在。”); 。。。}@Testpublic void test2() { // 另一種寫法 User user = userDao。selectById(userId); if (user == null) { throw new IllegalArgumentException(“使用者不存在。”); }}

有沒有感覺第一種判定非空的寫法很優雅,其實

Assert。notNull()

的原始碼部分也是用的第二種寫法。

public abstract class Assert { public Assert() { } public static void notNull(@Nullable Object object, String message) { if (object == null) { throw new IllegalArgumentException(message); } }}

可以看到,Assert 其實就是幫我們把

if {…}

封裝了一下,是不是很神奇。雖然很簡單,但不可否認的是編碼體驗至少提升了一個檔次。那麼我們能不能模仿

org。springframework。util。Assert

,也寫一個斷言類,不過斷言失敗後丟擲的異常不是

IllegalArgumentException

這些內建異常,而是我們自己定義的異常。

下面讓我們來嘗試一下:

default void assertNotNull(Object obj, Object。。。 args) { if (obj == null) { throw newException(args); }}

上面的Assert斷言方法是使用介面的預設方法定義的,然後有沒有發現當斷言失敗後,丟擲的異常不是具體的某個異常,而是交由1個

newException

介面方法提供。

善解人意的Enum

自定義異常

BaseException

有2個屬性,即code、message,這樣一對屬性,有沒有想到什麼類一般也會定義這2個屬性?沒錯,就是列舉類。將 Enum 和 Assert 結合起來,相信會讓你眼前一亮。

public interface IResponseEnum { int getCode(); String getMessage();}/** *

業務異常

*

業務處理時,出現異常,可以丟擲該異常

*/public class BusinessException extends BaseException { private static final long serialVersionUID = 1L; public BusinessException(IResponseEnum responseEnum, Object[] args, String message) { super(responseEnum, args, message); } public BusinessException(IResponseEnum responseEnum, Object[] args, String message, Throwable cause) { super(responseEnum, args, message, cause); }}public interface BusinessExceptionAssert extends IResponseEnum, Assert { @Override default BaseException newException(Object。。。 args) { String msg = MessageFormat。format(this。getMessage(), args); return new BusinessException(this, args, msg); } @Override default BaseException newException(Throwable t, Object。。。 args) { String msg = MessageFormat。format(this。getMessage(), args); return new BusinessException(this, args, msg, t); }}@Getter@AllArgsConstructorpublic enum ResponseEnum implements BusinessExceptionAssert { /** * Bad licence type */ BAD_LICENCE_TYPE(7001, “Bad licence type。”), /** * Licence not found */ LICENCE_NOT_FOUND(7002, “Licence not found。”) ; /** * 返回碼 */ private int code; /** * 返回訊息 */ private String message;}

這裡程式碼示例中定義了兩個列舉例項:

BAD_LICENCE_TYPE

LICENCE_NOT_FOUND

分別對應了BadLicenceTypeException

LicenceNotFoundException

兩種異常。

以後每增加一種異常情況,只需增加一個列舉例項即可,再也不用每一種異常都定義一個異常類了。然後再來看下如何使用,假設

LicenceService

有校驗Licence是否存在的方法,如下

/** * 校驗{@link Licence}存在 * @param licence */private void checkNotNull(Licence licence) { ResponseEnum。LICENCE_NOT_FOUND。assertNotNull(licence);}

若不使用斷言,程式碼可能如下:

private void checkNotNull(Licence licence) { if (licence == null) { throw new LicenceNotFoundException(); // 或者這樣 throw new BusinessException(7001, “Bad licence type。”); } }

使用列舉類結合(繼承)Assert,只需根據特定的異常情況定義不同的列舉例項,如上面的

BAD_LICENCE_TYPE

LICENCE_NOT_FOUND

,就能夠針對不同情況丟擲特定的異常(這裡指攜帶特定的異常碼和異常訊息),這樣既不用定義大量的異常類,同時還具備了斷言的良好可讀性,當然這種方案的好處遠不止這些

統一返回結果

在驗證統一異常處理器之前,順便說一下統一返回結果。說白了,其實是統一一下返回結果的資料結構。code、message 是所有返回結果中必有的欄位,而當需要返回資料時,則需要另一個欄位 data 來表示。

所以首先定義一個

BaseResponse

來作為所有返回結果的基類;

然後定義一個通用返回結果類

CommonResponse

,繼承

BaseResponse

,而且多了欄位 data;

為了區分成功和失敗返回結果,於是再定義一個

ErrorResponse

驗證統一異常處理

因為這一套統一異常處理可以說是通用的,所有可以設計成一個 common包,以後每一個新專案/模組只需引入該包即可。所以為了驗證,需要新建一個專案,並引入該 common包。

@Servicepublic class LicenceService extends ServiceImpl { @Autowired private OrganizationClient organizationClient; /** * 查詢{@link Licence} 詳情 * @param licenceId * @return */ public LicenceDTO queryDetail(Long licenceId) { Licence licence = this。getById(licenceId); checkNotNull(licence); OrganizationDTO org = ClientUtil。execute(() -> organizationClient。getOrganization(licence。getOrganizationId())); return toLicenceDTO(licence, org); } /** * 分頁獲取 * @param licenceParam 分頁查詢引數 * @return */ public QueryData getLicences(LicenceParam licenceParam) { String licenceType = licenceParam。getLicenceType(); LicenceTypeEnum licenceTypeEnum = LicenceTypeEnum。parseOfNullable(licenceType); // 斷言, 非空 ResponseEnum。BAD_LICENCE_TYPE。assertNotNull(licenceTypeEnum); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper。eq(Licence::getLicenceType, licenceType); IPage page = this。page(new QueryPage<>(licenceParam), wrapper); return new QueryData<>(page, this::toSimpleLicenceDTO); } /** * 新增{@link Licence} * @param request 請求體 * @return */ @Transactional(rollbackFor = Throwable。class) public LicenceAddRespData addLicence(LicenceAddRequest request) { Licence licence = new Licence(); licence。setOrganizationId(request。getOrganizationId()); licence。setLicenceType(request。getLicenceType()); licence。setProductName(request。getProductName()); licence。setLicenceMax(request。getLicenceMax()); licence。setLicenceAllocated(request。getLicenceAllocated()); licence。setComment(request。getComment()); this。save(licence); return new LicenceAddRespData(licence。getLicenceId()); } /** * entity -> simple dto * @param licence {@link Licence} entity * @return {@link SimpleLicenceDTO} */ private SimpleLicenceDTO toSimpleLicenceDTO(Licence licence) { // 省略 } /** * entity -> dto * @param licence {@link Licence} entity * @param org {@link OrganizationDTO} * @return {@link LicenceDTO} */ private LicenceDTO toLicenceDTO(Licence licence, OrganizationDTO org) { // 省略 } /** * 校驗{@link Licence}存在 * @param licence */ private void checkNotNull(Licence licence) { ResponseEnum。LICENCE_NOT_FOUND。assertNotNull(licence); }}

小結

測試的異常都能夠被捕獲,然後以 code、message 的形式返回。每一個專案/模組,在定義業務異常的時候,只需定義一個列舉類,然後實現介面

BusinessExceptionAssert

,最後為每一種業務異常定義對應的列舉例項即可,而不用定義許多異常類。使用的時候也很方便,用法類似斷言。

總結

使用 斷言 和 列舉類 相結合的方式,再配合統一異常處理,基本大部分的異常都能夠被捕獲。

為什麼說大部分異常,因為當引入 spring cloud security 後,還會有認證/授權異常,閘道器的服務降級異常、跨模組呼叫異常、遠端呼叫第三方服務異常等,這些異常的捕獲方式與本文介紹的不太一樣。

另外,當需要考慮國際化的時候,捕獲異常後的異常資訊一般不能直接返回,需要轉換成對應的語言,不過本文已考慮到了這個,獲取訊息的時候已經做了國際化對映,邏輯如下:

能代替try catch處理異常的優雅方式