本文摘錄總結于極客時間——《Java業務開發常見錯誤 100 例》
應用程式避免不了出異常,捕獲與處理異常是一個精細活。像是業務開發時不考慮如何處理異常,而在結尾時采用“流水線”的方式進行異常處理,也就是統一的為所有方法打上 try..catch..捕獲所有異常記錄日志,有些技巧的同學可能會使用 AOP 來進行類似的“統一異常處理”。
其實,這樣是不可取的,今天我們就來聊一聊異常處理相關的坑。
捕獲和處理異常容易犯的錯
- 不在業務代碼層面考慮處理異常,僅在架構層面粗犷捕獲和處理異常
為了了解錯在何處,我們先來看看大多數業務應用都采用的三層架構:
- Controller 層負責資訊收集、參數校驗、轉換服務層處理的資料适配前端,輕業務邏輯
- Service 層負責核心業務邏輯,包括外部服務調用、通路資料庫、緩存處理、消息處理等
- Repository 層負責資料通路邏輯,一般沒有業務邏輯
每層架構的工作性質不同,且從業務性質上來說異常可以分為業務異常和系統異常兩大類,這就決定了很難進行統一的異常處理。
- Repository 層出現的異常或許可以忽略,或許可以降級,或許需要轉換為一個友好的異常。如果一律捕獲異常僅僅記錄,可能業務邏輯已經出錯,而使用者和程式本身完全感覺不到。
- Service 曾往往涉及資料庫事務,出現異常同樣不适合捕獲,否則事務無法復原。此外 Service 層涉及業務邏輯,有些業務邏輯執行中遇到業務異常,可能需要在異常後轉入分支業務流程。如果業務異常都被架構捕獲了,業務功能就會不正常。
- 如果下層異常上升到 Controller 層還是無法處理的話,Controller 層往往會給予使用者友好提示,或是根據每一個 API 的異常表傳回指定的異常類型,同樣無法對所有異常一視同仁。
有,不建議在架構層面進行異常的自動、統一處理,尤其不要随意捕獲異常。但,架構可以做兜底工作。如果異常上升到最上層邏輯還是無法處理的話,可以以統一的方式進行異常轉換,比如通過 @RestControllerAdvice + @ExceptionHandler,來捕獲這些“未處理”異常:
- 對于自定義的業務異常,以 WARN 級别的日志記錄異常以及目前 URL、執行方法等資訊後,提取異常中的錯誤碼和消息等資訊,轉換為合适的 API 包裝體傳回給 A對于無法處理的系統異常,以 Error 級别的日志記錄異常和上下文資訊(比如 URL、參數、使用者 ID)後,轉換為普适的“伺服器忙,請稍後再試”異常資訊,同樣以 API 包裝體傳回給調用方。PI 調用方;
- 對于無法處理的系統異常,以 ERROR 級别的日志記錄異常和上下文資訊(比如 URL、參數、使用者 ID)後,轉換為普适的“伺服器忙,請稍後再試”異常資訊,同樣以 API 包裝體傳回給調用方。
就比如以下代碼的做法:
@RestControllerAdvice
@Slf4j
public class RestControllerExceptionHandler {
private static int GENERIC_SERVER_ERROR_CODE = 2000;
private static String GENERIC_SERVER_ERROR_MESSAGE = "伺服器忙,請稍後再試";
@ExceptionHandler
public APIResponse handle(HttpServletRequest req, HandlerMethod method, Exception ex) {
if (ex instanceof BusinessException) {
BusinessException exception = (BusinessException) ex;
log.warn(String.format("通路 %s -> %s 出現業務異常!", req.getRequestURI(), method.toString()), ex);
return new APIResponse(false, null, exception.getCode(), exception.getMessage());
} else {
log.error(String.format("通路 %s -> %s 出現系統異常!", req.getRequestURI(), method.toString()), ex);
return new APIResponse(false, null, GENERIC_SERVER_ERROR_CODE, GENERIC_SERVER_ERROR_MESSAGE);
}
}
}
-
第二個錯,出了異常就直接生吞
我們不能隻捕獲異常而什麼事情都不做,生吞的目的可能隻是希望自己的方法逃過受檢異常,隻是想把異常“處理掉”,也可能想當然的認為異常并不重要或者不可能産生。但不管是什麼原因,都不能生吞任何一個代碼,哪怕你加一個日志也行。
-
第三個錯,丢棄異常的原始資訊
這也是常見操作,比如有這麼一個會抛出受檢異常的方法 readFile:
private void readFile() throws IOException {
Files.readAllLines(Paths.get("a_file"));
}
像這樣調用 readFile 方法,捕獲異常後,完全不記錄原始異常,直接抛出一個轉換後異常,導緻出了問題不知道 IOException 具體是哪裡引起的:
@GetMapping("wrong1")
public void wrong1(){
try {
readFile();
} catch (IOException e) {
//原始異常資訊丢失
throw new RuntimeException("系統忙請稍後再試");
}
}
或是這樣隻記錄了異常資訊,卻丢失了異常的類型、棧幀等資訊:
catch (IOException e) {
//隻保留了異常消息,棧沒有記錄
log.error("檔案讀取錯誤, {}", e.getMessage());
throw new RuntimeException("系統忙請稍後再試");
}
這兩種處理方式都不太合理,推薦改為以下兩種方式:
// 1
catch (IOException e) {
log.error("檔案讀取錯誤", e);
throw new RuntimeException("系統忙請稍後再試");
}
// 2
catch (IOException e) {
throw new RuntimeException("系統忙請稍後再試", e);
}
- 第四個錯誤,抛出異常時不指定任何消息
經常可以看到有偷懶的同學抛出異常時不指定任何消息
throw new RuntimeException();
這樣的寫法被 ExceptionHandler 攔截到後輸出了下面的日志資訊:
[13:25:18.031] [http-nio-45678-exec-3] [ERROR] [c.e.d.RestControllerExceptionHandler:24 ] - 通路 /handleexception/wrong3 -> org.geekbang.time.commonmistakes.exception.demo1.HandleExceptionController#wrong3(String) 出現系統異常!
java.lang.RuntimeException: null
...
這裡的 null 很容易引起誤會,但其實是異常的 message 為空。
總之,如果你捕獲了異常打算處理時,除了除了通過日志正确記錄異常原始資訊外,通常還有三種處理模式:
- 轉換,即轉換新的異常抛出。對于新抛出的異常,最好具有特定的分類和明确的異常消息,而不是随便抛一個無關或沒有任何資訊的異常,并最好通過 cause 關聯老異常
- 重試,即重試之前的操作。比如遠端調用服務端過載逾時的情況,盲目重試會讓問題更嚴重,需要考慮目前情況是否适合重試。
- 恢複,即嘗試進行降級處理,或使用預設值來替代原始資料。
以上,就是通過 catch 捕獲處理異常的一些最佳實踐。
千萬别把異常定義為靜态變量
通常我們都是自定義一個業務異常類型,來包含更多的異常資訊,比如異常錯誤碼、友好的錯誤提示等(比如對于下單操作,使用者不存在傳回 2001,商品缺貨傳回 2002 等)。
之前老大在救火排查某項目生産問題時,遇到了一件非常詭異的事情:發現異常堆資訊顯示的方法調用路徑,在目前入參的情況下根本不可能産生,項目的業務邏輯又很複雜,就始終沒往異常資訊是錯的這方面想,總覺得是因為某個分支流程導緻業務沒有按照期望的流程進行。
經過艱難的排查之後,最終定位是把異常定義為了靜态變量,導緻異常棧資訊錯亂:
public class Exceptions {
public static BusinessException ORDEREXISTS = new BusinessException("訂單已經存在", 3001);
...
}
把異常定義為靜态變量會導緻異常資訊固化,這就和異常的棧一定是需要根據目前調用來動态擷取相沖突。
我們寫段代碼來模拟下這個問題:定義兩個方法 createOrderWrong 和 cancelOrderWrong 方法,它們内部都會通過 Exceptions 類來獲得一個訂單不存在的異常;先後調用兩個方法,然後抛出。
@GetMapping("wrong")
public void wrong() {
try {
createOrderWrong();
} catch (Exception ex) {
log.error("createOrder got error", ex);
}
try {
cancelOrderWrong();
} catch (Exception ex) {
log.error("cancelOrder got error", ex);
}
}
private void createOrderWrong() {
//這裡有問題
throw Exceptions.ORDEREXISTS;
}
private void cancelOrderWrong() {
//這裡有問題
throw Exceptions.ORDEREXISTS;
}
運作程式後看到如下日志,cancelOrder got error 的提示對應了 createOrderWrong 方法。顯然,cancelOrderWrong 方法在出錯後抛出的異常,其實是 createOrderWrong 方法出錯的異常:
[14:05:25.782] [http-nio-45678-exec-1] [ERROR] [.c.e.d.PredefinedExceptionController:25 ] - cancelOrder got error
org.geekbang.time.commonmistakes.exception.demo2.BusinessException: 訂單已經存在
at org.geekbang.time.commonmistakes.exception.demo2.Exceptions.<clinit>(Exceptions.java:5)
at org.geekbang.time.commonmistakes.exception.demo2.PredefinedExceptionController.createOrderWrong(PredefinedExceptionController.java:50)
at org.geekbang.time.commonmistakes.exception.demo2.PredefinedExceptionController.wrong(PredefinedExceptionController.java:18)
修複方式很簡單,改一下 Exceptions 類的實作,通過不同的方法把每一個異常都 new 出來:
public class Exceptions {
public static BusinessException orderExists(){
return new BusinessException("訂單已經存在", 3001);
}
}
送出線程池的任務出了異常會怎麼樣?
我們來看一個例子:送出 10 個任務到線程池異步處理,第 5 個任務抛出一個 RuntimeException,每個任務完成後都會輸出一行日志:
@GetMapping("execute")
public void execute() throws InterruptedException {
String prefix = "test";
ExecutorService threadPool = Executors.newFixedThreadPool(1, new ThreadFactoryBuilder().setNameFormat(prefix+"%d").get());
//送出10個任務到線程池處理,第5個任務會抛出運作時異常
IntStream.rangeClosed(1, 10).forEach(i -> threadPool.execute(() -> {
if (i == 5) throw new RuntimeException("error");
log.info("I'm done : {}", i);
}));
threadPool.shutdown();
threadPool.awaitTermination(1, TimeUnit.HOURS);
}
觀察日志可以發現兩點:
...
[14:33:55.990] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:26 ] - I'm done : 4
Exception in thread "test0" java.lang.RuntimeException: error
at org.geekbang.time.commonmistakes.exception.demo3.ThreadPoolAndExceptionController.lambda$null$0(ThreadPoolAndExceptionController.java:25)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
[14:33:55.990] [test1] [INFO ] [e.d.ThreadPoolAndExceptionController:26 ] - I'm done : 6
...
- 任務 1 到 4 所在的線程是 test0,任務 6 開始運作線上程 test1。由于我的線程池通過線程工廠為線程使用統一的字首 test 加上計數器進行命名,是以從線程名的改變可以知道因為異常的抛出老線程退出了,線程池隻能重新建立一個線程。如果每個異步任務都以異常結束,那麼線程池可能完全起不到線程重用的作用。
- 因為沒有手動捕獲異常進行處理,ThreadGroup 幫我們進行了未捕獲異常的預設處理,向标準錯誤輸出列印了出現異常的線程名稱和異常資訊。顯然,這種沒有以統一的錯誤日志格式記錄錯誤資訊列印出來的形式,對生産級代碼是不合适的.
&esmp; 修複方式有 2 步:
- 以 execute 方法送出到線程池的異步任務,最好在任務内部做好異常處理
- 設定自定義的異常處理程式作為保底,比如在聲明線程池時自定義線程池的未捕獲異常處理程式:
new ThreadFactoryBuilder()
.setNameFormat(prefix+"%d")
.setUncaughtExceptionHandler((thread, throwable)-> log.error("ThreadPool {} got exception", thread, throwable))
.get()
或者設定全局的預設未捕獲異常處理程式:
static {
Thread.setDefaultUncaughtExceptionHandler((thread, throwable)-> log.error("Thread {} got exception", thread, throwable));
}