本文翻譯自Tim McCune 的《Exception-Handling Anipatterns》
應該拋出一個異常還是應該返回一個null?是拋出checked類型異常還是拋出unchecked類型異常?對於很多中級的開發人員而言,異常處理往往是一件事后才去考慮的事情。他們經常使用的異常處理方式是try/catch/printStackTrace()。當這些開發人員想要嘗試更有新意的異常處理方式時,常常會陷入一些常見的異常處理反模式中。
隨着1998年《反模式:危機中軟件、架構和項目的重構》(原版名為《AntiPatterns: Refactoring Software, Architectures, and Projects in Crisis》)的出版,反模式的概念逐漸在軟件開發群體中流行起來。反模式利用現實的經驗來定義經常發生的編程錯誤。它描述了壞模式的基本形式,定義了這些壞模式可能會帶來的負面影響,規定了補救的方法,並且為每一個常見的壞模式定義了一個名稱。
異常的基本概念
關於異常處理最重要的一個概念是了解在Java中有三種通用的throwable 類:checked異常、unchecked異常以及errors。
Checked異常是那些必須使用throws語句來聲明的異常。它們繼承於Exception類,並且是一種“咄咄逼人”(in your face)的異常。一個checked類型的異常指出了一個預期的會在正常系統運行中產生的問題。舉一些例子,如與外界系統的通信,或者與用戶輸入有關的問題等。要注意的是,根據你的代碼預定義的功能不同,“用戶輸入”指的可能是用戶界面上的輸入,也可能是別人調用你代碼API時傳給你的參數。通常來說,對於一個checked類型異常的正確處理方式是“稍后再試”(try again later),或者提示用戶修改他的輸入。
Unchecked異常是那些不必使用throws語句來聲明的異常。它們繼承於RuntimeException類。一個unchecked異常通常指預期之外發生的問題,而這些問題通常是由於代碼中的bug產生的。最常見的例子就是NullPointerException。在JDK中有很多核心的異常是checked類型的異常,但它們真的不需要被定義成checked型異常,例如IllegalAccessException 和NoSuchMethodException。一個unchecked類型的異常不應該被重試,它的正確處理方式應該是什么都不做然后往上“冒泡”(bubble up),冒出所在的當前方法,並且冒出整個調用棧。(譯者注:函數的一層層調用可以看做是壓棧的行為,在此處作者的意思是應該讓unchecked類型的異常從出錯的位置開始,往上一直冒出整個調用棧,而不做任何處理)這就是為什么unchecked類型的異常不需要聲明在throws語句中的原因。最終,這個異常應該被最高層的調用來記錄(見下文)。
Errors是幾乎完全不可能恢復的嚴重問題。例如,OutOfMemoryError, LinkageError及StackOverflowError。
創建自己的異常類
大多數的軟件包或系統組件應該包含自定義的異常類。有兩種最主要的自定義異常的用法。
一是當有問題發生時簡單的拋出一個自定義異常,如:
throw new MyObjectNotFoundException("Couldn't find |
二是對某個異常進行包裝然后拋出另一個異常,如:
catch (NoSuchMethodException e) { |
包裝一個異常可以通過增加自己的消息來為用戶提供額外的信息(見上述例子),同時保留了原來異常的堆棧跟蹤。(譯者注:如果使用的是直接拋出一個新的異常,那么堆棧就是從拋出的那一刻開始追蹤,之前的異常來來源等信息就沒有了)這種做法也能讓你隱藏自己代碼實現的細節,這是對異常進行包裝的最重要的原因。例如Hibernate API。盡管Hibernate 在自己的實現中大量使用了JDBC,並且它所進行很多操作中都會拋出SQLException,但是Hibernate 並沒有在它的API中泄露任何的SQLException。反而是將這些異常包裝在HibernateException的各種子類中。使用這種方式可以讓你改動模塊的底層代碼的時無需改動模塊的公共API。
異常與事務(Transaction)
EJB 2
EJB 2規范的創建者決定利用checked和unchecked異常之間的差異來判定是否回滾一個活動的事務(active transaction)。如果一個EJB拋出了一個checked異常,那么事務仍然正常提交(commit)。如果一個EJB拋出了一個unchecked異常,那么事務將回滾。通常來說大家都是希望發生exception時,事務回滾的,因此要注意這一點。
EJB3
為了在某種程度上緩解上述提到的回滾的問題,EJB 3為ApplicationException annotation 增加了一個rollback元數。這可以讓你顯示地控制你的異常(不管是checked還是unchecked)是否希望事務回滾。例如:
@ApplicationException(rollback=true) |
消息驅動Bean(Message-Driven Beans)
需要注意的是,當使用隊列驅動的消息驅動Bean時,如果對活動事務進行回滾會讓正在處理的消息回滾到之前所在的消息隊列中。這個消息稍后會被分派到另外的消息驅動Bean上,如果你使用的是服務器集群的話,之后接收消息的消息驅動Bean或許還會在另一台機子上。這種重試會一直持續下去,直到其次數超過應用服務器設定的上限,在這種情況下,消息將會被放入死信隊列(dead letter queue)中。如果你的消息驅動Bean不想做這種重復的處理(比如處理的代價很高、開銷很大時),可以調用消息的getJMSRedelivered()函數,當它被重定向時,只要把這個消息扔掉就可以了。
記錄日志(Logging)
當遇到一個exception時,你的代碼必須處理它,讓它上浮、包裝它或者記錄(log)它。如果代碼中可以以編程的方式處理一個異常(如在網絡連接中進行重試),那么就處理它。如果不能,那么就應該讓它上浮(對於unchecked異常)或包裝它(對於checked異常)。然而,如果在調用棧中沒有任何一處可以以編碼的方式處理這個異常,那么對這個異常進行記錄會最終落到某段代碼的頭上。這段對異常進行記錄的代碼應該盡可能地處於調用鏈的高層。例如MDB(message-driven bean)的onMessage()函數,或一個類中的main函數。當你捕獲到一個異常時,應該對它進行適當地記錄。
盡管有Log4j這個常見的替代者,Java JDK中其實就含有java.util.logging包。另外,Apache 還提供了 Commons Logging 項目,它是很薄的一個軟件層,允許用戶使用插件的方式來替換不同的日志記錄實現方法。上述提到的所有記錄框架都擁有同樣的基本分類層次:
- FATAL:用在極端的情形中,即必須馬上獲得注意的情況。這個程度的錯誤通常需要觸發運維工程師的尋呼機。
- ERROR:顯示一個錯誤,或一個通用的錯誤情況,但還不至於會將系統掛起。這種程度的錯誤一般會觸發郵件的發送,將消息發送到alert list中,運維人員可以在文檔中記錄這個bug並提交。
- WARN:不一定是一個bug,但是有人可能會想要知道這一情況。如果有人在讀log文件,他們通常會希望讀到系統出現的任何警告。
- INFO:用於基本的、高層次的診斷信息。在長時間運行的代碼段開始運行及結束運行時應該產生消息,以便知道現在系統在干什么。但是這樣的信息不宜太過頻繁。
- DEBUG:用於協助低層次的調試。
如果你在使用commons-logging 或Log4j的話,要注意一個陷阱。在一個實現方式上,error,warn,info,和debug 方法需要你提供兩個參數,一個是消息的內容,一個是Throwable對象。如果是想要記錄一個異常被拋出的情況,那么記得要傳遞兩個參數。在另一個實現方式上,只接收一個參數,那么將exception對象傳遞給它,它會隱藏異常的跟蹤堆棧。
當調用log.debug()方法時,一種比較好的習慣是將它放在一個log.isDebugEnabled()檢查塊中。當然,這個建議純粹是為了代碼優化。這是一個值得養成的好習慣。
不要使用System.out 或System.err,而應該使用logger。Logger是可配置、靈活的,並且每一個輸出目的地可以決定本次記錄的嚴重程度(FATAL/ERROR/WARN/INFO/DEBUG)。向System.out打印一個消息是草率的,通常情況下這樣的行為不可原諒。
反模式(antipatterns)
記錄並拋出(log and throw)
例如
catch (NoSuchMethodException e) { |
或者
catch (NoSuchMethodException e) { |
或者
catch (NoSuchMethodException e) { |
這三種方式都是錯誤的。這類方式是最討人厭的錯誤處理反模式。要么記錄一個異常,要么拋出一個異常,但不要同時進行“拋出”和“記錄”兩種操作。同時進行這兩類操作會對同一個問題產生多種log消息,這會給運維人員分析日志帶來麻煩。
拋出異常基類(Throwing Exception)
看下面這個例子:
public void foo() throws Exception {
這樣做是草率的,它完全違背了使用checked異常的目的。它告訴調用你代碼的人“您現在調用的函數可能會出錯哦”,雖然這有一些作用的,但千萬別這么做。應該准確聲明你的方法有可能會拋出的異常的類型。如果要拋出的異常有很多種,那么可以將它們包裝到你定義的自定義異常中。(詳見下文的"Throwing the Kitchen Sink")
Throwing the Kitchen Sink(這個不知道怎么翻譯合適……)
例如:
public void foo() throws MyException, |
拋出多個checked類型的異常是可以的,只要函數調用者能針對不同的異常提供不同的處理方法即可。如果你拋出的幾個checked異常對調用者而已差不多是同樣的性質,那么應該將它們包裝成一類單獨的checked異常。
捕獲異常基類(Catching Exception)
例如:
try { |
這通常是錯誤的和草率的。這種方式下捕獲了原本應該被拋出的異常。捕獲異常基類的問題在於,如果你隨后要調用別的函數,而這個函數含有一個checked類型的異常(函數開發者希望你處理這個特定的checked異常),那么由於你之間捕獲了Exception基類(甚至是Throwable類),那么你或許永遠不知道你的代碼里有本應該處理但卻沒有處理異常,這樣一來你的代碼是錯誤的而你卻無從知曉(IDE不會提示,因為Exception基類被捕獲了)。
破壞性的包裝
例子:
catch (NoSuchMethodException e) { |
這種方式破壞了原本的異常對象e的追蹤堆棧,使用這種包裝方式你將無法追蹤這個異常之前的傳遞路徑。
記錄並拋出Null(Log and Return Null)
例子:
catch (NoSuchMethodException e) { |
或
catch (NoSuchMethodException e) { |
並不是所有情況下這樣處理都是錯的,但通常它是不正確的處理方式。相比於返回null,拋出異常讓該函數的調用者來處理會更好一些。只有在正常的情況下(非異常處理)才應該有返回null這樣的語句出現。例如,當查找的字串不存在時返回null。
捕獲然后忽略(Catch and Ignore)
例子:
catch (NoSuchMethodException e) { |
這種方式是陰險的,它不但不做任何處理而是返回null,並且還吞掉了原本的異常對象,使它喪失了所有的信息!!
在finally中拋出異常
例子:
try { |
如果 cleanUp()不會拋出任何異常,那么這樣寫是沒問題的。在上例中,如果blah()函數拋出了一個異常,然后在finally 語句塊中cleanUp()又拋出一個異常,那么第二個異常將會被拋出,而第一個異常則完全消失了。如果finally 語句塊中調用的函數會拋出異常,那么要么處理它,要么記錄它,千萬不要讓它逃出finally 語句塊的范圍。
一條消息分多行進行記錄(Multi-Line Log Messages)
例子:
LOG.debug("Using cache policy A"); |
不管在那個代碼層次上,都應該嘗試將消息組織到一起,對於上面這個例子,正確的編碼方式是:
LOG.debug("Using cache policy A, using retry policy B");
將統一組的日志記錄到兩個調用語句中,在測試用例的測試下或許看起來沒什么問題。但是在多線程(假設有500個線程)的系統中,信息將噴涌般地被記錄到log文件中,而講一條語句拆做兩條寫可能會讓這兩條語句中間相差十萬八千里,而它們本應該同時輸出的。
本應拋出UnsupportedOperation異常卻拋出null(Unsupported Operation Returning Null)
例子:
public String foo() { |
如果上述代碼是用在一個抽象基類中,用來提供鈎子(hooks)以供子類在重寫的話,那么是可以的。若非如此,則應該拋出一個UnsupportedOperationException 而不是返回一個null。對於方法的調用者而已,如果你拋出了一個UnsupportedOperationException,那么他們會更容易知道自己的方法為什么沒有正常工作。如果你是拋出null的話,函數的調用者可能就會接收到莫名其妙的NullPointerException了。
忽略InterruptedException (Ignoring InterruptedException )
例子:
while (true) { |
InterruptedException 是一個提示,用來告知代碼不管現在在做什么,都停下。一個線程被中斷的情況通常出現在事務處理時間耗盡或線程池被關閉。相比於忽略InterruptedException,代碼中更應該做的是趕快完成現在在做的工作,並結束當前線程。所以,正確的寫法應該是:
while (true) { |
依靠getCause()函數(Relying on getCause())
例子:
catch (MyException e) { |
依賴於getCause()函數的結果會讓你的代碼變得脆弱。如果你調用的函數或者你所依賴的代碼改變了它的底層實現,換了一種異常封裝,而你卻依賴之前的異常類型來進行判斷,怎么辦?其實你本意上是想判斷這個異常最初的根源是什么,也就是cause's cause。現在Apache的 commons-lang提供了一個ExceptionUtils.getRootCause() 方法來輕松獲得異常源。
結論
好的異常處理是搭建具有魯棒性和可靠性系統的關鍵。避免出現上文中提出的反模式可以幫助你搭建一個可維護的、可適應變化的,且能與其他系統共同和諧工作的系統。
參考資料:
· "Best Practices for Exception Handling" 翻譯見【解讀《Best Practices for Exception Handling》】
· "Three Rules for Effective Exception Handling"
· "Handling Errors Using Exceptions" from the Java tutorial
· Antipatternentry on Wikipedia
· Log4j