架構設計 - 異常處理
在開始正題之前,我想爲自己最愛的影片《黑客帝國》寫一段影評,黑客帝國三部曲分別是 Matrix、Reload、Revolution。早期的 Matrix 是用亡羊補牢或對知情者滅口的消極方式加以應對,可是這樣的運轉模式顯然無法長久,問題是會越積越多的,修補系統錯誤帶來的可能是更多的錯誤,必須有一個方法定期對系統進行 “大清洗 “並重新啓動(試想一個充滿異常的服務器,這個服務器從來不停機維護)這就是大家一再提到的 “Matrix 升級 “,但是麻煩的是連接在 Matrix 上的無數人類生命顯然是無法重新啓動的,這時對人類情感世界已經有相當瞭解的 Matrix 找到了一個 “最經濟 “的方案,那就是反過來利用異常來消滅異常,並利用異常創造重新啓動系統的機會,這個完美的計劃就是電影黑客帝國三部曲的全部內容。
我們在設計自己的系統或平臺時,就要考慮如何更加合理的設計異常,接下來我們就一起來看異常的設計。
No.1
異常概述
意外情況的發生阻礙事情的原計劃,比如每天上班,正常情況下家到公司需要 30 分鐘,某天由於在上班的路上發生了交通事故導致上班時間延長了 1 個小時,交通事故就是異常。而在程序中,異常就是程序在運行時期發生的問題,在理想的程序環境中,程序永遠不會出現問題,用戶的輸入永遠都是符合要求,邏輯沒有任何問題,要打開的文件一定存在,類型轉換一定是對的,內存一直是夠用的..... 總之沒有任何問題,但是一旦出現這些描述的反面情況,如果不進行合理的處理,系統就不能正常運行,系統就無法提供正常的服務給用戶。
要處理異常就要直到異常是如何發生的,就必須要知道異常的發生原因,要知道異常發生的原因就必須知道異常發生的場景。在系統中,模塊和模塊之間的交互都可能發生異常,以 JAVA 爲例,在程序中都是利用 Method 和其他模塊進行交互,那麼異常的產生、拋出、聲明和處理都在 Method 中,如下圖就是 java 程序,模塊和模塊之間的交互邏輯。
如圖所示,你寫的方法和外部實體交互大概可以分爲五類:
-
和資源(Resource)交互,見圖⑤處。這裏資源的範圍很廣,比如進程外部的數據庫,文件,SOA 服務,其他各種中間件;進程內的類,方法,線程…… 都算是資源。
-
給進程內的其他方法(User Method)提供服務,見圖②處。
-
依賴進程內的其他方法(Server Method),見圖③處。包括 Java 平臺提供的方法和其他第三方供應方提供的方法。
-
和系統環境交互,見圖⑧處。系統環境可能是直接環境——JVM,也可能是間接環境——操作系統或硬件等。
-
給外部實體提供服務,見圖①處。這種外部實體一般會通過容器(或其他類似的機制)和你的方法進行交互。
Java 方法和每一類實體進行交互時,都可能發生異常。當和資源交互時,常常會因爲資源不可用而發生異常,比如發生找不到文件、數據庫連接錯誤、找不到類、找不到方法…… 等等狀況。有可能是直接產生的,見圖⑤處;有可能是間接產生的,比如圖⑥處發生異常,Server Method 把異常拋給 Your Method,圖③處就間接發生了異常。一般來說,你寫的方法間接發生這類異常的可能性比直接發生要大得多,因爲直接產生這類異常的方法在 Java 平臺中已經提供了。
異常一般具有如下特點:
-
問題產生於外部依賴,自身邏輯和流程沒有問題。
-
此類問題通常是暫時的,服務端及時處理可以消除,用戶可以再次使用系統服務或採取替補方案。
-
並不影響整體流程運行。
當給 Client 端的方法(User Method )提供服務時,用戶可能會傳入一些不合法的數據(或者其他不恰當的使用方法),進而影響正常流程運行。你的方法應該檢查每一個輸入數據,如果發現不合法的數據,馬上阻止執行流程,並通知用戶方法。
當調用服務方法(Server Method )時,有可能會發生兩類異常。一類是你的使用方法不正確,導致服務中止;一類是服務方法出了異常,然後傳遞給你的方法。如果是第一種異常,你應該檢查並修改你的方法邏輯,消除 BUG。對於第二類異常,你要麼寫一個處理器處理,要麼繼續傳遞給上層方法。
當和系統環境交互時,有可能因爲 JVM 參數設置不當,有可能因爲程序產生了大量不必要的對象,也有可能因爲硬故障(操作系統或硬件出了問題),導致整個程序不可用。當這類異常發生時,最終用戶沒法選擇其他替代方案,操作到一半的數據會全部丟失。你的方法對這類異常一般沒什麼辦法,既不能通過修改主流程邏輯來消除,也不能通過增加異常處理器來處理。所以通常你的方法對這類異常不需要做任何處理。但是你必須檢查進程內的所有程序和系統環境是否正常,然後協調各方,修改 BUG 或恢復環境。
Java 的異常都是發生在方法內,所以研究 Java 異常,要以你設計的方法爲中心。我們以 “你的方法” 爲中心,總結一下處理辦法:當服務方法告訴 “你的方法 ” 的主流程邏輯有問題時,就要及時修復 BUG 來消除異常;當用戶方法非法使用 “你的方法” 時,應該直接中止主流程,並通知用戶方法,強迫用戶方法使用正確的方式,防止問題蔓延;當服務方法傳遞一個異常給 “你的方法” 時,你要判斷 “你的方法” 是否合適處理這個異常,如果不合適,傳遞給上層方法,如果合適,寫一個異常處理器處理這個異常。當系統環境出了問題,“你的方法”什麼也做不了。
以上所述,異常有三類:
-
檢查性異常:最具代表的檢查性異常是用戶錯誤或問題引起的異常,這是程序員無法預見的。例如要打開一個不存在文件時,一個異常就發生了,這些異常在編譯時不能被簡單地忽略。
-
運行時異常:運行時異常是可能被程序員避免的異常。與檢查性異常相反,運行時異常可以在編譯時被忽略。
-
錯誤:錯誤不是異常,而是脫離程序員控制的問題。錯誤在代碼中通常被忽略。例如,當棧溢出時,一個錯誤就發生了,它們在編譯也檢查不到的。
剛纔以 “你的方法” 爲中心,總結了在 “你的方法” 內部的處理辦法。現在以 “你” 爲中心,總結一下方法外部的處理方法:當資源不可用的時候,你應該協調各方,恢復資源;當發生系統故障時,你應該協調各方,恢復系統。綜上,已經基本分析清楚了異常發生的原因,以及相應的應對方法。
No.2
異常的好處
上面已經很清楚的闡述了什麼異常,並且知道怎麼使用異常,那麼在程序中使用異常具體有那些好處呢?
好處一
隔離錯誤處理代碼和常規代碼
Exception 提供了一種方法,把意外發生時的細節從程序主邏輯中隔離開來。在傳統的編程中,錯誤的檢測、報告和處理通常會導致像意大利麪條那麼混亂的代碼。
下面通過一組僞代碼來講解。
readFile{
打開文件
確定大小
分配內存
讀入內存
關閉文件
}
讀取文件正常流程是很簡單的,但是真實的運行過程中,有很多異常情況需要考慮:
-
當文件無法打開,應該如何處理?
-
當無法獲取文件的大小,應該如何處理?
-
當服務器內存不足,應該如何處理?
-
當讀取失敗,應該如何處理?
-
當文件無法關閉,應該如何處理?
爲了處理這麼多異常情況,我們的做法有兩種方式,方式一通過錯誤碼來表示每一個異常發生的狀態,方式二是通過異常來表示。
方式一:
errorCodeType readFile {
initialize errorCode = 0;
打開文件
if (theFileIsOpen) {
確定文件長度
if (gotTheFileLength) {
分配指定內存
if (gotEnoughMemory) {
將文件讀入內存
if (readFailed) {
errorCode = -1;
}
} else {
errorCode = -2;
}
} else {
errorCode = -3;
}
關閉文件
if (theFileDidntClose && errorCode == 0) {
errorCode = -4;
} else {
errorCode = errorCode and -4;
}
} else {
errorCode = -5;
}
return errorCode;
}
爲了保證流程正常進行,讀取文件需要檢測錯誤、返回狀態,原本很簡單的代碼,通過各種 if/else 判斷處理,代碼變得很繁瑣,代碼的可讀性變得很糟糕,如果通過第二種方式二,異常代替錯誤碼來處理正常流程,我們看看會發生什麼情況?
示例如下
readFile {
try {
打開文件
確定大小
分配內存
讀入內存
關閉文件
} catch (fileOpenFailed) {
業務處理;
} catch (sizeDeterminationFailed) {
業務處理;
} catch (memoryAllocationFailed) {
業務處理;
} catch (readFailed) {
業務處理;
} catch (fileCloseFailed) {
業務處理;
}
}
從上面的僞代碼可以看出,異常並沒有忽略或者代替你做 readFile 過程中異常情況處理,而是通過異常處理讓你的關注點更多的放在覈心邏輯的處理,並且提高了代碼的可讀性。
好處二
在調用棧中向上傳播錯誤
Exception 的第二個優勢就是,傳播錯誤報告方法調用堆棧的能力。比如在一個應用流程中,readFile 方法是最終被最上層得調用間接依賴,如:method1 調用了 method2,method2 調用了 method3,method3 調用了 readFile 方法,因爲 readFile 有很多異常情況需要處理,但是按照調用層次來看,method1 是最終需要處理 readFile 異常錯誤碼的方法,實現方法也有兩種,方式一:逐層放回錯誤碼,直到 method1 接受到錯誤碼;方式二:通過逐層拋出異常,method1 處理異常,其他層只關心上拋。
方式一:
method1 {
errorCodeType error;
error = call method2;
if (error){
錯誤處理;
}
else{
正常處理;
}
}
errorCodeType method2 {
errorCodeType error;
error = call method3;
if (error){
錯誤處理;
}
else{
正常處理;
}
}
errorCodeType method3 {
errorCodeType error;
error = call readFile;
if (error){
錯誤處理;
}
else{
正常處理;
}
}
上面的僞代碼可以看出,最終只有 method1 關心 readFile 所產生的錯誤,方式一將強制要求每個方法都關心並返回。如果有一種方式只讓關心作物的方法才關心錯誤檢測,中間環節只需要拋出異常是不是會好很多呢?方式二:就是通過調用堆棧向後搜索找到任何有興趣處理特定異常的方法
方式二:
method1 {
try {
method2;
} catch (exception e) {
doErrorProcessing;
}
}
method2 throws exception {
method3;
}
method3 throws exception {
readFile;
}
中間環節不需要關注異常的發生,只有關心異常的方法纔會捕獲異常進行相應的處理。
好處三
分組和區分錯誤類型
因爲程序中拋出的所有異常都是對象,所以異常的分組或分類是類層次結構的自然結果。Java 平臺中的一組相關異常類的一個例子是在 java.io- 及其 IOException 後代中定義的異常類。IOException 是最通用的,表示執行 I / O 時可能發生的任何類型的錯誤。其後代代表更具體的錯誤。例如 FileNotFoundException 意味着文件不在磁盤上未找到
一種方法可以編寫可以處理非常特定異常的特定處理程序。FileNotFoundException 類有沒有後代, 所以下面處理器只能處理一種類型的異常。
catch (FileNotFoundException e) {
...
}
一個方法也可以用更通用的處理器捕獲處理具體的異常。例如,爲了捕獲所有的 I/O 異常,不管具體的類型是什麼,只要給異常處理器指定一個 IOException 參數就行。
catch (IOException e) {
...
}
這個處理器可以捕獲所有的 I/O 異常,包括 FileNotFoundException,EOFException 等等。你可以通過查詢傳給異常處理器的參數,發現錯誤發生的細節。例如,用下面的代碼打印堆棧跟蹤信息:
catch (IOException e) {
// Output goes to System.err.
e.printStackTrace();
// Send trace to stdout.
e.printStackTrace(System.out);
}
你甚至可以構建一個可以處理所有異常的異常處理器:
// A (too) general exception handler
catch (Exception e) {
...
}
在大多數情況下,你希望異常處理器越具體越好。理由是在你決定最佳的恢復策略之前,你首先要知道錯誤的類型。事實上,如果不捕獲具體的錯誤,這個處理器就必須要容納任何可能性。太通用的異常處理器可能會讓代碼更容易出錯,因爲它們會捕獲和處理程序員意料之外的異常,這樣就超出處理器的能力範圍了。
No.3
J2EE 核心語言中的異常
Java 把異常當做是破壞正常流程的一個事件,當事件發生後,就會觸發處理機制。
Java 有一套獨立的異常處理機制,在遇到異常時,方法並不返回任何值(返回值屬於正常流程),而是拋出一個封裝了錯誤信息的對象。下圖是 Java 異常處理機制類層級結構圖:
在 Java 中,所有的異常都有一個共同的祖先 Throwable(可拋出)。Throwable 指定代碼中可用異常傳播機制通過 Java 應用程序傳輸的任何問題的共性。Throwable:有兩個重要的子類:Exception(異常)和 Error(錯誤),二者都是 Java 異常處理的重要子類,各自都包含大量子類。Error(錯誤): 是程序無法處理的錯誤,表示運行應用程序中較嚴重問題。大多數錯誤與代碼編寫者執行的操作無關,而表示代碼運行時 JVM(Java 虛擬機)出現的問題。例如,Java 虛擬機運行錯誤(Virtual MachineError),當 JVM 不再有繼續執行操作所需的內存資源時,將出現 OutOfMemoryError。這些異常發生時,Java 虛擬機(JVM)一般會選擇線程終止。這些錯誤表示故障發生於虛擬機自身、或者發生在虛擬機試圖執行應用時,如 Java 虛擬機運行錯誤(Virtual MachineError)、類定義錯誤(NoClassDefFoundError)等。這些錯誤是不可查的,因爲它們在應用程序的控制和處理能力之 外,而且絕大多數是程序運行時不允許出現的狀況。對於設計合理的應用程序來說,即使確實發生了錯誤,本質上也不應該試圖去處理它所引起的異常狀況。在 Java 中,錯誤通過 Error 的子類描述。
Exception(異常): 是程序本身可以處理的異常。Exception 類有一個重要的子類 RuntimeException。RuntimeException 類及其子類表示 “JVM 常用操作” 引發的錯誤。例如,若試圖使用空值對象引用、除數爲零或數組越界,則分別引發運行時異常(NullPointerException、ArithmeticException)和 ArrayIndexOutOfBoundException。
通常,Java 的異常 (包括 Exception 和 Error) 分爲可查的異常(checked exceptions)和不可查的異常(unchecked exceptions)。
運行時異常:都是 RuntimeException 類及其子類異常,如 NullPointerException(空指針異常)、IndexOutOfBoundsException(下標越界異常) 等,這些異常是不檢查異常,程序中可以選擇捕獲處理,也可以不處理。這些異常一般是由程序邏輯錯誤引起的,程序應該從邏輯角度儘可能避免這類異常的發生。運行時異常的特點是 Java 編譯器不會檢查它,也就是說,當程序中可能出現這類異常,即使沒有用 try-catch 語句捕獲它,也沒有用 throws 子句聲明拋出它,也會編譯通過。
非運行時異常 (編譯異常):是 RuntimeException 以外的異常,類型上都屬於 Exception 類及其子類。從程序語法角度講是必須進行處理的異常,如果不處理,程序就不能編譯通過。如 IOException、SQLException 等以及用戶自定義的 Exception 異常,一般情況下不自定義檢查異常。
JVM 字節碼分析異常處理機制
Java 異常處理的一般性建議
01
try-catch-finally 規則
-
必須在 try 之後添加 catch 或 finally 塊。try 塊後可同時接 catch 和 finally 塊,但至少有一個塊。
-
必須遵循塊順序:若代碼同時使用 catch 和 finally 塊,則必須將 catch 塊放在 try 塊之後。
-
catch 塊與相應的異常類的類型相關。
-
一個 try 塊可能有多個 catch 塊。若如此,則執行第一個匹配塊。即 Java 虛擬機會把實際拋出的異常對象依次和各個 catch 代碼塊聲明的異常類型匹配,如果異常對象爲某個異常類型或其子類的實例,就執行這個 catch 代碼塊,不會再執行其他的 catch 代碼塊
-
可嵌套 try-catch-finally 結構。
-
在 try-catch-finally 結構中,可重新拋出異常。
-
除了下列情況,總將執行 finally 做爲結束:JVM 過早終止(調用 System.exit(int));在 finally 塊中拋出一個未處理的異常;計算機斷電、失火、或遭遇病毒攻擊。
02
Throws 拋出異常的規則
-
如果是不可查異常(unchecked exception),即 Error、RuntimeException 或它們的子類,那麼可以不使用 throws 關鍵字來聲明要拋出的異常,編譯仍能順利通過,但在運行時會被系統拋出。
-
必須聲明方法可拋出的任何可查異常(checked exception)。即如果一個方法可能出現受可查異常,要麼用 try-catch 語句捕獲,要麼用 throws 子句聲明將它拋出,否則會導致編譯錯誤
-
僅當拋出了異常,該方法的調用者才必須處理或者重新拋出該異常。當方法的調用者無力處理該異常的時候,應該繼續拋出,而不是囫圇吞棗。
-
調用方法必須遵循任何可查異常的處理和聲明規則。若覆蓋一個方法,則不能聲明與覆蓋方法不同的異常。聲明的任何異常必須是被覆蓋方法所聲明異常的同類或子類。
No.4
異常處理和設計
下面介紹異常處理和設計注意的一些點:
1
使用異常,而不使用返回碼
關於這一點,在上面『異常的好處』有解釋。理解了這一點,程序員們纔會想要使用 Java 異常處理機制。
2
利用運行時異常設定方法使用規則
很常見的例子就是,某個方法的參數不能爲空。在實踐中,很多程序員的處理方式是,當傳入的這個參數爲空的時候,就返回一個特殊值(最常見的就是返回一個 null,讓用戶方法決定怎麼辦)。還有的處理方式是,自己給一個默認值去兼容這種不合法參數,自己決定怎麼辦。這兩種實踐都是不好的。
對於第一種處理方式,返回值是用來處理正常流程的,如果用來處理異常流程,就會讓用戶方法的正常流程變複雜。一次調用可能不明顯,當有多個連續調用就會變得很複雜了。對於第二種處理方式,看起來很強大,因爲 “容錯” 能力看起來很強,有些程序員甚至可能會爲此沾沾自喜。但是它也一樣讓正常流程變複雜了,這不是最糟糕的,最糟糕的是,你不知道下一次用戶會出什麼鬼點子,傳個你現有處理代碼處理不了的東西進來。這樣你又得加代碼,繼續變複雜……BUG 就是這樣產生的。
好的實踐方式就是,設定方法的使用規則,遇到不合法的使用方式時,立刻拋出一個運行時異常。這樣既不會讓主流程代碼變複雜,也不會製造不必要的 BUG。爲什麼是運行時異常而不是檢查異常呢?這是爲了強迫用戶修改代碼或者改正使用方式——這屬於用戶的使用錯誤。
3
消除運行時異常
當你的程序發生運行時異常,通常都是因爲你使用別人的方法的方式不正確(如果設計這個異常的人設計錯誤,就另當別論。比如設計者捕獲一個檢查異常,然後在處理器拋出一個運行時異常給用戶。如果遇上這樣的供應商,還是棄用吧)。所以,一般都是採取修改代碼的方式,而不是新增一個異常流程。
4
正確處理檢查異常
處理檢查異常的時候,處理器一定要做到下面的要求才算合格:
-
返回到一種安全狀態,並能夠讓用戶執行一些其他的命令;
-
允許用戶保存所有操作的結果,並以適當的方式終止程序。
不好的實踐案例一:因爲有的異常發生的概率很小,有些程序員就會寫出下面的代碼:
public Image loadImage(String s) {
try {
code...
} catch (Exception e)
{}
code2...
}
catch 代碼塊裏面什麼都不寫!或者只在裏面打一個 log。這樣既不會傳遞到上層方法,又不會報編譯錯誤,還不用動腦筋……
不好的實踐案例二:捕獲一個檢查異常,什麼都不做(或只打一個 log),然後拋出一個運行時異常:
public Image loadImage(String s) {
try {
code...
} catch (Exception e){
throw new RuntimeExcepiton();
}
}
這樣也不會讓上層方法感覺到這個異常的存在,也不會報編譯錯誤了,也不用動什麼腦筋……
在案例一中,一旦出現了異常,try 代碼塊裏的代碼沒執行完,用戶要求做的事情沒做完,卻又沒有任何反饋或者得到一個錯誤反饋。
在案例二中,一旦出現了異常,try 代碼塊裏的代碼沒執行完,雖然把運行時異常拋給用戶了,用戶也不會去處理這個異常,又沒有辦法通過改變使用方式消除異常,直接讓用戶代碼崩潰掉。
對於檢查異常,好的實踐方式是:
-
讓可以處理這個異常的方法去處理。衡量的標準就是在你這個方法寫一個處理器,這個處理器能不能做到本節開頭的那兩個要求,如果不能,就往上拋。如果你不能知道所有用戶的所有需求,你通常就做不到那兩個要求。
-
有必要的時候可以通過鏈式異常包裝一下,再拋出。
-
最終的處理器一定要做到本節開頭的那兩個要求。
5
使主流程代碼保持整潔
一個 try 代碼塊後面可以跟多個 catch 代碼塊,這就讓一些可能會發生不同異常的代碼可以寫在一塊,讓代碼看起來很清晰。相反,在一個方法裏寫多個 try-catch,或者寫嵌套的 try-catch,就會讓主流程代碼變得很混亂。
6
使用 try-with-resources
try-with-resources 語句比起普通的 try 語句,乾淨整潔的多。而且最終拋出的異常是正常流程中拋出的異常。
7
儘量處理最具體的異常
儘量使用最具體的異常類作爲處理器匹配的類型。這樣處理器就不用兼顧很多種情形,不易出錯。從 Java7 開始,一個處理器可以處理多種異常類型。
注意:同一個 try 語句中,比較具體的異常的 catch 代碼塊應寫在前面,比較通用的異常的 catch 代碼塊應寫在後面。
8
設計自己的異常類型要遵循的原則
當你是一個模塊開發者,你就很有必要設計一組或多組自己的異常類型。一般情況下,要遵守如下原則:
-
確定什麼場景下,需要創建自己的異常類型。
-
爲你的接口方法的使用規則創建一組運行時異常。
-
封裝別人的檢查異常的時候,一定也要用檢查異常。這樣異常才能傳遞給上層方法處理。
-
設計一組有層次結構的異常,而不是設計一堆零零散散的異常。
-
區分清楚異常發生的原因,然後決定你的異常是檢查異常還是運行時異常。
-
模塊內部不需要處理自己定義的異常。
總結
Java 異常處理機制的目的至少有三個:一是歸類處理不同的異常,二是提供足夠的信息方便調試,三是讓主流程代碼保持整潔。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/VJ6BwJa4lHZt-eXm7wGoeg