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設計人員的思考方式,即他們認爲大多數失敗情況都相同,並且可以用相同類型的異常來傳達。
異常條件 | 意外事件 | 錯誤 |
認爲是(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的<faultcode>和普通<faultstring>失敗消息將 SOAP <fault>元素嵌入到響應中。如果應用程序與Web瀏覽器進行通信,屏障將安排發送普通的HTML響應,表示無法處理此請求。</fault></faultstring></faultcode>
在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異常模型使用得當,將保持應用程序的簡潔性、可維護性 和正確性。把面向方面編程技術的錯誤和意外事件處理作爲橫切關注點,可爲應用程序的架構帶來某些明顯的優勢