時間:2005-10-19 作者:Alexandre Vasseur,?Joakim Dahlstedt,?Jonas Bonér |
|

面向方面編程(Aspect-Oriented Programming,AOP)正在軟件社區和企業界中獲得強大的發展動力。自從20世紀90年代Xerox引入了AOP之后,AOP經過研究團體、開源社區和企業界的數次推動和革新,已經越來越成熟了。在Java領域,近兩年開源運動已經獲得了極大的推動,這導致AspectWerkz和AspectJ最近合并在一起,現在它們都歸入Eclipse Foundation,代號為AspectJ 5。AspectJ是由BEA Systems公司和IBM公司發起的,可以認為它是使用Java實現AOP的事實標準。
隨著AOP流行程度的逐漸增加和研究團體的不懈努力,詞匯表、概念和實現已經趨于一致,這得到了更完善的工具支持,允許更好的開發者體驗,比如,出現了AspectJ Eclipse插件AspectJ Development Tools (AJDT)。
AOP已經經歷了多種實現技術,從源代碼操作到字節碼測試(這是Java中一種廣泛采用的技術,特別是在Java 5 JVMTI出現之后)。如今,在應用程序管理和監視領域,有多種應用AOP的企業級產品采用了這種技術,并且最近隨著基于POJO(Plain Old Java Object)的中間件和透明集群的出現而變得越來越流行。
因此,無論如何,字節碼測試越來越可能成為你最終必須掌握的東西。你將不得不回答下面這些問題:字節碼測試技術究竟能夠把可管理性、透明性和效率擴展和實現到什么程度?依賴于字節碼測試的AOP實現會不會發展到盡頭,以至無法為更高的效率、易用性和動態性做進一步的革新?JVM對AOP的支持會解決這些問題嗎?能夠解決到什么程度?本系列文章通過揭示BEA JRockit JVM AOP支持的內幕以及激發在這個領域中的爭論,來提供這些問題的具體答案。
第一篇文章介紹AOP概念并且簡單地說明為什么許多實現(比如AspectJ)要基于字節碼操作。它解釋了與字節碼測試技術相關的一些限制,以及它們為什么會在長期運行過程中影響可伸縮性和可用性。然后,最后一節介紹JRockit JVM對AOP的支持,這一技術的目標是克服這些限制,為AOP和其他截取機制提供一個高效率的后端。
本系列文章的第2部分將通過具體的API細節和示例來說明這種支持的力度。
什么是面向方面編程 ?
面向對象的分析和設計引入了繼承、抽象和多態等概念,由此為我們提供了降低軟件復雜性的工具。但是,開發人員在軟件設計過程中仍然經常會面對無法用面向對象軟件開發技術輕易解決的問題。這些問題之一就是如何處理應用程序中的橫切關注點(Cross-cutting concerns)。
橫切關注點
關注點就是設計人員感興趣的某一概念或區域。例如,在一個訂貨系統中,核心關注點可能是訂單處理和生產,而系統關注點可能是事務處理和安全管理。
橫切關注點是影響多個類或模塊的關注點,即未能很好地局部化和模塊化的關注點。
橫切關注點的表現有:
·代碼糾結——當一個模塊或代碼段同時管理多個關注點時發生這種情況。
·代碼分散——當一個關注點分布在許多模塊中并且未能很好地局部化和模塊化時發生這種情況。
這些現象會從幾個方面影響軟件;例如,它們會導致軟件難以維護和重用,并且難以編寫和理解。
關注點的隔離
面向方面編程試圖通過引入“關注點的隔離”這一概念來解決這些問題。采用這一概念,可以以一種模塊化而且適當局部化的方式實現關注點。AOP解決這個問題的辦法是在設計空間中增加額外一維,并且引入了一些構造,這些構造使我們能夠定義橫切關注點,將它們轉移進新的維,并且以模塊化方式將它們打包。
AOP 引入的新構造
AOP引入了一些新的構造。聯結點(join point)構造準確反映了程序流中定義良好的點,例如調用方法的地方或者捕獲異常的地方。切點(pointcut)構造使我們能夠挑選出匹配某一標準的聯結點。建議(advice)構造使我們能夠添加應該在匹配的聯結點執行的代碼。引入(introduction)構造使我們能夠向現有的類添加額外的代碼,例如,向現有的類添加方法、字段或接口。最后,AOP引入了方面(aspect)構造,這是模塊化的AOP單元。方面由聯結點、切點、建議和引入來定義(也稱為類型間聲明)。
用 AspectJ 實現 AOP 的示例
下面是一些簡單的AspectJ 5代碼示例,它們在一定程度上說明了如何在代碼中實現上面定義的概念。要想進一步了解特定的AOP語言細節,請參考AspectJ文檔。
// using a dedicated syntax // that compliments the Java language public aspect Foo { pointcut someListOperation() : call(* List+.add(..)); pointcut userScope() : within(com.biz..*); before() : someListOperation() && userScope() { System.out.println("called: " + thisJoinPoint.getSignature() ); } }
以上代碼使用了一種專門的語法。可以使用Java注釋寫出等效的代碼。
// the Java 5 annotations approach @Aspect public class Foo { @Pointcut("call(* java.util.List+.add(..))") public void someListOperation() {} @Pointcut("within(com.biz..*)") public void userScope() {} @Before("someListOperation() && userScope()") public void before(JoinPoint thisJoinPoint) { System.out.println("called: " + thisJoinPoint.getSignature() ); } }
以上代碼定義了一個方面Foo,它具有兩個切點someListOperation()和userScope()。這些切點將在應用程序中挑選出一組聯結點。它們組合在一起成為一個布爾表達式someListOperation() && userScope(),這樣在擴展List的任何類型實例上,在每次調用名為add的任何方法之前都會執行before建議,前提條件是:調用是從com.biz包(及其子包)中的某些代碼發出的。這樣,before建議會在所有這些聯結點上輸出將被調用的方法的簽名。第二個代碼示例定義了一個非常相似的方面,只是采用了一種依賴Java 5注釋的替代語法。
什么是 編織 ?
正如前一節和代碼示例所描述的,方面可以對整個應用程序進行橫切。編織(waving)就是將方面和常規的面向對象應用程序“織”成一個單元(單個應用程序)的過程。
編織可以在不同時期進行:
- 編譯時編織:例如,在部署之前(因此也在運行時之前)進行代碼的后期處理(AspectJ 1.x中采用)。
- 裝載時編織:在裝載類的時候(也就是在部署時)進行編織(AspectWerkz 2.0中采用)。
- 運行時編織:編織可以在應用程序生命周期中的任何時候進行(JRockit 和SteamLoom中采用)。
這個過程還能以多種不同方式進行:
- 源代碼編織:輸入是已開發的源代碼,而輸出是經過修改的調用方面的源代碼(AspectJ 1.x中采用)。
- 字節碼編織:輸入是編譯出來的應用程序類的字節碼,而輸出是經過調整的編織過的應用程序的字節碼(AspectWerkz 2.0和AspectJ 1.1以及更高版本中采用)。
源代碼編織受到一定的限制,所有源代碼必須可用并提供給編織器,這樣才能應用方面。這就導致某些目標不可能實現,例如實現通用的監視服務。編譯時編織也受到同一問題的困擾:在編譯后進行部署之前,需要把將部署的所有字節碼準備好。
本系列文章全面介紹了字節碼編織和JVM 編織,從下一節開始將討論這些內容。
隨便提一下動態代理(Dynamic proxies),這是一種受限的編織方式,它在JVM中已經存在了一段時間了。這個API自從1.3版開始就是JDK的一部分,它允許為一個接口(和/或一系列接口)創建一個動態虛擬代理,這樣就有可能截取對這個代理的每個調用,并且將其重定向到你希望的任何地方。根據定義,這并不是真正的編織,但是它與編織類似的地方是它提供了進行方法截取的簡單方式。各種框架采用它來進行簡單的AOP,例如Spring Framework。
基于字節碼測試進行編織的問題
值得強調的是,下面提到的問題與字節碼測試相關,因此,當前的AOP實現(比如AspectJ)會受到它們的困擾。總的來說,這些問題會影響所有基于字節碼測試的產品,比如應用程序監視解決方案、分析工具或其他應用AOP的解決方案。 |
測試是低效率的
編織的實際測試部分往往非常消耗CPU,而且有時還會消耗大量內存。這會影響啟動時間。例如,要想截取所有對toString()方法的調用或者對某個字段的所有訪問,需要逐一分析所有類中的幾乎每一條字節碼指令。這還意味著字節碼測試框架將創建許多中間表示結構,以一種有用的方式公開字節碼指令。這可能意味著編織器需要分析整個應用程序(包括第三方庫等)中所有類中的所有字節碼指令。在糟糕的情況下,這可能會涵蓋超過10, 000個類。
如果使用多個編織器,那么開銷就會成倍增加。
雙重 記錄 :為編織器構建類數據庫是代價高昂的
為了知道類、方法或字段是否應該被編織,編織器需要對這個類或成員的元數據進行匹配。大多數AOP框架和應用AOP的產品具有某種高級表達式語言(切點表達式),用于定義代碼塊(建議)應該被編織在哪里(在哪些聯結點上)。例如,這些表達式語言使你能夠挑選出具有某種返回類型的所有方法,這種類型實現了類型T的接口。在代表對特定方法M進行調用的字節碼指令中,這一信息是不可用的。了解這個特定方法M是否應該被編織的唯一辦法是,在某種形式的類數據庫中查找它,查詢它的返回類型,并且檢查它的返回類型是否實現了給定的接口T。
你可能會認為:為什么不只使用java.lang.reflect.* API?在這里使用反射的問題是,如果不觸發這個類的類裝載,就無法通過反射查詢Java類型,這將在我們掌握進行編織所需的足夠信息(在裝載時編織基礎架構中)之前觸發這個類的編織。簡單地說,這就成了典型的雞生蛋/蛋生雞問題。
因此,編織器需要一個類數據庫(常常從硬盤讀取原始字節碼,在內存中建立),這樣才能對實際的聯結點是否需要某個方法進行必要的查詢。有時候,可以通過限制表達式語言的可表達性來避免這個問題,但是這種做法常常會限制產品的可用性。
一旦編織完成,這個內存中的類數據庫就是多余的。JVM已經在它自己的數據庫中保存了所有信息,而且是經過優化的(比如,它為java.lang.reflect API服務就使用這些信息)。所以,我們最終對整個類結構(對象模型)進行了雙重記錄,這會不必要地消耗可觀的內存,而且由于創建這個類數據庫以及在發生變化時維護它,會增加啟動開銷。
如果使用多個編織器,那么開銷就會成倍增加。
HotSwap :在運行時改變字節碼會增加復雜性
Java 5引入了HotSwap API,作為JVMTI規范的一部分。在Java 5之前,這個API只有運行于調試模式時才可用,而且只對本機C/C++ JVM擴展有效。這個API允許在運行時修改字節碼——即重新定義一個類。一些AOP框架和應用AOP的產品使用它模擬運行時編織功能。
盡管這個API非常強大,但是它在以下這些方面限制了可用性和可伸縮性:
- 它的效率不高。因為在運行時改變字節碼,所以在運行時也會產生測試開銷(CPU開銷和內存開銷)。另外,如果需要修改許多地方,就意味著要重新定義許多類。然后,JVM將不得不重新執行它以前可能執行過的所有優化和內聯工作。
- 它受到很大的限制。這個API沒有指定當前運行字節碼的地方可以安全更改。因此,編織器需要假設此字節碼在硬盤上,否則它就需要跟蹤此字節碼。當使用多個編織器時,這是個大問題,這在下一節解釋。
另外,HotSwap API的當前實現不支持方案修改(schema change),規范中將此功能聲明為可選的。這意味著不可能在運行時修改類的方案,例如,添加底層測試模型可能需要的方法/字段/接口。這導致不可能實現某些運行時編織類型,并且因此要求用戶提前“準備好”類。
多個代理是個問題
當多個產品正在使用字節碼測試時,可能會發生出乎意料的問題。問題涉及到先后次序、更改通知、更改撤消等。這在當今可能還不是個大問題,但是以后將成為嚴重的問題。編織器可以視為代理(JVMTI規范中就是采用這種稱呼),它在裝載時或運行時執行測試。當使用多個代理時,就會存在很高的風險,因為代理以各自的方式獲得字節碼,并可能以出乎下一個代理意料的方式修改字節碼,而原來假設是只有單獨配置的代理。
下面是當兩個代理互不了解時出現問題的例子。如果有人使用兩個代理——一個編織器和某個應用程序性能產品,它們都在裝載時執行字節碼測試,根據配置,編織后的代碼可能是也可能不是性能度量的一部分,如下所示:
// say this is the original user code void businessMethod() { userCode.do(); } //---- Case 1 // say the AOP weaver was applied BEFORE the // performance management weaver // the woven code will behave like: void businessMethod() { try { performanceEnter(); aopBeforeExecuting();//hypothetical advice userCode.do() } finally { performanceExit(); } } // ie the AOP code affect the measure //---- Case 2 // say the AOP weaver was applied AFTER the // performance management weaver // the woven code will behave like: void businessMethod() { aopBeforeExecuting();//hypothetical advice try { performanceEnter(); userCode.do() } finally { performanceExit(); } } // ie the AOP code will NOT affect the measure
關于這些代理的先后次序有一個問題;在聯結點(或切點)級別上沒有控制次序的細粒度配置方法。
某些其他情況可能導致更加無法預測的結果。例如,當一個字段訪問被截取時,這往往意味著字段獲取(field get)字節碼指令被移動到一個新添加的方法,并且被替換為對這個新方法的調用。因此,下一個編織器將在代碼中的另一個位置(在那個新添加的方法中)看到一個字段訪問,而它自己的匹配機制和配置可能不匹配這個位置。
總之,主要問題如下:
- 代理看到哪些字節碼?問題是,正常情況下,被編織的字節碼是從類裝載管道獲得的,但是建立類數據庫所依賴的字節碼是從硬盤讀取的。當涉及多個代理時,硬盤上的字節碼不再是正在執行的字節碼了;因為某個代理可能已經修改了字節碼,這意味著第二個代理看到的是錯誤的字節碼視圖。當使用HotSwap API時,也會發生這種情況。
- 當代理A撤消或者改變它的編織操作時,可能會出現問題。如果另一個代理B在代理A之后已經執行了修改,那么代理B可能已經重新構造了字節碼,導致字節碼看起來完全不一樣了(盡管其功能是一樣的),在此情況下,代理A就不知道該怎么做了。
截取反射式調用是不可能的
當前的編織方式只能測試(至少是部分地)可靜態確定的執行流。請考慮以下代碼示例,它在給定的實例foo上調用方法void doA()。
public void invokeA(Object foo) throws Throwable { Method mA = foo.getClass().getDeclaredMethod("doA",
new Class[0]); mA.invoke(foo, new Object[0]); }
在現代的代碼庫中常常使用這種反射式訪問來創建實例、調用方法或者訪問字段。
從字節碼的角度來看,對方法void doA()的調用是看不到的。編織器只看到對java.lang.reflect API的調用。還沒有簡單且高效的辦法可以對通過反射執行的調用進行編織。目前,這對于如何執行編織以及如何實現AOP是很重要的限制。最好的辦法是,開發人員使用執行端切點來代替。顯然,從JVM的角度來看,存在一個對doA()方法的方法調度,盡管這在源代碼或字節碼中沒有出現。已經證明,JVM編織是以高效的方式解決這個問題的唯一編織機制。
其他問題
某些人對字節碼測試持懷疑態度,尤其是在動態執行的情況下(在裝載時或運行時)。對于動態修改代碼,存在著一種不應低估的情緒化影響,尤其是在與某種盲目的革命性新技術(比如AOP或服務的透明式插入)結合使用時。在涉及多個代理時可能發生的混亂將增加人們的懷疑。
另一個潛在的問題是Java規范中對類文件規定的64Kb邊界。方法體的字節碼指令總長度被限制為64Kb。在編織已經很大的類文件(例如,將JSP文件編譯為servlet時產生的類文件)時,這可能會導致問題。在處理這個類時,可能會突破64Kb的限制,這就會導致運行時錯誤。
提議的解決方案
對于上面討論的大多數問題,JVM編織是自然的解決方案。為了理解其原因,我們將查看兩個示例。這些示例說明,JVM已經做了執行編織所需的大多數工作:當類被裝載時,JVM讀取字節碼以便建立為java.lang.reflect.* API服務所需的數據。另一個例子是方法調度。現代的JVM將方法或代碼塊的字節碼編譯為更高級而且更高效的構造和執行流(在可以應用代碼內聯的地方進行代碼內聯)。由于HotSwap API的需要,JRockit JVM(可能還包括其他JVM)還會記錄哪個方法調用了其他方法,這樣如果在運行時重新定義某個類,那么在所有期望的位置(內聯的或非內聯的),類中定義的方法體仍然可以被熱交換。
因此,不必為了編織進一個建議調用而修改字節碼,比如說在特定的方法調用之前。JVM實際上可以掌握關于這個建議調用的知識,它會在任何匹配的聯結點上對此建議進行調度,然后再調度實際的方法。
由于不接觸字節碼,可以預期到直接的好處,比如:
- 不會由于字節碼測試而導致啟動開銷。
- 對于在任何位置、任何時間、以線性開銷添加和刪除建議的完全的運行時支持。
- 對建議的反射式調用的隱式支持。
- 不需要占用額外的內存來將類模型復制到某些框架特有的結構。
本系列的第二篇文章將詳細描述提議的JRockit JVM對AOP的支持。
以下代碼示例作了總結性說明。它在調用sayHello()方法之前對靜態方法advice()進行調度:
public class Hello { // -- the sample method to intercept public void sayHello() { System.out.println("Hello World"); } // -- using the JRockit JVM support for AOP static void weave() throws Throwable { // match on method name StringFilter methodName = new StringFilter( "sayHello", StringFilter.Type.EXACT ); // match on callee type ClassFilter klass = new ClassFilter( Hello.class, false, null ); // advice is a regular method dispatch Method advice = Aspect.class.getDeclaredMethod( "advice", new Class[0] ); // get a JRockit weaver and subscribe the // advice to the join point picked out by the filter Weaver w = WeaverFactory.createWeaver(); w.addSubscription(new MethodSubscription( new MethodFilter( 0, null, klass, methodName, null, null ), MethodSubscription.InsertionType.BEFORE, advice )); } // -- sample code static void test() { new Hello().sayHello(); } public static void main(String a[]) throws Throwable { weave(); test(); } // -- the sample aspect public static class Aspect { public static void advice() { System.out.println("About to say:"); } } }
結束語
在Java社區中已經開始流行使用字節碼測試來實現中間件領域中的高級技術,比如AOP或者透明式服務插入。但是,幾個關鍵的限制妨礙了字節碼測試,而且它的廣泛使用將導致更多的問題,影響可伸縮性和可用性。
因為字節碼測試在某種程度上已經成了在AOP中實現編織的標準方式,所以本文中描述的限制和問題將會妨礙它(可能已經妨礙它了)。
我們相信,JVM對AOP的支持是這些問題的自然解決方案。我們將要提供一個已經在JRockit JVM中實現的基于訂閱的API,它與JVM方法調度組件緊密集成。本系列中的下一篇文章將更詳細地講解這個API,并且解釋如何解決每個問題。