Java 理論與實踐: 用動態代理進行修飾
動態代理為實現許多常見設計模式(包括 Facade、Bridge、Interceptor、Decorator、Proxy(包括遠端和虛擬代理)和 Adapter 模式)提供了替代的動態機制。雖然這些模式不使用動態代理,只用普通的類就能夠實現,但是在許多情況下,動態代理方式更方便、更緊湊,可以清除許多手寫或生成的類。
Proxy 模式中要建立“stub”或“surrogate”物件,它們的目的是接受請求並把請求轉發到實際執行工作的其他物件。遠端方法呼叫(RMI)利用 Proxy 模式,使得在其他 JVM 中執行的物件就像本地物件一樣;企業 JavaBeans (EJB)利用 Proxy 模式新增遠端呼叫、安全性和事務分界;而 JAX-RPC Web 服務則用 Proxy 模式讓遠端服務表現得像本地物件一樣。在每一種情況中,潛在的遠端物件的行為是由介面定義的,而介面本質上接受多種實現。呼叫者(在大多數情況下)不能區分出它們只是持有一個對 stub 而不是實際物件的引用,因為二者實現了相同的介面;stub 的工作是查詢實際的物件、封送引數、把引數傳送給實際物件、解除封送返回值、把返回值返回給呼叫者。代理可以用來提供遠端控制(就像在 RMI、EJB 和 JAX-RPC 中那樣),用安全性策略包裝物件(EJB)、為昂貴的物件(EJB 實體 Bean)提供惰性裝入,或者新增檢測工具(例如日誌記錄)。
在 5.0 以前的 JDK 中,RMI stub(以及它對等的 skeleton)是在編譯時由 RMI 編譯器(rmic)生成的類,RMI 編譯器是 JDK 工具集的一部分。對於每個遠端介面,都會生成一個 stub(代理)類,它代表遠端物件,還生成一個 skeleton 物件,它在遠端 JVM 中做與 stub 相反的工作 —— 解除封送引數並呼叫實際的物件。類似地,用於 Web 服務的 JAX-RPC 工具也為遠端 Web 服務生成代理類,從而使遠端 Web 服務看起來就像本地物件一樣。
不管 stub 類是以原始碼還是以位元組碼生成的,程式碼生成仍然會向編譯過程新增一些額外步驟,而且因為命名相似的類的泛濫,會帶來意義模糊的可能性。另一方面,動態代理機制支援在編譯時沒有生成 stub 類的情況下,在執行時建立代理物件。在 JDK 5.0 及以後版本中,RMI 工具使用動態代理代替了生成的 stub,結果 RMI 變得更容易使用。許多 J2EE 容器也使用動態代理來實現 EJB。EJB 技術嚴重地依靠使用攔截(interception)來實現安全性和事務分界;動態代理為介面上呼叫的所有方法提供了集中的控制流程路徑。
動態代理機制的核心是 InvocationHandler
介面,如清單 1 所示。呼叫控制代碼的工作是代表動態代理實際執行所請求的方法呼叫。傳遞給呼叫控制代碼一個 Method
物件(從 java.lang.reflect
包),引數列表則傳遞給方法;在最簡單的情況下,可能僅僅是呼叫反射性的方法 Method.invoke()
並返回結果。
清單 1. InvocationHandler 介面
public interface InvocationHandler { Object invoke(Object proxy, Method method, Object[] args) throws Throwable; } |
每個代理都有一個與之關聯的呼叫控制代碼,只要代理的方法被呼叫時就會呼叫該控制代碼。根據通用的設計原則:介面定義型別、類定義實現,代理物件可以實現一個或多個介面,但是不能實現類。因為代理類沒有可以訪問的名稱,它們不能有建構函式,所以它們必須由工廠建立。清單 2 顯示了動態代理的最簡單的可能實現,它實現 Set
介面並把所有 Set
方法(以及所有
Object 方法)分派給封裝的Set
例項。
清單 2. 包裝 Set 的簡單的動態代理
public class SetProxyFactory { public static Set getSetProxy(final Set s) { return (Set) Proxy.newProxyInstance (s.getClass().getClassLoader(), new Class[] { Set.class }, new InvocationHandler() { public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { return method.invoke(s, args); } }); } } |
SetProxyFactory
類包含一個靜態工廠方法 getSetProxy()
,它返回一個實現了 Set
的動態代理。代理物件實際實現 Set
——
呼叫者無法區分(除非通過反射)返回的物件是動態代理。SetProxyFactory
返回的代理只做一件事,把方法分派給傳遞給工廠方法的 Set
例項。雖然反射程式碼通常比較難讀,但是這裡的內容很少,跟上控制流程並不難 —— 只要某個方法在 Set
代理上被呼叫,它就被分派給呼叫控制代碼,呼叫控制代碼只是反射地呼叫底層包裝的物件上的目標方法。當然,絕對什麼都不做的代理可能有點傻,是不是呢?
對於像 SetProxyFactory
這樣什麼都不做的包裝器來說,實際有個很好的應用 —— 可以用它安全地把物件引用的範圍縮小到特定介面(或介面集)上,方式是,呼叫者不能提升引用的型別,使得可以更安全地把物件引用傳遞給不受信任的程式碼(例如外掛或回撥)。清單 3 包含一組類定義,實現了典型的回撥場景。從中會看到動態代理可以更方便地替代通常用手工(或用 IDE 提供的程式碼生成嚮導)實現的 Adapter 模式。
清單 3. 典型的回撥場景
public interface ServiceCallback { public void doCallback(); } public interface Service { public void serviceMethod(ServiceCallback callback); } public class ServiceConsumer implements ServiceCallback { private Service service; ... public void someMethod() { ... service.serviceMethod(this); } } |
ServiceConsumer
類實現了 ServiceCallback
(這通常是支援回撥的一個方便途徑)並把 this
引用傳遞給 serviceMethod()
作為回撥引用。這種方法的問題是沒有機制可以阻止 Service
實現把 ServiceCallback
提升為 ServiceConsumer
,並呼叫 ServiceConsumer
不希望 Service
呼叫的方法。有時對這個風險並不關心
—— 但有時卻關心。如果關心,那麼可以把回撥物件作為內部類,或者編寫一個什麼都不做的介面卡類(請參閱清單 4 中的 ServiceCallbackAdapter
)並用 ServiceCallbackAdapter
包裝ServiceConsumer
。ServiceCallbackAdapter
防止 Service
把 ServiceCallback
提升為 ServiceConsumer
。
清單 4. 用於安全地把物件限制在一個介面上以便不被惡意程式碼不能的介面卡類
public class ServiceCallbackAdapter implements ServiceCallback { private final ServiceCallback cb; public ServiceCallbackAdapter(ServiceCallback cb) { this.cb = cb; } public void doCallback() { cb.doCallback(); } } |
編寫 ServiceCallbackAdapter
這樣的介面卡類簡單卻乏味。必須為包裝的介面中的每個方法編寫重定向類。在 ServiceCallback
的示例中,只有一個需要實現的方法,但是某些介面,例如 Collections 或 JDBC 介面,則包含許多方法。現代的 IDE 提供了“Delegate Methods”嚮導,降低了編寫介面卡類的工作量,但是仍然必須為每個想要包裝的介面編寫一個介面卡類,而且對於只包含生成的程式碼的類,也有一些讓人不滿意的地方。看起來應當有一種方式可以更緊湊地表示“什麼也不做的限制介面卡模式”。
清單 2 中的 SetProxyFactory
類當然比用於 Set
的等價的介面卡類更緊湊,但是它仍然只適用於一個介面:Set
。但是通過使用泛型,可以容易地建立通用的代理工廠,由它為任何介面做同樣的工作,如清單
5 所示。它幾乎與 SetProxyFactory
相同,但是可以適用於任何介面。現在再也不用編寫限制介面卡類了!如果想建立代理物件安全地把物件限制在介面 T
,只要呼叫getProxy(T.class,object)
就可以了,不需要一堆介面卡類的額外累贅。
清單 5. 通用的限制介面卡工廠類
public class GenericProxyFactory { public static<T> T getProxy(Class<T> intf, final T obj) { return (T) Proxy.newProxyInstance(obj.getClass().getClassLoader(), new Class[] { intf }, new InvocationHandler() { public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { return method.invoke(obj, args); } }); } } |
當然,動態代理工具能做的,遠不僅僅是把物件型別限制在特定介面上。從 清單 2 和 清單
5 中簡單的限制介面卡到 Decorator 模式,是一個小的飛躍,在 Decorator 模式中,代理用額外的功能(例如安全檢測或日誌記錄)包裝呼叫。清單 6 顯示了一個日誌InvocationHandler
,它在呼叫目標物件上的方法之外,還寫入一條日誌資訊,顯示被呼叫的方法、傳遞的引數,以及返回值。除了反射性的 invoke()
呼叫之外,這裡的全部程式碼只是生成除錯資訊的一部分
—— 還不是太多。代理工廠方法的程式碼幾乎與GenericProxyFactory
相同,區別在於它使用的是 LoggingInvocationHandler
而不是匿名的呼叫控制代碼。
清單 6. 基於代理的 Decorator,為每個方法呼叫生成除錯日誌
private static class LoggingInvocationHandler<T> implements InvocationHandler { final T underlying; public LoggingHandler(T underlying) { this.underlying = underlying; } public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { StringBuffer sb = new StringBuffer(); sb.append(method.getName()); sb.append("("); for (int i=0; args != null && i<args.length; i++) { if (i != 0) sb.append(", "); sb.append(args[i]); } sb.append(")"); Object ret = method.invoke(underlying, args); if (ret != null) { sb.append(" -> "); sb.append(ret); } System.out.println(sb); return ret; } } |
如果用日誌代理包裝 HashSet
,並執行下面這個簡單的測試程式:
Set s = newLoggingProxy(Set.class, new HashSet()); s.add("three"); if (!s.contains("four")) s.add("four"); System.out.println(s); |
會得到以下輸出:
add(three) -> true contains(four) -> false add(four) -> true toString() -> [four, three] [four, three] |
這種方式是給物件新增除錯包裝器的一種好的而且容易的方式。它當然比生成代理類並手工建立大量 println()
語句容易得多(也更通用)。我進一步改進了這一方法;不必無條件地生成除錯輸出,相反,代理可以查詢動態配置儲存(從配置檔案初始化,可以由 JMX MBean
動態修改),確定是否需要生成除錯語句,甚至可能在逐個類或逐個例項的基礎上進行。
在這一點上,我認為讀者中的 AOP 愛好者們幾乎要跳出來說“這正是 AOP 擅長的啊!”是的,但是解決問題的方法不止一種 —— 僅僅因為某項技術能解決某個問題,並不意味著它就是最好的解決方案。在任何情況下,動態代理方式都有完全在“純 Java”範圍內工作的優勢,不是每個公司都用(或應當用) AOP 的。
代理也可以用作真正的介面卡,提供了物件的一個檢視,匯出與底層物件實現的介面不同的介面。呼叫控制代碼不需要把每個方法呼叫都分派給相同的底層物件;它可以檢查名稱,並把不同的方法分派給不同的物件。例如,假設有一組表示持久實體 (Person
、Company
和 PurchaseOrder
)
的 JavaBean 介面,指定了屬性的 getter 和 setter,而且正在編寫一個持久層,把資料庫記錄對映到實現這些介面的物件上。現在不用為每個介面編寫或生成類,可以只用一個 JavaBean 風格的通用代理類,把屬性儲存在 Map 中。
清單 7 顯示的動態代理檢查被呼叫方法的名稱,並通過查詢或修改屬性圖直接實現 getter 和 setter 方法。現在,這一個代理類就能實現多個 JavaBean 風格介面的物件。
清單 7. 用於把 getter 和 setter 分派給 Map 的動態代理類
public class JavaBeanProxyFactory { private static class JavaBeanProxy implements InvocationHandler { Map<String, Object> properties = new HashMap<String, Object>(); public JavaBeanProxy(Map<String, Object> properties) { this.properties.putAll(properties); } public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { String meth = method.getName(); if (meth.startsWith("get")) { String prop = meth.substring(3); Object o = properties.get(prop); if (o != null && !method.getReturnType().isInstance(o)) throw new ClassCastException(o.getClass().getName() + " is not a " + method.getReturnType().getName()); return o; } else if (meth.startsWith("set")) { // Dispatch setters similarly } else if (meth.startsWith("is")) { // Alternate version of get for boolean properties } else { // Can dispatch non get/set/is methods as desired } } } public static<T> T getProxy(Class<T> intf, Map<String, Object> values) { return (T) Proxy.newProxyInstance (JavaBeanProxyFactory.class.getClassLoader(), new Class[] { intf }, new JavaBeanProxy(values)); } } |
雖然因為反射在 Object
上工作會有潛在的型別安全性上的損失,但是,JavaBeanProxyFactory 中的 getter 處理會進行一些必要的額外的型別檢測,就像我在這裡用 isInstance()
對 getter 進行的檢測一樣。
正如已經看到的,動態代理擁有簡化大量程式碼的潛力 —— 不僅能替代許多生成的程式碼,而且一個代理類還能代替多個手寫的類或生成的程式碼。什麼是成本呢? 因為反射地分派方法而不是採用內建的虛方法分派,可能有一些效能上的成本。在早期的 JDK 中,反射的效能很差(就像早期 JDK 中幾乎其他每件事的效能一樣),但是在近 10 年,反射已經變得快多了。
不必進入基準測試構造的主題,我編寫了一個簡單的、不太科學的測試程式,它迴圈地把資料填充到 Set
,隨機地對 Set
進行插入、查詢和刪除元素。我用三個 Set
實現執行它:一個未經修飾的 HashSet
,一個手寫的、只是把所有方法轉發到底層的 HashSet
的 Set
介面卡,還有一個基於代理的、也只是把所有方法轉發到底層 HashSet
的 Set
介面卡。每次迴圈迭代都生成若干隨機數,並執行一個或多個 Set
操作。手寫的介面卡比起原始的 HashSet
只產生很少百分比的效能負荷(大概是因為
JVM 級有效的內聯緩衝和硬體級的分支預測);代理介面卡則明顯比原始 HashSet
慢,但是開銷要少於兩個量級。
我從這個試驗得出的結論是:對於大多數情況,代理方式即使對輕量級方法也執行得足夠好,而隨著被代理的操作變得越來越重量級(例如遠端方法呼叫,或者使用序列化、執行 IO 或者從資料庫檢索資料的方法),代理開銷就會有效地接近於 0。當然也存在一些代理方式的效能開銷無法接受的情況,但是這些通常只是少數情況。
動態代理是強大而未充分利用的工具,可以用於實現許多設計模式,包括 Proxy、Decorator 和 Adapter。這些模式基於代理的實現容易編寫,更難出錯,並且具備更好的通用性;在許多情況下,一個動態代理類可以充當所有介面的 Decorator 或 Proxy,這樣就不用每個介面都編寫一個靜態類。除了最關注效能的應用程式之外,動態代理方式可能比手寫或機器生成 stub 的方式更可取。