ClassLoader解決方案只需要投入一次成本,它提供了一個解決類版本衝突的方法
最近,我不斷聽到同事和熟人抱怨J2EE應用伺服器中出現的軟體版本衝突。這個基礎問題由來已久,但是,隨著應用程式與應用伺服器之間共用的Java庫日益增多,這個問題似乎也越來越嚴重。當應用伺服器使用一個Java包的A版本,而位於這台伺服器上的應用程式卻使用這個包的B版本時,如果這兩個版本不相容,那麼就會產生版本衝突。當應用程式試圖使用這個包,系統載入的是版本A中的類,而不是B版本中的類。如果這兩種類的行為不同,就會出現問題。
這種情況相當普遍,部分原因是因為如此多的應用伺服器都在某種程度上依靠於開源軟體。商業性軟體的發布周期通常沒有開源軟體的發布周期那麼短。因此,新發布的應用伺服器經常不包含一些庫的最新版本。另外,企業軟體升級周期又落後於供應商的發布周期,而且為了保持穩定性,有時候也會跳過發布周期。結果,開發人員想當然地使用最新最好的Java庫,一頭紮進組合更舊版本的一些庫的J2EE伺服器中。
這個問題也會出現在其他方向上。在升級應用伺服器時,多年來一直使用的公司專屬應用程式軟體可能會遇見相容問題。如果程式依靠於與應用伺服器打包在一起的舊版本的庫,那麼在程式試圖訪問一個不存在的API元素時,新的庫會引發運行時異常,比如NoSuchMethodException。
很多時候,程式員沒有注意到正在使用的是不同版本的庫(這裡,我認為類是Java包的集合),因為他們沒有使用已經變化了的那一部分API,或者沒有引起在後來版本中得到修正的缺陷。問題一旦發生,開發人員就不得不想辦法解決它。替換應用伺服器庫會使應用伺服器或者該伺服器上的其他應用程式中斷。如果開發人員沒有對應用伺服器的管理控制權,那麼這個解決方案根本不可行。
共用和類共用
這個問題的癥結在於大多數J2EE供應商為他們的產品設計了一個類載入階層,這個階層最終會把類載入委託給應用伺服器的類載入程式。即使給每個Web應用程式指派單獨的類載入程式,防止Web應用程式彼此幹涉,伺服器本身使用的庫仍然可以被所有Web應用程式共用。我想肯定一些產品沒有這個問題,但是我聽到的有關報道說,這個問題幾乎出現於大多數主要的開放原始碼產品和封閉原始碼產品上。
編程人員用來解決版本衝突的常用方法是:將庫的原始碼中的包的名稱改成只由其自己代碼使用的惟一名稱,重新編譯庫,並將所有匯入語句及其引用更新為其原始碼中的包的名稱。這個解決方案只在訪問庫的原始碼時起作用,然而並不是總是如此。儘管原始碼修正和重新編譯是一個短期可靠的解決方案,從長遠角度來看,實際上它花費的時間更長。編寫shell指令碼或使用IDE外掛程式能夠使包自動重新命名。
尋找並替換原始碼中出現的所有包的名稱,這樣將捕獲包名稱在包聲明和匯入語句之外的地方的使用方式,比如說,在使用完全限定類名稱時,或有在用於反射的字串中插入名稱時。設定檔和其他支援檔案都可以包含名稱,並且反射中使用的一些名稱可以自動產生。您不必總是仔細地檢查代碼來重新命名某一個名稱的所有執行個體。更重要的是,當升級到新版本時,必須重複一遍整個過程。基於以前的修改的補丁檔案不會重新命名出現在新版本中的所有包的名稱,並且shell指令碼可能要求進行更新來應對代碼更改。
最後,原始碼修正為維護帶來了一個難題。您要花費人手和時間來維護一個原本不需要維護的東西,它實際上是原始碼樹的一個獨立分支。
原始碼修正的兩個主要缺陷是:必須訪問原始碼,而維護原始碼需要做相當多的工作。維護一個單獨的庫的分支看起來似乎不是十分困難,但是那些想解決這個問題的人必須解決與多個庫的衝突。
原始碼包重名命名的一個替換解決方案是重寫二進位類檔案。重寫類檔案有一個好處:不需要維護一個單獨的原始碼分支,也不需要維護原始碼。惟一需要的是JAR檔案。專門重新命名JAR檔案中的包的工具很少,但是大多數代碼混淆工具(code obfuscation tool)都有重新命名包和JAR檔案中包含的類的能力。用這個方法可以使庫的升級變得容易一些。您所要做的就是用重寫工具處理JAR檔案,然後就大功告成。
想做便做!
儘管類檔案重寫看上去似乎很有效,但實際上這還不是一個完美的解決方案。當庫使用反射以及在字串或設定檔中嵌入包的名稱時,這種方法不起作用。它還不能把您從更改應用程式原始碼中包名稱的使用方式的不懈努力中解脫出來。
一個更全面的解決方案是做一些應用伺服器供應商應該首先作的事情,然後通過使用一個自訂類載入器把您的庫的副本與伺服器的庫的副本分離開。要做到這一點,必須編寫一些額外的代碼,但是不必改變現有源檔案使用包名稱的方式。庫升級變得簡單是因為您只需使用新的JAR檔案取代舊的檔案即可。如何做到這一點的呢?
版本衝突的根源是應用伺服器的類載入設計。Web應用程式類載入器在試圖自己定位一個類之前,把類的載入委託給了這個類的父類載入器。因此,如果應用伺服器的類載入器能夠在系統位置上找到這個類,那麼它會載入那個版本,而不是載入和Web應用程式一起打包的那個版本。如果使用您自己的沒有父類的類載入器來引導應用程式,那麼您就可以繞過應用伺服器使用的庫。
作為這項技巧的一個例子,我定義了一個叫做Printer的介面和一個叫做VersionPrinter的實作類別,這個類表示一個應用程式。 VersionPrinter依靠於Version類,但是需要特定的5.0.0版本。然而,應用伺服器用的是1.0.2版本。因此,在調用VersionPrinter.print時,就會輸出字串“version: 1.0.2”。
哪一個版本?
清單1. VersionPrinter使用一個新的5.0.0版本的Version,但是應用伺服器裝載的是老的1.0.2版本。
public interface Printer { public void print();}public class VersionPrinter implements Printer { public void print() { Version v = new Version(); System.out.println("version: " + v.getVersion()); }}public class Version { public String getVersion() { return "1.0.2"; }}public class Version { public String getVersion() { return "5.0.0"; }}
自訂類載入
清單2. 使用自訂類載入器和一個動態代理,您可以繞過應用伺服器的類路徑。
package example;import java.io.*;import java.net.URL;import java.net.URLClassLoader;import java.lang.reflect.*;public final class Main { static class PrinterInvoker implements InvocationHandler { Object adaptee; public PrinterInvoker(Object adaptee) { this.adaptee = adaptee; } public Object invoke( Object proxy, Method method, Object[] args) throws Throwable { Method adapteeMethod = adaptee.getClass().getMethod( method.getName(), method.getParameterTypes()); if(!adapteeMethod.isAccessible()) adapteeMethod.setAccessible(true); return adapteeMethod.invoke(adaptee, args); } } public static final void main(String[] args) throws Exception { VersionPrinter vp = new VersionPrinter(); vp.print(); // hardcoded demo paths from build.xml File path1 = new File(System.getProperty("user.dir"), "build.src2"); File path2 = new File(System.getProperty("user.dir") , "build.src"); URL[] classpath = new URL[] { path1.toURL(), path2.toURL() }; URLClassLoader cl = new URLClassLoader( classpath, null); Object obj = cl.loadClass( "example.VersionPrinter").newInstance(); Printer p = (Printer)Proxy.newProxyInstance( Printer.class.getClassLoader(), new Class[] { Printer.class }, new PrinterInvoker(obj)); p.print(); }}
通過定義一個類載入器,可以繞過應用伺服器的庫,這個類載入器在查看伺服器庫目錄之前,會首先查看自己的庫目錄。通過把兩個Version類放進兩個不同的構建目錄中,並先在類載入路徑中放置了包含Version類的5.0.0版本的目錄(參見清單2),我類比了這個類載入器。接著,我建立了一個URLClassLoade執行個體,並用自訂路徑和一個空的父類對其進行初始化。空的父類可以確保類的載入不會委託給父類。然後,我載入了這個類,並使用一個動態代理把它映射到一個已知介面。在運行這個樣本程式時,直接調用VersionPrinter.print將輸出“version: 1.0.2”,而動態代理調用將輸出“version: 5.0.0”,這些輸出結果顯示了想要使用的類版本,而不是預設版本。
使用例子中的技巧,您根本不必更改應用程式代碼。有時,編程人員會自己載入一些類似Version的特殊類,但也許您不想那樣做。如果打算這樣,您將不得不更改VersionPrinter。這樣,就必須通過反射訪問每一個衝突類。那會使代碼變得一團糟。您想做是:建立一個由介面定義的應用程式進入點(比如,Printer),並自訂載入那個應用程式。然後,自訂類載入器將載入這個應用程式使用的所有更深層的類。
一次性購買
實現一個能夠用自訂類路徑和委派servlet(delegate servlet)配置的封裝器servlet是有可能的。封裝器 servlet將使用這個自訂類路徑來載入委派的servlet,並將所有調用委派給那個委派servlet。不幸的是,一些應用伺服器中的servlet方法需要訪問由應用伺服器類載入器載入的資源。因此,封裝器servlet技術不能保證在所有情況下都有效。您仍然可以使用實現一個選擇性地將類載入委派給父類的類載入器的技巧。在從特定包中載入類時,可以將類載入器配置成不將類載入委派給父類。
類載入器解決方案所需的額外努力是一個缺點,但是該解決方案的花費是一次性的。動態代理的使用應該不會降低效能,只要您在離主應用進入點儘可能近的地方使用它即可,這樣會最大程度地減少反射性方法調用的數量。載入的類將消耗額外的記憶體,但那是為在相同JVM中使用同一個類的不同版本付出的代價。一些新版的J2EE伺服器可能提供了他們自己的版本衝突解決方案。至少我想起來有一台伺服器重新命名了它所使用的包,因此,您不必重新命名這些表包。不過,即使現在遇見類版本衝突,您也已經有了一個擺脫困境的方法。
關於作者
Daniel F. Savarese是一名獨立軟體開發人員和技術顧問。他曾是ORO公司的創始人,Caltech進階計算處理中心的進階科學家和WebOS軟體開發的副總裁。Daniel是Jakarta ORO 文本處理包和Jakarta Commons NET網路通訊協定庫的原始作者.他還是《How to Build a Beowulf》(MIT Press, 1999)一書的合著者之一。
原文出處
http://www.ftponline.com/channels/java/javapro/2005_03/magazine/columns/proshop/