異常處理反模式

snake_hand發表於2013-04-08

本文翻譯自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, LinkageErrorStackOverflowError

 

建立自己的異常類

大多數的軟體包或系統元件應該包含自定義的異常類。有兩種最主要的自定義異常的用法。

一是當有問題發生時簡單的丟擲一個自定義異常,如:

throw new MyObjectNotFoundException("Couldn't find
    object id " + id);

 

二是對某個異常進行包裝然後丟擲另一個異常,如:

catch (NoSuchMethodException e) {
  throw new MyServiceException("Couldn't process
      request", e);
}

包裝一個異常可以通過增加自己的訊息來為使用者提供額外的資訊(見上述例子),同時保留了原來異常的堆疊跟蹤。(譯者注:如果使用的是直接丟擲一個新的異常,那麼堆疊就是從丟擲的那一刻開始追蹤,之前的異常來來源等資訊就沒有了)這種做法也能讓你隱藏自己程式碼實現的細節,這是對異常進行包裝的最重要的原因。例如Hibernate API。儘管Hibernate 在自己的實現中大量使用了JDBC,並且它所進行很多操作中都會丟擲SQLException,但是Hibernate 並沒有在它的API中洩露任何的SQLException。反而是將這些異常包裝在HibernateException的各種子類中。使用這種方式可以讓你改動模組的底層程式碼的時無需改動模組的公共API。

 

異常與事務(Transaction

EJB 2

EJB 2規範的建立者決定利用checkedunchecked異常之間的差異來判定是否回滾一個活動的事務(active transaction)。如果一個EJB丟擲了一個checked異常,那麼事務仍然正常提交(commit)。如果一個EJB丟擲了一個unchecked異常,那麼事務將回滾。通常來說大家都是希望發生exception時,事務回滾的,因此要注意這一點。

EJB3

為了在某種程度上緩解上述提到的回滾的問題,EJB 3ApplicationException annotation 增加了一個rollback元數。這可以讓你顯示地控制你的異常(不管是checked還是unchecked)是否希望事務回滾。例如:

@ApplicationException(rollback=true)
public class FooException extends Exception
...

訊息驅動BeanMessage-Driven Beans

需要注意的是,當使用佇列驅動的訊息驅動Bean時,如果對活動事務進行回滾會讓正在處理的訊息回滾到之前所在的訊息佇列中。這個訊息稍後會被分派到另外的訊息驅動Bean上,如果你使用的是伺服器叢集的話,之後接收訊息的訊息驅動Bean或許還會在另一臺機子上。這種重試會一直持續下去,直到其次數超過應用伺服器設定的上限,在這種情況下,訊息將會被放入死信佇列(dead letter queue)中。如果你的訊息驅動Bean不想做這種重複的處理(比如處理的代價很高、開銷很大時),可以呼叫訊息的getJMSRedelivered()函式,當它被重定向時,只要把這個訊息扔掉就可以了。

 

記錄日誌(Logging

當遇到一個exception時,你的程式碼必須處理它,讓它上浮、包裝它或者記錄(log)它。如果程式碼中可以以程式設計的方式處理一個異常(如在網路連線中進行重試),那麼就處理它。如果不能,那麼就應該讓它上浮(對於unchecked異常)或包裝它(對於checked異常)。然而,如果在呼叫棧中沒有任何一處可以以編碼的方式處理這個異常,那麼對這個異常進行記錄會最終落到某段程式碼的頭上。這段對異常進行記錄的程式碼應該儘可能地處於呼叫鏈的高層。例如MDBmessage-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) {
  LOG.error("Blah", e);
  throw e;
}

或者

catch (NoSuchMethodException e) {
  LOG.error("Blah", e);
  throw new MyServiceException("Blah", e);
}

或者

catch (NoSuchMethodException e) {
  e.printStackTrace();
  throw new MyServiceException("Blah", e);
}

這三種方式都是錯誤的。這類方式是最討人厭的錯誤處理反模式。要麼記錄一個異常,要麼丟擲一個異常,但不要同時進行“丟擲”和“記錄”兩種操作。同時進行這兩類操作會對同一個問題產生多種log訊息,這會給運維人員分析日誌帶來麻煩。

丟擲異常基類(Throwing Exception

看下面這個例子:

public void foo() throws Exception {

這樣做是草率的,它完全違背了使用checked異常的目的。它告訴呼叫你程式碼的人“您現在呼叫的函式可能會出錯哦”,雖然這有一些作用的,但千萬別這麼做。應該準確宣告你的方法有可能會丟擲的異常的型別。如果要丟擲的異常有很多種,那麼可以將它們包裝到你定義的自定義異常中。(詳見下文的"Throwing the Kitchen Sink"

Throwing the Kitchen Sink(這個不知道怎麼翻譯合適……)

例如:

public void foo() throws MyException,
    AnotherException, SomeOtherException,
    YetAnotherException
{

丟擲多個checked型別的異常是可以的,只要函式呼叫者能針對不同的異常提供不同的處理方法即可。如果你丟擲的幾個checked異常對呼叫者而已差不多是同樣的性質,那麼應該將它們包裝成一類單獨的checked異常。

捕獲異常基類(Catching Exception

例如:

try {
  foo();
} catch (Exception e) {
  LOG.error("Foo failed", e);
}

這通常是錯誤的和草率的。這種方式下捕獲了原本應該被丟擲的異常。捕獲異常基類的問題在於,如果你隨後要呼叫別的函式,而這個函式含有一個checked型別的異常(函式開發者希望你處理這個特定的checked異常),那麼由於你之間捕獲了Exception基類(甚至是Throwable類),那麼你或許永遠不知道你的程式碼裡有本應該處理但卻沒有處理異常,這樣一來你的程式碼是錯誤的而你卻無從知曉(IDE不會提示,因為Exception基類被捕獲了)。

破壞性的包裝

例子:

catch (NoSuchMethodException e) {
  throw new MyServiceException("Blah: " +
      e.getMessage());
}

這種方式破壞了原本的異常物件e的追蹤堆疊,使用這種包裝方式你將無法追蹤這個異常之前的傳遞路徑。

記錄並丟擲NullLog and Return Null

例子:

catch (NoSuchMethodException e) {
  LOG.error("Blah", e);
  return null;
}

catch (NoSuchMethodException e) {
  e.printStackTrace();
  return null;
}  // Man I hate this one

並不是所有情況下這樣處理都是錯的,但通常它是不正確的處理方式。相比於返回null,丟擲異常讓該函式的呼叫者來處理會更好一些。只有在正常的情況下(非異常處理)才應該有返回null這樣的語句出現。例如,當查詢的字串不存在時返回null

捕獲然後忽略(Catch and Ignore

例子:

catch (NoSuchMethodException e) {
  return null;
}

這種方式是陰險的,它不但不做任何處理而是返回null,並且還吞掉了原本的異常物件,使它喪失了所有的資訊!!

finally中丟擲異常

例子:

try {
  blah();
} finally {
  cleanUp();
}

如果 cleanUp()不會丟擲任何異常,那麼這樣寫是沒問題的。在上例中,如果blah()函式丟擲了一個異常,然後在finally 語句塊中cleanUp()又丟擲一個異常,那麼第二個異常將會被丟擲,而第一個異常則完全消失了。如果finally 語句塊中呼叫的函式會丟擲異常,那麼要麼處理它,要麼記錄它,千萬不要讓它逃出finally 語句塊的範圍。

一條訊息分多行進行記錄(Multi-Line Log Messages

例子:

LOG.debug("Using cache policy A");
LOG.debug("Using retry policy B");

不管在那個程式碼層次上,都應該嘗試將訊息組織到一起,對於上面這個例子,正確的編碼方式是:

LOG.debug("Using cache policy A, using retry policy B");

將統一組的日誌記錄到兩個呼叫語句中,在測試用例的測試下或許看起來沒什麼問題。但是在多執行緒(假設有500個執行緒)的系統中,資訊將噴湧般地被記錄到log檔案中,而講一條語句拆做兩條寫可能會讓這兩條語句中間相差十萬八千里,而它們本應該同時輸出的。

本應丟擲UnsupportedOperation異常卻丟擲nullUnsupported Operation Returning Null

例子:

public String foo() {
  // Not supported in this implementation.
  return null;
}

如果上述程式碼是用在一個抽象基類中,用來提供鉤子(hooks)以供子類在重寫的話,那麼是可以的。若非如此,則應該丟擲一個UnsupportedOperationException 而不是返回一個null。對於方法的呼叫者而已,如果你丟擲了一個UnsupportedOperationException,那麼他們會更容易知道自己的方法為什麼沒有正常工作。如果你是丟擲null的話,函式的呼叫者可能就會接收到莫名其妙的NullPointerException了。

忽略InterruptedException Ignoring InterruptedException 

例子:

while (true) {
  try {
    Thread.sleep(100000);
  } catch (InterruptedException e) {}
  doSomethingCool();
}

InterruptedException 是一個提示,用來告知程式碼不管現在在做什麼,都停下。一個執行緒被中斷的情況通常出現在事務處理時間耗盡或執行緒池被關閉。相比於忽略InterruptedException,程式碼中更應該做的是趕快完成現在在做的工作,並結束當前執行緒。所以,正確的寫法應該是:

while (true) {
  try {
    Thread.sleep(100000);
  } catch (InterruptedException e) {
    break;
  }
  doSomethingCool();
}

依靠getCause()函式(Relying on getCause()

例子:

catch (MyException e) {
  if (e.getCause() instanceof FooException) {
    ...

依賴於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

· Commons Logging

· EJB specifications