原文出處:http://dev2dev.bea.com/pub/a/2006/11/effective-exceptions.html

摘要

  Java開發人員做出的有關架構的最重要的決定之一便是如何使用Java異常模型。Java異常處理成為社區中討論最多的話題之一。一些人認為 Java語言中的已檢查異常(Checked Exceptions)是一次失敗的嘗試。本文認為錯誤并不在于Java模型本身,而在于Java庫設計人員沒有認識到方法失敗的兩個基本原因。本文提倡 思考異常情況的本質,并描述了有助于用戶設計的設計模式。最后,本文討論了異常處理在面向方面編程(Aspect Oriented Programming)模型中作為橫切關注點(crosscutting concern)的情況。如果使用得當,Java異常將對程序開發人員大有裨益。本文將幫助讀者正確使用Java異常。

為什么異常非常重要

  Java應用程序中的異常處理可以告訴用戶構建應用程序的架構強度。架構是指在應用程序的各個層面上所做出的并始終遵守的決策。其中最重要的決 策之一便是應用程序中類、子系統或層之間進行互相通信的方式。方法通過Java異常可以為操作傳遞另一種結果,因此應用程序架構特別值得我們去關注。

   判斷Java架構師技能的高低和開發團隊是否訓練有素,其中比較好的方法是查看應用程序中的異常處理代碼。首先需要觀察的是有多少代碼專門用于捕捉異 常、記錄異常、確定發生的事件和異常轉化。簡潔、緊湊和有條理的異常處理表明團隊有使用Java異常的一致方法。當異常處理代碼的數量將要超過其他方面的 代碼時,可以斷定團隊成員之間的溝通已經打破(或者這種溝通從一開始就不存在),每個人都用自己的方法來處理異常。

  臨時異常處理的結果 非常具有預見性。如果問團隊成員為什么在代碼的某個特定點丟棄、捕捉、或忽略某個異常,回答通常是:“除此之外,我不知道怎么做。”如果問他們在編寫代碼 的異常實際發生時會產生什么情況,他們通常會皺眉,回答類似于:“我不知道。我們從來沒有測試過。”

  要判斷Java組件是否有效地利用 了Java異常,可以查看其客戶端的代碼。如果客戶端代碼中包含大量計算操作失敗時間、原因和處理方法的邏輯,原因幾乎都是由于組件的錯誤報告設計。有缺 陷的報告會在客戶端產生大量的“記錄和遺留”(log and forget)代碼,而沒有任何用途。最糟糕的是扭曲的邏輯路徑、互相嵌套的try/catch/finally程序塊,以及其他導致脆弱和無法管理的應 用程序的混亂。

  事后處理異常(或者根本不處理)是造成軟件項目混亂和延遲的主要原因。異常處理關系到軟件設計的各個方面。為異常建立架構約定應該是項目中首先要做出的決定之一。合理使用Java異常模型將對保持應用程序的簡潔性、可維護性和正確性大有幫助。

挑戰異常準則

  如何正確使用Java異常模型已經成為大量討論的主題。Java并不是支持異常語義的第一種語言;但是,通過Java編譯器可強制使用規則來控 制如何聲明和處理特定的異常。許多人認為編譯時異常檢查對精確軟件設計有幫助,它與其他語言特征能夠很好地協調起來。圖1表明了Java異常的層次結構。

   通常,Java編譯器會根據java.lang.Throwable強制方法拋出異常,包括其聲明中“throw”子句的異常。同樣,編譯器會驗證方法 的客戶端是捕獲聲明異常類型還是指定自己拋出異常類型。這些簡單的規則對于全世界的Java開發人員產生了深遠的影響。

  編譯器針對 Throwable繼承樹的兩個分支放寬了異常檢查行為。java.lang.Error和java.lang.RuntimeException的子類 免于編譯時檢查。在兩者中,軟件設計人員通常對運行時異常更感興趣。通常使用術語“未檢查異常”(unchecked exception)來區分其他的所有“已檢查異常”(checked exception)

圖 1 Java異常層次結構

我認為已檢查異常會受到那些重視Java語言中強類型的人的歡迎。畢竟,由編譯器對數據類型產生的約束會鼓勵嚴格編碼和精確思維。編譯時類型檢查有 助于防止在運行時產生難以應付的意外事件。編譯時異常檢查將起到相同的效果,提醒開發人員注意的是,方法會有潛在的其他結果需要進行處理。

   在早期,推薦無論何處都盡量使用已檢查異常,以便充分借助編譯器生產出無差錯軟件。Java API庫的設計人員顯然贊成已檢查異常準則,并廣泛使用這些異常來模仿在庫方法中發生的任何意外事件。在J2SE 5.1 API規范中,已檢查異常類型仍然比未檢查異常類型多,比例要超過二比一。

  對于程序員來說,Java類庫中的絕大多數公共方法好像為每一個可能的失敗都聲明了已檢查異常。例如,java.io包對已檢查異常IOException的依賴性特別大。至少63個Java庫包直接或間接通過其數十個子類之一發布了此異常。

   I/O失敗很嚴重但也很少見。另外,程序代碼通常沒有能力從失敗中恢復。Java程序員發現他們必須提供IOException和類似在簡單的Java 庫方法調用時可能發生的不可恢復的事件。捕捉這些異常只會打亂原有簡潔的代碼,因為捕捉塊并不能改善此類情況。而不捕捉這些異常可能會更糟糕,因為編譯器 要求將這些異常加入到方法所拋出的異常列表中。這公開了一些實現細節,優秀的面向對象設計自然會將這些細節隱藏起來。

  這種無法成功的情況導致許多嚴重違反Java編程模式的異常處理。當今的程序員經常被告誡要提防這些情況。同樣,在創建工作區方面也產生大量正確和錯誤的建議。

  一些Java天才開始質疑Java已檢查異常模型是否是一次失敗的嘗試。可以確定某個地方出了問題,但是這和Java語言中的異常檢查無關。失敗的原因是Java API設計人員的思考方式,即他們認為大多數失敗情況都相同,并且可以用相同類型的異常來傳達。

錯誤和意外事件

  假想金融應用軟件中的CheckingAccount類。CheckingAccount屬于客戶,維護當前余額,并且可以接受存款,根據支票 接受終止支付命令,以及處理入帳的支票。CheckingAccount對象必須協調并發線程的訪問,因為每一個線程都可以改變它的狀態。 CheckingAccount的processCheck()方法將Check對象作為參數,從賬戶余額中正常扣除支票金額。但是調用 processCheck()的支票結算客戶端必須為兩類意外事件做好準備。首先,CheckingAccount 可能有為支票注冊的終止支付命令。第二,賬戶中可能沒有足夠的資金來支付支票金額。

  所以,processCheck()方法使用三種可 能的方式來響應其調用者。正常的響應方式是支票得到處理,在方法簽名中聲明的結果返回給調用服務。這兩類意外事件響應代表了金融領域非常真實的情況,它們 需要與支票結算客戶端進行通信。所有這三種processCheck()響應都是為模仿典型的支票賬戶行為而精心設計的。

  在Java中 表示意外事件響應的通常方法是定義兩種異常,即StopPaymentException和InsufficientFundsException。客戶 端忽略這兩個異常是不正確的,因為在應用程序正常操作時會被拋出這兩個異常。這兩個異常有助于表達方法的所有行為,和方法簽名一樣十分重要。

  客戶端可以輕松地處理這兩類異常。如果終止支票的支付,客戶端可以取得支票進行特殊處理。如果沒有足夠的資金,為支付此支票,客戶端將從客戶的儲蓄帳戶中轉移資金,并重新嘗試。

  這些意外事件可以預見,它是使用CheckingAccount API的自然結果。它們并不表示軟件或執行環境的失敗。將這些異常條件與實際的失敗對比,實際的失敗是由于CheckingAccount類的內部實現細節問題造成的。

   假設CheckingAccount在數據庫中維持持久的狀態并使用JDBC API進行訪問。在該API中,幾乎每一個數據庫訪問方法都有失敗的可能性,但原因與CheckingAccount實現無關。例如,有人會忘記打開數據 庫服務器、不小心拔下了網線,或改變了訪問數據庫的密碼。

  JDBC依靠單獨的已檢查異常SQLException來報告一切可能的錯 誤。大多數錯誤都與數據庫的配置、連接和硬件設備有關。processCheck()方法并不能以有意義的方式處理這些異常條件。很遺憾,因為 processCheck()至少知道它自己的實現方式。調用堆棧中的上游方法能夠處理這些問題的可能性會更小。

  CheckingAccount示例解釋了導致方法執行不能返回預期結果的兩個基本原因。下面首先介紹一些描述性術語:

意外事件

   是一種可以預見的情況,要求方法做出某種響應,以便能夠表達方法所期望的目的。方法的調用者預見這些情況并采取策略應付這些情況。

錯誤

   是一種計劃外的情況,它阻止方法達到其預期目的,并且如果不引用方法的內部實現,則無法描述這種情況。

  從這兩個術語來看,終止支付命令和透支是processCheck()方法兩個可能的意外事件。SQL問題代表了可能的錯誤異常條件。processCheck()的調用者應當提供一種處理意外事件的方式,但當錯誤發生時,并不能合理地處理該錯誤。

映射Java異常

  對于意外事件和錯誤,思考其原因將有助于為應用程序架構中的Java異常建立約定。

異常條件 意外事件 錯誤
認為是(Is considered to be) 設計的一部分 難以應付的意外
預期發生(Is expected to happen) 有規律但很少發生 從不
誰來處理(Who cares about it) 調用方法的上游代碼 需要修復此問題的人員
實例(Examples) 另一種返回模式 編程缺陷,硬件故障,配置錯誤,文件丟失,服務器無法使用
最佳映射(Best Mapping) 已檢查異常 未檢查異常

  意外事件異常條件完美地映射到Java已檢查異常。由于它們是方法語義契約中不可或缺的一部分,因此必須借助編譯器來確保問題得到了處理。如果 開發人員堅持在編譯器有問題時處理或聲明意外事件異常,此時編譯器會成為一種阻礙,可以斷定此軟件設計必須進行部分重構。這其實是一件好事。

   錯誤條件對編程人員來說能夠引起關注,而對于軟件邏輯卻并非如此。“軟件診斷學家”收集錯誤信息以診斷和修復引起錯誤發生的根源。因此,未檢查Java 異常是錯誤的完美表現方式,它們可以使錯誤通知完整地過濾調用堆棧上的所有方法,傳遞到專門用于捕捉錯誤的層,捕獲其中所包含的診斷信息,并為此活動提供 一份受約束的合理結論。錯誤產生方法并不需要聲明,上游代碼也不需要捕獲它們,方法的實現得到了有效的隱藏——產生最少的代碼混亂。

  較 新的Java API(比如Spring Framework和Java Data Object庫)很少或根本不依賴于已檢查異常。Hibernate ORM framework從release 3.0起重新定義了關鍵設備,以免于使用已檢查異常。這反映了由這些框架報告的絕大部分異常異常條件是不可恢復的,這些異常源于方法調用的不正確編碼或數 據庫服務器失效等基本組件原因。實際上,強制調用者去捕捉或聲明這樣異常幾乎沒有任何益處。

架構中的錯誤處理

   在架構中有效處理錯誤的第一步是承認處理錯誤的必要性。承認這一點對于工程師來說有困難,因為他們認為自己有能力創造無缺陷的軟件,并引以為豪。下面這些 理由可能有所幫助。首先,應用程序開發會在常見錯誤上花費大量的時間。提供程序員產生的錯誤將使團隊診斷和修復這些錯誤變得十分簡單。第二,對于錯誤異常 條件過度使用Java庫中的已檢查異常將強制代碼來處理這些錯誤,即使調用順序完全正確。如果沒有適當的錯誤處理框架,由此產生的暫時異常處理將向應用程 序中插入平均信息量。

  成功的錯誤處理框架必須達到四個目標:

  • 使代碼混亂最小化
  • 捕捉并保留診斷信息
  • 通知合適的人員
  • 比較得體地退出活動

  錯誤會分散應用程序的真正目的。因此,用于錯誤處理的代碼數量應當最小化,并在理想情況下,應與程序的語義部分隔離。錯誤處理必須滿足糾錯人員 的需要。他們需要知道是否發生錯誤并且獲取相關信息以判斷錯誤原因。盡管從定義上說,錯誤不可恢復,但可靠的錯誤處理措施將以得體地方式終結出現錯誤的活 動。

 

對于錯誤異常條件使用未檢查異常

   有許多原因使我們做出使用未檢查異常表示錯誤異常條件的架構性決定。作為對編程錯誤的回報,Java運行時將拋出RuntimeException的子類 (比如ArithmeticException和ClassCastException),針對架構設定先例。未檢查異常使上游方法擺脫了包含無關代碼的 要求,從而最大限度地減少了混亂。

  錯誤處理策略應當承認Java庫和其他API中的方法可能使用已檢查異常來表示應用程序環境下的錯誤異常條件。在這種情況下,采用架構慣例在其出現的地方捕捉API異常,將它作為錯誤,并拋出未檢查異常來說明錯誤異常條件并捕捉診斷信息。

   在這種情況下拋出的特定異常類型應當由架構本身定義。不要忘記錯誤異常的主要目的是傳達診斷信息并記錄,以幫助開發人員發現問題產生的原因。使用多錯誤 異常類型可能有些過度,因為架構會對它們進行完全相同的處理。在絕大多數情況下,把良好的描述性文本消息嵌入到單獨的錯誤類型中,便可完成此項工作。使用 Java的一般RuntimeException來表示錯誤條件很容易進行防御。從Java 1.4時起,RuntimeException同其他throwable類一樣,支持異常處理鏈式機制,允許設計人員捕捉并報告導致錯誤的已檢查異常。

   設計人員可以定義自己的未檢查異常進行報告錯誤。如果需要使用不支持異常鏈接機制的Java 1.3或更早版本,這一步是必需的。實現相似的鏈接功能去捕捉并轉換引起應用程序錯誤的異常相當簡單。在錯誤報告異常中,應用程序可能需要特別的行為。這 可能是為架構創建RuntimeException子類的另一個原因。

建立錯誤屏障

   決定哪些異常要拋出以及何時拋出將成為錯誤處理框架的重要決定。同樣重要的問題是何時捕捉錯誤異常及其后如何做。這里的目標是使應用程序的功能部分從處理錯誤的職責中分離出來。關注點分離通常是比較好的做法。負責處理錯誤的中央設備將為您帶來很多的好處。

   在錯誤屏障(fault barrier)模式下,任何應用程序組件都可以拋出錯誤異常,但只有作為“錯誤屏障”的組件才可以捕捉到錯誤異常。開發人員為了處理錯誤問題在應用程序 中插入了大量復雜代碼,而采用此模式可消除大部分此類代碼。從邏輯上講,錯誤屏障存在于靠近調用堆棧的頂端。在這里,它可以阻斷異常向上傳播,以避免觸發 默認動作。默認動作根據應用程序類型的不同而不同。對于獨立的Java應用程序來說,默認動作意味著終止活動線程。對于駐留在應用服務器上的Web應用程 序,默認動作意味著應用服務器會向瀏覽器發送不友好的(且令人為難的)響應。

  錯誤屏障組件的首要職責是記錄包含在錯誤異常中的信息,以 便進行下一步行動。應用程序日志是迄今為止做這件事情最理想的方法。異常的鏈信息、堆棧跟蹤等對于診斷專家來說都是有價值的信息。發送錯誤信息最差的地方 是通過用戶界面。將客戶牽涉到應用程序的排錯過程中,將對開發人員或客戶沒有任何益處。如果開發人員真的把診斷信息添加到了用戶界面上,這說明開發人員的 記錄策略需要改進。

  錯誤屏障的下一個職責是以受控方式停止操作。這個職責的含義由應用程序的設計決定,但是通常會涉及到為等待響應的客 戶端發出總體響應。如果應用程序是Web service,這意味著使用soap:Server的和普通失敗消息將 SOAP 元素嵌入到響應中。如果應用程序與Web瀏覽器進行通信,屏障將安排發送普通的HTML響應,表示無法處理此請求。

   在Struts應用程序中,錯誤屏障采用全局異常處理程序的形式,配置成可以處理RuntimeException的任何子類。錯誤屏障類將擴展 org.apache.struts.action.ExceptionHandler,在需要實現自定義處理時重寫方法。這將處理由于疏忽產生的錯誤條 件和處理Struts操作中明顯發現的錯誤條件。圖2顯示了意外事件異常和錯誤異常。

圖2 意外事件異常和錯誤異常

如果開發人員正在使用Spring MVC框架,簡單地擴展SimpleMappingExceptionResolver并進行配置使其能處理RuntimeExceptio及其子類,便 能建立起錯誤屏障。通過重寫resolveException()方法,在使用超類方法向發送普通錯誤顯示的查看組件發出請求之前,開發人員可以添加任何 自定義處理。

  當架構包含錯誤屏障并且開發人員也意識到了它的存在時,編寫一勞永逸的錯誤異常處理代碼的吸引力急劇下降。結果是在應用程序中產生更簡潔和更易維護的代碼。

架構中的意外事件處理

  隨著錯誤處理委托給屏障,主要組件之間的意外事件通信變得更加簡單。意外事件代表了另一種方法結果,此結果與主要返回結果同樣重要。因此,已檢 查異常類型是傳遞意外事件條件存在性并提供對付異常條件所需信息的良好工具。最佳實踐是借助Java編譯器來提醒開發人員他們正在使用API的所有方面, 同樣需要提供方法結果的全部范圍。

  通過單獨使用方法的返回類型,可以傳遞簡單的意外事件。例如,返回null引用而非實際對象可以說明 此對象由于明確的原因而無法創建。Java I/O方法通常返回整數值-1,而不是字節值或字節計數,用來表明文件的結束。如果方法的語義非常簡單允許這樣做,另一種返回值可以使用這種方式,因為它 們消除了由異常而帶來的開銷。不利方面是方法調用者負責檢測返回值,來查看它是主要結果還是意外事件結果。然而,編譯器并不強制調用者做這樣的測試。

   如果方法具有void返回類型,異常將是表明意外事件發生的唯一方法。如果方法返回對象引用,則返回值所表達的意思僅限于兩個值(null和non- null)。如果方法返回整數值,通過選擇確保與主要返回值不相沖沖突的值,就可以表達數個意外事件條件。但是現在已經進入了錯誤代碼檢查的范疇,這是 Java異常模型需要避免的情況。

提供有用的信息

   定義不同的錯誤報告異常類型沒有任何道理,因為錯誤屏障會對它們進行同樣的處理。意外事件異常差異很大,因為它們會向方法調用者傳達各種條件。您的架構可能明確指定這些異常都應該擴展java.lang.Exception或指定的基類。

   不要忘記這些異常是完整的Java類型,可以調整特定的字段、方法以及為特殊目的而構建的構造函數。例如,假想的CheckingAccount processCheck()方法拋出的InsufficientFundsException類型可能包括OverdraftProtection對 象,此對象能夠轉移資金以彌補另一個賬戶的資金短缺,此賬戶的身份取決于設置核算賬戶的方式。

記錄還是不記錄

   記錄錯誤異常有實際意義是因為它們的目的是吸引開發人員去注意需要糾正的情況。但這并不適用于意外事件異常。它們可能代表相對少見的事件,但是在應用程序 的生命周期內,這些意外事件依然會發生。它們表明了如發生異常應用程序將按照其設計意圖進行工作。按照慣例,在意外事件捕捉模塊中加入記錄代碼只會增加混 亂代碼而沒有任何益處。如果意外事件表示重要事件,最好為方法產生一條記錄項,用于在拋出意外事件異常并通知其調用者之前記錄此事件。

異常方面

  在面向方面編程(Aspect Oriented Programming (AOP))中,錯誤和意外事件的處理是橫切關注點。例如,要實現錯誤屏障模式,所有參與的類都必須遵守公共約定:

  • 錯誤屏障方法必須駐留在遍歷參與類的方法調用的頭部。
  • 它們都必須使用未檢查異常來表示錯誤條件。
  • 它們都必須使用特定的未檢查異常類型,以便錯誤屏障能夠接收到。
  • 它們都必須從較低層方法中捕捉并轉換已檢查異常,這些異常在它們的執行環境中被視為錯誤。
  • 它們不能干擾錯誤異常到屏障的傳播。

  這些關注點跨越了其他不相關類的邊界。結果產生了少量分散錯誤處理代碼并致使屏障類與參與者之間的隱式耦合(盡管對于完全沒有使用模式來說是一 次重大改進)。AOP允許將錯誤處理關注點封裝到應用于參與類的公共Aspect中。Java AOP框架(比如AspectJ和Spring AOP)把異常處理作為聯接點,錯誤處理行為(或建議)能夠附加到上面。這樣,在錯誤屏障模式中綁定參與者的慣例就有所放寬。錯誤處理現在可以存在于獨立 的非內聯方面(out-of-line aspect)中,避免了將“屏障”方法置于方法調用序列的前面。

  如果開發人員在架構中使用AOP,錯誤和意外事件的處理是方面在整個應用程序中應用的理想候選對象。完全探究錯誤和意外事件處理在AOP中如何運作,這是個令人感興趣的話題,留作以后討論。

結束語

  盡管Java異常模型在其生命周期內已經引發了激烈的爭論,但是當Java異常模型運用得當時,將會帶來巨大的價值。作為架構師,應當決定如何 建立最大限度利用模型的慣例。思考一下錯誤和意外事件異常能夠幫助開發人員做出正確的選擇。Java異常模型使用得當,將保持應用程序的簡潔性、可維護性 和正確性。把面向方面編程技術的錯誤和意外事件處理作為橫切關注點,可為應用程序的架構帶來某些明顯的優勢。