天天看點

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

作者:架構思考
如何處理Java異常?作者檢視了一些異常處理的規範,對 Java 異常處理機制有更深入的了解,并将自己的學習内容記錄下來,希望對有同樣困惑的同學提供一些幫助。

一、概述

最近在代碼CR的時候發現一些值得注意的問題,特别是在對Java異常處理的時候,比如有的同學對每個方法都進行 try-catch,在進行 IO 操作時忘記在 finally 塊中關閉連接配接資源等等問題。回想自己對 java 的異常處理也不是特别清楚,看了一些異常處理的規範,并沒有進行系統的學習,是以為了對 Java 異常處理機制有更深入的了解,我查閱了一些資料将自己的學習内容記錄下來,希望對有同樣困惑的同學提供一些幫助。

在Java中處理異常并不是一個簡單的事情,不僅僅初學者很難了解,即使一些有經驗的開發者也需要花費很多時間來思考如何處理異常,包括需要處理哪些異常,怎樣處理等等。

在寫本文之前,通過查閱相關資料了解如何處理Java異常,首先檢視了阿裡巴巴Java開發規範,其中有15條關于異常處理的說明,這些說明告訴了我們應該怎麼做,但是并沒有詳細說明為什麼這樣做,比如為什麼推薦使用 try-with-resources 關閉資源 ,為什麼 finally 塊中不能有 return 語句,這些問題當我們從位元組碼層面分析時,就可以非常深刻的了解它的本質。

通過本文的的學習,你将有如下收獲:

  • 了解Java異常的分類,什麼是檢查異常,什麼是非檢查異常
  • 從位元組碼層面了解Java的異常處理機制,為什麼finally塊中的代碼總是會執行
  • 了解Java異常處理的不規範案例
  • 了解Java異常處理的最佳實踐
  • 了解項目中的異常處理,什麼時候抛出異常,什麼時候捕獲異常

二、java 異常處理機制

1、java 異常分類

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

總結:

  • Thorwable類(表示可抛出)是所有異常和錯誤的超類,兩個直接子類為Error和Exception,分别表示錯誤和異常。
  • 其中異常類Exception又分為運作時異常(RuntimeException)和非運作時異常, 這兩種異常有很大的差別,也稱之為非檢查異常(Unchecked Exception)和檢查異常(Checked Exception),其中Error類及其子類也是非檢查異常。

檢查異常和非檢查異常

  • 檢查異常:也稱為“編譯時異常”,編譯器在編譯期間檢查的那些異常。由于編譯器“檢查”這些異常以確定它們得到處理,是以稱為“檢查異常”。如果抛出檢查異常,那麼編譯器會報錯,需要開發人員手動處理該異常,要麼捕獲,要麼重新抛出。除了RuntimeException之外,所有直接繼承 Exception 的異常都是檢查異常。
  • 非檢查異常:也稱為“運作時異常”,編譯器不會檢查運作時異常,在抛出運作時異常時編譯器不會報錯,當運作程式的時候才可能抛出該異常。Error及其子類和RuntimeException 及其子類都是非檢查異常。

說明:檢查異常和非檢查異常是針對編譯器而言的,是編譯器來檢查該異常是否強制開發人員處理該異常

  • 檢查異常導緻異常在方法調用鍊上顯式傳遞,而且一旦底層接口的檢查異常聲明發生變化,會導緻整個調用鍊代碼更改。
  • 使用非檢查異常不會影響方法簽名,而且調用方可以自由決定何時何地捕獲和處理異常

建議使用非檢查異常讓代碼更加簡潔,而且更容易保持接口的穩定性

檢查異常舉例

在代碼中使用 throw 關鍵字手動抛出一個檢查異常,編譯器提示錯誤,如下圖所示:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

通過編譯器提示,有兩種方式處理檢查異常,要麼将異常添加到方法簽名上,要麼捕獲異常:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

方式一:将異常添加到方法簽名上,通過 throws 關鍵字抛出異常,由調用該方法的方法處理該異常:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

方式二:使用 try-catch 捕獲異常,在 catch 代碼塊中處理該異常,下面的代碼是将檢查異常包裝在非檢查異常中重新抛出,這樣編譯器就不會提示錯誤了,關于如何處理異常後面會詳細介紹:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

非檢查異常舉例

所有繼承 RuntimeException 的異常都是非檢查異常,直接抛出非檢查異常編譯器不會提示錯誤:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

自定義檢查異常

自定義檢查異常隻需要繼承 Exception 即可,如下代碼所示:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

自定義檢查異常的處理方式前面已經介紹,這裡不再贅述。

自定義非檢查異常

自定義非檢查異常隻需要繼承 RuntimeException 即可,如下代碼所示:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

2、從位元組碼層面分析異常處理

前面已經簡單介紹了一下Java 的異常體系,以及如何自定義異常,下面我将從位元組碼層面分析異常處理機制,通過位元組碼的分析你将對 try-catch-finally 有更加深入的認識。

try-catch-finally的本質

首先查閱 jvm 官方文檔,有如下的描述說明:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

從官方文檔的描述我們可以知道,圖檔中的位元組碼是在 JDK 1.6 (class 檔案的版本号為50,表示java編譯器的版本為jdk 1.6)及之前的編譯器生成的,因為有 jsr 和 ret 指令可以使用。然而在 idea 中通過 jclasslib 插件檢視 try-catch-finally 的位元組碼檔案并沒有 jsr/ret 指令,通過查閱資料,有如下說明:

jsr / ret 機制最初用于實作finally塊,但是他們認為節省代碼大小并不值得額外的複雜性,是以逐漸被淘汰了。Sun JDK 1.6之後的javac就不生成jsr/ret指令了,那finally塊要如何實作?

javac采用的辦法是把finally塊的内容複制到原本每個jsr指令所在的地方,這樣就不需要jsr/ret了,代價則是位元組碼大小會膨脹,但是降低了位元組碼的複雜性,因為減少了兩個位元組碼指令(jsr/ret)。

案例一:try-catch 位元組碼分析

在 JDK 1.8 中 try-catch 的位元組碼如下所示:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

這裡需要說明一下 athrow 指令的作用:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

異常表

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

athrow指令:在Java程式中顯示抛出異常的操作(throw語句)都是由 athrow指令來實作的,athrow 指令抛出的Objectref 必須是類型引用,并且必須作為 Throwable 類或 Throwable 子類的執行個體對象。它從操作數堆棧中彈出,然後通過在目前方法的異常表中搜尋與 objectref 類比對的第一個異常處理程式:

  • 如果在異常表中找到與 objectref 比對的異常處理程式,PC 寄存器被重置到用于處理此異常的代碼的位置,然後會清除目前幀的操作數堆棧,objectref 被推回操作數堆棧,執行繼續。
  • 如果在目前架構中沒有找到比對的異常處理程式,則彈出該棧幀,該異常會重新抛給上層調用的方法。如果目前幀表示同步方法的調用,那麼在調用該方法時輸入或重新輸入的螢幕将退出,就好像執行了監視退出指令(monitorexit)一樣。
  • 如果在所有棧幀彈出前仍然沒有找到合适的異常處理程式,這個線程将終止。

異常表:異常表中用來記錄程式計數器的位置和異常類型。如上圖所示,表示的意思是:如果在 8 到 16 (不包括16)之間的指令抛出的異常比對 MyCheckedException 類型的異常,那麼程式跳轉到16 的位置繼續執行。

分析上圖中的位元組碼:第一個 athrow 指令抛出 MyCheckedException 異常到操作數棧頂,然後去到異常表中查找是否有對應的類型,異常表中有 MyCheckedException ,然後跳轉到 16 繼續執行代碼。第二個 athrow 指令抛出 RuntimeException 異常,然後在異常表中沒有找到比對的類型,目前方法強制結束并彈出目前棧幀,該異常重新抛給調用者,任然沒有找到比對的處理器,該線程被終止。

案例二:try-catch-finally 位元組碼分析

在剛剛的代碼基礎之上添加 finally 代碼塊,然後分析位元組碼如下:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

異常表的資訊如下:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

添加 finally 代碼塊後,在異常表中新增了一條記錄,捕獲類型為 any,這裡解釋一下這條記錄的含義:

在 8 到 27(不包括27) 之間的指令執行過程中,抛出或者傳回任何類型的結果都會跳轉到 26 繼續執行。

從上圖的位元組碼中可以看到,位元組碼索引為 26 後到結束的指令都是 finally 塊中的代碼,再解釋一下finally塊的位元組碼指令的含義,從 25 開始介紹,finally 塊的代碼是從 26 開始的:

25 athrow // 比對到異常表中的異常 any,清空操作數棧,将 RuntimeExcepion 的引用添加到操作數棧頂,然後跳轉到 26 繼續執行26 astore_2 // 将棧頂的引用儲存到局部變量表索引為 2 的位置27 getstatic #2 <java/lang/System.out : Ljava/io/PrintStream;> // 擷取類的靜态字段引用放在操作數棧頂30 ldc #9 <執行finally 代碼>//将字元串的放在操作數棧頂32 invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/String;)V>// 調用方法35 aload_2// 将局部變量表索引為 2 到引用放到操作數棧頂,這裡就是前面抛出的RuntimeExcepion 的引用36 athrow// 在異常表中沒有找到對應的異常處理程式,彈出該棧幀,該異常會重新抛給上層調用的方法

案例三:finally 塊中的代碼為什麼總是會執行

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)
「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

簡單分析一下上面代碼的位元組碼指令:位元組碼指令 2 到 8 會抛出 ArithmeticException 異常,該異常是 Exception 的子類,正好比對異常表中的第一行記錄,然後跳轉到 13 繼續執行,也就是執行 catch 塊中的代碼,然後執行 finally 塊中的代碼,最後通過 goto 31 跳轉到 finally 塊之外執行後續的代碼。

如果 try 塊中沒有抛出異常,則執行完 try 塊中的代碼然後繼續執行 finally 塊中的代碼,因為編譯器在編譯的時候将 finally 塊中的代碼添加到了 try 塊代碼後面,執行完 finally 的代碼後通過 goto 31 跳轉到 finally 塊之外執行後續的代碼 。

編譯器會将 finally 塊中的代碼放在 try 塊和 catch 塊的末尾,是以 finally 塊中的代碼總是會執行。

通過上面的分析,你應該可以知道 finally 塊的代碼為什麼總是會執行了,如果還是有不明白的地方歡迎留言讨論。

案例四:finally 塊中使用 return 位元組碼分析

public int getInt() {
    int i = 0;
    try {
        i = 1;
        return i;
    } finally {
        i = 2;
        return i;
    }
}
public int getInt2() {
    int i = 0;
    try {
        i = 1;
        return i;
    } finally {
        i = 2;
    }
}           

先分析一下 getInt() 方法的位元組碼:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

局部變量表:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

異常表:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

總結:從上面的位元組碼中我們可以看出,如果finally 塊中有 return 關鍵字,那麼 try 塊以及 catch 塊中的 return 都将會失效,是以在開發的過程中不應該在 finally 塊中寫 return 語句。

先分析一下 getInt2() 方法的位元組碼:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

異常表:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

從上圖位元組碼的分析,我們可以知道,雖然執行了finally塊中的代碼,但是傳回的值還是 1,這是因為在執行finally代碼塊之前,将原來局部變量表索引為 1 的值 1 儲存到了局部變量表索引為 2 的位置,最後傳回到是局部變量表索引為 2 的值,也就是原來的 1。

總結:如果在 finally 塊中沒有 return 語句,那麼無論在 finally 代碼塊中是否修改傳回值,傳回值都不會改變,仍然是執行 finally 代碼塊之前的值。

try-with-resources 的本質

下面通過一個打封包件的代碼來示範說明一下 try-with-resources 的本質:

/**
     * 打包多個檔案為 zip 格式
     *
     * @param fileList 檔案清單
     */
    public static void zipFile(List<File> fileList) {
        // 檔案的壓縮包路徑
        String zipPath = OUT + "/打包附件.zip";
        // 擷取檔案壓縮包輸出流
        try (OutputStream outputStream = new FileOutputStream(zipPath);
             CheckedOutputStream checkedOutputStream = new CheckedOutputStream(outputStream, new Adler32());
             ZipOutputStream zipOut = new ZipOutputStream(checkedOutputStream)) {
            for (File file : fileList) {
                // 擷取檔案輸入流
                InputStream fileIn = new FileInputStream(file);
                // 使用 common.io中的IOUtils擷取檔案位元組數組
                byte[] bytes = IOUtils.toByteArray(fileIn);
                // 寫入資料并重新整理
                zipOut.putNextEntry(new ZipEntry(file.getName()));
                zipOut.write(bytes, 0, bytes.length);
                zipOut.flush();
            }
        } catch (FileNotFoundException e) {
            System.out.println("檔案未找到");
        } catch (IOException e) {
            System.out.println("讀取檔案異常");
        }
    }           

可以看到在 try() 的括号中定義需要關閉的資源,實際上這是Java的一種文法糖,檢視編譯後的代碼就知道編譯器為我們做了什麼,下面是反編譯後的代碼:

public static void zipFile(List<File> fileList) {
        String zipPath = "./打包附件.zip";
        try {
            OutputStream outputStream = new FileOutputStream(zipPath);
            Throwable var3 = null;
            try {
                CheckedOutputStream checkedOutputStream = new CheckedOutputStream(outputStream, new Adler32());
                Throwable var5 = null;
                try {
                    ZipOutputStream zipOut = new ZipOutputStream(checkedOutputStream);
                    Throwable var7 = null;
                    try {
                        Iterator var8 = fileList.iterator();
                        while(var8.hasNext()) {
                            File file = (File)var8.next();
                            InputStream fileIn = new FileInputStream(file);
                            byte[] bytes = IOUtils.toByteArray(fileIn);
                            zipOut.putNextEntry(new ZipEntry(file.getName()));
                            zipOut.write(bytes, 0, bytes.length);
                            zipOut.flush();
                        }
                    } catch (Throwable var60) {
                        var7 = var60;
                        throw var60;
                    } finally {
                        if (zipOut != null) {
                            if (var7 != null) {
                                try {
                                    zipOut.close();
                                } catch (Throwable var59) {
                                    var7.addSuppressed(var59);
                                }
                            } else {
                                zipOut.close();
                            }
                        }
                    }
                } catch (Throwable var62) {
                    var5 = var62;
                    throw var62;
                } finally {
                    if (checkedOutputStream != null) {
                        if (var5 != null) {
                            try {
                                checkedOutputStream.close();
                            } catch (Throwable var58) {
                                var5.addSuppressed(var58);
                            }
                        } else {
                            checkedOutputStream.close();
                        }
                    }
                }
            } catch (Throwable var64) {
                var3 = var64;
                throw var64;
            } finally {
                if (outputStream != null) {
                    if (var3 != null) {
                        try {
                            outputStream.close();
                        } catch (Throwable var57) {
                            var3.addSuppressed(var57);
                        }
                    } else {
                        outputStream.close();
                    }
                }
            }
        } catch (FileNotFoundException var66) {
            System.out.println("檔案未找到");
        } catch (IOException var67) {
            System.out.println("讀取檔案異常");
        }
    }           

JDK1.7開始,java引入了 try-with-resources 聲明,将 try-catch-finally 簡化為 try-catch,在編譯時會進行轉化為 try-catch-finally 語句,我們就不需要在 finally 塊中手動關閉資源。

try-with-resources 聲明包含三部分:try(聲明需要關閉的資源)、try 塊、catch 塊。它要求在 try-with-resources 聲明中定義的變量實作了 AutoCloseable 接口,這樣在系統可以自動調用它們的close方法,進而替代了finally中關閉資源的功能,編譯器為我們生成的異常處理過程如下:

  • try 塊沒有發生異常時,自動調用 close 方法,
  • try 塊發生異常,然後自動調用 close 方法,如果 close 也發生異常,catch 塊隻會捕捉 try 塊抛出的異常,close 方法的異常會在catch 中通過調用 Throwable.addSuppressed 來壓制異常,但是你可以在catch塊中,用 Throwable.getSuppressed 方法來擷取到壓制異常的數組。

三、java 異常處理不規範案例

異常處理分為三個階段:捕獲->傳遞->處理。try……catch的作用是捕獲異常,throw的作用将異常傳遞給合适的處理程式。捕獲、傳遞、處理,三個階段,任何一個階段處理不當,都會影響到整個系統。下面分别介紹一下常見的異常處理不規範案例。

捕獲

  • 捕獲異常的時候不區分異常類型
  • 捕獲異常不完全,比如該捕獲的異常類型沒有捕獲到
try{
    ……
} catch (Exception e){ // 不應對所有類型的異常統一捕獲,應該抽象出業務異常和系統異常,分别捕獲
    ……
}           

傳遞

  • 異常資訊丢失
  • 異常資訊轉譯錯誤,比如在抛出異常的時候将業務異常包裝成了系統異常
  • 吃掉異常
  • 不必要的異常包裝
  • 檢查異常傳遞過程中不适用非檢查檢異常包裝,造成代碼被throws污染
try{
    ……
} catch (BIZException e){ 
    throw new BIZException(e); // 重複包裝同樣類型的異常資訊 
} catch (Biz1Exception e){ 
    throw new BIZException(e.getMessage()); // 沒有抛出異常棧資訊,正确的做法是throw new BIZException(e); 
} catch (Biz2Exception e){
    throw new Exception(e); // 不能使用低抽象級别的異常去包裝高抽象級别的異常,這樣在傳遞過程中丢失了異常類型資訊
} catch (Biz3Exception e){
    throw new Exception(……); // 異常轉譯錯誤,将業務異常直接轉譯成了系統異常
} catch (Biz4Exception e){
    …… // 不抛出也不記Log,直接吃掉異常
} catch (Exception e){
    throw e;
}           

處理

  • 重複處理
  • 處理方式不統一
  • 處理位置分散
try{
    try{
        try{
            ……
        } catch (Biz1Exception e){
            log.error(e);  // 重複的LOG記錄
            throw new e;
        }
        
        try{
            ……
        } catch (Biz2Exception e){
            ……  // 同樣是業務異常,既在内層處理,又在外層處理
        }
    } catch (BizException e){
        log.error(e); // 重複的LOG記錄
        throw e;
    }
} catch (Exception e){
    // 通吃所有類型的異常
    log.error(e.getMessage(),e);
}           

四、java 異常處理規範案例

1、阿裡巴巴Java異常處理規約

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)
「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)
「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

阿裡巴巴Java開發規範中有15條異常處理的規約,其中下面兩條使用的時候是比較困惑的,因為并沒有告訴我們應該如何定義異常,如何抛出異常,如何處理異常:

  • 【強制】捕獲異常是為了處理它,不要捕獲了卻什麼都不處理而抛棄之,如果不想處理它,請将該異常抛給它的調用者。最外層的業務使用者,必須處理異常,将其轉化為使用者可以了解的内容。
  • 【推薦】定義時區分unchecked / checked 異常,避免直接使用RuntimeException抛出,更不允許抛出Exception或者Throwable,應使用有業務含義的自定義異常。

後面的章節我将根據自己的思考,說明如何定義異常,如何抛出異常,如何處理異常,接着往下看 。

2、異常處理最佳實踐

1、使用 try-with-resource 關閉資源。

2、抛出具體的異常而不是 Exception,并在注釋中使用 @throw 進行說明。

3、捕獲異常後使用描述性語言記錄錯誤資訊,如果是調用外部服務最好是包括入參和出參。

logger.error("說明資訊,異常資訊:{}", e.getMessage(), e)

4、優先捕獲具體異常。

5、不要捕獲 Throwable 異常,除非特殊情況。

6、不要忽略異常,異常捕獲一定需要處理。

7、不要同時記錄和抛出異常,因為異常會列印多次,正确的處理方式要麼抛出異常要麼記錄異常,如果抛出異常,不要原封不動的抛出,可以自定義異常抛出。

8、自定義異常不要丢棄原有異常,應該将原始異常傳入自定義異常中。

throw MyException("my exception", e);

9、自定義異常盡量不要使用檢查異常。

10、盡可能晚的捕獲異常,如非必要,建議所有的異常都不要在下層捕獲,而應該由最上層捕獲并統一處理這些異常。。

11、為了避免重複輸出異常日志,建議所有的異常日志都統一交由最上層輸出。就算下層捕獲到了某個異常,如非特殊情況,也不要将異常資訊輸出,應該交給最上層統一輸出日志。

五、項目中的異常處理實踐

1、如何自定義異常

在介紹如何自定義異常之前,有必要說明一下使用異常的好處,參考Java異常的官方文檔,總結有如下好處:

  • 能夠将錯誤代碼和正常代碼分離
  • 能夠在調用堆棧上傳遞異常
  • 能夠将異常分組和區分

在Java異常體系中定義了很多的異常,這些異常通常都是技術層面的異常,對于應用程式來說更多出現的是業務相關的異常,比如使用者輸入了一些不合法的參數,使用者沒有登入等,我們可以通過異常來對不同的業務問題進行分類,以便我們排查問題,是以需要自定義異常。那我們如何自定義異常呢?前面已經說了,在應用程式中盡量不要定義檢查異常,應該定義非檢查異常(運作時異常)。

在我看來,應用程式中定義的異常應該分為兩類:

  • 業務異常:使用者能夠看懂并且能夠處理的異常,比如使用者沒有登入,提示使用者登入即可。
  • 系統異常:使用者看不懂需要程式員處理的異常,比如網絡連接配接逾時,需要程式員排查相關問題。

下面是我設想的對于應用程式中的異常體系分類:

「後端」阿裡開發者的Java異常處理和最佳實踐(含案例分析)

在真實項目中,我們通常在遇到不符合預期的情況下,通過抛出異常來阻止程式繼續運作,在抛出對應的異常時,需要在異常對象中描述抛出該異常的原因以及異常堆棧資訊,以便提示使用者和開發人員如何處理該異常。

一般來說,異常的定義我們可以參考Java的其他異常定義就可以了,比如異常中有哪些構造方法,方法中有哪些構造參數,但是這樣的自定義異常隻是通過異常的類名對異常進行了一個分類,對于異常的描述資訊還是不夠完善,因為異常的描述資訊隻是一個字元串。我覺得異常的描述資訊還應該包含一個錯誤碼(code),異常中包含錯誤碼的好處是什麼呢?我能想到的就是和http請求中的狀态碼的優點差不多,還有一點就是能夠友善提供翻譯功能,對于不同的語言環境能夠通過錯誤碼找到對應語言的錯誤提示資訊而不需要修改代碼。

基于上述的說明,我認為應該這樣來定義異常類,需要定義一個描述異常資訊的枚舉類,對于一些通用的異常資訊可以在枚舉中定義,如下所示:

/**
 * 異常資訊枚舉類
 *
 */
public enum ErrorCode {
    /**
     * 系統異常
     */
    SYSTEM_ERROR("A000", "系統異常"),
    /**
     * 業務異常
     */
    BIZ_ERROR("B000", "業務異常"),
    /**
     * 沒有權限
     */
    NO_PERMISSION("B001", "沒有權限"),
    ;
    /**
     * 錯誤碼
     */
    private String code;
    /**
     * 錯誤資訊
     */
    private String message;
    ErrorCode(String code, String message) {
        this.code = code;
        this.message = message;
    }
    /**
     * 擷取錯誤碼
     *
     * @return 錯誤碼
     */
    public String getCode() {
        return code;
    }
    /**
     * 擷取錯誤資訊
     *
     * @return 錯誤資訊
     */
    public String getMessage() {
        return message;
    }
    /**
     * 設定錯誤碼
     *
     * @param code 錯誤碼
     * @return 傳回目前枚舉
     */
    public ErrorCode setCode(String code) {
        this.code = code;
        return this;
    }
    /**
     * 設定錯誤資訊
     *
     * @param message 錯誤資訊
     * @return 傳回目前枚舉
     */
    public ErrorCode setMessage(String message) {
        this.message = message;
        return this;
    }
}           

自定義系統異常類,其他類型的異常類似,隻是異常的類名不同,如下代碼所示:

/**
 * 系統異常類
 *
 */
public class SystemException extends RuntimeException {
    private static final long serialVersionUID = 8312907182931723379L;
 /**
     * 錯誤碼
     */
    private String code;
 
    /**
     * 構造一個沒有錯誤資訊的 <code>SystemException</code>
     */
    public SystemException() {
        super();
    }
    /**
     * 使用指定的 Throwable 和 Throwable.toString() 作為異常資訊來構造 SystemException
     *
     * @param cause 錯誤原因, 通過 Throwable.getCause() 方法可以擷取傳入的 cause資訊
     */
    public SystemException(Throwable cause) {
        super(cause);
    }
    /**
     * 使用錯誤資訊 message 構造 SystemException
     *
     * @param message 錯誤資訊
     */
    public SystemException(String message) {
        super(message);
    }
    /**
     * 使用錯誤碼和錯誤資訊構造 SystemException
     *
     * @param code    錯誤碼
     * @param message 錯誤資訊
     */
    public SystemException(String code, String message) {
        super(message);
        this.code = code;
    }
    /**
     * 使用錯誤資訊和 Throwable 構造 SystemException
     *
     * @param message 錯誤資訊
     * @param cause   錯誤原因
     */
    public SystemException(String message, Throwable cause) {
        super(message, cause);
    }
    /**
     * @param code    錯誤碼
     * @param message 錯誤資訊
     * @param cause   錯誤原因
     */
    public SystemException(String code, String message, Throwable cause) {
        super(message, cause);
        this.code = code;
    }
    /**
     * @param errorCode ErrorCode
     */
    public SystemException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.code = errorCode.getCode();
    }
    /**
     * @param errorCode ErrorCode
     * @param cause     錯誤原因
     */
    public SystemException(ErrorCode errorCode, Throwable cause) {
        super(errorCode.getMessage(), cause);
        this.code = errorCode.getCode();
    }
    /**
     * 擷取錯誤碼
     *
     * @return 錯誤碼
     */
    public String getCode() {
        return code;
    }
}
           

上面定義的 SystemException 類中定義了很多的構造方法,我這裡隻是給出一個示例,是以保留了不傳入錯誤碼的構造方法,保留不使用錯誤碼的構造方法,可以提高代碼的靈活性,因為錯誤碼的規範也是一個值得讨論的問題,關于如何定義錯誤碼在阿裡巴巴開發規範手冊中有介紹,這裡不再詳細說明。

2、如何使用異常

前面介紹了如何自定義異常,接下來介紹一下如何使用異常,也就是什麼時候抛出異常。異常其實可以看作方法的傳回結果,當出現非預期的情況時,就可以通過抛出異常來阻止程式繼續執行。比如期望使用者有管理者權限才能删除某條記錄,如果使用者沒有管理者權限,那麼就可以抛出沒有權限的異常阻止程式繼續執行并提示使用者需要管理者權限才能操作。

抛出異常使用 throw 關鍵字,如下所示:

throw new BizException(ErrorCode.NO_PERMISSION);

什麼時候抛出業務異常,什麼時候抛出系統異常?

業務異常(bizException/bussessException): 使用者操作業務時,提示出來的異常資訊,這些資訊能直接讓使用者可以繼續下一步操作,或者換一個正确操作方式去使用,換句話就是使用者可以自己能解決的。比如:“使用者沒有登入”,“沒有權限操作”。

系統異常(SystemException): 使用者操作業務時,提示系統程式的異常資訊,這類的異常資訊時使用者看不懂的,需要告警通知程式員排查對應的問題,如 NullPointerException,IndexOfException。另一個情況就是接口對接時,參數的校驗時提示出來的資訊,如:缺少ID,缺少必須的參數等,這類的資訊對于客戶來說也是看不懂的,也是解決不了的,是以我把這兩類的錯誤應當統一歸類于系統異常

關于應該抛出業務異常還是系統異常,一句話總結就是:該異常使用者能否處理,如果使用者能處理則抛出業務異常,如果使用者不能處理需要程式員處理則抛出系統異常。

在調用第三方的 rpc 接口時,我們應該如何處理異常呢?首先我們需要知道 rpc 接口抛出異常還是傳回的包含錯誤碼的 Result 對象,關于 rpc 應該傳回異常還是錯誤碼有很多的讨論,關于這方面的内容可以檢視相關文檔,這個不是本文的重點,通過實際觀察知道 rpc 的傳回基本都是包含錯誤碼的 Result 對象,是以這裡以傳回錯誤碼的情況進行說明。首先需要明确 rpc 調用失敗應該傳回系統異常,是以我們可以定義一個繼承 SystemException 的 rpc 異常 RpcException,代碼如下所示:

/**
 * rpc 異常類
 */
public class RpcException extends SystemException {
    private static final long serialVersionUID = -9152774952913597366L;
    /**
     * 構造一個沒有錯誤資訊的 <code>RpcException</code>
     */
    public RpcException() {
        super();
    }
    /**
     * 使用指定的 Throwable 和 Throwable.toString() 作為異常資訊來構造 RpcException
     *
     * @param cause 錯誤原因, 通過 Throwable.getCause() 方法可以擷取傳入的 cause資訊
     */
    public RpcException(Throwable cause) {
        super(cause);
    }
    /**
     * 使用錯誤資訊 message 構造 RpcException
     *
     * @param message 錯誤資訊
     */
    public RpcException(String message) {
        super(message);
    }
    /**
     * 使用錯誤碼和錯誤資訊構造 RpcException
     *
     * @param code    錯誤碼
     * @param message 錯誤資訊
     */
    public RpcException(String code, String message) {
        super(code, message);
    }
    /**
     * 使用錯誤資訊和 Throwable 構造 RpcException
     *
     * @param message 錯誤資訊
     * @param cause   錯誤原因
     */
    public RpcException(String message, Throwable cause) {
        super(message, cause);
    }
    /**
     * @param code    錯誤碼
     * @param message 錯誤資訊
     * @param cause   錯誤原因
     */
    public RpcException(String code, String message, Throwable cause) {
        super(code, message, cause);
    }
    /**
     * @param errorCode ErrorCode
     */
    public RpcException(ErrorCode errorCode) {
        super(errorCode);
    }
    /**
     * @param errorCode ErrorCode
     * @param cause     錯誤原因
     */
    public RpcException(ErrorCode errorCode, Throwable cause) {
        super(errorCode, cause);
    }
}           

這個 RpcException 所有的構造方法都是調用的父類 SystemExcepion 的方法,是以這裡不再贅述。定義好了異常後接下來是處理 rpc 調用的異常處理邏輯,調用 rpc 服務可能會發生 ConnectException 等網絡異常,我們并不需要在調用的時候捕獲異常,而是應該在最上層捕獲并處理異常,調用 rpc 的處理demo代碼如下:

private Object callRpc() {
    Result<Object> rpc = rpcDemo.rpc();
    log.info("調用第三方rpc傳回結果為:{}", rpc);
    if (Objects.isNull(rpc)) {
        return null;
    }
    if (!rpc.getSuccess()) {
        throw new RpcException(ErrorCode.RPC_ERROR.setMessage(rpc.getMessage()));
    }
    return rpc.getData();
}           

3、如何處理異常

我們應該盡可能晚的捕獲異常,如非必要,建議所有的異常都不要在下層捕獲,而應該由最上層捕獲并統一處理這些異常。前面的已經簡單說明了一下如何處理異常,接下來将通過代碼的方式講解如何處理異常。

rpc 接口全局異常處理

對于 rpc 接口,我們這裡将 rpc 接口的傳回結果封裝到包含錯誤碼的 Result 對象中,是以可以定義一個 aop 叫做 RpcGlobalExceptionAop,在 rpc 接口執行前後捕獲異常,并将捕獲的異常資訊封裝到 Result 對象中傳回給調用者。

Result 對象的定義如下:

/**
 * Result 結果類
 *
 */
public class Result<T> implements Serializable {
    private static final long serialVersionUID = -1525914055479353120L;
    /**
     * 錯誤碼
     */
    private final String code;
    /**
     * 提示資訊
     */
    private final String message;
    /**
     * 傳回資料
     */
    private final T data;
    /**
     * 是否成功
     */
    private final Boolean success;
    /**
     * 構造方法
     *
     * @param code    錯誤碼
     * @param message 提示資訊
     * @param data    傳回的資料
     * @param success 是否成功
     */
    public Result(String code, String message, T data, Boolean success) {
        this.code = code;
        this.message = message;
        this.data = data;
        this.success = success;
    }
    /**
     * 建立 Result 對象
     *
     * @param code    錯誤碼
     * @param message 提示資訊
     * @param data    傳回的資料
     * @param success 是否成功
     */
    public static <T> Result<T> of(String code, String message, T data, Boolean success) {
        return new Result<>(code, message, data, success);
    }
    /**
     * 成功,沒有傳回資料
     *
     * @param <T> 範型參數
     * @return Result
     */
    public static <T> Result<T> success() {
        return of("00000", "成功", null, true);
    }
    /**
     * 成功,有傳回資料
     *
     * @param data 傳回資料
     * @param <T>  範型參數
     * @return Result
     */
    public static <T> Result<T> success(T data) {
        return of("00000", "成功", data, true);
    }
    /**
     * 失敗,有錯誤資訊
     *
     * @param message 錯誤資訊
     * @param <T>     範型參數
     * @return Result
     */
    public static <T> Result<T> fail(String message) {
        return of("10000", message, null, false);
    }
    /**
     * 失敗,有錯誤碼和錯誤資訊
     *
     * @param code    錯誤碼
     * @param message 錯誤資訊
     * @param <T>     範型參數
     * @return Result
     */
    public static <T> Result<T> fail(String code, String message) {
        return of(code, message, null, false);
    }
    /**
     * 擷取錯誤碼
     *
     * @return 錯誤碼
     */
    public String getCode() {
        return code;
    }
    /**
     * 擷取提示資訊
     *
     * @return 提示資訊
     */
    public String getMessage() {
        return message;
    }
    /**
     * 擷取資料
     *
     * @return 傳回的資料
     */
    public T getData() {
        return data;
    }
    /**
     * 擷取是否成功
     *
     * @return 是否成功
     */
    public Boolean getSuccess() {
        return success;
    }
}           

在編寫 aop 代碼之前需要先導入 spring-boot-starter-aop 依賴:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>           

RpcGlobalExceptionAop 代碼如下:

/**
 * rpc 調用全局異常處理 aop 類
 *
 */
@Slf4j
@Aspect
@Component
public class RpcGlobalExceptionAop {
    /**
     * execution(* com.xyz.service ..*.*(..)):表示 rpc 接口實作類包中的所有方法
     */
    @Pointcut("execution(* com.xyz.service ..*.*(..))")
    public void pointcut() {}
    @Around(value = "pointcut()")
    public Object handleException(ProceedingJoinPoint joinPoint) {
        try {
            //如果對傳入對參數有修改,那麼需要調用joinPoint.proceed(Object[] args)
            //這裡沒有修改參數,則調用joinPoint.proceed()方法即可
            return joinPoint.proceed();
        } catch (BizException e) {
            // 對于業務異常,應該記錄 warn 日志即可,避免無效告警
            log.warn("全局捕獲業務異常", e);
            return Result.fail(e.getCode(), e.getMessage());
        } catch (RpcException e) {
            log.error("全局捕獲第三方rpc調用異常", e);
            return Result.fail(e.getCode(), e.getMessage());
        } catch (SystemException e) {
            log.error("全局捕獲系統異常", e);
            return Result.fail(e.getCode(), e.getMessage());
        } catch (Throwable e) {
            log.error("全局捕獲未知異常", e);
            return Result.fail(e.getMessage());
        }
    }
}           

aop 中 @Pointcut 的 execution 表達式配置說明:

execution(public * *(..)) 定義任意公共方法的執行
execution(* set*(..)) 定義任何一個以"set"開始的方法的執行
execution(* com.xyz.service.AccountService.*(..)) 定義AccountService 接口的任意方法的執行
execution(* com.xyz.service.*.*(..)) 定義在service包裡的任意方法的執行
execution(* com.xyz.service ..*.*(..)) 定義在service包和所有子包裡的任意類的任意方法的執行
execution(* com.test.spring.aop.pointcutexp…JoinPointObjP2.*(…)) 定義在pointcutexp包和所有子包裡的JoinPointObjP2類的任意方法的執行           

http 接口全局異常處理

如果是 springboot 項目,http 接口的異常處理主要分為三類:

  • 基于請求轉發的方式處理異常;
  • 基于異常處理器的方式處理異常;
  • 基于過濾器的方式處理異常。

基于請求轉發的方式:真正的全局異常處理。

實作方式有:

  • BasicExceptionController

基于異常處理器的方式:不是真正的全局異常處理,因為它處理不了過濾器等抛出的異常。

實作方式有:

  • @ExceptionHandler
  • @ControllerAdvice+@ExceptionHandler
  • SimpleMappingExceptionResolver
  • HandlerExceptionResolver

基于過濾器的方式:近似全局異常處理。它能處理過濾器及之後的環節抛出的異常。

實作方式有:

  • Filter

關于 http 接口的全局異常處理,這裡重點介紹基于異常處理器的方式,其餘的方式建議查閱相關文檔學習。

在使用基于異常處理器的方式之前需要導入 spring-boot-starter-web 依賴即可,如下所示:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>           

通過@ControllerAdvice+@ExceptionHandler 實作基于異常處理器的http接口全局異常處理:

/**
* http 接口異常處理類
*/
@Slf4j
@RestControllerAdvice("org.example.controller")
public class HttpExceptionHandler {
    /**
     * 處理業務異常
     * @param request 請求參數
     * @param e 異常
     * @return Result
     */
    @ExceptionHandler(value = BizException.class)
    public Object bizExceptionHandler(HttpServletRequest request, BizException e) {
        log.warn("業務異常:" + e.getMessage() , e);
        return Result.fail(e.getCode(), e.getMessage());
    }
    /**
     * 處理系統異常
     * @param request 請求參數
     * @param e 異常
     * @return Result
     */
    @ExceptionHandler(value = SystemException.class)
    public Object systemExceptionHandler(HttpServletRequest request, SystemException e) {
        log.error("系統異常:" + e.getMessage() , e);
        return Result.fail(e.getCode(), e.getMessage());
    }
    /**
     * 處理未知異常
     * @param request 請求參數
     * @param e 異常
     * @return Result
     */
    @ExceptionHandler(value = Throwable.class)
    public Object unknownExceptionHandler(HttpServletRequest request, Throwable e) {
        log.error("未知異常:" + e.getMessage() , e);
        return Result.fail(e.getMessage());
    }
}           

在 HttpExceptionHandler 類中,@RestControllerAdvice = @ControllerAdvice + @ResponseBody ,如果有其他的異常需要處理,隻需要定義@ExceptionHandler注解的方法處理即可。

六、總結

讀完本文應該了解Java異常處理機制,當一個異常被抛出時,JVM會在目前的方法裡尋找一個比對的處理,如果沒有找到,這個方法會強制結束并彈出目前棧幀,并且異常會重新抛給上層調用的方法(在調用方法幀)。如果在所有幀彈出前仍然沒有找到合适的異常處理,這個線程将終止。如果這個異常在最後一個非守護線程裡抛出,将會導緻JVM自己終止,比如這個線程是個main線程。

最後對本文的内容做一個簡單的總結,Java語言的異常處理方式有兩種,一種是 try-catch 捕獲異常,另一種是通過 throw 抛出異常。在程式中可以抛出兩種類型的異常,一種是檢查異常,另一種是非檢查異常,應該盡量抛出非檢查異常,遇到檢查異常應該捕獲進行處理不要抛給上層。在異常處理的時候應該盡可能晚的處理異常,最好是定義一個全局異常處理器,在全局異常處理器中處理所有抛出的異常,并将異常資訊封裝到 Result 對象中傳回給調用者。

文章來源:王迪_https://zhuanlan.zhihu.com/p/617291696

繼續閱讀