系統中的業務異常

系統中的業務異常

系統中的業務異常

搭建系統框架時,關於異常,我們一般要考慮這樣幾件事情。

系統中有哪些異常

這個問題其實很簡單:一類是業務異常,例如“用戶輸入的證件號不合法”、“銀行卡四要素鑑權失敗”、“餘額不足”等業務邏輯上的問題;除此之外的全都是系統異常,例如網絡超時、數據庫鎖超時、甚至堆棧溢出內存溢出等等。

業務異常中,有幾種特殊的異常。當我們是通過類似樂觀鎖的方式來檢測冪等時,在流程中任何一點上都有可能發現當前數據、當前業務已經執行過一遍了。這時系統不能按正常邏輯繼續處理,必須中斷並回滾此前的操作;同時需要接口上給調用方返回一個成功的結果。此時,就需要一個專門的異常來處理這種問題。

此外,雖然絕大多數情況下,發生異常就應當回滾事務,但偶爾也有例外:某些異常不需要回滾事務。這種異常也需要有額外的標記和處理。

系統異常一般沒什麼別的辦法,除了重試就是拋出。但是業務異常,值得我們考慮考慮。

<hr/>

業務異常有什麼特點

在業務系統中、由我們手動拋出的異常,是一類怎樣的異常呢?

問題常常能且只能用戶處理

設想一下,用戶輸入的證件號不合法,除了提示用戶重新輸入一遍,還有什麼辦法?四要素鑑權失敗,除了提示用戶重新輸入一遍,還有什麼辦法?餘額不足了,除了讓用戶去充值,還有什麼辦法?沒辦法,你只能交給用戶去處理。

而且很多時候,這類問題可以交給用戶去處理。

證件號錯了,用戶可以重新輸入一個正確的;四要素鑑權失敗,用戶可以重新輸入一遍;餘額不足了,用戶可以充值。業務異常的問題是可以交給用戶來處理的——這也是最省事兒的方法。

當然,還有一種辦法是給開發提bug或工單,要求開發查一下……但是這樣做了的話,通常會把開發的仇恨值拉滿:“都已經有那麼明確的提示了,還要我們查什麼”。

系統常常只有一種處理流程

很多業務功能中,系統都只有一種處理流程,沒有替代流程。因此,這個流程出現問題後,我們就只能中斷系統流程、反饋給用戶去處理。

顯然,如果系統有可替代方案的話,那麼,主流程發生問題時,我們就可以嘗試“恢復上下文”並使用替代流程再處理一遍。如果所有的替代流程都失敗了,最後再交給用戶處理。

例如, 把String解析爲Date時,我們可以先按照"yyyy-MM-dd"解析一遍;如果解析失敗再嘗試按"yyyyMMdd"解析一遍。又如,如果業務允許,那麼用戶四要素鑑權失敗時,可以嘗試一次三要素鑑權;如果三要素還失敗,再嘗試一次二要素鑑權。還有,系統做逾期還款時,有一個“自動減免三天罰息”的邏輯:如果錢不夠還了,先嚐試減免一天,還不夠就再減免一天,還不夠就再減免一天……直到夠還了、或者已經把罰息違約金減免完了、或者已經減免了三天的錢了。這些都是有替代流程的情況,就不能簡單地把異常丟給用戶處理。

<hr/>

怎樣定義業務異常類

通常,我們需要聲明一個特定的異常類,用來標記當前發生的問題是“業務邏輯發生問題”,而不是“系統發生故障”。例如,如果是網絡故障,可能會拋出IOException,如果是數據庫問題,可能會是SQLException,這種就是“系統發生故障”。但是,如果是“用戶輸入的證件號不合法”、“銀行卡四要素鑑權失敗”、“餘額不足”等業務邏輯上的問題,很多時候我們都使用業務異常直接向上拋出。

命名

所謂編程,95%的情況下都是在命名。業務異常也一樣。雖然大多數情況下業務異常都會命名爲BizException、BusinessException或者ServiceException之類的名字,但是,我更建議用系統名字來命名業務異常:MySystemException、HerSystemException等。

如果你感受過一個系統裏有四五個ServiceException類,你就知道我爲什麼建議這樣命名了。我曾經在系統A中拋出了一個系統B中聲明的ServiceException,結果是顯然的:系統A中的攔截器沒有對系統B的ServiceException做處理,我拋出的那個異常就直接被當做500返回給客戶端了。

Checked or Runtime

業務異常是使用受檢異常還是運行時異常?時至今日,這個問題已經沒有太多的疑問了:應該使用運行時異常。

如果使用受檢異常,那麼拋出異常的代碼就必須在方法名上聲明throws。然而,系統對絕大多數業務異常都束手無策,對方法上聲明的throws也別無選擇,只有把它沿着調用鏈逐層向上傳遞。這種做法除了引入冗餘的代碼和編譯問題外,對系統沒有什麼實質性的幫助。

使用運行時異常,則可以讓系統代碼對底層異常無感知——既不需要在方法名上聲明throws,也不需要逐層把異常聲明向上傳遞。這樣對大多數代碼和程序員都更友好。

error code

業務異常裏帶上錯誤碼,其實是被接口返回值綁定的。接口返回值要給前端一個錯誤碼,用以區分後端發生了什麼錯誤、前端如何處理。當發生異常時,爲了保證接口返回值的一致性,就要把異常轉換成對應的錯誤碼。系統異常一般會統一轉換爲一個錯誤碼;業務異常不能這樣一刀切,就只好自己帶上錯誤碼了。如果所有接口都不需要返回錯誤碼,而是直接跳轉頁面,那麼業務異常裏其實可以不返回錯誤碼。

我所見的大多數情況下,業務異常裏的錯誤碼都是一串數字。0000代表成功、1000代表入參不合法、2000代表四要素鑑權失敗、3000代表餘額不足,等等。

用數字做錯誤碼有兩個好處。

其一是比較安全。我們並不知道訪問系統的到底是用戶還是***,因而不能讓他知道到底出了什麼錯。例如用戶登錄不上系統時,不能告訴對方到底是用戶名錯誤還是密碼錯誤,以免被爆破出用戶信息。此時,如果系統返回一個1001錯誤碼,請問到底是用戶名錯了還是密碼錯了?不知道,總歸你重新輸入就對了。

其二是可以方便地做歸併處理。這一點上HttpStatusCode就是一個非常好的例子。我們都知道,HttpStatusCode中,2xx是正常返回,3xx是資源位置變化,4xx是訪問不到服務,5xx是服務內部異常。這樣,我們就可以用if(code >=200 && code <300)這樣的代碼來把這一類問題統一處理了。

當然,有好處就有壞處。最直接的壞處就是錯誤碼一多,開發自己也記不清楚哪個錯誤碼代表什麼問題了。隨之導致的“次生災害”就是新增異常時,錯誤碼很容易發生重複。對trouble shooting、接口對接來說,這都是不大但也不小的問題。

除了數字之外,也有些系統直接用可以表意的字符串來做錯誤碼:“SUCCESS”自然是成功,“ILLEGAL_PARAM”表示參數不合法,“FOUR_ITMES_ERROR”表示四要素鑑權失敗、“BALANCE_NOT_ENOUGH”表示餘額不足,諸如此類。

這種方式當然不如使用數字那麼安全,但是更加一目瞭然,接口對接和查問題時更加方便快捷——安全和便捷在很多場景下都是這樣矛盾的。而且,這類錯誤碼出現重複的可能性比純數字的更小。如果系統僅僅提供中後臺服務而不需要直接和前端用戶交互,那麼這種可以表意的錯誤碼是更好的選擇。

error message

毋庸置疑,業務異常裏應該帶上錯誤信息:無論是給用戶看還是給開發看,我們都需要一個明確的信息。

不過,給用戶看的和給開發看的還是有區別的。

給用戶看的重點在於描述怎樣解決這個問題:四要素鑑權失敗了需要去聯繫銀行確認銀行卡預留手機號;餘額不足了需要點擊某個鏈接去充值等等。

給開發看的重點在於描述問題是什麼:是唯一鍵衝突、還是鎖超時、或者乾脆就是死鎖了?是網絡超時了、還是404了?

一般來說,給用戶看的文案應該由產品或者交互來定;但是產品對系統中的“異常”瞭解得並不多。因而,絕大多數的業務異常的文案都是開發自己定的。這就使得很多時候用戶面對系統提示會一臉茫然:這是發生了什麼問題?除了打客服電話之外,我還能怎麼辦?所以有時候我會覺得,異常信息和用戶提示,不能一概而論。給用戶的提示信息應該由接口、甚至前端來處理;而異常中的信息只提供給開發trouble shooting用。當然,要這樣做的話,需要付出很大的編碼成本,未必划算。

data

異常裏要不要帶上發生問題時的上下文數據?這取決於捕獲這個異常之後要做怎樣的處理。如果需要使用上下文數據,往往就只能在異常裏把它帶出來了。

例如,我們有一個校驗,要求在攔截住用戶之後,把用戶類型(新用戶、老用戶、黑名單用戶)發送給前端,前端據此將用戶引導到不同的產品頁面上。這時候,我們就使用了一個“帶數據的異常”來處理這種情況:

public class ApplyController{
    public Result<Apply> apply(long userId){
        Result<Apply> result = new Result<>();
        try{
            Apply apply = applyService.apply(userId);
            result.setCode(SUCCESS_CODE);
            result.setCode(SUCCESS_MSG);
            result.setData(apply);
        }catch(UserTypeException ute){
           result.setCode(ute.getCode());
           result.setMsg(ute.getMsg());
           // 在這裏處理了用戶類型校驗異常帶出來的數據
           Apply apply = new Apply();
           apply.setUserType(ute.getUserType());
           result.setData(apply);
        }
        // 其它異常交給AOP統一處理
        return result;
    }
}

是否需要使用子類

絕大多數情況下,我們自定義的業務異常都只需要一個類就行:

public class MySystemException extends RuntimeException{
    private final String code;
    private final String msg;
    public MySystemException(String code, String msg){
        this.code = code;
        this.msg = msg;
    }
    // getters,略
}

但是,就如UserTypeException所展示的那樣:有些時候僅僅使用MySystemException 滿足不了需求,我們還需要對它進行擴展,通過一個子類來傳遞更多信息。

public class UserTypeExceptin extends MySystemException {
    private final UserType userType;
    public UserTypeException(Usertype userType){
        super("2002","抱歉,您不能申請這款產品。");
        this.userType = userType;
    }
    // getters
}

使用標準異常還是自定義異常

標準異常和自定義異常的主要區別在於:所有使用Java的人都應該熟悉Java的標準異常;但並不是所有人都熟悉你的自定義異常。

如果你的異常只在自己系統的內部使用,那麼建議使用自定義異常。自定義異常比標準異常更“定製化”,在處理各種問題時也更加靈活方便。而且,開發和維護一個系統的人有義務去了解這個系統內部的一些約定、規範和標準,自定義異常自然也在此列。

但是,當你的異常需要提供給其他人使用的時候——例如你要給人提供一個jar包,而jar包中不得不拋出一些異常的時候,這時就應當儘量使用標準異常,因爲調用方並沒有義務瞭解你的內部實現細節。

這就像你在自己家鄉被狗咬了,你可以用家鄉話喊“起開起開”;但是如果你在外國被狗咬了,你就得喊“help”,甚至可能要喊“SOS”、“May Day”,即使在日本、法國這種非英語國家,這些呼救消息也是通用的。

總之,所謂“自定義”異常,是在你的“自定義”範圍內的標準異常。如果是別人走進你的“自定義”範圍,那麼可以要求別人來遵循你的標準。但是如果要走出這個範圍,那麼你的“自定義”就不能作爲標準了。

<hr/>

怎樣處理業務異常

作爲技術,知道“是什麼”和“爲什麼”之後,我們還需要知道“怎麼做”。

只在必要的地方使用異常

相信所有介紹異常的文章中都會提到這一點,只不過表述方式會有差別:“使用if條件判斷代替異常”、“只在真正出現問題的時候才使用異常”、“不要用異常來控制業務流程”,諸如此類。

所謂使用異常的“必要的地方”,個人理解,需要滿足以下兩個條件。

每一段代碼塊都有其前置條件和後置條件。前置條件是對輸入數據的約束,而後置條件則是對輸出條件的約束。使用異常的第一個條件,就是代碼流程中的數據違反了這兩個條件中的任意一項。例如,參數校驗不通過是違反前置條件的一種常見情形,而調用某個服務接口得到一個超時異常則是一種違反後置條件的情形。在這些場景下,我們都可以考慮通過拋出異常來反饋問題。

使用異常的第二個條件,就是自己處理不了違反兩項條件約束的情況。如果某項參數校驗失敗後,我們可以給它設置一個默認值,那就不用拋出異常了。如果調用接口超時後我們可以重試、或者可以轉爲異步處理,那也不必急着拋出異常。只有同時滿足了兩個條件,我們纔可以說,這個地方有必要拋出一個異常。

在業務入口處統一處理異常

這是最簡單、也是最常見的一種做法。無論是使用ControllerAdvice,還是用AOP,我們都可以統一地捕獲Controller、Dubbo或gRpc接口、MQ消費者甚至定時任務中拋出的異常,並針對各入口的特性進行處理。具體的實現方式這裏就不贅述了。

當然,使用這種方法來處理異常,有一定的前提條件。

首先,要在業務入口進行處理,那麼這異常必須符合前文提到的兩個特點,即“只能交給用戶處理”和“沒有替代流程”。自然的,這裏的“用戶”也包括系統服務的調用方。否則的話,系統應當在更合適的地方——如異常的拋出點,或者離異常最近的業務現場——進行必要的處理。

其次,統一處理必須以統一規範爲基礎。如果有的接口返回Result<T>,有的接口返回Response<U>,甚至有些接口直接返回String/Long,顯然我們無法對它們做統一處理。

第三,統一處理只會針對業務異常和系統異常兩個基類來做處理,而不會、也不應該針對某種特定異常進行特殊處理。如果在某些特殊場景下需要使用特定異常並進行特殊處理,應該在那個場景內進行處理,而不應當把某個特殊場景的需求放到統一處理的模塊中來。

在特殊場景下使用特定異常並進行特殊處理

那麼,都有哪些場景下需要使用特定異常呢?

從根子上來說,當我們需要拋出異常、並且除了統一處理所需信息(如錯誤碼、錯誤提示文案等信息)之外,還需要藉助異常來傳遞更多信息時,我們就有必要使用特定異常了。

比較常見的場景是某些第三方框架組件要求使用特定異常來標記某些特殊流程。例如,Spring的事務管理框架就需要在@Transactional註解中標記rollbackFor和noRollbackFor兩項屬性,而這兩項屬性都只接受Class<? extends Throwable>;Spring的重試框架也需要在@Retryable註解中標記include和exclude兩項屬性,他們同樣只接受Class<? extends Throwable>。如果我們要使用這兩個框架提供的某些功能,一般都需要聲明並拋出某種特定異常。這種情況下,這些異常所傳遞的信息就是通過其類定義告訴對應的處理模塊:“要/不要回滾事務”或“要/不要進行重試”。

前面提到過,在使用樂觀鎖來做冪等處理時,由於樂觀鎖常常在業務調用鏈的深處纔會被觸發,此時我們往往只能用異常來告訴調用方:這個業務操作已經執行過至少一次了,不要重複操作。同時,考慮到接口冪等性,第N次調用與第一次調用應該返回同樣的結果,也就是“處理成功”的結果。因此,當觸發樂觀鎖時,我們應當聲明、拋出並捕獲一個特定異常,並將這個異常結果轉換爲“處理成功”。這也是一種使用特定異常的場景,樂觀鎖異常也是通過其類定義傳遞出“這個業務操作已經執行過至少一次了”這個信息。

有些系統會通過清洗接口層日誌,來分析業務中出現的問題。而爲了進行日誌清洗和分析,除了接口最終返回結果之外,有時還需要把業務調用鏈深處的一些數據也“帶”到接口層日誌中,如是調用哪個三方接口時出現了錯誤、出錯時的入參和返回值分別是什麼,等等。此時,除了聲明一個特定異常,恐怕也沒有別的東西可以把這類信息傳遞出來了。

我遇到過的最特殊的一個場景,是這樣一個查詢接口。

最初,它的功能是從數據庫中查出一張用戶綁定的銀行卡,並判斷這張卡有沒有通過四要素認證。如果沒有通過四要素認證,那麼就拋出業務異常,以告知用戶重新綁定一張銀行卡。

隨着業務擴展,另一個模塊也來調用這個接口。但是新的模塊要求:如果數據庫中查到了卡、只是這張卡沒有通過四要素認證,那麼需要把這張卡的數據返回給它,以方便它回填到四要素認證表單中,從而提供更好的用戶體驗。

這麼一來,同一個接口、針對同一種情況,有了兩種處理邏輯。一種不需要返回數據,直接拋出業務異常;另一種則需要返回數據,還需要返回“是否通過四要素認證”這樣一個信息。

顯然地,我的處理方式是聲明瞭一個新的業務異常,在這個業務異常中帶上了相關數據。這樣,既兼容了原有邏輯,又滿足了新的需求,輕鬆簡單。

當然,這個問題還有其它的解決方案,不妨拿出來對比一下。

系統中的業務異常

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章