“JUnit Cook's Tour”中的可用性和維護性
簡介:在“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 也了解開發人員可以用不同的方式編寫測試,但他們 卻堅持認為,開發人員應該只用一種方式編寫測試,即封裝成一個對象。為什麼 這樣做呢?為了讓測試使用起來更容易。可難就難在:要享受服務的好處,就必 須按照這種形式。
這種權衡影響了設計的成形和演化。可以以特定 客戶機為目標,按照某種可用性和能力的組合來構建系統。如果客戶機改變了, 那麼可以增加一些層次或者改變可用性與能力的組合,每次都要使用和圍繞著已 經建立的系統。幸運的話,系統可能有足夠的靈活度,這個演化的過程最終會集 中到客戶機解決方案上。Gamma 和 Beck 用模式密集 來表達這種集中:
一旦發現真正要解決的問題,就可以開始“壓縮”解決方案,形 成一個越來越密集的在此起決定作用的模式場。
設計造成的模式密集
將測試用例確定為關鍵抽象並使用 Command 封裝它之後, Cook's Tour 進一步確定了新的需求,為表示這一關鍵抽象的對象增加了新 的特性。下面的圖示對此做作了很好的說明:
圖 1. JUnit 模式框圖
Gamma 和 Beck 遵循了(或者應該說指引著)現在的標准設計過程 :發現關鍵抽象,並將它們封裝到對象中,添加模式來安排對象擔任的角色和提 供的服務。不幸的是,正是這些造成了模式密集。關鍵抽象的職責和關系在不斷 增加,直到像步入中年的父母一樣只能按照老套的習慣行事。(如果需求超過了 它們的能力,那麼它們隨後會陷入困境。)
給定一個測試用例……
AOP 提供了描述抽象的另一種方法:說明連接點的切入點。連接點 是程序執 行中可以有效連接行為的點。連接點的類型取決於 AOP 的方式,但所有連接點在 一般程序修改中都應該是穩定的,容易作出有意義的說明。可以使用切入點 指定 程序的連接點,用通知(advice)指定連接的行為。通知就是陳述“如果 X,則 Y”的一種方式。
Command 模式說,“我不關心運行的代碼是什麼,把它放在該方法中就行了。 ”它要求將代碼放在命令類的命令方法中,對於 JUnit 而言,該命令方法是 Test 的 runTest() 方法,如 TestCase:
public class MainTest extends TestCase {
public void runTest (...) {...}
}
相反,切入點說“讓某個連接點作為測試用例吧。”它只要求測試用例是某個 連接點。不需要將代碼放在特定類的特定方法中,只需要用切入點指定一個測試 用例:
pointcut testCase() : ... ;
比如,可以將測試用例定義為 Runnable.run() 或main 方法,當然也可以使 用 JUnit 測試:
pointcut testCase() : execution(void Runnable+.run());
pointcut testCase() : execution(static void main(String[]));
pointcut testCase() : execution(public void Test+.run(TestResult));
pointcut testCase() : execution(public void TestCase+.test*());
切入點的可用性
切入點的可用性非常突出。在這裡,只要測試能夠被切入點選擇,就可以作為 測試用例,即使它不是作為測試編寫的。如果能夠通過通知而不是通過 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() 來實現這一點:
public void runBare() throws Throwable {
setUp();
try {
// run the test method
runTest();
} finally {
tearDown();
}
}
在 AspectJ 中,如果代碼需要在連接點之前和之後運行,可以結合使用 before 通知和 after 通知,或者像下面這樣單獨使用 around 通知:
/** around each test case, do setup and cleanup */
Object around() : testCase() {
setup(thisJoinPoint);
try {
// continue running the test case join point
return proceed();
} finally {
cleanup(thisJoinPoint);
}
}
protected void setup(JoinPoint jp) {}
protected void cleanup (JoinPoint jp) {}
這樣的通知提供了三個自由度:
可用於支持 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 共享的調用順序協議,如下所示:
Test.runTest(TestResult) calls...
TestResult.run(TestCase) calls...
TestResult.runProtected(Test, Protectable) calls...
Protectable.protect() calls...
TestCase.runBare() calls...
Test.runTest() ...
(TestCase.runTest() invokes test method...)
這表明模式密集使得代碼很難修改。如果要修改裝置模板方法或者收集參數, 就必須在 TestResult 或 TestCase (或者子類)中同時修改二者。另外,因為 測試裝置的 setUp() 和 cleanUp() 方法在結果處理(result handling)的受保 護上下文中運行,該調用序列包含了設計決策:裝置代碼中拋出的任何異常都視 作測試錯誤。如果希望單獨報告裝置錯誤,那麼不但要同時修改兩個組件,還必 須修改它們相互調用的方式。AspectJ 能否做得更好一點呢?
在 AspectJ 中,可以使用通知提供同樣的保證但避免了鎖定調用的順序:
/** Record test start and end, failure or error */
void around (): testCase() {
startTest(thisJoinPoint);
try {
proceed();
endTest(thisJoinPoint);
} catch (Error e) {
error(thisJoinPoint, e);
} catch (Exception e) {
failure(thisJoinPoint, e);
}
}
與上述的裝置處理通知一樣,這可以用於任何類型的測試或者結果收集,但實 現該方法需要向下類型轉換。這一點將在後面進行修正。那麼該通知如何與裝置 通知交互呢?這依賴於首先運行的是什麼。
誰先開始?
在 JUnit 中,結果收集和裝置管理的模板方法必須(永遠?)按照固定的調 用順序。在 AspectJ 中,大量通知可以在一個連接點上運行,而無需知道該連接 點上的其他通知。如果不需要交互,那麼可以(應該)忽略它們運行的順序。但 是,如果知道其中一個可能影響另一個,則可使用優先級控制運行的順序。本例 中,如果賦予結果處理通知更高的優先級,那麼連接點在運行的時候,結果處理 通知就會在裝置處理通知之前運行,可以調用 proceed(..) 來運行後者,最後再 收回控制權。下面是運行時的順序:
# start running the join point
start result-handling around advice; proceed(..) invokes..
start fixture-handling around advice; proceed(..) invokes..
run underlying test case join point
finish fixture-handling around advice
finish result- handling around advice
# finish running the join point
如果需要,可以顯式控制兩個通知的優先級,不論通知是否在相同或不同的方 面中,甚至是來自其他方面。在這裡因為順序決定了裝置錯誤是否作為測試錯誤 報告的設計決策,可能希望顯式設置優先級。我也可以使用單獨的方面聲明裝置 錯誤的處理策略:
aspect ReportingFixtureErrors {
// fixture errors reported by result-handling
declare precedence: ResultHandling+, FixtureHandling+;
}
這兩個 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 結果收集器 的框架:
public class TestContext {
static final ThreadLocal<TestContext> TEST_CONTEXT
= new ThreadLocal<TestContext>();
/** Clients call this to get test context */
public static TestContext getTestContext(Object test) {
...
}
...
}
方法 getTestContext(Object test) 可支持結果收集器和測試之間的不同聯 系(每個測試、每個套件、每個線程、每個 VM),但 TestContext 的子類型需 要向下強制轉換,不支持其他類型。
展開測試
拋出異常不僅要展開測試,還將報告錯誤。如果測試客戶機直接使用 getTestContext(..) 通知錯誤,那麼需要展開測試而不是報告更多的錯誤。為此 ,需要聲明一個專門的異常類,指出已經告知結果。API 契約方式需要定義拋出 異常的客戶機和捕捉異常的裝置都需要知道的類。為了向客戶機隱藏類型細節, 可以像下面這樣聲明一個返回用戶拋出的異常的方法:
public class TestContext {
...
public Error safeUnwind() {
return new ResultReported();
}
private static class ResultReported extends Error {}
}
然後測試拋出 TestContext 定義的所有異常:
public void testClient() {
...
TestContext tc = TestContext.getTestContext(this);
tc.addFailure(..);
..
throw tc.safeUnwind(); // could be any Error
}
}
這樣就把測試和 TestContext 綁定到了一起,但是 safeUnwind() 僅供那些 進行自己的結果報告的測試使用。
保證異常被報告
下面是為 TestContext 收集結果的通知。這個通知具有足夠的通用性,可用 於不同的測試用例和不同的 TestContext 子類型:
/** Record for each test start and end or exception */
void around() : testCase() {
ITest test = wrap(getTest (thisJoinPoint));
TestContext testContext = TestContext.getTestContext(test);
testContext.startTest(test);
try {
proceed();
testContext.endTest(test);
} catch (ResultReported thrown) {
testContext.checkReported (test);
} catch (Error thrown) {
testContext.testError (test, null, thrown);
} catch (Throwable thrown) {
testContext.testFailure(test, null, thrown);
}
}
protected abstract Object getTest(JoinPoint jp);
因為該通知加強了 TestContext 的不變性,所以我把這個方面嵌套在 TestContext 中。為了讓裝置開發人員指定不同的測試用例,切入點和方法都是 抽象的。比如,下面將其用於 TestCase:
aspect ManagingJUnitContext
extends TestContext.ManagingTestResults {
public pointcut testCase() : within(testing.junit..*)
&& execution(public !static void TestCase+.test*());
protected Object getTest(JoinPoint jp) {
assert jp.getTarget() instanceof TestCase;
return jp.getTarget();
}
}
我在一個重要的地方限制了這一解決方案: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[] 數組生成一些測試,並反復運行連接點。稍微復雜一點的,可以結合使用連接點 上兩類不同的變量。下面的通知將檢查測試能否在所有彩色和單色打印機上工作 :
void around(Printer printer) : testCase() && context (printer) {
// for all known printers...
for (Printer p : Printer.findPrinters()) {
// try both mono and color...
p.setMode(Printer.MONOCHROME);
proceed(p);
p.setMode (Printer.COLOR);
proceed(p);
}
// also try the original printer, in mono and color
printer.setMode (Printer.MONOCHROME);
proceed(printer);
printer.setMode (Printer.COLOR);
proceed(printer);
}
雖然這段代碼是針對 Printer 的,但無論測試的是打印還是初始化,無論 Printer 是方法調用的目標還是方法參數,都沒有關系。因此即使通知要求某種 具體的類型,這或多或少與引用來自何處是無關的;這裡通知將連接點和如何獲 得上下文都委派給了定義切入點的子方面。
配置測試的第二種方法(更常用)是對測試組件使用 API。Printer 的例子 說明了如何明確設置模式。為了更一般化,可以支持泛化的適配器接口 IConfigurable,如下所示:
public abstract aspect Configuration {
protected abstract pointcut configuring(IConfigurable s);
public interface IConfigurable {
Iterator getConfigurations();
void configure(Object input);
}
void around(IConfigurable me) : configuring(me) {
Iterator iter = me.getConfigurations();
while (iter.hasNext()){
me.configure(iter.next());
proceed(me);
}
}
}
該通知只能用於某些上下文是 IConfigurable 的情況,但是如果能運行, 那麼可以運行底層連接點多次。
如何與連接點上的其他測試類型、其他通知、運行該連接點的其他代碼交互 呢?對於測試而言,如果測試不是 IConfigurable 的,那麼該通知將不運行。這 裡沒有矛盾。
對於其他通知,假設將 configuring() 定義為 testCase() 並包括其他的 通知,因為這樣可以高效地創建很多測試,結果和裝置通知都應該有更低的優先 級,以便能夠管理和報告不同的配置與結果。此外,配置應該以某種形式包含在 結果收集器用來報告結果的測試標識中;這是那些知道測試可配置、可標識的組 件的職責(下面一節還將進一步討論這些組件)。
對於運行連接點的代碼,與通常的 around 通知不同的是,它對每個配置都 調用 proceed(..) 一次,因此底層連接點可運行多次。在這裡通知應該返回什麼 結果呢?與結果處理通知一樣,我惟一能確定的是 void,因此,我限制該通知返 回 void,並把這個問題交給編寫切入點的測試開發人員。
各取所需
假設我是一位裝置開發人員,需要調整來適應新的測試,如果必須在測試類中 實現 IConfigurable,那麼 看起來似乎不得不增加測試的“模式密集”。為了避 免這種情況,可以在 AspectJ 中聲明其他類型的成員或者父類,包括接口的默認 實現,只要所有定義保持二進制兼容即可。使用內部類型聲明增加了通知的類型 安全,從而更容易避免從 Object 的向下類型轉換。
是不是像其他成員那樣增加了目標類型的復雜性呢?其他類型的公共成員聲明 是可見的,因此在理論上可能增加目標類型的復雜性。但是,也可以將這些成員 聲明為某個方面私有的其他類型,因此,只有這個方面才能使用它們。這樣就可 以裝配組合對象,而不會造成把所有成員在類中聲明可能造成的一般沖突和交互 。
下面的代碼給出了一個例子,用 init(String) 方法使 Run 適應於 IConfigurable:
public class Run {
public void walk() { ... }
public void init(String arg) { ... }
}
public aspect RunConfiguration extends Configuration {
protected pointcut configuring (IConfigurable s) :
execution(void Run+.walk()) && target(s);
declare parents : Run implements IConfigurable;
/** Implement IConfigurable.getConfigurations() */
public Iterator Run.getConfigurations() {
Object[] configs = mockConfigurations ();
return Arrays.asList(configs).iterator();
}
/** Implement IConfigurable.configure(Object next) */
public void Run.configure(Object config) {
// hmm - downcast from mockConfigurations() element
String[] inputs = (String[]) config;
for (String input: inputs) {
init(input);
}
}
static String[][] mockConfigurations() {
return new String[][] { {"one", "two"}, {"three", "four"}};
}
}
測試標識符
測試標識符可由結果報告、選擇或配置以及底層的測試本身共享。在一些系統 中,只需要告訴用戶哪些測試正在運行即可;在另外一些系統中,需要一個惟一 的、一致的鍵來說明那些失敗的測試獲得通過(bug 修正),哪些通過的測試失 敗了(回歸)。JUnit 僅提供了一種表示,它繞過了共享的需要,使用 String Object.toString() 來獲得 String 表示。AspectJ 裝置也可作同樣的假設,但 是也可用上面所述的 IConfigurable 來補充測試,根據系統需要為給定類型的測 試計算和存儲標識符。“相同的”測試可以根據需要來配置不同的標識符(比如 ,用於診斷和回歸測試的標識符),這減少了 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 時,我們首先想到的是應用它是多麼 復雜。”該模式中,節點和葉子的角色被添加到已有的組件上,並且它們都需要 知道自己在實現組件接口時的職責。它們之間定義了調用協議,並由節點實現, 節點也包含子節點。這意味著節點知道子節點,同時裝置也知道節點。
在 JUnit 中,TestSuite(已經)非常了解 TestCase,JUnit 測試運行者假 設要通過加載 suite 類來生成一個套件。從配置中可以看到,支持可配置的測試 需要管理測試套件的生成。組合增加了模式密集。
Composite 模式在 AspectJ 中可使用內部類型聲明實現,如上面關於配置的 一節所述。在 AspectJ 中,所有成員都是在一個方面中聲明的,而不是分散在已 有的類中。這樣更容易發現角色是否被已有類的關注點污染了,在查看實現的時 候也更容易了解這是一個模式(而不僅僅是類的另一個成員)。最後,組合是可 用抽象方面實現的模式之一,可以使用標簽接口來規定擔任該角色的類。這意味 著可以編寫可重用的模式實現。(關於設計模式的 AspectJ 實現的更多信息,請 參閱 Nicholas Lesiecki 所撰寫的“Enhance design patterns with AspectJ” ,參見 參考資料。)
遞歸
AspectJ 能否不借助 Composite 模式而滿足原來的需要呢?AspectJ 提供了 運行多個測試的很多方法。上面關於配置的例子是一種方法:把一組子測試和一 個測試關聯,使用通知在切入點 recursing() 選擇的連接點上遞歸運行各個成分 。該切入點規定了應該遞歸的組合操作:
// in abstract aspect AComposite
/** tag interface for subaspects to declare */
public interface IComposite {}
/** pointcut for subaspects to declare */
protected abstract pointcut recursing(IComposite c);
/** composites have children */
public ArrayList<IComposite> IComposite.children
= new ArrayList<IComposite>();
/** when recursing, go through all subtree targets */
void around(IComposite c) : recursing(c) {
// recurse...
}
下面說明了如何將該方面應用於 Run:
public aspect CompositeRun extends AComposite {
declare parents : Run implements IComposite;
public pointcut recursing (IComposite c) :
execution(void Run+.walk()) && target (c);
}
將連接點封裝為對象
在連接點上遞歸?這就是有趣的地方。在 AspectJ around 通知中,可以使用 proceed(..) 運行連接點的其他部分。為了實現遞歸,可以通過將 proceed(..) 調用封裝在匿名類中來隱藏連接點的其他部分。為了在遞歸方法中傳遞,匿名類 應該擴展方法已知的包裝器類型。比如,下面定義了 IClosure 包裝器接口,將 proceed(..) 包裝到 around 通知中,並把結果傳遞給 recurse(..) 方法:
// in aspect AComposite...
/** used only when recursing here */
public interface IClosure {
public void runNext(IComposite next);
}
/** when recursing, go through all subtree targets */
void around(IComposite c) : recursing(c) {
recurseTop(c, new IClosure() {
// define a closure to invoke below
public void runNext(IComposite next) {
proceed(next);
}});
}
/** For clients to find top of recursion. */
void recurseTop(IComposite targ, IClosure closure) {
recurse(targ, closure);
}
/** Invoke targ or recurse through targ's children. */
void recurse(IComposite targ, IClosure closure) {
List children
= (null == targ?null:targ.children);
if ((null == children) || children.isEmpty()) {
// assume no children means leaf to run
closure.runNext(targ);
} else {
// assume children mean not a leaf to run
for (Object next: children) {
recurse((IComposite) next, closure);
}
}
}
使用 IClosure 可以結合 Command 模式和使用 proceed(..) 的通知的優點。 與 Command 類似,它也可以用新規定的參數在運行或重新運行中傳遞。與 proceed(..) 類似,它隱藏了連接點中其他上下文、其他低優先級通知和底層連 接本身的細節。它和連接點一樣通用,比通知更安全(因為上下文更加隱蔽), 並且和 Command 一樣可以重用。因為對目標類型沒有要求,所以,與 Command 相比,IClosure 的結合性更好。
如果逐漸習慣於封閉 proceed(..),不必感到奇怪,對許多 Java 開發人員來 說,這僅僅是一種很常見的怪事。如果在連接點完成之後調用 IClosure 對象, 那麼結果可能有所不同。
可用性
RunComposite 方面將這種組合解決方案應用於 Run 類,只需要用 IComposite 接口標記該類,並定義 recursing() 切入點即可。但是,為了將組 件安裝到樹中,需要添加子類,這意味著某個組裝器組件必須知道 Run 是帶有子 類的 IComposite。下面顯示了組件以及它們之間的關系:
Assembler, knows about...
Run component, and
CompositeRun concrete aspect, who knows about...
Run component, and
AComposite abstract aspect
您可能希望讓 CompositeRun 方面也負責發現每次運行的子類(就像帶配置的 組合那樣),但使用單獨的裝配器意味著不必將 Run 組合(對於所有運行都是一 樣的)與 Run 組合的特定應用(隨著聯系子類和特定 Run 子類的方式不同而變 )攪在一起。面向對象依賴性的規則依賴於穩定性的方向,特別是,(變化更多 的)具體的元素應該取決於是否要完全依賴於(更穩定的)抽象成分。按照這一 原則,上面的依賴性似乎不錯。
結合性
與配置一樣,組合通知(應用於測試用例時)應該優先於裝置和結果報告。如 果配置影響到測試的確認,那麼組合也應該優先於配置。按照這些約束,可得到 下面的順序:
Composition # recursion
Configuration # defining test, identity
Context # reporting results
Fixture # managing test data
test # underlying test
作為設計抽象的切入點
上面就是我對 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。
本文配套源碼