在“JUnit: A Cook's Tour”一文中,作者 Erich Gamma 和 Kent Beck 討論了 JUnit 的設計。他們指出,與很多成熟框架中的關鍵抽象一樣,TestCase
也有很高的模式密集,易于使用而難以修改。在 AOP@Work 系列的第四期文章中,Wes Isberg 重溫了 Cook's Tour,說明如何通過使用 AOP 切入點設計來代替面向對象設計,在一定程度上避免導致成熟的設計難以修改的模式密集。
即使是最好的 Java™ 程序,也會隨著時間的推移而老化。為了滿足新的需求,設計也在不斷演化,關鍵對象承擔著各種模式角色,直到它們變得難以使用或者難以修改,最終不得不重構或者重寫系統。面向方面的編程(AOP)提供了一些將特性結合起來提供服務的更優雅的方法,這些方法可以減少交互、降低工作量、延長設計和代碼的壽命。
本文將分析 Erich Gamma 和 Kent Beck 在“JUnit: A Cook's Tour”)一文中提出的設計。對于他們提出的每種 Java 模式,都給出一種 AspectJ 替代方案,并說明這種方案是否滿足下列標準設計目標:
- 功能性:提供的服務是否強大、有用?
- 可用性:客戶能否方便地得到服務?
- 可擴展性:程序變化時是否容易擴展或者調整?
- 結合(分解)性:能否與其他部分協作?
- 保護:面對運行時錯誤或者級聯錯誤,如何保障 API 的安全?
- 可理解性:代碼是否清晰易懂?
設計的每一步中,Gamma 和 Beck 都面臨著兩難選擇,比如可用性與可維護性、可理解性與結合性。在所有的選擇中,他們采取的都是簡單可用的路線,即便這意味著要放棄次要的目標。因此,他們的設計使得編寫單元測試變得很容易。但我還是要問一問,如果使用 AOP 的話,能否避免其中一部分設計取舍呢?
這樣問也許看起來不夠通情達理,有些過于苛求。JUnit 把自己的工作做得很好,設計中的取舍被很多開發人員所了解,并認為是很正常的做法。要看看 AOP 能否做得更好,我必須問自己一些問題,比方說,能否增加更多的特性,使其更適合那些需要更多服務但不能滿足 JUnit 最起碼要求的客戶。我這樣做不是為了改變 JUnit,而是要在達到主要目標的同時不放棄次要的設計目標。
本文中所有的例子都使用了 AspectJ,但也可用于其他 AOP 方法,而且即使剛接觸 AspectJ,這些例子也很容易理解。(事實上,閱讀過 Cook's Tour 或者了解設計模式,可能要比您使用過 AspectJ 或 JUnit 更有幫助。)要下載本文中的源代碼,請單擊頁面頂部或底部的 代碼 圖標(或請參閱下載)。
使用 Command 模式還是進行假設?
下面是 Gamma 和 Beck 寫在“JUnit: A Cook's Tour”開頭的一段話:
測試用例通常存在于開發人員的腦子里,但實現起來有不同的方式,如打印語句、調試器表達式、測試腳本。如果想要讓測試處理起來更容易,則必須使它們成為對象。
為了使測試成為對象,他們使用了 Command 模式,該模式“將請求封裝成對象,從而可以……建立請求隊列或者記錄請求”。能不能再簡單一點呢?
既然焦點是可用性,有點奇怪的是,Gamma 和 Beck 也了解開發人員可以用不同的方式編寫測試,但他們卻堅持認為,開發人員應該只用一種方式編寫測試,即封裝成一個對象。為什么這樣做呢?為了讓測試使用起來更容易?呻y就難在:要享受服務的好處,就必須按照這種形式。
這種權衡影響了設計的成形和演化?梢砸蕴囟ǹ蛻魴C為目標,按照某種可用性和能力的組合來構建系統。如果客戶機改變了,那么可以增加一些層次或者改變可用性與能力的組合,每次都要使用和圍繞著已經建立的系統。幸運的話,系統可能有足夠的靈活度,這個演化的過程最終會集中到客戶機解決方案上。Gamma 和 Beck 用模式密集 來表達這種集中:
一旦發現真正要解決的問題,就可以開始“壓縮”解決方案,形成一個越來越密集的在此起決定作用的模式場。
設計造成的模式密集
將測試用例確定為關鍵抽象并使用 Command
封裝它之后,Cook's Tour 進一步確定了新的需求,為表示這一關鍵抽象的對象增加了新的特性。下面的圖示對此做作了很好的說明:
Gamma 和 Beck 遵循了(或者應該說指引著)現在的標準設計過程:發現關鍵抽象,并將它們封裝到對象中,添加模式來安排對象擔任的角色和提供的服務。不幸的是,正是這些造成了模式密集。關鍵抽象的職責和關系在不斷增加,直到像步入中年的父母一樣只能按照老套的習慣行事。(如果需求超過了它們的能力,那么它們隨后會陷入困境。)
給定一個測試用例……
AOP 提供了描述抽象的另一種方法:說明連接點的切入點。連接點 是程序執行中可以有效連接行為的點。連接點的類型取決于 AOP 的方式,但所有連接點在一般程序修改中都應該是穩定的,容易作出有意義的說明?梢允褂切入點 指定程序的連接點,用通知(advice)指定連接的行為。通知就是陳述“如果 X,則 Y”的一種方式。
Command 模式說,“我不關心運行的代碼是什么,把它放在該方法中就行了!彼髮⒋a放在命令類的命令方法中,對于 JUnit 而言,該命令方法是 Test
的 runTest()
方法,如 TestCase
:
|
相反,切入點說“讓某個連接點作為測試用例吧!彼灰鬁y試用例是某個 連接點。不需要將代碼放在特定類的特定方法中,只需要用切入點指定一個測試用例:
|
比如,可以將測試用例定義為 Runnable.run()
或main 方法,當然也可以使用 JUnit 測試:
|
切入點的可用性
切入點的可用性非常突出。在這里,只要測試能夠被切入點選擇,就可以作為測試用例,即使它不是作為測試編寫的。如果能夠通過通知而不是通過 API 提供服務,那么就可以減少開發人員在這些服務上的工作量。
通過 AOP,開發人員不需要做什么就能提供服務。這就產生了一種新的 API 客戶機:不需要了解它,但可以為它提供服務,或者說它依賴于該服務。對于一般的 API,客戶機和提供者之間有明確的契約和調用的具體時間。而對于 AOP,更像是人們依賴于政府的方式:無論叫警察、到 DMV 登記,還是吃飯或者上銀行,人們都(無論是否意識到)仰仗于規則,在規定好的點(無論是否明確)上操作。
將 AOP 納入視野之后,可用性就變成了一個更廣泛的連續體,從 API 契約到基于容器的編程模型,再到 AOP 的多種形式?捎眯缘膯栴},也從服務接口對客戶機公開了多少功能,轉變成了客戶機希望或需要對服務了解多少以及如何選擇(無論是司機、騙子,還是應聘者)。
可重用性
與方法一樣,也可以將切入點聲明成抽象的;即在通知中使用切入點,但是讓子方面具體聲明它。通常,抽象切入點規定的不是具體的時間或地點(如賓夕法尼亞大道 1600 號,星期二),而是很多人感興趣的一般事件(如選舉)。然后可以說明關于此類事件的事實(如,“選舉中,新聞機構……”,或者“選舉后,勝利者……”),用戶可以指定某項選舉的時間、地點和人物。如果將測試用例作為抽象切入點,那么我敢說,很多測試裝置的特性都能用“如果 X,則 Y”的形式表示,而且不需要知道如果 X 的很多細節,就能夠編寫大多數則 Y 的結論。
如何使用通知來實現特性,同時又避免模式密集帶來的危險呢?在類中添加新特性時,每個新成員都能看到其他可見的成員,這增加了理論上的復雜性。相反,AspectJ 最大限度地減少了通知之間的交互。一個連接點上的兩個通知彼此是不可見的,它們都只綁定在它們聲明的連接點上下文變量中。如果一個通知影響到另一個通知,并且需要排序,那么我可以規定它們的相對優先級,而不需要知道所有的通知并規定完整的順序。每個通知都使用最少的連接點信息,僅透漏類型安全、異常檢查等必需的自身信息。(AspectJ 在 AOP 技術中差不多是惟一支持這一級別的封裝的。)由于減少了交互,與在類中添加成員相比,向連接點添加通知所增加的復雜性要小得多。
至于 Cook's Tour 的其他部分,我使用 testCase()
切入點實現了 Gamma 與 Beck 添加到 TestCase
中的特性。在其中的每一步中,我都努力避免他們必須要做的那些取舍,評估順序對連接點是否重要,避免對連接點上下文作出假設,支持能夠想到的各種 API 客戶機。
是使用模板方法還是使用 around 通知?
使用 Command 封裝測試代碼之后,Gamma 和 Beck 認識到使用某種通用數據裝置測試的一般流程:“建立數據裝置、對裝置運行某些代碼并檢查結果,然后清除裝置”。為了封裝該過程,他們使用了 Template Method 模式:
該模式的目的是,“定義操作中算法的框架,將某些步驟推遲到子類中。Template Method 允許子類重定義算法中的某些步驟,而不需要改變算法的結構!
在 JUnit 中,開發人員使用 setUp()
和 cleanUp()
為 TestCase
管理數據。JUnit 設施負責在運行每個測試用例之前和之后調用這些方法;TestCase
使用模板方法 runBare()
來實現這一點:
|
在 AspectJ 中,如果代碼需要在連接點之前和之后運行,可以結合使用 before 通知和 after 通知,或者像下面這樣單獨使用 around 通知:
|
這樣的通知提供了三個自由度:
- 可用于支持 around 通知的任何連接點。
- 可用于任何類型的測試,因為對運行的代碼沒有任何假設。
- 通過將裝置的建立/清除代碼放在可以被覆蓋或者委托實現的方法中,可以適應不同類型測試對象所需的不同的裝置管理方式。有些可能管理自己的數據,如
TestCase
;有些可能得益于依賴性倒置(dependency inversion),在外部建立配置。
但是,這些方法都使用JoinPoint
,在連接點提供了可用于任何上下文的Object
(可能包含 this 對象、 target 對象和任何參數)。使用JoinPoint
將使Object
向下強制轉換成實際的類型,從而獲得了類型安全的一般性。(下面我將介紹一種不損失一般性而獲得類型安全的方法。)
通知提供了和 Template Method 相同的保證但沒有 Java 實現的約束。在 JUnit 中,TestCase
必須控制命令方法來實現模板方法,然后為實現真正的測試還要委派給另一個方法,為 command 代碼創建 TestCase
專用的協議。因此,雖然 Command 使得測試很容易操縱,command 契約對開發人員而言實際上從 Test
到 TestCase
是不同的,因而使得 API 的職責更難以理解。
使用 Collecting Parameter 還是使用 ThreadLocal?
Cook's Tour 繼續它的漫步:“如果 TestCase
在森林中運行,那么誰還關心它的結果呢?”當然,Gamma 和 Beck 的回答是:需要記錄失敗和總結經驗。為此,他們使用了 Collecting Parameter 模式:
如果需要收集多個方法的結果,應該在方法中添加一個參數傳遞收集結果的對象。
JUnit 將結果處理封裝在一個 TestResult
中。從這里,訂閱者可以找到所有測試的結果,測試裝置可以在這里管理需要的結果集合。為了完成采集工作,Template Method TestResult.runProtected(..)
將測試執行放在 start 和 end 輔助調用(housekeeping call)之間,把拋出的異常解釋為負的測試結果。
結合性
現在有了 N>1 個模式,模式實現之間的交互如何呢?如果對象可以很好地協作,則稱為可結合的。類似地,模式實現可能直接沖突(比如兩者需要不同的超類)、并存但不交互,或者并存且以或多或少富有成效的方式進行交互。
在 JUnit 中,裝置關注點和結果收集關注點的相互作用形成了 TestCase
和 TestResult
共享的調用順序協議,如下所示:
|
這表明模式密集使得代碼很難修改。如果要修改裝置模板方法或者收集參數,就必須在 TestResult
或 TestCase
(或者子類)中同時修改二者。另外,因為測試裝置的 setUp()
和 cleanUp()
方法在結果處理(result handling)的受保護上下文中運行,該調用序列包含了設計決策:裝置代碼中拋出的任何異常都視作測試錯誤。如果希望單獨報告裝置錯誤,那么不但要同時修改兩個組件,還必須修改它們相互調用的方式。AspectJ 能否做得更好一點呢?
在 AspectJ 中,可以使用通知提供同樣的保證但避免了鎖定調用的順序:
|
與上述的裝置處理通知一樣,這可以用于任何類型的測試或者結果收集,但實現該方法需要向下類型轉換。這一點將在后面進行修正。那么該通知如何與裝置通知交互呢?這依賴于首先運行的是什么。
誰先開始?
在 JUnit 中,結果收集和裝置管理的模板方法必須(永遠?)按照固定的調用順序。在 AspectJ 中,大量通知可以在一個連接點上運行,而無需知道該連接點上的其他通知。如果不需要交互,那么可以(應該)忽略它們運行的順序。但是,如果知道其中一個可能影響另一個,則可使用優先級控制運行的順序。本例中,如果賦予結果處理通知更高的優先級,那么連接點在運行的時候,結果處理通知就會在裝置處理通知之前運行,可以調用 proceed(..)
來運行后者,最后再收回控制權。下面是運行時的順序:
|
如果需要,可以顯式控制兩個通知的優先級,不論通知是否在相同或不同的方面中,甚至是來自其他方面。在這里因為順序決定了裝置錯誤是否作為測試錯誤報告的設計決策,可能希望顯式設置優先級。我也可以使用單獨的方面聲明裝置錯誤的處理策略:
|
這兩個 Handling
方面不需要知道對方的存在,而兩個 JUnit 類 TestResult
與 TestCase
,必須就誰首先運行命令達成一致。如果以后要改變這種設計,只需要修改 ReportingFixtureErrors
即可。
Collecting Parameter 的可用性
多數 JUnit 測試開發人員都不直接使用 TestResult
,就是說在調用鏈的每個方法中要作為參數來傳遞它,Gamma 和 Beck 稱之為“簽名污染”。相反,他們提供了 JUnit 斷言來通知失效或者展開測試。
TestCase
擴展了 Assert
,后者定義了一些有用的 static assert{something}(..)
方法,以檢查和記錄失效。如果斷言失敗,那么這些方法將拋出 AssertionFailedError
,TestResult
在結果處理裝置模板方法中捕獲這些異常并進行解釋。這樣,JUnit 就巧妙地回避了 API 用戶來回傳遞收集參數的關注點,讓用戶忘掉了 TestResult
的要求。JUnit 將結果報告關注點和驗證與日志服務捆綁在了一起。
捆綁
捆綁使用戶更難于選擇需要的服務?梢允褂 Assert.assert{something}(..)
將 TestCase
綁到 TestResult
上,進一步限制收集參數的靈活性。這樣對測試增加了失效實時處理(fast-fail)語義, 即使有些測試可能希望在確認失效后繼續執行。為了直接報告結果, JUnit 測試可以實現 Test
,但這樣就失去了 TestCase
的其他特性(可插接的選擇器、裝置處理、重新運行測試用例等)。
這是模式密集的另一個代價:API 用戶常常被迫接受或者拒絕整個包。另外,雖然將問題捆綁到一起可能比較方便,但有時候會降低可用性。比如,很多類或方法常量首先作為 JUnit 斷言寫入,如果不自動觸發異常這些常量,則可以在產品診斷中重復使用它們。
如上所述,AspectJ 可以支持 JUnit 斷言風格的結果處理,但能否在支持希望得到直接結果收集的靈活性的 API 用戶的同時,又單獨決定何時展開測試呢?甚至允許用戶定義自己的結果收集器報告中間結果?我認為能夠做到。這一種解決方案包括四部分:(1) 支持結果收集器的工廠;(2)組件在不污染方法簽名的情況下使用結果收集器;(3) 可以在直接報告給結果收集器后展開測試;(4) 保證正確報告拋出的異常。撰寫 Cook's Tour 的時候這些還很難做到這些,但是現在有了新的 Java API 和 AspectJ,所以這一切都變得很容易。
ThreadLocal 收集器
為了讓所有組件都能使用結果收集器和實現工廠,我使用了一個公共靜態方法來獲得線程本地(thread-local)結果收集器。下面是 TestContext
結果收集器的框架:
|
方法 getTestContext(Object test)
可支持結果收集器和測試之間的不同聯系(每個測試、每個套件、每個線程、每個 VM),但 TestContext
的子類型需要向下強制轉換,不支持其他類型。
展開測試
拋出異常不僅要展開測試,還將報告錯誤。如果測試客戶機直接使用 getTestContext(..)
通知錯誤,那么需要展開測試而不是報告更多的錯誤。為此,需要聲明一個專門的異常類,指出已經告知結果。API 契約方式需要定義拋出異常的客戶機和捕捉異常的裝置都需要知道的類。為了向客戶機隱藏類型細節,可以像下面這樣聲明一個返回用戶拋出的異常的方法:
|
然后測試拋出 TestContext
定義的所有異常:
|
這樣就把測試和 TestContext
綁定到了一起,但是 safeUnwind()
僅供那些進行自己的結果報告的測試使用。
保證異常被報告
下面是為 TestContext
收集結果的通知。這個通知具有足夠的通用性,可用于不同的測試用例和不同的 TestContext
子類型:
|
因為該通知加強了 TestContext
的不變性,所以我把這個方面嵌套在 TestContext
中。為了讓裝置開發人員指定不同的測試用例,切入點和方法都是抽象的。比如,下面將其用于 TestCase
:
|
我在一個重要的地方限制了這一解決方案:around 通知聲明它返回 void。如果我聲明該通知返回 Object
,就可以在任何連接點上使用該通知。但是因為要捕獲異常需要正常返回,我還需要知道返回的是什么 Object
。我可以返回 null 然后等待好消息,但我更愿意向任何子方面表明該問題,而不是等它在運行時拋出 NullPointerException
。
雖然聲明 void
限制了 testCase()
切入點的應用范圍,但是這樣降低了復雜性,增強了安全性。 AspectJ 中的通知具有和 Java 語言中的方法相同的類型安全和異常檢查。通知可以聲明它拋出了一個經過檢查的異常,如果切入點選擇了不拋出異常的連接點,那么 AspectJ 將報告錯誤。類似地,around 通知可以聲明一個返回值((上面的“void”),要求所有鏈接點具有同樣的返回值。最后,如果通過綁定具體的類型來避免向下類型轉換(比如使用 this(..)
,參見后述),那么就必須能夠在連接點上找到這種類型。這些限制保證了 AspectJ 通知和 Java 方法具有同樣的構建時安全性(不同于基于反射或代理的 AOP 方法)。
有了這些限制,就可以同時支持有客戶機控制的和沒有客戶機控制這兩種情況下的結果收集,不必依賴客戶機來加強不變性。無論對于新的客戶機類型、新的結果收集器類型,還是和 TestContext
類及其子類型的何種交互,這種解決方案都是可擴展的。
Adapter、Pluggable Selector 還是配置?
Cook's Tour 提出用 Pluggable Selector
作為由于為每個新測試用例創建子類造成的“類膨脹”的解決方案。如作者所述:
想法是使用一個可參數化的類執行不同的邏輯,不需要子類化……Pluggable Selector 在實例變量中保存一個……方法選擇器。
于是,TestCase
擔負了使用 Pluggable Selector 模式將 Test.run(TestResult)
轉化成 TestCase.test...()
的 Adapter 角色,可以用 name 字段作為方法選擇器。TestCase.runTest()
方法反射調用和 name 字段對應的方法。這種約定使得開發人員通過添加方法就能增加測試用例。
這樣方便了 JUnit 測試開發人員,但是增加了裝置開發人員修改和擴展的難度,為了適應 runTest()
,構造函數 TestCase(String name)
的參數必須是不帶參數的公共實例方法的名稱。結果,TestSuite
實現了該協議,因此如果需要修改 TestCase.runTest()
中的反射調用,就必須修改 TestSuite.addTestSuite(Class)
,反之亦然。要基于 TestCase
創建數據驅動或規格驅動的測試,就必須為每種配置創建單獨的套件,在套件名中包含配置,用 TestSuite
定義后配置每個測試。
配置連接點
AspectJ 能否更進一步出來測試,而不僅僅是選擇處理測試配置呢?在一個連接點上配置測試有兩種方法。
首先,可以通過改變連接點上的上下文來直接配置連接點,如方法參數或者執行對象本身。執行 main(String[])
方法的一個簡單例子是用不同的 String[]
數組生成一些測試,并反復運行連接點。稍微復雜一點的,可以結合使用連接點上兩類不同的變量。下面的通知將檢查測試能否在所有彩色和單色打印機上工作:
|
雖然這段代碼是針對 Printer
的,但無論測試的是打印還是初始化,無論 Printer
是方法調用的目標還是方法參數,都沒有關系。因此即使通知要求某種具體的類型,這或多或少與引用來自何處是無關的;這里通知將連接點和如何獲得上下文都委派給了定義切入點的子方面。
配置測試的第二種方法(更常用)是對測試組件使用 API。Printer
的例子說明了如何明確設置模式。為了更一般化,可以支持泛化的適配器接口 IConfigurable
,如下所示:
|
該通知只能用于某些上下文是 IConfigurable
的情況,但是如果能運行,那么可以運行底層連接點多次。
如何與連接點上的其他測試類型、其他通知、運行該連接點的其他代碼交互呢?對于測試而言,如果測試不是 IConfigurable
的,那么該通知將不運行。這里沒有矛盾。
對于其他通知,假設將 configuring()
定義為 testCase()
并包括其他的通知,因為這樣可以高效地創建很多測試,結果和裝置通知都應該有更低的優先級,以便能夠管理和報告不同的配置與結果。此外,配置應該以某種形式包含在結果收集器用來報告結果的測試標識中;這是那些知道測試可配置、可標識的組件的職責(下面一節還將進一步討論這些組件)。
對于運行連接點的代碼,與通常的 around 通知不同的是,它對每個配置都調用 proceed(..)
一次,因此底層連接點可運行多次。在這里通知應該返回什么結果呢?與結果處理通知一樣,我惟一能確定的是 void
,因此,我限制該通知返回 void
,并把這個問題交給編寫切入點的測試開發人員。
各取所需
假設我是一位裝置開發人員,需要調整來適應新的測試,如果必須在測試類中實現 IConfigurable
,那么 看起來似乎不得不增加測試的“模式密集”。為了避免這種情況,可以在 AspectJ 中聲明其他類型的成員或者父類,包括接口的默認實現,只要所有定義保持二進制兼容即可。使用內部類型聲明增加了通知的類型安全,從而更容易避免從 Object 的向下類型轉換。
是不是像其他成員那樣增加了目標類型的復雜性呢?其他類型的公共成員聲明是可見的,因此在理論上可能增加目標類型的復雜性。但是,也可以將這些成員聲明為某個方面私有的其他類型,因此,只有這個方面才能使用它們。這樣就可以裝配組合對象,而不會造成把所有成員在類中聲明可能造成的一般沖突和交互。
下面的代碼給出了一個例子,用 init(String)
方法使 Run
適應于 IConfigurable
:
|
測試標識符
測試標識符可由結果報告、選擇或配置以及底層的測試本身共享。在一些系統中,只需要告訴用戶哪些測試正在運行即可;在另外一些系統中,需要一個惟一的、一致的鍵來說明那些失敗的測試獲得通過(bug 修正),哪些通過的測試失敗了(回歸)。JUnit 僅提供了一種表示,它繞過了共享的需要,使用 String Object.toString()
來獲得 String 表示。AspectJ 裝置也可作同樣的假設,但是也可用上面所述的 IConfigurable
來補充測試,根據系統需要為給定類型的測試計算和存儲標識符!跋嗤摹睖y試可以根據需要來配置不同的標識符(比如,用于診斷和回歸測試的標識符),這減少了 Java 語言中模式密集可能造成的沖突。雖然配置對于方面和配置的組件是本地的(從而可以是私有的),對于很多關注點,標識符都可以是可見的,所以可以用公共接口表示它。
使用組合還是使用遞歸?
Cook's Tour 認識到裝置必須運行大量的測試——“一套一套的測試”。Composite 模式可以很好地滿足這種要求:
該模式的目的是,“把對象組合到樹狀結構中,以表示部分-整體關系。組合使客戶機能夠統一地看待單個對象和對象的組合!
Composite 模式引入了三個參與方:Component、Composite 與 Leaf。Component 聲明了我們希望用來與測試交互的接口。Composite 實現了該接口,并維護一個測試集合。Leaf 表示 Composite 中的測試用例,該用例符合 Component 接口。
這就形成了 JUnit 設計環,因為 Test.runTest(..)
Command 接口是 Leaf TestCase
Composite TestSuite
實現的 Component 接口。
可維護性
Cook's Tour 支出,“應用 Composite 時,我們首先想到的是應用它是多么復雜!痹撃J街,節點和葉子的角色被添加到已有的組件上,并且它們都需要知道自己在實現組件接口時的職責。它們之間定義了調用協議,并由節點實現,節點也包含子節點。這意味著節點知道子節點,同時裝置也知道節點。
在 JUnit 中,TestSuite
(已經)非常了解 TestCase
,JUnit 測試運行者假設要通過加載 suite 類來生成一個套件。從配置中可以看到,支持可配置的測試需要管理測試套件的生成。組合增加了模式密集。
Composite 模式在 AspectJ 中可使用內部類型聲明實現,如上面關于配置的一節所述。在 AspectJ 中,所有成員都是在一個方面中聲明的,而不是分散在已有的類中。這樣更容易發現角色是否被已有類的關注點污染了,在查看實現的時候也更容易了解這是一個模式(而不僅僅是類的另一個成員)。最后,組合是可用抽象方面實現的模式之一,可以使用標簽接口來規定擔任該角色的類。這意味著可以編寫可重用的模式實現。(關于設計模式的 AspectJ 實現的更多信息,請參閱 Nicholas Lesiecki 所撰寫的“Enhance design patterns with AspectJ”,參見 參考資料。)
遞歸
AspectJ 能否不借助 Composite 模式而滿足原來的需要呢?AspectJ 提供了運行多個測試的很多方法。上面關于配置的例子是一種方法:把一組子測試和一個測試關聯,使用通知在切入點 recursing()
選擇的連接點上遞歸運行各個成分。該切入點規定了應該遞歸的組合操作:
|
下面說明了如何將該方面應用于 Run
:
|
將連接點封裝為對象
在連接點上遞歸?這就是有趣的地方。在 AspectJ around 通知中,可以使用 proceed(..)
運行連接點的其他部分。為了實現遞歸,可以通過將 proceed(..)
調用封裝在匿名類中來隱藏連接點的其他部分。為了在遞歸方法中傳遞,匿名類應該擴展方法已知的包裝器類型。比如,下面定義了 IClosure
包裝器接口,將 proceed(..)
包裝到 around 通知中,并把結果傳遞給 recurse(..)
方法:
|
使用 IClosure
可以結合 Command 模式和使用 proceed(..)
的通知的優點。與 Command 類似,它也可以用新規定的參數在運行或重新運行中傳遞。與 proceed(..)
類似,它隱藏了連接點中其他上下文、其他低優先級通知和底層連接本身的細節。它和連接點一樣通用,比通知更安全(因為上下文更加隱蔽),并且和 Command 一樣可以重用。因為對目標類型沒有要求,所以,與 Command 相比,IClosure 的結合性更好。
如果逐漸習慣于封閉 proceed(..)
,不必感到奇怪,對許多 Java 開發人員來說,這僅僅是一種很常見的怪事。如果在連接點完成之后調用 IClosure
對象,那么結果可能有所不同。
可用性RunComposite
方面將這種組合解決方案應用于 Run
類,只需要用 IComposite
接口標記該類,并定義 recursing()
切入點即可。但是,為了將組件安裝到樹中,需要添加子類,這意味著某個組裝器組件必須知道 Run
是帶有子類的 IComposite
。下面顯示了組件以及它們之間的關系:
|
您可能希望讓 CompositeRun
方面也負責發現每次運行的子類(就像帶配置的組合那樣),但使用單獨的裝配器意味著不必將 Run
組合(對于所有運行都是一樣的)與 Run
組合的特定應用(隨著聯系子類和特定 Run
子類的方式不同而變)攪在一起。面向對象依賴性的規則依賴于穩定性的方向,特別是,(變化更多的)具體的元素應該取決于是否要完全依賴于(更穩定的)抽象成分。按照這一原則,上面的依賴性似乎不錯。
結合性
與配置一樣,組合通知(應用于測試用例時)應該優先于裝置和結果報告。如果配置影響到測試的確認,那么組合也應該優先于配置。按照這些約束,可得到下面的順序:
|
作為設計抽象的切入點
上面就是我對 JUnit Cook's Tour 的評述。我所討論的所有面向方面的設計解決方案都可在本文的代碼壓縮包中找到。這些解決方案具有以下特點:
- 依賴于切入點而不是類型,要么不作任何假定,要么將上下文規格推遲到具體的子方面中。
- 都是獨立的,可單獨使用。
- 可以在同一系統中多次重用。
- 可以共同工作,有時候需要定義它們的相對優先級。
- 不需要修改客戶機部分。
- 與 JUnit 相比,可做的工作更多,需要客戶機的干預也更少。
對于給定的 Java 模式,AspectJ 提供了完成同一任務的多種方式,有時候是一個簡單的短語。這里采用了在可重用解決方案中使用切入點和做最少假定的方法,這些主要是為了說明 AspectJ 如何通過封裝通知和切入點來減少交互,從而很容易在連接點上改變行為。有時候,可以使用具體的(非重用的)方面,將特性組合到一個方面中;或者使用內部類型聲明來實現對應的 Java 模式可能更清楚。但是這些解決方案示范了最大限度地減少一個連接點上的交互的技術,從而使將切入點用作一流設計抽象變得更簡單。
切入點僅僅是減少重重假設的設計方法的第一步。應在可能不需要對象的地方真正利用切入點。如果對象是必需的,那么應該嘗試在方面中使用內部類型聲明來組合對象,從而使不同的(模式)角色保持區別,即使在同一個類中定義也是如此。與面向對象編程一樣,應避免不同的組件了解對方。如果必須彼此了解,比較具體的一方應該知道抽象的一方,裝配器應該知道各個部分。如果彼此都要知道,那么這種關系應該盡量簡練、明確、穩定和可實施的。
全速 AOP
AspectJ 1.0 是三年前發布的。多數開發人員都看過并嘗試了 AspectJ 的入門應用,即模塊化跟蹤這樣的橫切關注點。但是有些開發人員更進一步,嘗試進行我所說的“全速 AOP”:
- 設計失敗和設計成功一樣平常。
- 重用(或者可重用)切入點這樣的橫切規格。
- 方面可能有很多互相依賴的成分。
- 方面用于倒置依賴和解耦代碼。
- 方面用于連接組件或子系統。
- 方面打包成可重用的二進制庫。
- 系統中有很多方面。一些對另一些無關緊要,但還有一些則依賴于另一些。
- 雖然方面可能是不可插接的,但是插入后可以增加基本功能或者結構。
- 可以重構庫代碼創建更好的連接點模型。
是什么讓一些開發人員裹足不前呢?在最初聽說 AOP 或者學習基礎知識后,似乎進入了一個平臺階段。比如進入了這樣的思維陷阱:
AOP 模塊化了橫切關注點,因此我要在代碼中尋找橫切關注點。我找到了所有需要的跟蹤、同步等關注點,因此不再需要做其他事了。
這個陷阱就像在面向對象編程初期單純按照“is-a”和“has-a”思考一樣。尋找單個的關注點(即使是橫切關注點),就丟失了關系和協議,在規范化為模式時,關系和協議是編碼實踐的支柱。
另一種思維陷阱是:
AOP 模塊化橫切關注點。因此應該尋找那些分散和糾纏在代碼中的代碼。這些代碼似乎都很好的本地化了,因此不需要 AOP。
雖然分散和糾纏可能是非模塊化橫切關注點的標志,AOP 除了收集分散的代碼或者糾纏在一起的復雜方法或對象之外,還有很多用處。
最后,最難以避開的思維陷阱是:
AOP 用新的語言設施補充了面向對象編程,因此應該用于解決面向對象編程不能解決的關注點。面向對象編程解決了所有關注點,因此我不需要 AOP。
本文中沒有討論橫切關注點,我重新實現的多數解決方案照目前來看都是經過很好模塊化的。我給出的代碼并非完全成功的(特別是與 JUnit 比較),但給出這些代碼的目的并不僅僅是說明它們能夠做到或者證明代碼能更好地本地化,而在于提出面向對象開發人員是否必須忍受此類設計權衡的問題。我相信,如果在實現主要目標的同時能夠不放棄次要目標,那么就可以避免編寫難以使用或修改的代碼。
結束語
重溫“JUnit: A Cook's Tour”,更好地理解 AspectJ 減少和控制連接點交互的方法,這是在設計中有效使用切入點的關鍵。模式密集可能導致成熟的面向對象框架難以修改,但這是面向對象開發人員設計系統的方法所帶來的自然結果。本文提供的解決方案,即用切入點代替對象,盡可能地避免了交互或者最大限度地減少了交互,避免了 JUnit 的不靈活性,本文還展示了您如何能夠在自己的設計中做到這一點。通過避免開發人員已經逐漸認可的設計權衡,這些解決方案表明,即使您認為代碼已經被很好地模塊化了,AOP 仍然很有用。希望本文能鼓勵您在更多的應用程序中全面使用 AOP。
文章來源于領測軟件測試網 http://www.kjueaiud.com/