1. 程式人生 > >Java異常處理終結篇——如何進行Java異常處理設計

Java異常處理終結篇——如何進行Java異常處理設計

有一句這樣話:一個衡量Java設計師水平和開發團隊紀律性的好方法就是讀讀他們應用程式裡的異常處理程式碼。

本文主要討論開發Java程式時,如何設計異常處理的程式碼,如何時拋異常,捕獲到了怎麼處理,而不是講異常處理的機制和原理。

在我自己研究Java異常處理之前,我查過很多資料,翻過很多書藉,試過很多搜尋引擎,換過很多英文和中文關鍵字,但是關於異常處理設計的文章實在太少,在我研究完Java異常處理之後,我面試過很多人,也問過很多老員工,極少碰到對Java異常有研究的人,看來研究這個主題的人很少,本文內容本是個人研究異常時做的筆記,現整理一下與大家一起分享。

首先我們簡單的回顧一下基礎知識,Java中有兩種異常,嚴格的說是三種,包含四個類,層次圖如下:


Throwable是一個可拋類,只有其子類可以被關鍵字throw丟擲,請勿直接繼承本類,Error是表示系統級錯誤,如記憶體耗盡了,我們一般情況下不用管,Exception是所有異常的父類,所以他的子類,除了RuntimeException及其子類,是屬於編譯時異常,這種異常必須在程式碼裡被顯示的捕獲語句包住,否則編譯不過,而RuntimeException及其子類表示執行時異常,不強制要求寫出顯示的捕獲程式碼,但如果沒有被捕獲到,則執行緒會被強制中斷。

我們主要關注後兩種,他們的特點已領教了,下面我們通過回答問題的方式來分析異常設計,在開始之前,請確保你已經知道使這兩種異常:

捕獲到了編譯時異常怎麼處理:

這個話題恐怖是最古老的啦,網上的文章多數都是討論這個話題,但這些文章大部分只是給了幾條禁止的原則,他們是:1)不要直接忽略異常;2)不要用try-catch包住過多語句;3)不要用異常處理來處理程式的正常控制流;4)不要隨便將異常迎函式棧向上傳遞,能處理儘量處理。他們都對,但是要做異常處理的設計,資訊還是不夠,比如第一條他只是告訴了不要忽略,但沒有告訴我們怎麼處理,所以很多人直接e.printStackTrace()了,這種處理比直接忽略是好一點,但還不夠好。對於第二條,他的理由是避免耗資源很大,不過“過多語句”這句話描述的太模糊了,沒說明到底多少才算過多,以致於很多人的try-catch語句只包住會拋編譯時異常的那一行程式碼,如果一段程式碼中有多行程式碼會拋編譯時異常,那這一段程式碼中可能有多個try-catch語句塊,像這樣:

LLJTran llj = new LLJTran(file);
try {
    llj.read(LLJTran.READ_INFO,true);
} catch (LLJTranException e) {
    // ...
}

// ...

OutputStream out = null;
try {
    File out = new File(file.getPath()+"_bak.jpg");
    llj.xferInfo(null, out, LLJTran.REPLACE, LLJTran.REPLACE);
} catch (IOException e) {
    // ...
}

// ...

try {
    out.close();
} catch (IOException e) {
    // ...
}

這樣有什麼壞處呢,到處都是異常處理的程式碼,很容易給人造成困惑,很難找出哪些是正常流程的程式碼,而且還違背了Java異常機制的初衷,Java異常機制是為了把異常處理的程式碼與正常流程的程式碼分開,避免程式中出現過多的像傳統程式那樣的非法值判斷語句,以致於擾亂了正常流程。但上述代段充斥著try-catch語句塊,已經擾亂了主流程,並極大影響了可讀性。

try-catch既不能包太多程式碼,又不能包太少,那應該包多少才適合呢,這個問題我查過的資料中都沒有提,我的個人建議是包住邏輯關係緊密的程式碼,比如開啟檔案,讀取檔案,關閉檔案,我認為就是邏輯關係緊密的程式碼,如果你發現包住的程式碼很多,可以封裝一些方法,如讀取檔案的程式碼很長就應該封裝成一個方法,這個方法可以申明IOException,(其實讀檔案的細節本來屬於低層邏輯,開啟,讀取,關閉才屬於同層邏輯,如果讀取程式碼很短,初期為了省事才不封成讀取細節的程式碼,不過後期可以重構並封裝成方法,這是《重構·改善繼有的程式碼設計》一書中的思想——軟體應該不斷的重構和加善)。這樣才能達到把異常程式碼與正常流程程式碼分離的目的。

第3)條沒問題,第4)條也有問題,“不要隨便”很模糊,那什麼時候才能向上傳遞呢。

吐槽完了,我們現在來說說到底該如何處理捕獲到的編譯時異常:

一、恢復並繼續執行:這個結果是最完美的,也是編譯時異常出生的目的——捕獲異常,並恢復繼續執行程式。所以如果你捕獲了一個異常是先盡力恢復,這種情況其實就是在主方案行不通時,用備選方案,而且主方案能否行通不能事先知道,必須執行的時候才能知道,所以在一般情況下,備選方案比主方案要的執行結果要差。比如一個視訊程式,它要呼叫一個下載節目列表的方法,可能如下:

InputStream download() throws IOException {
    // ...
}

但伺服器不保證總是可用,有可能被攻擊了,有可能其它原因,因為是個意外事件,所以又不可能事先知道,於是異常就發生在執行過程中,幸好客戶端有備選方案,它在本地儲存了一個預設列表,當伺服器不可用時,就載入本地列表,所以客戶端對這個異常的處理可以如下:

public void loadProgramList() {
    InputStream inputStream;
    try {
        inputStream = download();
    } catch (IOException e) {
        // Log this exception
        System.out.println("The server occurred errors");
        // Use the local file
        inputStream = openLocalFile();
    }
    
    //...
}

private InputStream download() throws IOException {
    // ...
}

private InputStream openLocalFile() {
    // ...
}

可惜的是,不是任何時候的異常都可以恢復,反而一般情況是不能恢復的。

二、向上傳播異常:向上傳播就是在本方法上用throws申明,本方法裡的程式碼不對某異常做任何處理。如果不能用上述恢復措施,就檢查能不能向上傳播,什麼情況下可以向上傳播呢?有多種說法,一種說法是當本方法恢復不了時,這個說法顯然是錯誤,因為上層也不一定能恢復。另外還有兩種說法是:1.當上層邏輯可以恢復程式時;2.當本方法除了列印之外不能做任何處理,而且不確定上層能否處理。這種兩種說法都是正確的,但還不夠,因為也有的情況,明確知道上層恢復不了也需要上層處理,所以我認為正確的做法是:當你認為本異常應該由上層處理時,才向上傳播。不過這得根據你程式的設計來靈活思考,比如你的類設計了一個上層方法集中處理異常,而下層有一些private方法只是簡單的用throws申明。當上層方法捕獲到異常時,雖然不能恢復執行,但可以做一些處理,如轉換成便於閱讀的文字,或者用下面討論的轉譯。

三、轉譯異常:轉譯即把低層邏輯的異常轉化成為高層邏輯的異常,因為有可能低層邏輯的異常在高層邏輯中不能被理解,主要實現是新寫一個Exception的子類,然後在低層邏輯捕獲異常,改拋這個新寫的異常,比如剛剛那個視訊程式,他的主流程可能是:1.載入節目列表,2.顯示播放節目。而載入節目列表子流程又包含讀取節目檔案、解析節目檔案、顯示節目列表。而讀取節目檔案有可能出現IO異常(有可能本地和網上的檔案都讀不了了),解析節目檔案可能出現解析異常,這時如果把這些異常,直接向上傳播,變成這樣,你覺得合理嗎:

public void mainFlow() {
    // 1.load program list
    try {
        loadProgramList();
    } catch (IOException e) {
        // I don't understand what is this exception.
    } catch (ParseException e) {
        // I don't understand what is this exception.
    }
    
    // 2.play program
    // ...
}

public void loadProgramList() throws IOException, ParseException {
    // 1.Read program file
    InputStream inputStream;
    try {
        inputStream = download();
    } catch (IOException e) {
        // Log this exception
        System.out.println("The server occurred errors");
        // Use the local file
        inputStream = openLocalFile();    //Maybe throw IOException.
    }
    
    // 2.Parse program file
    parserProgramFile(inputStream);        //Maybe throw ParseException.
    
    // 3.Display program file
    //...
}

由於loadProgramList將兩個可能的異常向上傳播,在mainFlow裡,必須顯示捕獲這兩個異常,但在mainFlow根本就不能理解這兩個異常代表什麼,mainFlow裡只需要知道載入節目列表異常就可以了,所以我們可以寫一個異常類LoadProgramException代表載入節目異常,並在loadProgramList裡丟擲,於是程式碼變成這樣:

public void mainFlow() {
    // 1.load program list
    try {
        loadProgramList();
    } catch (LoadProgramException e) {        // look at here
        // ...
    }
    
    // 2.play program
    // ...
}

public void loadProgramList() throws LoadProgramException {        // look at here
    // 1.Read program file
    InputStream inputStream = null;
    try {
        inputStream = download();
    } catch (IOException e) {
        // Log this exception
        System.out.println("The server occurred errors");
        // Use the local file
        try {
            inputStream = openLocalFile();
        } catch (IOException e1) {
            throw new LoadProgramException("Read program file error.", e1);        // look at here
        }
    }
    
    // 2.Parse program file
    try {
        parserProgramFile(inputStream);
    } catch (ParseException e) {
        throw new LoadProgramException("Parse program file error.", e);            // look at here
    }
    
    // 3.Display program file
    //...
}

// ...

class LoadProgramException extends Exception {
    public LoadProgramException(String msg, Throwable cause) {
        super(msg, cause);
    }
    // ...
}

注意:LoadProgramException建構函式的第一個引數是代表原因,用於組成異常鏈,異常鏈是一種機制,異常轉譯時,儲存原來的異常,這樣當這個異常再被轉譯時,還會被儲存,於是就成了一條鏈了,包含了所有的異常,所以你可以看到這樣的異常列印:

Exception in thread "main" java.lang.NoClassDefFoundError: graphics/shapes/Square
    at Main.main(Main.java:7)
Caused by: java.lang.ClassNotFoundException: graphics.shapes.Square
    at java.net.URLClassLoader$1.run(URLClassLoader.java:366)
    at java.net.URLClassLoader$1.run(URLClassLoader.java:355)
    at java.security.AccessController.doPrivileged(Native Method)
    at java.net.URLClassLoader.findClass(URLClassLoader.java:354)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
    at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:308)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
    ... 1 more

這個異常鏈中就是包含了兩個異常,最前面是頂級異常,後面再列印一個Cause by,然後再列印低一層異常,直到列印完所有的異常。

另外,主流程中還有一個播放流程也可以定義一個播放異常的類,再做這樣的轉譯處理,但是,如果流程多,是不是得寫多個異常類呢,有人建議是每個包定義一個異常類,但並不是絕對的,這個細粒度還要根據具體的程式邏輯來決定,這種把握能力就要靠經驗了,這可能就是架構師的過人之處了。

四、改拋為執行時異常:這個很好玩,也是一條很方便的處理手法(我常用,我用這個還發現了一個Android系統的bug),即當你捕獲到異常時,重新丟擲,這跟轉譯很相似,有一點區別,這裡拋的是執行時異常,而轉譯拋的是編譯時異常。那什麼時候使用這個手法呢?簡單的說就是當某個異常出現時,你必須讓程式掛掉。解釋一下:如果某個異常情況一旦出現,程式便無法繼續執行,而且你明確知道本方法和上層邏輯做不出任何有意義的處理,你只能讓程式退出。所以你就拋一個執行時異常讓程式掛掉。舉個例子,比如在加密通訊中,伺服器捕獲到了一個非法資料異常,這是無法恢復的,而且就是拋一執行異常,讓執行緒掛掉,連線便會自動中斷。

五、記錄並消耗掉異常:這個手法就是把異常記錄下來(到檔案或控制檯)然後忽略掉異常,有可能隨後就讓本方法返回null,這個手法一般用在不是很嚴重的異常,相當於是warning級別的錯誤,出現這個異常對程式的執行可能影響不太,比如程式的某個偏好設定檔案(如視窗位置,最近檔案等)損壞,但這個檔案資訊很少,程式只要使用預設配置即可。

有沒必要顯示捕獲執行時異常:

執行時異常一般是不需要捕獲的,因為它的目的就是讓程式在無法恢復時掛掉,但是也有特殊需求,比如你要收集所有的未捕獲異常記錄,可能用於統計,也可能用於將來除錯。還有其它原因使你不想讓程式直接掛掉,比如你想把友好資訊告訴使用者。

什麼時候需要拋異常:

馬上就要討論如何拋異常了,但在必須先知道,什麼時候需要拋異常,簡單的說就是遇到一個異常情況,這是一個模稜兩可的問題,就像美不美這個問題一樣,我幾種說法,你看你能理解哪一種,一種是正常情況的反面,即非正常情況,那什麼是非正常情況呢,這也是仁者見仁,智者見智,比如說讀到檔案尾,這個算正常還是異常呢,都說得過去,所以這裡給一個判斷方法做為參考,如果是一個典型情況,就不當成是異常,所以讀到檔案尾就沒有被當成一個異常,返回了-1。還有一種說法是,程式執行的必要條件不能成立,使得本方法無法繼續履行自己的職責。這兩種說法都不錯,你都可以用,而且覆蓋了大部分情況。

何時選用編譯時異常:編譯時異常是Java特有的,其它語言沒有,剛出來時很流行,所以你可以看到流處理包裡充斥著IOException,但經過多年的使用,有人覺得編譯時異常是一種實驗性錯誤,應該完全丟棄,說這個話的人就是《Think In Java》的一書的作者Eckel,我認為這種說法太絕對了,關於這個是與否也有很大的爭論。《Effective Java》一書的作者則認為應避免不必要的編譯時異常,因為你拋編譯時異常會給強制要求呼叫者捕獲,這會增加他的負擔,我是這一觀點的支持者。那到底何時拋編譯時異常呢?當你發現一個異常情況時,檢查這兩個條件,為真時選用編譯時異常:一、如果呼叫者可以恢復此異常情況,二、如果呼叫者不能恢復,但能做出有意義的事,如轉譯等。如果你不確定呼叫者能否做出有意義的事,就別使編譯時異常,免得被抱怨。還有一條原則,應盡最大可能使用編譯時異常來代替錯誤碼,這條也是編譯時異常設計的目的。另外,必須注意使用編譯時異常的目的是為了恢復執行,所以設計異常類的時候,應提供儘量多的異常資料,以便於上層恢復,比如一個解析錯誤,可以在設計的異常類寫幾個變數來儲存異常資料:解析出錯的句子的內容,解析出錯句子的行號,解析出錯的字元在行中的位置。這些資訊可能幫助呼叫恢復程式。

何時選用執行時異常:首先,執行時異常肯定是不可恢復的異常,否則按上段方法處理。這個不可恢復指的是執行時期不可恢復,如果可以修改原始碼來避免本異常的發生呢,那說明這是一個程式設計錯誤,對於程式設計錯誤,一定要拋執行時異常,程式設計錯誤一般可以通過修改程式碼來永久性避免該異常,所以這種情況應該讓程式掛掉,相當於爆出一個bug,從而提醒程式設計師修改程式碼。這種程式設計錯誤可以總結一下,API是呼叫者與實現者之間的契約,呼叫者必須遵守契約,比如傳入的引數不允許為空,這一點是隱含契約,沒必要明確寫出來的,如果違反契約,實現者就可以拋執行時異常,讓程式掛掉以提醒呼叫者。

其它情況是否應使用執行時異常,上面提到過,就是誰都無能為力的異常情況,還有就是你不確定到底能不能恢復,除此之外,你可以這樣判斷:如果你希望程式掛掉,就用執行時異常。需要說明的是,請儘量使用系統自帶異常,而不是新寫。網上還有一條建議是使用執行時異常時, 一定要將所有可能的異常寫進文件。這認為只要把不常用的寫上即可,像NullPointException每個方法都有可能拋,但沒必要每個方法都寫說明。

將編譯時異常重構成執行時異常:

你可能手頭上有一份以前的程式碼,大量的使有了編譯時異常,但很多都是沒有必要的編譯時異常,導致呼叫上不方便,《Effective Java》裡有一種方法可以將編譯時異常轉為執行時異常:將原來拋編譯時異常的方法,拆成兩個方法,其中一個是用來指示異常是否為發生,即將以下程式碼:

// Invocation with checked exception
try {
obj.action(args);
} catch(TheCheckedException e) {
// Handle exceptional condition
...
}

改為這樣:

// Invocation with state-testing method and unchecked exception
if (obj.actionPermitted(args)) {
obj.action(args);
} else {
// Handle exceptional condition
...
}

步驟是:1)將原來方法foo的異常申明刪掉,並在實現裡面改拋為執行時異常;2)新增一個方法isFoo,返回一個布林值指示是否會有異常情況出現;3)在foo呼叫前加一個if語句,判斷isFoo的返回值,如果為真才呼叫foo,否則不呼叫;4)刪掉呼叫處的try-catch。

UI層處理異常的注意點:

UI層和其下邏輯層的區別是UI層的出錯資訊是被使用者看,而其下層邏層出錯資訊是被程式設計師看到,使用者可不希望看一個列印的異常棧,更不希望程式無緣無故掛掉,使用者希望看到友好的提示資訊。為到達這一目的,我們可以設一個屏障,屏障可以捕獲所有遺漏的異常,從而阻止程式直接掛掉,屏障當然恢復不了執行,但可以記錄錯誤便於日後除錯,還可以輸出友好資訊給使用者。Spring和Struts就有這樣的處理。

還有一點需要注意,使用者的傳入引數出現非法的概率很高,所以控制層接受到引數時一定要校驗,而不是原封不動的傳到其低層模組。

經歷了一週的熬夜,總算把異常處理總結歸納成文了,但由於文章太長,肯定有一些錯誤和語言不精煉的地方,我會仔細檢察並及時改正,希望本文對大家有一定的幫助。

附錄

在我查過的資料中,以《Effective Java》書中對異常處理設計的研究得最系統,本文很多思想來自於它,下面我把其中的幾條原則翻譯(非直譯)並貼上:

第57條:只對異常情況使用異常。(說明:即不要用異常處理控制正常程式流)。

第58條:對可恢復異常使用編譯時異常,對程式設計錯誤使用執行時異常。

第59條:應避免不必要的編譯時異常:如果呼叫者即使合理的使用API也不能避免異常的發生,並且呼叫者可以對捕獲的異常做出有意義的處理,才使用編譯時異常。

第60條:應偏好使用自帶異常

第61條:丟擲的異常應適合本層抽象(就是上面說的轉譯)

第62條:把方法可能拋的所有異常寫入文件,包括執行時異常

第63條:用異常類記錄的資訊要包含失敗時的資料

第64條:力求失敗是原子化的(解釋:就是如果呼叫一個方法發生了異常,就應該使物件返回呼叫前的狀態)

第65條:不要忽略異常