概要
Java 提供了一個豐富的異常處理架構,但是許多程式員發現:跳過這個豐富的異常處理架構只使用類屬Exceptions要容易得多。本文探討了產生、捕捉和忽視類屬Exceptions的風險,並為處理複雜軟體項目內的一般的複雜異常建議了最好的處理方法。
在最近的一個項目中,有一塊代碼實現原始碼的清除。因為它有許多不同的調用,很有可能會產生六個不同的異常。原來的程式員在試圖簡化代碼(或者儲存鍵入程式)之後宣告:該程式還不止產生6個不同的異常。這就使得代碼調用必須封裝在一個可以捕捉Exception 的try/catch 塊中。該程式員確定:因為該代碼用於清除原始碼,失敗並不重要,所以catch 塊為空白,就好像系統關閉了一樣。
很明顯,這不是最好的編程解決方案,但是好像也看不出有什麼大的錯誤,除了在原始碼的第三行有一個小小的邏輯問題:
Listing 1. Original cleanup code private void cleanupConnections() throws ExceptionOne, ExceptionTwo { for (int i = 0; i < connections.length; i++) { connection[i].release(); // Throws ExceptionOne, ExceptionTwo connection[i] = null; } connections = null;}protected abstract void cleanupFiles() throws ExceptionThree, ExceptionFour;protected abstract void removeListeners() throws ExceptionFive, ExceptionSix;public void cleanupEverything() throws Exception { cleanupConnections(); cleanupFiles(); removeListeners();}public void done() { try { doStuff(); cleanupEverything(); doMoreStuff(); } catch (Exception e) {}} |
在代碼的另一個部分,connections 排列沒有初始化,直到第一個串連建立為止。但是如果串連沒有創見,那麼串連排列則為null 。所以在某些情況下,對connections[i].release()的調用將產生NullPointerException。這也是一個相對容易的解決方案。只要添加一個對connections != null的檢查。
但是,並沒有報告異常。cleanupConnections()產生一個異常, cleanupEverything()又產生一個異常,最後異常被done()捕捉到。done()方法對一場不做任何處理,甚至也不記錄一下。因為 只能通過done()調用cleanupEverything() ,所以看不到異常。因此代碼也沒有得到解決。
因此,在這個失敗案例中,沒有調用cleanupFiles()和removeListeners() 方法(所以它們的資源沒有釋放),也沒有調用doMoreStuff(),這樣done()中的最終處理沒有完成。更糟糕的是,系統關閉時沒有調用done();相反的,程式只調用done()來完成每個事務。所以每個事務中的資源都漏掉了。
這個問題無疑是一個主要的問題:錯誤沒有報告,資源遺漏。但是代碼本身似乎毫無問題,並且從代碼的編寫方式看,這個問題難於回溯。但是,應用幾個簡單的指導方針,就可以發現並解決問題:
· 不忽視異常
· 不捕捉類屬Exception
· 不產生類屬Exception
不忽視異常
Listing 1的代碼中最明顯的一個問題是:程式中的錯誤完全被忽視。產生了非預期的異常(異常本質上就是非預期的),而帶抹布準備處理該異常。一場甚至沒有報告因為代碼假設:預期的異常不會出現。
在多數情況下,異常至少應該記錄。幾個記錄包(見補充欄的"Logging Exceptions") 可以記錄系統錯誤和異常,而且絲毫不影響系統的效能。大多數的記錄系統也允許列印堆棧路徑,以便提供關於異常出現的地點和原因的有價值的資訊。最後,因為記錄通常都寫入檔案內,我們可以回顧和分析異常的紀錄。要查看記錄堆棧路徑的範例,見補充欄的Listing 11 。
記錄異常在某些特殊情況下並不重要。如在finally 子句就是這樣。
finally 子句中的異常
在 Listing 2中,有些資料是從檔案中讀取的。無論異常是否讀取資料檔案都需要關閉,所以close()方法可封裝在finally子句中。但是如果異常關閉了檔案,我們也拿它沒辦法:
Listing 2 public void loadFile(String fileName) throws IOException { InputStream in = null; try { in = new FileInputStream(fileName); readSomeData(in); } finally { if (in != null) { try { in.close(); } catch(IOException ioe) { // Ignored } } }} |
注意loadFile()仍然報告IOException給調用方法,只要實際資料裝載失敗的原因在於 I/O (輸入/輸出)的問題。也請注意:即使忽視close()的異常,代碼仍然會使用注釋將它明確的標出來,以便讓從事該代碼的人都清楚這件事。你可以應用同樣的程式來清除所有的I/O 資訊流、關閉外掛程式和JDBC串連等等。
忽視異常的一個重要原因是保證只有一個方法被包裹在忽視的try/catch塊內(這樣封裝塊內的其他方法仍然可以調用)而且只捕捉到某個特殊的異常。這種特殊情況本質上區別於類屬Exception的捕捉。 在其他情況下,一場應該(至少)記錄,最好是使用堆棧路徑記錄。
不捕捉類屬Exception
通常在一個複雜的軟體內,給定的原始碼執行的方法將產生一系列異常。動態裝載類和初始化對象將產生幾種不同的異常,包括ClassNotFoundException、InstantiationException, IllegalAccessException和ClassCastException。
忙碌的程式員往往不添加四個不同的catch塊到try 塊上,而是將方法調用包裹在可以捕捉類屬Exception的try/catch塊內 (如下Listing 3所示)。儘管這看起來沒什麼不好,但是還是會產生一些意想不到的負面影響。例如:如果 className()為 null, Class.forName()將會給出NullPointerException,這個異常可以捕捉到。
在這種情況下,塊將會捕捉它並不想捕捉的異常,因為NullPointerException 是RuntimeException 的子集,而RuntimeException相應的又是 Exception的子集。這樣類屬catch (Exception e)將捕捉RuntimeException的所有子集,包括NullPointerException、 IndexOutOfBoundsException、和ArrayStoreException。而實際上,程式員並不願意捕捉那些異常的。
在Listing 3,null className 將導致NullPointerException,它指出調用方法的類名無效:
Listing 3 public SomeInterface buildInstance(String className) { SomeInterface impl = null; try { Class clazz = Class.forName(className); impl = (SomeInterface)clazz.newInstance(); } catch (Exception e) { log.error("Error creating class: " + className); } return impl;} |
類屬catch 子句的另一個後果是記錄受到限制因為catch 並不知道被捕捉的特殊異常。有些程式員在面對這種問題的時候,總是添加一個查看異常類型的檢查(如Listing 4),這是與使用catch塊的初衷相違背的:
Listing 4 catch (Exception e) { if (e instanceof ClassNotFoundException) { log.error("Invalid class name: " + className + ", " + e.toString()); } else { log.error("Cannot create class: " + className + ", " + e.toString()); } } |
Listing 5提供了一個特例,可能有些程式員會感興趣。他不需要 instanceof操作器因為它可以捕捉到特殊異常。每一個檢查過的異常(ClassNotFoundException、 InstantiationException、 IllegalAccessException)都被捕捉並得到處理。產生ClassCastException (類完全裝載,但是並不執行SomeInterface 介面)的特殊情況也可以通過檢查該異常得以校正。
Listing 5 public SomeInterface buildInstance(String className) { SomeInterface impl = null; try { Class clazz = Class.forName(className); impl = (SomeInterface)clazz.newInstance(); } catch (ClassNotFoundException e) { log.error("Invalid class name: " + className + ", " + e.toString()); } catch (InstantiationException e) { log.error("Cannot create class: " + className + ", " + e.toString()); } catch (IllegalAccessException e) { log.error("Cannot create class: " + className + ", " + e.toString()); } catch (ClassCastException e) { log.error("Invalid class type, " + className + " does not implement " + SomeInterface.class.getName()); } return impl;} |
在某些情況下,再次給出已知異常(或者建立新異常)比在方法中處理該異常更可取。這就使得調用方法可通過將異常放入一個已知的上下文中來處理異常。
以下的Listing 6提供了buildInterface()方法的備選版本,在裝載和初始化類的過程中如果出現問題它就會給出 ClassNotFoundException。在本例中,調用方法確實收到一個恰當的初始化的對象或者異常。因此,調用方法不需要檢查返回對象是否為空白。
注意:此例使用了Java 1.4 方法:建立一個新的異常,它包裹在另一個異常之內,以便儲存原始的堆棧路徑資訊。否則,堆棧路徑顯示方法buildInstance()建立了異常,而實際上應該是newInstance()產生了潛在的異常:
Listing 6 public SomeInterface buildInstance(String className) throws ClassNotFoundException { try { Class clazz = Class.forName(className); return (SomeInterface)clazz.newInstance(); } catch (ClassNotFoundException e) { log.error("Invalid class name: " + className + ", " + e.toString()); throw e; } catch (InstantiationException e) { throw new ClassNotFoundException("Cannot create class: " + className, e); } catch (IllegalAccessException e) { throw new ClassNotFoundException("Cannot create class: " + className, e); } catch (ClassCastException e) { throw new ClassNotFoundException(className + " does not implement " + SomeInterface.class.getName(), e); }} |
在某些情況下,代碼可恢複某些錯誤條件。在這種情況下,捕捉特殊異常很重要,這樣代碼才能計算出條件是否可以恢複。運用這種思想分析一下Listing 6 中的類範例。
在Listing 7中,代碼返回一個用於無效className 的預設對象,但是給出了一個用於非法操作的異常,像無效資料類型轉換或者安全違背。
注意: IllegalClassException 是一個域異常類,這裡提到它只是為了示範。
Listing 7 public SomeInterface buildInstance(String className) throws IllegalClassException { SomeInterface impl = null; try { Class clazz = Class.forName(className); return (SomeInterface)clazz.newInstance(); } catch (ClassNotFoundException e) { log.warn("Invalid class name: " + className + ", using default"); } catch (InstantiationException e) { log.warn("Invalid class name: " + className + ", using default"); } catch (IllegalAccessException e) { throw new IllegalClassException("Cannot create class: " + className, e); } catch (ClassCastException e) { throw new IllegalClassException(className + " does not implement " + SomeInterface.class.getName(), e); } if (impl == null) { impl = new DefaultImplemantation(); } return impl;} |
何時應該捕捉類屬Exception
當捕捉類屬Exception很方便而且有必要的時候就可以捕捉類屬Exception。這些情況都是很特殊的,但是它對於大型的容錯系統來說很重要。在 Listing 8中,請求總是依次從請求隊列中讀取和處理。但是如果請求處理過程中出現任何異常的話(要麼是BadRequestException 要麼是RuntimeException的任何一個子類,包括NullPointerException),那麼迴圈時在處理之外可捕捉到該異常。所以任何錯誤都可使得處理迴圈停止,而且不能執行任何儲存命令。這就表示請求處理過程中處理錯誤的方式不好:
Listing 8 public void processAllRequests() { Request req = null; try { while (true) { req = getNextRequest(); if (req != null) { processRequest(req); // throws BadRequestException } else { // Request queue is empty, must be done break; } } } catch (BadRequestException e) { log.error("Invalid request: " + req, e); }} |
我們可以採用一個更好的方式來完成請求處理:對邏輯做兩個完美的改變,如Listing 9。首先,將try/catch 快移到請求處理迴圈內。這樣,處理迴圈內的任何錯誤都可捕捉到並得到處理,而且它們也不會導致迴圈破裂。因此,迴圈繼續處理請求,即使單個請求處理失敗也沒關係。其次,修改try/catch塊,使他捕捉類屬Exception,這樣任何異常都可在迴圈內捕捉到而且請求也可以繼續進行:
Listing 9 public void processAllRequests() { while (true) { Request req = null; try { req = getNextRequest(); if (req != null) { processRequest(req); // Throws BadRequestException } else { // Request queue is empty, must be done break; } } catch (Exception e) { log.error("Error processing request: " + req, e); } }} |
捕捉類屬Exception聽起來好像違背了這部分開始時所說的原則——的確這樣。但是具體情況具體分析。在此例中,捕捉類屬Exception是為了防止單個異常造成整個系統停止。在請求、事務或者事件在迴圈內處理的情況下,迴圈需要繼續進行,即使處理過程中出現異常也要進行。
在Listing 9中,處理迴圈中的try/catch塊可看作最進階別異常處理器,這個最進階別異常處理器需要捕捉和記錄出現在該層級的代碼上的任何異常。這樣,一場就不會被忽視和丟失,但是異常並不打斷需要處理的請求的剩餘部分。
每一個大型的複雜的系統都有一個最進階別的異常處理器(也許每個子系統一個,取決於系統執行處理的方式)。最進階別的異常處理器無意解決產生異常的根本原因,但是它應該不必停止處理就能夠捕捉和記錄問題。這並不是說所有的異常都應該在這個層級給出。任何可在更低層級處理的異常,也就是說,當問題出現時哪裡的邏輯瞭解更多的條件,就在哪裡處理異常。但是如果異常在較低層級不能得到處理的話,繼續前進並一路上給出異常。這樣,所有那些不可恢複的錯誤只會在一個地方(最進階別的異常處理器)得到處理,而不是貫穿整個系統。
不給出類屬Exception
Listing 1 中給出的整個問題在程式員決定給出來自cleanupEverything()方法的類屬Exception時就開始了。當方法給出六種不同的異常時代碼變得相當混亂:方法的宣言變得不可讀,調用方法被迫捕捉六種不同的異常,見Listing 10:
Listing 10 public void cleanupEverything() throws ExceptionOne, ExceptionTwo, ExceptionThree, ExceptionFour, ExceptionFive, ExceptionSix { cleanupConnections(); cleanupFiles(); removeListeners();}public void done() { try { doStuff(); cleanupEverything(); doMoreStuff(); } catch (ExceptionOne e1) { // Log e1 } catch (ExceptionTwo e2) { // Log e2 } catch (ExceptionThree e3) { // Log e3 } catch (ExceptionFour e4) { // Log e4 } catch (ExceptionFive e5) { // Log e5 } catch (ExceptionSix e6) { // Log e6 }} |
但是即使代碼有點淩亂,它至少還是清楚的。使用特殊的異常可避免幾個非常現實的問題:給出類屬Exception隱藏了根本問題的細節,從而沒有機會來處理真正的問題。而且,給出類屬Exception強迫到該方法的任何調用要麼捕捉類屬 Exception (捕捉類屬Exception 有問題,如前面討論的一樣)要麼通過重新給出類屬Exception傳播問題。
一般來說,當方法宣布它正給出類屬Exception ,它無非是下面兩個原因中的一個:一種情況是方法調用了幾種其他的可能給出許多不同異常的方法(像Mediator 或者Façade 設計模式)並且隱藏了異常條件的細節。該方法不是建立和給出域層級的異常(用來包裹低層級的異常),它只是簡單的宣布它要給出異常而且不管結果。另一種情況是方法例示並給出了類屬Exception (throw new Exception()) 因為程式員根本就沒有思考要使用何種異常才能真正的表達他所處的情況。
這兩種情況都只要稍微思考設計一下就可以解決:詳細的域層級的異常真正應該如何給出? 這個設計包括簡單的宣布方法要給出可能實際存在的異常。另一個方法是建立一個域層級的異常來包裹給出的異常並且宣布一下。在大多數情況下,方法給出的異常(或者異常組)應該儘可能的詳細。詳細的異常可提供有關錯誤條件的更多資訊從而允許錯誤情況得到處理或者至少是得到詳細記錄。
類屬Exception類為 checked exception,意思是對宣布它給出Exception 的方法的任何調用必須要麼宣布它自己給出Exception ,要麼包裹方法調用在能夠捕捉類屬Exception 的try/catch 塊內。我前面已經解釋過與該方法有關的問題。
謹慎使用類屬Exception
本文探討了處理類屬Exception 的幾個方面:它們不應該給出而且它們也不能被忽視,它們應該很少(只有在特殊情況下)被捕捉。它們不提供允許你有效處理它們的詳細資料,也不提供允許你不再捕捉你不想捕捉的異常的詳細資料。
異常是Java的一個強大的組成部分,只要使用正確,就能提高程式員的效率並縮短你的開發週期,特別是在測試和調試階段。如果異常使用不正確,它們就會與你作對:隱藏你系統內的問題。注意你使用類屬Exception的位置和方式。