簡介:契約式設計(Design by Contract)是切實可行的技術,可以闡明組 件 設計的細節、為客戶記錄正確的組件用法,並用編程的方式測試組件使用的順應 性(compliance)。在 AOP@Work 的最後一篇中,Dean Wampler 介紹 了 Contract4J,這是契約式設計的工具,它用 Java ™ 5 標注 (annotation)指定合約,並在運行時用 AspectJ 方面計算合約。在成為 AOP 工具包中新增的一個舉足輕重的工具的同時,Contract4J 迎合了面向方面設計 的 新趨勢。
假設您剛剛加入一個構建銀行應用程序的項目。在浏覽代碼時,您發現(已 經 簡化的)BankAccount 的下面這個接口:
interface BankAccount {
float getBalance();
float deposit(float amount);
float withdraw(float amount);
...
}
上面這個接口雖然簡潔,但遺留了許多問題沒有回答。deposit() 或 withdraw() 的 amount 參數可以是負數或零麼?允許負余額(透支)麼?如果 指 定了錯誤的 amount,deposit() 或 withdrawal() 中會發生什麼情況呢?
顯然,對於該接口的實現者和使用公開該接口的組件的人來說,能夠回答這 些 問題是重要的。一種隱式地指定行為的方法是使用以 JUnit(請參閱 參考資料 ) 編寫的單元測試。使用 JUnit 測試,可以用各種合法和不合法的參數調用這些 方 法,並作出有關預期結果行為發生的斷言。另一種方法是契約式設計,這是闡明 組件設計細節的一項切實可行的技術。
在 AOP@Work 系列的最後這篇文章中,我將介紹 Contract4J,這是一個基於 AspectJ 的工具,支持契約式設計。我將介紹如何用 Contract4J 隱式地指定組 件行為,為用戶記錄組件的正確用法,並用編程的方式測試組件使用的順應性。 在這篇文章最後,我將討論 Contract4J 如何迎合面向方面設計中正在出現的趨 勢。
契約式設計概述
使用契約式設計,可以用可編程表達式指定對於組件輸入和返回結果的要求 。 在開發人員和 QA 測試期間,對表達式進行計算,如果測試失敗,程序執行立即 終止。程序的終止帶有有用的診斷信息,迫使開發人員立即修復 bug。
強制立即終止看起來可能有點麻煩。為什麼要放過錯誤消息還繼續運行呢? 雖 然繼續運行看起來可能比較有生產效率,但實際上不是的。首先,如果沒被強制 要求立即處理 bug,就會推遲修復 bug,這樣 bug 就會累積。其次,失敗的測 試 應當代表發生了意料之外的事(例如,引用為空),正常的執行不能繼續。雖然 可以放入 “意外處理” 代碼,但是這反而可能會把實現復雜化,出現永遠不會 發生的情況,從而增加代碼的復雜性和更多 bug 的風險。
指定組件行為
契約式設計是一種發現和修復代碼中邏輯錯誤的工具。它並不解決其他諸如 性 能、用戶輸入之類的問題。契約式設計使用三類測試來指定和確保組件的行為:
對組件輸入(例如傳遞給方法的參數)的測試叫做前置條件測試。它們指定 組 件執行請求的操作之前需要滿足的條件。客戶必須滿足這些要求才能使用組件。
後置條件測試 確保組件完成操作的時候結果符合要求,假設前置條件已經滿 足。後置條件測試通常用方法返回值的斷言來表示。
最後,不變條件測試 斷言永遠不變的條件。類在方法調用之前和之後必須保 持不變(一旦對象已經構建)。方法在調用之前和之後必須保持不變;字段則在 對象的整個生命周期中保持不變。
注意,在生產部署時可以關閉契約式設計測試,以消除它們的開銷。
單元測試和契約式設計
契約式設計比起單元測試有些優勢,但是這兩種方法是互補的。契約式設計 的 一個主要優勢是,它在接口或類本身當中提供了關於預期行為的顯式信息。對於 組件開發人員和客戶來說,結果實際上就是一種能夠用編程方式進行測試的文檔 。契約式設計也做了顯式的合約定義,而在單元測試中這些更隱式。我喜歡交替 使用這兩種技術,特別是在使用難以進行單元測試的技術(例如 EJB2 bean)時 。很快就會看到,Contract4J 添加了強迫使用約束的特性,這比 Junit 測試中 隱式的非正式文檔有相當大的優勢。
Contract4J 簡介
Contract4J 是一個開源的開發人員工具,它用 Java 5 標注(請參閱 參考 資 料)實現契約式設計。在幕後,它用方面在應當執行測試的程序連接點處(例如 ,對方法的調用)插入 “建議”,它還對這些測試的失敗進行處理,即終止程 序 執行。
再來看 BankAccount 接口,但是這次使用 Contract4J 標注。注意,我用黑 體把原來的代碼突出,並且對有些字符串作了換行,以便更加清晰:
清單 1. 使用 Contract4J 標注的 BankAccount
@Contract
@Invar("$this.balance > = 0.0")
interface BankAccount {
@Post("$return >= 0.0")
float getBalance ();
@Pre("amount >= 0.0")
@Post("$this.balance == $old ($this.balance)+amount
&& $return == $this.balance")
float deposit(float amount);
@Pre("amount >= 0.0 &&
$this.balance -- amount >= 0.0")
@Post("$this.balance == $old($this.balance)-amount
&& $return == $this.balance")
float withdraw(float amount);
...
}
表 1 定義了清單 1 中看到的關鍵字:
表 1. Contract4J 關鍵字示例
關鍵字 定義 $this 要測試的對象 $target 目前僅用於字段不變測試的字段(可以用 $this.field_name 來引用 字段)。未來的使用可能會把 $target 擴展到其他上下文。 $return 方法返回的對象(或基本的值)。只在後置條件測試中有效。 $args[n] 傳遞給方法的第 n 個 參數,從 0 開始計數。也可以用名稱引用參 數 。 $old 括號中的內容的 “舊” 值(在實際執行連接點之前)。只在不變條 件測試和後置條件測試中有效。因為 Java 不要求所有的類都支持 “克隆”, 所 以 Contract4J 無法知道是否可克隆特定對象。所以,$old(...) 中的表達式應 當只包含基本的值或不會改變的對象。否則 “舊” 值在連接點執行的時候可能 會變化,從而產生意料之外的結果。示例表達式包含 $old("$this.userName") 和 $old("$this.calcMin(x,y)")。Contract4J 文檔詳細描述了允許的表達式。BankAccount 合約
根據前一節學到的內容,清單 1 中的標注應當不再神秘。@Contract 標注表 示擁有合約規范的接口(或類)。@Pre、@Post 和 @Invar 標注分別定義前置條 件測試、後置條件測試和不變條件測試。還會注意到清單 1 中的測試是定義成 字 符串的 Java 表達式,結果為 true 或 false。
如果遺漏了測試表達式,Contract4J 會根據上下文使用合理的默認設置。例 如,字段的默認不變條件要求字段不能為空。類似地,默認的方法前置條件要求 所有非基本的輸入參數不能為空,默認的方法後置條件要求返回值不能為空。
BankAccount 的接口規范包含一個類范圍的不變條件測試,即余額總要大於 或 等於 0(對不起,不允許透支)。getBalance() 方法有一個後置條件,即它必 須 返回大於或等於 0 的值。注意,雖然接口不能定義字段,但不變條件測試和其 他 測試引用了一個隱含的余額字段。不過,因為接口定義了對應的 JavaBean 存取 器方法,所以 Contract4J 推導出字段的存在。注意,getBalance() 的後置條 件 測試看起來可能與類的不變條件測試重復,但它只是部分地測試方法會返回余額 這個假設。
合約還有幾個前置條件,要求客戶傳遞給 withdraw() 和 deposit() 的參數 大於或等於 0。withdraw() 有額外的前置條件,要求取款的數額不能超過現有 余 額。最後,withdraw() 和 deposit() 有相似的後置條件要求;返回的值必須等 於新余額,新余額必須等於舊余額減去或加上輸入的數額。
通用提示
Contract4J 發行版中的 README(請參閱 下面)更詳細地討論了它的語法, 包括已知的限制和特性。發行版中 Contract4J 自己的單元測試提供了有效和無 效的測試表達式的豐富示例。
也可以在類或方面上編寫合約測試,在這些類或方面中可以在構造函數上定 義 測試,在實例字段上定義不變條件。(上面的類不變條件實際上就是一個字段不 變條件的規范!)方法和構造函數也可以有不變條件測試。
由於正如組件用戶和子類所看到的,合約會影響離散的執行點,所以 Contract4J 把 public、protected 和包可見的方法當成 “原子的”。這意味 著 可以在方法中臨時地違反測試,只要在方法完成的時候滿足條件即可。注意,對 於帶有測試的其他方法或字段的調用也會觸發這些測試,所以要有一些特殊情況 的例外,以防止方面代碼中的無限遞歸之類的問題。而且,Contract4J 目前不 允 許在 private 方法上定義測試,因為外部客戶看不到這些方法。對靜態方法的 測 試也不支持,因為它們不影響對象的狀態。但是,在未來的版本中可能會消除這 兩個 “理論上的” 限制。
最後,字段的不變條件測試只是在讀寫字段之後才進行,以便允許惰性計算 。 類似地,字段的不變條件測試從不在對象構造期間進行,但是它們會在構造完成 之後進行。
Contract4J 的替代
編寫契約式設計測試,實際上可以不需要 Contract4J。只要編寫自己的方面 (就像在本系列前一篇文章中討論的那樣;請參閱 參考資料)即可。 Contract4J 的優勢是沒有 AspectJ 經驗的開發人員也能使用它;只需要對構建過程做簡單 的 修改即可,下面我將討論這個問題。Contract4J 還提供了非常簡潔的定義測試 的 方式,采用熟悉的 Java 構造,不必定義許多額外的 AspectJ “樣板文件”。 合 約不僅可以執行,還是用戶可見的代碼、文檔和信息的組成部分,而如果是在分 散的方面中捕獲到的,這些信息則會很模糊。
正如前面提到過的,單元測試和契約式設計用不同的方式實現類似的目標。 像 Contract4J 這樣的契約式設計工具在單元測試比較分散或比較困難的時候最 有幫助。集成和內置測試,有助於捕獲這些經常被更低級的測試忽略的模糊的集 成問題。不論是否使用 Contract4J 進行單元測試,考慮組件的合約都會改進設 計。
Contract4J
在運行時,Contract4J 使用內置的方面建議應該在其中執行測試的連接點。 這些方面中的切入點查找合適的標注。前置條件測試由 before 建議處理,該建 議就在對應的方法執行連接點之前執行。before 建議使用 Apache Jakarta Commons JEXL 解釋器把測試字符串中的特殊關鍵字轉換成合適的對象,並計算 生成的表達式,返回 true(通過)或 false(失敗)。如果測試失敗,就報告 錯誤消息,指出故障點,同時程序執行中斷。
例如,在 清單 1 中,如果調用 withdraw(),那麼就在執行方法之前, Contract4J 會用 amount 的輸入值計算表達式 amount >= 0。例如,如果 amount = -1.0,那麼測試失敗,就會報告出帶有堆棧信息、指出故障位置的報 告,並且應用程序退出。
同樣,後置條件測試大致與 after 建議對應。但是,為了支持 $old 關鍵字 ,實際上使用的是 around 建議,在該建議中,計算 $old 關鍵字中的 “子表 達式”,保存結果,執行原來的連接點,然後插入 “舊” 值,再計算完整的測 試表達式。
最後,不變條件測試使用 around 建議,在該建議中,在連接點執行之前和 之後都計算測試,同時具有前面提到過的例外。
調用像 JEXL 這樣的解析器確實會增加不小的開銷,因為 Contract4J 只設 計為在開發和測試期間使用,而在這兩個期間內,開銷不是嚴重的問題。但是, 可能會發現有些頻繁執行的代碼塊不應當擁有測試。
采用 Contract4J
因為 Contract4J 的合約測試是用熟悉的 Java 規范編寫的,所以把它采用 到 Java 環境中很簡單,包括四個步驟:
1.下載 Contract4J 並解壓縮到方便的地方。除非想重新構建它(按照 README 中包含的說明),否則只需要 contract4j5.jar 文件。
2.把 contract4j5.jar 文件的位置添加到構建 CLASSPATH。
3.下載 並安裝 AspectJ。
4.從當前 Java 編譯器切換到 AspectJ 的 “ajc” 編譯器,它也可以編譯 Java 代碼。AspectJ 的主頁上提供了詳細信息,發行版自帶了 Ant 腳本。或者 ,如果喜歡繼續使用現有的 Java 編譯器,可以有兩個附加選項:
可以在構 建的末尾加入一個 ajc “織入” 步驟,把 contract4j5.jar 中的方面編織進 預編譯的類或 JAR 中。
可以在裝入時 “織入” 合約,正如 AspectJ 文檔中所解釋的。
5.現在請開始把 Contract4J 標注添加到源代碼中,以定義自己的合約!
定制 Contract4J
在運行時使用屬性文件或 API 調用,可以開啟或禁止所有測試,即前置條件 測試、後置條件測試或者不變條件測試。正常情況下,對於生產部署,構建時應 當不用 contract4j5.jar,以便不增加運行時開銷。
使用 API 調用可以有豐富的定制,包括 “插件鉤子”,用來插入自己的 Java 類,實現不同的行為。甚至可以替換 JEXL 表達式解釋器。
Contract4J 的主頁(請參閱 參考資料)提供了有關 API 、其他定制選項以 及允許的測試表達式、已知的限制和特性方面的豐富文檔。也可以在發行版中的 構建 “ant docs” 目標,以生成完整的 Javadocs。
Contract4J 和 AOP
除了是開發人員的有用工具之外,Contract4J 的意義還有兩個原因:首先, 它是越來越多的采用方面的 Java 開發工具中的一個,對於開發人員來說或多或 少地是透明的。另一個示例是在本系列前面討論過的 Glassbox Inspector。此 外,Spring 框架大量地采用純 Java 和 AspectJ 方面來支持中間件服務,而 JBoss 也使用純 Java 方面實現同一目的。請參閱 參考資料,了解關於這三個 項目的更多內容。
其次,Contract4J 使用簡單的基於接口的方式進行方面設計。面向方面社區 中的許多人目前都在把基於接口的設計這個概念從對象世界擴展到正在出現的方 面/對象世界中,所以這個主題值得進一步討論。
定義方面接口
標注通常用來指示代碼的元信息。在這個示例中,Contract4J 用標注捕獲組 件的合約約束,這些約束已經成為了接口的一個基本組成部分,而不是 “附屬 於” 接口的東西。實際上,合約不是通常 AOP 意義上的 “橫切”,不屬於與 組件的主要問題域 “正交” 的問題域的一部分。在 BankAccount 示例中,帳 戶余額允許的值,即帳戶對象 “狀態” 的一部分,是帳戶對象的一個有機部分 ,而不是與帳戶對象的正交。
所以,嚴格來說,契約式設計看起來可能根本不是 AOP 技術的備選方案。但 是,雖然合約本身是 BankAccount 域所必不可少的部分,但這個信息的使用則 是 橫切的。Contract4J 的興趣在於強制用編程的方式實現合約,而 Contract4J 標注的設計目的就是為了支持這個目標。不過,在自動生成單元測 試的工具或 IDE 中,可以方便地利用通過標注公開的合約信息:如果組件使用 不當,就會警告用戶。
使用標注,Contract4J 定義了模式形式的協議,即一種接口,用來表達合約 信息。在這一方面,Contract4J 類似於 Observer 模式:標注形式的合約規范 是 “可以觀察的”,可以由工具操作。協議用結構化英語的形式表達,例如方 法後置條件可以這麼表達:
if @Contract is on a class and
@Post is on a method, then
get the @Post expression string and
evaluate it, aborting if false.
Contract4J 方面用 AspectJ 實現這一邏輯。方面不要求被測試類的顯式信 息,例如它們的名稱或方法名稱。方面關心的只是標注。所以,方面可以完全保 持通用和可重用。可以把這與許多典型的 AspectJ 方面對比,後者編寫時顯式 地引用了特定的包和類,從而使重用和演變更加困難。
一個類似的使用標注來承載元信息的示例是,定義來與 Hibernate 和 EJB3 一起用於表達 POJO 中持久性需求的標注集(請參閱 參考資料)。
方面接口的挑戰
當然,基於標注的元信息能走多遠,依賴於方面設計技術能走多遠。就像使 用對象設計時,我們應當預料到創建可重用的、松散耦合的、面向方面的系統和 可重用的、通用的方面庫會大量地要求基於接口的技術。接口定義了耦合組件的 適當抽象,卻沒有公開太多細節。所以,在軟件發展的過程中,接口要比底層組 件更穩定。
而就方面來說,它們帶來了獨特的挑戰。從方面的性質來說,方面實際上廣 泛地接觸到了系統的其余部分,而對象組件則更加 “本地化”。方面還有用新 的、更精細的方式修改對象狀態和行為的能力,從而引起了對維護系統完整性、 健壯性、甚至整體理解的擔心。
為了解決這些問題,研究人員和實踐人員都在探尋方面/對象系統的接口不應 只包含我們已經習慣的方法和狀態信息,還應當包含合約信息,合約信息限定方 面允許對其他組件所做的修改(聽起來有點熟悉?)例如,方面可能不允許對組 件做狀態修改,或者出於性能和安全原因而被限制為不能建議某些 “關鍵區域 ”。
所以,不是方面切入點直接耦合到組件細節,比如特定的命名規范,方面而 是要耦合到組件實現的接口。組件將用接口向方面公開允許的連接點和狀態信息 。方面將只通過公開的接口建議組件。由於接口要比實現它們的組件更穩定,所 以耦合也會變得更穩定,在系統發展的時候,也更能跟得上變化。就像在對象系 統中一樣,基於方面的編程可以讓設計人員能夠更容易地構建健壯的、大型的、 仍然可以重用的方面/對象系統。
結束語
Contract4J 使用 Java 5 標注,以直觀的方式使得契約式設計測試的定義變 得更有效更簡單。這些測試在測試時自動計算,有助於捕獲代碼中的邏輯錯誤。 Contract4J 利用了 AspectJ 的威力,卻不要求開發人員是使用 AspectJ 的專 家。