編寫高質量程式碼:改善Java程式的151個建議(第8章:異常___建議110~113)

阿赫瓦里發表於2016-10-12

  不管人類的思維有多麼縝密,也存在" 智者千慮必有一失 "的缺憾。無論計算機技術怎麼發展,也不可能窮盡所有的場景___這個世界是不完美的,也是有缺陷的。完美的世界只存在於理想中。

  對於軟體帝國的締造者來說,程式也是不完美的,異常情況會隨時出現,我們需要它為我們描述例外事件,需要它處理非預期的情景,需要它幫我們建立“完美世界”。

建議110:提倡異常封裝

  Java語言的異常處理機制可以去確保程式的健壯性,提高系統的可用率,但是Java API提供的異常都是比較低階的(這裡的低階是指 " 低階別的 " 異常),只有開發人員才能看的懂,才明白髮生了什麼問題。而對於終端使用者來說,這些異常基本上就是天書,與業務無關,是純計算機語言的描述,那該怎麼辦?這就需要我們對異常進行封裝了。異常封裝有三方面的優點:

(1)、提高系統的友好性

  例如,開啟一個檔案,如果檔案不存在,則回報FileNotFoundException異常,如果該方法的編寫者不做任何處理,直接拋到上層,則會降低系統的友好性,程式碼如下所示:

public static void doStuff() throws FileNotFoundException {
        InputStream is = new FileInputStream("無效檔案.txt");
        /* 檔案操作 */
    }

  此時doStuff的友好性極差,出現異常時(如果檔案不存在),該方法直接把FileNotFoundException異常拋到上層應用中(或者是終端使用者),而上層應用(或使用者要麼自己處理),要麼接著拋,最終的結果就是讓使用者面對著" 天書 " 式的文字發呆,使用者不知道這是什麼問題,只是知道系統告訴他"  哦,我出錯了,什麼錯誤?你自己看著辦吧 "。

  解決辦法就是封裝異常,可以把異常的閱讀者分為兩類:開發人員和使用者。開發人員查詢問題,需要列印出堆疊資訊,而使用者則需要了解具體的業務原因,比如檔案太大、不能同時編寫檔案等,程式碼如下: 

public static void doStuff2() throws MyBussinessException{
        try {
            InputStream is = new FileInputStream("無效檔案.txt");
        } catch (FileNotFoundException e) {
            //方便開發人員和維護人員而設定的異常資訊
            e.printStackTrace();
            //丟擲業務異常
            throw new MyBussinessException();
        }
        /* 檔案操作 */
    }

(2)、提高系統的可維護性

  看如下程式碼: 

public  void doStuff3(){
        try{
            //doSomething
        }catch(Exception e){
            e.printStackTrace();
        }
        
    }

  這是大家很容易犯的錯誤,丟擲異常是吧?分類處理多麻煩,就寫一個catch塊來處理所有的異常吧,而且還信誓旦旦的說" JVM會列印出棧中的錯誤資訊 ",雖然這沒錯,但是該資訊只有開發人員自己看的懂,維護人員看到這段異常時基本上無法處理,因為需要到程式碼邏輯中去分析問題。

  正確的做法是對異常進行分類處理,並進行封裝輸出,程式碼如下:  

public  void doStuff4(){
        try{
            //doSomething
        }catch(FileNotFoundException e){
            log.info("檔案未找到,使用預設配置檔案....");
            e.printStackTrace();
        }catch(SecurityException e1){
            log.info(" 無權訪問,可能原因是......");
            e1.printStackTrace();
        }
    }

  如此包裝後,維護人員看到這樣的異常就有了初步的判斷,或者檢查配置,或者初始化環境,不需要直接到程式碼層級去分析了。

(3)、解決Java異常機制自身的缺陷

  Java中的異常一次只能丟擲一個,比如doStuff方法有兩個邏輯程式碼片段,如果在第一個邏輯片段中丟擲異常,則第二個邏輯片段就不再執行了,也就無法丟擲第二個異常了,現在的問題是:如何才能一次丟擲兩個(或多個)異常呢?

  其實,使用自行封裝的異常可以解決該問題,程式碼如下: 

class MyException extends Exception {
    // 容納所有的異常
    private List<Throwable> causes = new ArrayList<Throwable>();

    // 建構函式,傳遞一個異常列表
    public MyException(List<? extends Throwable> _causes) {
        causes.addAll(_causes);
    }

    // 讀取所有的異常
    public List<Throwable> getExceptions() {
        return causes;
    }
}

  MyException異常只是一個異常容器,可以容納多個異常,但它本身並不代表任何異常含義,它所解決的是一次丟擲多個異常的問題,具體呼叫如下:

public void doStuff() throws MyException {
        List<Throwable> list = new ArrayList<Throwable>();
        // 第一個邏輯片段
        try {
            // Do Something
        } catch (Exception e) {
            list.add(e);
        }
        // 第二個邏輯片段
        try {
            // Do Something
        } catch (Exception e) {
            list.add(e);
        }
        // 檢查是否有必要丟擲異常
        if (list.size() > 0) {
            throw new MyException(list);
        }
    }

  這樣一來,DoStuff方法的呼叫者就可以一次獲得多個異常了,也能夠為使用者提供完整的例外情況說明。可能有人會問:這種情況會出現嗎?怎麼回要求一個方法丟擲多個異常呢?

  絕對有可能出現,例如Web介面註冊時,展現層依次把User物件傳遞到邏輯層,Register方法需要對各個Field進行校驗並註冊,例如使用者名稱不能重複,密碼必須符合密碼策略等,不要出現使用者第一次提交時系統顯示" 使用者名稱重複 ",在使用者修改使用者名稱再次提交後,系統又提示" 密碼長度小於6位 " 的情況,這種操作模式下的使用者體驗非常糟糕,最好的解決辦法就是異常封裝,建立異常容器,一次性地對User物件進行校驗,然後返回所有的異常。

建議111:採用異常鏈傳遞異常

  設計模式中有一個模式叫做責任鏈模式(Chain of Responsibility) ,它的目的是將多個物件連成一條鏈,並沿著這條鏈傳遞該請求,直到有物件處理它為止,異常的傳遞處理也應該採用責任鏈模式。

  上一建議中我們提出了異常需要封裝,但僅僅封裝還是不夠的,還需要傳遞異常。我們知道,一個系統友好性的標誌是使用者對該系統的" 粘性",粘性越高,系統越友好,粘性越低系統友好性越差,那問題是怎麼提高系統的“粘性”呢?友好的介面和功能是一個方面,另外一個方面就是系統出現非預期情況的處理方式了。

  比如我們的JavaEE專案一般都有三層結構:持久層,邏輯層,展現層,持久層負責與資料庫互動,邏輯層負責業務邏輯的實現,展現層負責UI資料庫的處理,有這樣一個模組:使用者第一次訪問的時候,需要從持久層user.xml中讀取資訊,如果該檔案不存在則提示使用者建立之,那問題來了:如果我們直接把持久層的異常FileNotFoundException拋棄掉,邏輯層根本無法得知發生了何事,也就不能為展現層提供一個友好的處理結果了,最終倒黴的就是發展層:沒有辦法提供異常資訊,只能告訴使用者說“出錯了,我也不知道出什麼錯了”___毫無友好性可言。

  正確的做法是先封裝,然後傳遞,過程如下:

(1)、把FIleNotFoundException封裝為MyException。

(2)、丟擲到邏輯層,邏輯層根據異常程式碼(或者自定義的異常型別)確定後續處理邏輯,然後丟擲到展現層。

(3)、展現層自行決定要展現什麼,如果是管理員則可以展現低層級的異常,如果是普通使用者則展示封裝後的異常。

  明白了異常為什麼要傳遞,那接著的問題就是如何傳遞了。很簡單,使用異常鏈進行異常的傳遞,我們以IOException為例來看看是如何傳遞的,程式碼如下:

public class IOException extends Exception {

    public IOException() {
        super();
    }
    //定義異常原因
    public IOException(String message) {
        super(message);
    }
    //定義異常原因,並攜帶原始異常
    public IOException(String message, Throwable cause) {
        super(message, cause);
    }
    //保留原始異常資訊
    public IOException(Throwable cause) {
        super(cause);
    }
}

  在IOException的建構函式中,上一個層級的異常可以通過異常鏈進行傳遞,鏈中傳遞異常的程式碼如下所示:

       try{
            //doSomething
        }catch(Exception e){
            throw new IOException(e);
        }

  捕捉到Exception異常,然後把它轉化為IOException異常並丟擲(此種方式也叫作異常轉譯),呼叫者獲得該異常後再呼叫getCause方法即可獲得Exception的異常資訊,如此即可方便地查詢到產生異常的基本資訊,便於解決問題。

  結合上一建議來說,異常需要封裝和傳遞,我們在進行系統開發時不要" 吞噬 " 異常,也不要赤裸裸的丟擲異常,封裝後再丟擲,或者通過異常鏈傳遞,可以達到系統更健壯,更友好的目的。

建議112:受檢異常儘可能轉化為非受檢異常

  為什麼說是" 儘可能"的轉化呢?因為" 把所有的受檢異常(Checked Exception)"都轉化為非受檢異常(Unchecked Exception)" 這一想法是不現實的:受檢異常是正常邏輯的一種補償手段,特別是對可靠性要求比較高的系統來說,在某些條件下必須丟擲受檢異常以便由程式進行補償處理,也就是說受檢異常有合理存在的理由,那為什麼要把受檢異常轉化為非受檢異常呢?難道說受檢異常有什麼缺陷或者不足嗎?是的,受檢異常確實有不足的地方:

(1)、受檢異常使介面宣告脆弱

  OOP(Object Oriented Programming,物件導向程式設計) 要求我們儘量多地面向介面程式設計,可以提高程式碼的擴充套件性、穩定性等,但是涉及異常問題就不一樣了,例如系統初期是這樣設計一個介面的:  

interface User{
    //修改使用者密碼,丟擲安全異常
    public void changePassword() throws MySecurityException;
}

  隨著系統的開發,User介面有了多個實現者,比如普通的使用者UserImpl、模擬使用者MockUserImpl(用作測試或系統管理)、非實體使用者NonUserImpl(如自動執行機,邏輯處理器等),此時如果發現changePassword方法可能還需要丟擲RejectChangeException(拒絕修改異常,如自動執行正在處理的任務時不能修改其程式碼),那就需要修改User介面了:changePassword方法增加丟擲RejectChangeException異常,這會導致所有的User呼叫者都要追加了對RejectChangeException異常問題的處理。

  這裡產生了兩個問題:一、 異常是主邏輯的補充邏輯,修改一個補充邏輯,就會導致主邏輯也被修改,也就是出現了實現類 " 逆影響 " 介面的情景,我們知道實現類是不穩定的,而介面是穩定的,一旦定義了異常,則增加了介面的不穩定性,這是物件導向設計的嚴重褻瀆;二、實現的變更最終會影響到呼叫者,破壞了封裝性,這也是迪米特法則所不能容忍的。

(2)、受檢異常使程式碼的可讀性降低

  一個方法增加可受檢異常,則必須有一個呼叫者對異常進行處理,比如無受檢異常方法doStuff是這樣呼叫的:

public static void main(String[] args) {
        doStuff();
    }

  doStuff方法一旦增加受檢異常就不一樣了,程式碼如下: 

public static void main(String[] args) {
        try{
            doStuff();
        }catch(Exception e){
            e.printStackTrace();
        }
    }

  doStuff方法增加了throws Exception,呼叫者就必須至少增加4條語句來處理該異常,程式碼膨脹許多,可讀性也降低了,特別是在多個異常需要捕捉的情況下,多個catch塊多個異常處理,而且還可能在catch塊中再次丟擲異常,這大大降低了程式碼的可讀性。

(3)、受檢異常增加了開發工作量

  我們知道,異常需要封裝和傳遞,只有封裝才能讓異常更容易理解,上層模組才能更好的處理,可這會導致低層級的異常沒玩沒了的封裝,無端加重了開發的工作量。比如FileNotFoundException進行封裝,並丟擲到上一個層級,於是增加了開發工作量。

  受檢異常有這麼多的缺點,那有沒有什麼方法可以避免或減少這些缺點呢?有,很簡單的一個規則:將受檢異常轉化為非受檢異常即可,但是我們也不能把所有的受檢異常轉化為非受檢異常,原因是在編碼期上層模組不知道下層模組會丟擲何種非受檢異常,只有通過規則或文件來描述,可以這樣說: 

  • 受檢異常提出的是" 法律下的自由 ",必須遵守異常的約定才能自由編寫程式碼。
  • 非受檢異常則是“ 協約性質的自由 ”,你必須告訴我你要拋什麼異常,否則不會處理。

  以User介面為例,我們在宣告介面時不再宣告異常,而是在具體實現時根據不同的情況產生不同的非受檢異常,這樣持久層和邏輯層丟擲的異常將會由展現自行決定如何展示,不再受異常的規則約束了,大大簡化開發工作,提高了程式碼的可讀性。

  那問題又來了,在開發和設計時什麼樣的受檢異常有必要化為非受檢異常呢?" 儘可能 " 是以什麼作為判斷依據呢?受檢異常轉換為非受檢異常是需要根據專案的場景來決定的,例如同樣是刷卡,員工拿著自己的工卡到考勤機上打考勤,此時如果附近有磁性物質干擾,則考勤機可以把這種受檢異常轉化為非受檢異常,黃燈閃爍後不做任何記錄登記,因為考勤失敗這種情景不是" 致命 "的業務邏輯,出錯了,重新刷一下即可。但是到銀行網點取錢就不一樣了,拿著銀行卡到銀行取錢,同樣有磁性物質干擾,刷不出來,那這種異常就必須登記處理,否則會成為威脅銀行卡安全的事件。彙總成一句話:當受檢異常威脅到了系統的安全性,穩定性,可靠性、正確性時,則必須處理,不能轉化為非受檢異常,其它情況則可以轉化為非受檢異常。

  注意:受檢異常威脅到系統的安全性,穩定性、可靠性、正確性時,不能轉換為非受檢異常。

建議113:不要在finally塊中處理返回值

  在finally程式碼塊中處理返回值,這是考試和麵試中經常出現的題目。雖然可以以此來出考試題,但在專案中絕對不能再finally程式碼塊中出現return語句,這是因為這種處理方式非常容易產生" 誤解 ",會誤導開發者。例如如下程式碼:  

public class Client113 {
    public static void main(String[] args) {
        try {
            System.out.println(doStuff(-1));
            System.out.println(doStuff(100));
        } catch (Exception e) {
            System.out.println("這裡是永遠不會到達的");
        }
    }
    //該方法丟擲受檢異常
    public static int doStuff(int _p) throws Exception {
        try {
            if (_p < 0) {
                throw new DataFormatException(" 資料格式錯誤 ");
            } else {
                return _p;
            }

        } catch (Exception e) {
            // 異常處理
            throw e;
        } finally {
            return -1;
        }
    }
}

  對於這段程式碼,有兩個問題:main方法中的doStuff方法的返回值是什麼?doStuff方法永遠都不會丟擲異常嗎?

  答案是:doStuff(-1)的值是-1,doStuff(100)的值也是-1,呼叫doStuff方法永遠都不會丟擲異常,有這麼神奇?原因就是我們在finally程式碼塊中加入了return語句,而這會導致出現以下兩個問題:

(1)、覆蓋了try程式碼塊中的return返回值

  當執行doStuff(-1)時,doStuff方法產生了DataFormatException異常,catch塊在捕捉此異常後直接丟擲,之後程式碼執行到finally程式碼塊,就會重置返回值,結果就是-1了。也就是出現先返回,再重置返回的情況。

  有人可能會思考,是不是可以定義變數,在finally中修改後return呢?程式碼如下: 

public static int doStuff() {
        int a = 1;
        try {
            return a;
        } catch (Exception e) {

        } finally {
            // 重新修改一下返回值
            a = -1;
        }
        return 0;
    }

  該方法的返回值永遠是1,不會是-1或0(為什麼不會執行到" return 0 " 呢?原因是finally執行完畢後該方法已經有返回值了,後續程式碼就不會再執行了),這都是源於異常程式碼塊的處理方式,在程式碼中try程式碼塊就標誌著執行時會有一個Throwale執行緒監視著該方法的執行,若出現異常,則交由異常邏輯處理。

  我們知道方法是在棧記憶體中執行的,並且會按照“ 先進後出 ”的原則執行,main方法呼叫了doStuff方法,則main方法在下層,doStuff方法在上層,當doStuff方法執行完" return a " 時,此方法的返回值已經確定int型別1(a變數的值,注意基本型別都是拷貝值,而不是引用),此時finally程式碼塊再修改a的值已經與doStuff返回者沒有任何關係了,因此該方法永遠都會返回1.

  繼續追問,那是不是可以在finally程式碼塊中修改引用型別的屬性以達到修改返回值的效果呢?程式碼如下: 

class Person {
    private String name;

    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
}
public static Person doStuffw() {
        Person person = new Person();
        person.setName("張三");
        try {
            return person;
        } catch (Exception e) {    

        } finally {
            // 重新修改一下值
            person.setName("李四");
        }
        person.setName("王五");
        return person;
    }

  此方法的返回值永遠都是name為李四的Person物件,原因是Person是一個引用物件,在try程式碼塊中的返回值是Person物件的地址,finally中再修改那當然會是李四了。

(2)、遮蔽異常

  為什麼明明把異常throw出去了,但main方法卻捕捉不到呢?這是因為異常執行緒在監視到有異常發生時,就會登記當前的異常型別為DataFormatException,但是當執行器執行finally程式碼塊時,則會重新為doStuff方法賦值,也就是告訴呼叫者" 該方法執行正確,沒有產生異常,返回值為1 ",於是乎,異常神奇的消失了,其簡化程式碼如下所示:  

    public static void doSomeThing(){
        try{
            //正常丟擲異常
            throw new RuntimeException();
        }finally{
            //告訴JVM:該方法正常返回
            return;
        }
    }
public static void main(String[] args) {
        try {
            doSomeThing();
        } catch (RuntimeException e) {
            System.out.println("這裡是永遠不會到達的");
        }
    }
    

  上面finally程式碼塊中的return已經告訴JVM:doSomething方法正常執行結束,沒有異常,所以main方法就不可能獲得任何異常資訊了。這樣的程式碼會使可讀性大大降低,讀者很難理解作者的意圖,增加了修改的難度。

  在finally中處理return返回值,程式碼看上去很完美,都符合邏輯,但是執行起來就會產生邏輯錯誤,最重要的一點是finally是用來做異常的收尾處理的,一旦加上了return語句就會讓程式的複雜度徒然上升,而且會產生一些隱蔽性非常高的錯誤。

  與return語句相似,System.exit(0)或RunTime.getRunTime().exit(0)出現在異常程式碼塊中也會產生非常多的錯誤假象,增加程式碼的複雜性,大家有興趣可以自行研究一下。

  注意:不要在finally程式碼塊中出現return語句。

相關文章