3. 異常處理實踐

1. 捕獲和處理異常的常見錯誤

錯誤1 統(tǒng)一異常處理

統(tǒng)一異常處理的表現(xiàn)形式是不在業(yè)務層面進行異常處理,而是在框架層面粗獷的捕獲和處理異常。
一般大多數(shù)業(yè)務應用采用 Controller - Service - RePository 進行業(yè)務處理,分層負責不同的處理。

  • Controller 層負責信息收集、參數(shù)校驗、轉換服務層處理的數(shù)據適配前端,輕業(yè)務邏輯;
  • Service 層負責核心業(yè)務邏輯,包括各種外部服務調用、訪問數(shù)據庫、緩存處理、消息處理等;
  • Repository 層負責數(shù)據訪問實現(xiàn),一般沒有業(yè)務邏輯。
    每層架構的工作性質不同,且從業(yè)務性質上異??赡芊譃闃I(yè)務異常和系統(tǒng)異常兩大類,這就決定了很難進行統(tǒng)一的異常處理。

Repository 層出現(xiàn)異?;蛟S可以忽略,或許可以降級,或許需要轉化為一個友好的異常。如果一律捕獲異常僅記錄日志,很可能業(yè)務邏輯已經出錯,而用戶和程序本身完全感知不到。

Service 層往往涉及數(shù)據庫事務,出現(xiàn)異常同樣不適合捕獲,否則事務無法自動回滾。此外 Service 層涉及業(yè)務邏輯,有些業(yè)務邏輯執(zhí)行中遇到業(yè)務異常,可能需要在異常后轉入分支業(yè)務流程。如果業(yè)務異常都被框架捕獲了,業(yè)務功能就會不正常。

如果下層異常上升到 Controller 層還是無法處理的話,Controller 層往往會給予用戶友好提示,或是根據每一個 API 的異常表返回指定的異常類型,同樣無法對所有異常一視同仁。

因此,我不建議在框架層面進行異常的自動、統(tǒng)一處理,尤其不要隨意捕獲異常。但,框架可以做兜底工作。如果異常上升到最上層邏輯還是無法處理的話,可以以統(tǒng)一的方式進行異常轉換,比如通過 @RestControllerAdvice + @ExceptionHandler,來捕獲這些“未處理”異常:

對于自定義的業(yè)務異常,以 Warn 級別的日志記錄異常以及當前 URL、執(zhí)行方法等信息后,提取異常中的錯誤碼和消息等信息,轉換為合適的 API 包裝體返回給 API 調用方;

對于無法處理的系統(tǒng)異常,以 Error 級別的日志記錄異常和上下文信息(比如 URL、參數(shù)、用戶 ID)后,轉換為普適的“服務器忙,請稍后再試”異常信息,同樣以 API 包裝體返回給調用方。

錯誤2 捕獲異常后直接生吞

在任何時候,我們捕獲了異常都不應該生吞,也就是直接丟棄異常不記錄、不拋出。這樣的處理方式還不如不捕獲異常,因為被生吞掉的異常一旦導致 Bug,就很難在程序中找到蛛絲馬跡,使得 Bug 排查工作難上加難。

錯誤3 丟棄異常的原始信息

示例1:
有這么一個會拋出受檢異常的方法 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("系統(tǒng)忙請稍后再試"); // 沒有記錄原始異常
    }
}

或者是這樣,只記錄了異常消息,卻丟失了異常的類型、棧等重要信息:

catch (IOException e) {
    //只保留了異常消息,棧沒有記錄
    log.error("文件讀取錯誤, {}", e.getMessage());
    throw new RuntimeException("系統(tǒng)忙請稍后再試");
}

留下的日志是這樣的,看完一臉茫然,只知道文件讀取錯誤的文件名,至于為什么讀取錯誤、是不存在還是沒權限,完全不知道。

正確處理方式
catch (IOException e) {
    log.error("文件讀取錯誤", e);  // 使用日志進行記錄
    throw new RuntimeException("系統(tǒng)忙請稍后再試");
}

或者,把原始異常作為轉換后新異常的 cause,原始異常信息同樣不會丟:

catch (IOException e) {
    throw new RuntimeException("系統(tǒng)忙請稍后再試", e); // 異常拋出的時候帶上原始信息
}

錯誤4 拋出異常時不指定任何消息

一些代碼中的偷懶做法,直接拋出沒有 message 的異常:

throw new RuntimeException();

總之,如果你捕獲了異常打算處理的話,除了通過日志正確記錄異常原始信息外,通常還有三種處理模式:

    1. 轉換,即轉換新的異常拋出。對于新拋出的異常,最好具有特定的分類和明確的異常消息,而不是隨便拋一個無關或沒有任何信息的異常,并最好通過 cause 關聯(lián)老異常。
    1. 重試,即重試之前的操作。比如遠程調用服務端過載超時的情況,盲目重試會讓問題更嚴重,需要考慮當前情況是否適合重試。
    1. 恢復,即嘗試進行降級處理,或使用默認值來替代原始數(shù)據。

注意1 finally 中的異常捕獲

在一些資源釋放中,我們通常選擇在 finally 中對資源進行釋放,但是 finally 中也會存在拋出異常的情況。
示例:

@GetMapping("wrong")
public void wrong() {
    try {
        log.info("try");
        //異常丟失
        throw new RuntimeException("try");
    } finally {
        log.info("finally");
        throw new RuntimeException("finally");
    }
}

最后在日志中只能看到 finally 中的異常,雖然 try 中的邏輯出現(xiàn)了異常,但卻被 finally 中的異常覆蓋了。這是非常危險的,特別是 finally 中出現(xiàn)的異常是偶發(fā)的,就會在部分時候覆蓋 try 中的異常,讓問題更不明顯。

至于異常為什么被覆蓋,原因也很簡單,因為一個方法無法出現(xiàn)兩個異常。修復方式是,finally 代碼塊自己負責異常捕獲和處理:

@GetMapping("right")
public void right() {
    try {
        log.info("try");
        throw new RuntimeException("try");
    } finally {
        log.info("finally");
        try {
            throw new RuntimeException("finally");
        } catch (Exception ex) {
            log.error("finally", ex);
        }
    }
}

或者可以把 try 中的異常作為主異常拋出,使用 addSuppressed 方法把 finally 中的異常附加到主異常上

@GetMapping("right2")
public void right2() throws Exception {
    Exception e = null;   // 定義異常
    try {
        log.info("try");
        throw new RuntimeException("try");
    } catch (Exception ex) {
        e = ex;
    } finally {
        log.info("finally");
        try {
            throw new RuntimeException("finally");
        } catch (Exception ex) {
            if (e!= null) {
                e.addSuppressed(ex); // 添加異常
            } else {
                e = ex;
            }
        }
    }
    throw e;
}

運行方法可以得到如下異常信息,其中同時包含了主異常和被屏蔽的異常:

java.lang.RuntimeException: try
  at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.right2(FinallyIssueController.java:69)
  at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
  ...
  Suppressed: java.lang.RuntimeException: finally
    at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.right2(FinallyIssueController.java:75)
    ... 54 common frames omitted

其實這正是 try-with-resources 語句的做法,對于實現(xiàn)了 AutoCloseable 接口的資源,建議使用 try-with-resources 來釋放資源,否則也可能會產生剛才提到的,釋放資源時出現(xiàn)的異常覆蓋主異常的問題。比如如下我們定義一個測試資源,其 read 和 close 方法都會拋出異常:

public class TestResource implements AutoCloseable {

    public void read() throws Exception{
        throw new Exception("read error");
    }

    @Override
    public void close() throws Exception {
        throw new Exception("close error");
    }
}

使用傳統(tǒng)的 try-finally 語句,在 try 中調用 read 方法,在 finally 中調用 close 方法:

@GetMapping("useresourcewrong")
public void useresourcewrong() throws Exception {

    TestResource testResource = new TestResource();
    try {
        testResource.read();
    } finally {
        testResource.close();
    }
}

可以看到,同樣出現(xiàn)了 finally 中的異常覆蓋了 try 中異常的問題:

java.lang.Exception: close error
  at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.close(TestResource.java:10)
  at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.useresourcewrong(FinallyIssueController.java:27)

而改為 try-with-resources 模式之后:

@GetMapping("useresourceright")
public void useresourceright() throws Exception {
    try (TestResource testResource = new TestResource()){
        testResource.read();
    }
}

try 和 finally 中的異常信息都可以得到保留:

java.lang.Exception: read error
  at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.read(TestResource.java:6)
  ...
  Suppressed: java.lang.Exception: close error
    at org.geekbang.time.commonmistakes.exception.finallyissue.TestResource.close(TestResource.java:10)
    at org.geekbang.time.commonmistakes.exception.finallyissue.FinallyIssueController.useresourceright(FinallyIssueController.java:35)
    ... 54 common frames omitted

注意2 線程池任務異常處理

我們來看一個例子:提交 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);
}

觀察日志可以發(fā)現(xiàn)兩點:

...
[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。由于我的線程池通過線程工廠為線程使用統(tǒng)一的前綴 test 加上計數(shù)器進行命名,因此從線程名的改變可以知道因為異常的拋出老線程退出了,線程池只能重新創(chuàng)建一個線程。如果每個異步任務都以異常結束,那么線程池可能完全起不到線程重用的作用。

因為沒有手動捕獲異常進行處理,ThreadGroup 幫我們進行了未捕獲異常的默認處理,向標準錯誤輸出打印了出現(xiàn)異常的線程名稱和異常信息。顯然,這種沒有以統(tǒng)一的錯誤日志格式記錄錯誤信息打印出來的形式,對生產級代碼是不合適的,ThreadGroup 的相關源碼如下所示:

public void uncaughtException(Thread t, Throwable e) {
        if (parent != null) {
            parent.uncaughtException(t, e);
        } else {
            Thread.UncaughtExceptionHandler ueh =
                Thread.getDefaultUncaughtExceptionHandler();
            if (ueh != null) {
                ueh.uncaughtException(t, e);
            } else if (!(e instanceof ThreadDeath)) {
                System.err.print("Exception in thread \""
                                 \+ t.getName() + "\" ");
                e.printStackTrace(System.err);
            }
        }
    }

修復方式有 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));
}

通過線程池 ExecutorService 的 execute 方法提交任務到線程池處理,如果出現(xiàn)異常會導致線程退出,控制臺輸出中可以看到異常信息。那么,把 execute 方法改為 submit,線程還會退出嗎,異常還能被處理程序捕獲到嗎?

修改代碼后重新執(zhí)行程序可以看到如下日志,說明線程沒退出,異常也沒記錄被生吞了:

[15:44:33.769] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 1
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 2
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 3
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 4
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 6
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 7
[15:44:33.770] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 8
[15:44:33.771] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 9
[15:44:33.771] [test0] [INFO ] [e.d.ThreadPoolAndExceptionController:47  ] - I'm done : 10

為什么會這樣呢?

查看 FutureTask 源碼可以發(fā)現(xiàn),在執(zhí)行任務出現(xiàn)異常之后,異常存到了一個 outcome 字段中,只有在調用 get 方法獲取 FutureTask 結果的時候,才會以 ExecutionException 的形式重新拋出異常:

public void run() {
...
    try {
        Callable<V> c = callable;
        if (c != null && state == NEW) {
            V result;
            boolean ran;
            try {
                result = c.call();
                ran = true;
            } catch (Throwable ex) {
                result = null;
                ran = false;
                setException(ex);
            }
...
}

protected void setException(Throwable t) {
    if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
        outcome = t;
        UNSAFE.putOrderedInt(this, stateOffset, EXCEPTIONAL); // final state
        finishCompletion();
    }
}

public V get() throws InterruptedException, ExecutionException {
    int s = state;
    if (s <= COMPLETING)
        s = awaitDone(false, 0L);
    return report(s);
}

private V report(int s) throws ExecutionException {
    Object x = outcome;
    if (s == NORMAL)
        return (V)x;
    if (s >= CANCELLED)
        throw new CancellationException();
    throw new ExecutionException((Throwable)x);
}

修改后的代碼如下所示,我們把 submit 返回的 Future 放到了 List 中,隨后遍歷 List 來捕獲所有任務的異常。這么做確實合乎情理。既然是以 submit 方式來提交任務,那么我們應該關心任務的執(zhí)行結果,否則應該以 execute 來提交任務:

List<Future> tasks = IntStream.rangeClosed(1, 10).mapToObj(i -> threadPool.submit(() -> {
    if (i == 5) throw new RuntimeException("error");
    log.info("I'm done : {}", i);
})).collect(Collectors.toList());

tasks.forEach(task-> {
    try {
        task.get();
    } catch (Exception e) {
        log.error("Got exception", e);
    }
});

執(zhí)行這段程序可以看到如下的日志輸出:

[15:44:13.543] [http-nio-45678-exec-1] [ERROR] [e.d.ThreadPoolAndExceptionController:69  ] - Got exception
java.util.concurrent.ExecutionException: java.lang.RuntimeException: error

總結

第一,注意捕獲和處理異常的最佳實踐。首先,不應該用 AOP 對所有方法進行統(tǒng)一異常處理,異常要么不捕獲不處理,要么根據不同的業(yè)務邏輯、不同的異常類型進行精細化、針對性處理;其次,處理異常應該杜絕生吞,并確保異常棧信息得到保留;最后,如果需要重新拋出異常的話,請使用具有意義的異常類型和異常消息。

第二,務必小心 finally 代碼塊中資源回收邏輯,確保 finally 代碼塊不出現(xiàn)異常,內部把異常處理完畢,避免 finally 中的異常覆蓋 try 中的異常;或者考慮使用 addSuppressed 方法把 finally 中的異常附加到 try 中的異常上,確保主異常信息不丟失。此外,使用實現(xiàn)了 AutoCloseable 接口的資源,務必使用 try-with-resources 模式來使用資源,確保資源可以正確釋放,也同時確保異??梢哉_處理。

第三,雖然在統(tǒng)一的地方定義收口所有的業(yè)務異常是一個不錯的實踐,但務必確保異常是每次 new 出來的,而不能使用一個預先定義的 static 字段存放異常,否則可能會引起棧信息的錯亂。

第四,確保正確處理了線程池中任務的異常,如果任務通過 execute 提交,那么出現(xiàn)異常會導致線程退出,大量的異常會導致線程重復創(chuàng)建引起性能問題,我們應該盡可能確保任務不出異常,同時設置默認的未捕獲異常處理程序來兜底;如果任務通過 submit 提交意味著我們關心任務的執(zhí)行結果,應該通過拿到的 Future 調用其 get 方法來獲得任務運行結果和可能出現(xiàn)的異常,否則異常可能就被生吞了。

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容