Java動態模塊化運行原理與實踐 OSGI

 
  • 在Java模塊化編程中,我們可以使用服務進行Bundle間的通信,通過服務可以讓模塊系統動態化,這樣就能應對在運行時服務的變化問題。

我們之前曾瞭解過面向Java EE 6平臺的上下文和依賴性注入OSGi依賴性管理,比如Bundle的訪問域等內容。其實,標準Java代碼和模塊化Java代碼的區別之一就是依賴在運行時是如何綁定的。在本篇文章中,我們將詳細討論模塊化Java中的動態模塊化,包括對Bundle ClassPath、類的垃圾回收以及查找綁定等。

Bundle ClassPath

對於一個普通Java程序,只有一個classpath——啓動應用程序所使用的那個。該路徑通常是在命令行中用-classpath選項指定的,或者通 過CLASSPATH 環境變量來設定。Java類裝載器在運行時解析類的時候會掃描此路徑,無論這一過程是靜態地(已編譯進代碼)還是動態地(使用反射及 class.forName())。然而,在運行時也可以使用多個類加載器;像Jetty和Tomcat這樣的Web應用引擎都是使用多個類加載器,以便支持應用熱部署。

在OSGi中,每個bundle都有其自己的類加載器。需要被其他bundle訪問的類則被委派(delegated)給這些其他bundle的類裝載器。因此,儘管在傳統應用中,來自logging類庫、client和server JAR中的類都是由同一個類加載器加載的,但在OSGi模塊系統中,他們都是由自己的類加載器加載的。

結果是,一個VM中有可能有多個類加載器,其中可能存在名字相同的不同Class的對象。也就是說,在同一個VM中,一個叫做 com.infoq.example.App的類,其不同版本可以由com.infoq.example bundle的第1版和第2版同時輸出。Client bundle版本1使用該類的第1版,而client版本2使用該類的第2版。這在模塊化系統中相當普遍;在同一個VM中,有些代碼可能需要裝載一個類庫 的老版本,同時更新點的代碼(在另一個bundle中)卻需要該類庫的新版本。好在OSGi爲你管理起這種依賴傳遞,確保不再出現不兼容類引發的問題。

類的垃圾回收

每個類都有一個對其類裝載器的引用。因此如果想要從不同的bundle訪問這些類,不但要有對該類實例的引用,而且還要有對該類的類裝載器的引用。當一個bundle持有另一個bundle的類時,它也會將該bundle固定在內存中。在前篇文章的例子中,client被固定到該server上。

在靜態世界裏,無論你是否把自己的類固定到其他類(或類庫)都無所謂;因爲不會有什麼變化。可是,在動態世界裏,在運行時將類庫或工具替換成新版本就有可 能了。這聽起來可能有點複雜,但是在可熱部署應用的Web應用引擎早期就出現了(如Tomcat,最早發佈於1999年)。每個Web應用程序都綁定到 Servlet API的某個版本上,當其停止時,裝載該Web應用的類加載器也就廢棄掉了。當Web應用重新被部署時,又創建了一個新的類加載器,新版類就由其裝載。只要servlet引擎沒有保持對老版應用的引用,這些類就像其他Java對象一樣被垃圾回收器回收了。

並不是所有的類庫都能意識到Java代碼中可能存在類泄漏的問題,就像是內存泄漏。一個典型的例子就是Log4J的addAppender()調用,一旦其執行了,將會把你的類綁定在Log4J bundle的生命週期上。即使你的bundle停止了,Log4J仍將維對appender的引用,並繼續發送日誌事件(除非該bundle在停止時恰當地調用了removeAppender()方法)。

查找和綁定

爲了成爲動態,我們需要有一個能查找服務的機制,而不是持久持有他們(以免bundle停止)。這是通過使用簡單Java接口和POJO來實現的,也就是大家所熟知的services(注意他們與WS-DeathStar或其他任何XML底層架構都沒有關係;他們就是普通Java對象——Plain Old Java Objects)。

典型工廠實現方式是使用從properties文件中獲取的某種形式的類名,然後用Class.forName()來實例化相應的類,OSGi則不同,它 維護了一個‘服務註冊器’,其實這是一個包含了類名和服務的映射列表。這樣,OSGi系統就可以使用 context.getService(getServiceReference("java.sql.Driver")),而不是 class.forName("com.example.JDBCDriver")來獲取一個JDBC驅動器。這就把client代碼解放出來了,它不需 知道任何特定客戶端實現;相反,它可以在運行時綁定任何可用驅動程序。移植到不同的數據庫服務器也就非常簡單了,只需停止一個模塊並啓動一個新模 塊;client甚至不需要重新啓動,也不需要改變任何配置。

這樣做是因爲client只需知道其所需的服務的API(基本上都是接口,儘管OSGi規範允許使用其他類)。在上述情況中,接口名是 java.sql.Driver;返回的接口實例是具體的數據庫實現(不必瞭解是哪些類,編碼在那裏)。此外,如果服務不可用(數據庫不存在,或數據庫臨 時停掉了),那麼這個方法會返回null以說明該服務不可用。

爲了完全動態,返回結果不應被緩存。換句話說,每當需要服務的時候,需要重新調用getService。框架會在底層執行緩存操作,因此不存在太大的性能 問題。但重要的是,它允許數據庫服務在線被替換成新的服務,如果沒有緩存代碼,那麼下次調用時,client將透明地綁定到新服務上。

付諸實施

爲了證明這一點,我們將創建一個用於縮寫URL的OSGi服務。其思路是服務接收一個長URL,如http://www.example.com/articles/modular-java-what-is-it,將其轉換爲短點的URL,如http://tr.im/EyH1。該服務也可以被廣泛應用在Twitter這樣的站點上,還可以用它來把長URL轉成短的這樣便籤背後也寫得下。甚至像《新科學家》和《Macworld》這樣的雜誌也是用這些短URL來印刷媒體鏈接的。

爲了實現該服務,我們需要:

◆一個縮寫服務的接口
◆一個註冊爲縮寫實現的bundle
◆一個驗證用client

儘管並沒有禁止把這些東西都放在同一個bundle中,但是我們還是把他們分別放在不同的bundle裏。(即便他們在一個bundle中,最好也讓bundle通過服務來通訊,就好像他們處於不同的bundle一樣;這樣他們就可以方便地與其他服務提供者進行集成。

把縮寫服務接口與其實現(或client)分開放在單獨bundle中是很重要的。該接口代表了client和server之間的‘共享代碼’,這樣,該 接口在每個bundle中都會加載。正因如此,每個bundle實際上都被固定到了該接口特定版本上,所有服務都有共同的生命週期,將接口放在單獨 bundle中(在整個OSGi VM生命週期中都在運行),我們的client就可以自由變化。如果我們把該接口放在某個服務實現的bundle中,那麼該服務發生變化後我們就不能重新 連接到client上了。

shorten接口的manifest和實現如下:

  1. Bundle-ManifestVersion: 2 
  2. Bundle-Name: Shorten  
  3. Bundle-SymbolicName: com.infoq.shorten  
  4. Bundle-Version: 1.0.0 
  5. Export-Package: com.infoq.shorten  
  6. ---   
  7. package com.infoq.shorten;  
  8.  
  9. public interface IShorten {  
  10.     public String shorten(String url) throws IOException;  

上面的例子建立了一個擁有單一接口(com.infoq.shorten.IShorten)的bundle(com.infoq.shorten),並將其輸出給client。參數是一個URL,返回一個唯一的壓縮版URL。

和接口定義相比,實現就相對有趣一些了。儘管最近縮寫名稱的應用開始多起來了,但是所有這些應用的祖師爺都是 TinyURL.com。(具有諷刺意味的是,http://tinyurl.com實際上可以被壓縮的更短http://ow.ly/AvnC)。如今比較流行有:ow.ly、bit.ly、tr.im等等。這裏並不是對這些服務全面介紹,也不是爲其背書,我們的實現也可以使用其他同類服務。本文之所以使用TinyURL和Tr.im,是由於他們都可以匿名基於GET提交,易於實現,除此之外沒有其他原因。

每種實現實際上都非常小;都以URL爲參數(要縮寫的東西)並返回新的壓縮過的文本:

  1. package com.infoq.shorten.tinyurl;  
  2. import java.io.BufferedReader;  
  3. import java.io.InputStreamReader;  
  4. import java.net.URL;  
  5. import com.infoq.shorten.IShorten;  
  6.  
  7. public class TinyURL implements IShorten {  
  8.     private static final String lookup =   
  9.         "http://tinyurl.com/api-create.php?url=";  
  10.     public String shorten(String url) throws IOException {  
  11.         String line = new BufferedReader(  
  12.             new InputStreamReader(  
  13.                 new URL(lookup + url).openStream())).readLine();  
  14.         if(line == null)  
  15.             throw new IllegalArgumentException(   
  16.                 "Could not shorten " + url);  
  17.         return line;  
  18.     }  

Tr.im的實現類似,只需用http://api.tr.im/v1/trim_simple?url=替代lookup的值即可。這兩種實現的源代碼分別在com.infoq.shorten.tinyurl和com.infoq.shorten.trim bundle裏。

那麼,完成縮寫服務的實現後,我們如何讓其他程序訪問它呢?爲此,我們需要把實現註冊爲OSGi框架的服務。BundleContext類的registerService(class,instance,properties)方法可以讓我們定義一個服務以供後用,該方法通常在bundle的start()調用期間被調用。如上篇文章所講,我們必須定義一個BundleActivator。實現該類後,我們還要把Bundle-Activator放在MANIFEST.MF裏以便找到該實現。代碼如下:

  1. Manifest-Version: 1.0 
  2. Bundle-ManifestVersion: 2 
  3. Bundle-Name: TinyURL  
  4. Bundle-SymbolicName: com.infoq.shorten.tinyurl  
  5. Bundle-Version: 1.0.0 
  6. Import-Package: com.infoq.shorten,org.osgi.framework  
  7. Bundle-Activator: com.infoq.shorten.tinyurl.Activator  
  8. ---  
  9. package com.infoq.shorten.tinyurl;  
  10. import org.osgi.framework.BundleActivator;  
  11. import org.osgi.framework.BundleContext;  
  12. import com.infoq.shorten.IShorten;  
  13.  
  14. public class Activator implements BundleActivator {  
  15.     public void start(BundleContext context) {  
  16.         context.registerService(IShorten.class.getName(),  
  17.             new TinyURL(),null);  
  18.     }  
  19.     public void stop(BundleContext context) {  
  20.     }  

儘管registerService()方法接收一個字符串作爲其第一個參數,而且用"com.infoq.shorten.IShorten"也是可以的,但是最好還是用class.class.getName()這種形式,這樣如果你重構了包或改變了類名,在編譯時就可發現問題。如果用字符串,進行了錯誤的重構,那麼只有在運行時你才能知道問題所在。

registerService()的第二個參數是實例本身。之所以將其與第一個參數分開,是因爲你可以將同一個服務實例輸出給多個服務接口(如果需要帶有版本的API,這就有用了,你可以進化接口了)。另外,一個bundle輸出同一類型的多個服務也是有可能的。

最後一個參數是服務屬性(service properties)。允許你給服務加上額外元數據註解,比如標註優先級以表明該服務相對於其他服務的重要性,或者調用者關心的其他信息(比如功能描述和廠商)。

只要該bundle一啓動,縮寫服務就可用了。當bundle停止,框架將自動取消服務註冊。如果我們想要自己取消註冊(比方說,對錯誤代碼和網絡接口不可用所作出的響應)也很容易(用context.unregisterService())。

使用服務

一旦服務起來並運行之後,我們就可以用client訪問它了。如果運行的是Equinox,你可以用services命令羅列所有已安裝的服務,以及它們是由誰註冊的:

  1. {com.infoq.shorten.IShorten}={service.id=27}  
  2.   Registered by bundle: com.infoq.shorten.trim-1.0.0 [1]  
  3.   No bundles using service.  
  4. {com.infoq.shorten.IShorten}={service.id=28}  
  5.   Registered by bundle: com.infoq.shorten.tinyurl-1.0.0 [2]  
  6.   No bundles using service. 

在調用服務處理URL之前,client需要解析服務。我們需要獲得一個服務引用,它可以讓我們查看服務自身內部的屬性,然後利用其來獲得我們感興趣的服務。可是,我們需要能夠重複處理相同及不同的URL,以便我們可以把它集成到Equinox或Felix的shell裏。實現如下:

  1. package com.infoq.shorten.command;  
  2. import org.osgi.framework.BundleContext;  
  3. import org.osgi.framework.ServiceReference;  
  4. import com.infoq.shorten.IShorten;  
  5.  
  6. public class ShortenCommand {  
  7.     protected BundleContext context;  
  8.     public ShortenCommand(BundleContext context) {  
  9.         this.context = context;  
  10.     }  
  11.     protected String shorten(String url) throws IllegalArgumentException, IOException {  
  12.         ServiceReference ref =  
  13.             context.getServiceReference(IShorten.class.getName());  
  14.         if(ref == null)  
  15.             return null;  
  16.         IShorten shorten = (IShorten) context.getService(ref);  
  17.         if(shorten == null)  
  18.             return null;  
  19.         return shorten.shorten(url);  
  20.     }  

當shorten方法被調用時,上面這段程序將查找服務引用並獲得服務對象。然後我們可以把服務對象賦值給一個IShorten對象,並使用它與前面講到 的已註冊服務進行交互。注意這些都是在同一個VM中發生的;沒有遠程調用,沒有強制異常,沒有參數被序列化;只是一個POJO與另一個POJO對話。實際 上,這裏與最開始class.forName()例子的唯一區別是:我們如何獲得shorten POJO。

爲了在Equinox和Felix裏面使用這一服務,我們需要放一些樣板代碼進去。必須提一下,當我們定義manifest時,我們可以在Felix和 Equinox命令行上聲明可選依賴,這樣,當我們兩者中任何一個安裝之後,我們就可以運行了。(一個更好的解決方案是將其部署爲單獨的bundles, 這樣我們可以去掉選項;但是如果bundle不存在,activator將會失敗,因此無法啓動)。Equinox和Felix特定命令的源代碼在com.infoq.shorten.command bundle中。

如果我們安裝了命令client bundle,我們將得到一個新命令,shorten,通過OSGi shell可以調用它。要運行該命令,需要先執行java -jar equinox.jar -console -noExit或java -jar bin/felix.jar,然後安裝bundle,之後你就可以使用該命令了:

  1. java -jar org.eclipse.osgi_* -console -noExit  
  2. osgi> install file:///tmp/com.infoq.shorten-1.0.0.jar  
  3. Bundle id is 1 
  4. osgi> install file:///tmp/com.infoq.shorten.command-1.0.0.jar  
  5. Bundle id is 2 
  6. osgi> install file:///tmp/com.infoq.shorten.tinyurl-1.0.0.jar  
  7. Bundle id is 3 
  8. osgi> install file:///tmp/com.infoq.shorten.trim-1.0.0.jar  
  9. Bundle id is 4 
  10. osgi> start 1 2 3 4 
  11. osgi> shorten http://www.infoq.com  
  12. http://tinyurl.com/yr2jrn  
  13. osgi> stop 3 
  14. osgi> shorten http://www.infoq.com  
  15. http://tr.im/Eza8 

注意,在運行時TinyURL和Tr.im服務都是可用的,但是一次只能使用一種服務。可以設置一個服務級別(service ranking), 這是一個整數,取值範圍在Integer.MIN_VALUE和Integer.MAX_VALUE之間,當服務最初註冊時給 Constants.SERVICE_RANKING賦予相應值。值越大表示級別越高,當需要服務時,會返回最高級別的服務。如果沒有服務級別(默認值爲 0),或者多個服務的服務級別相同,那麼就使用自動分配的Constants.SERVICE_PID,可能是任意順序的一個服務。

另一個需注意的問題是:當我們停止一個服務時,client會自動失敗轉移到列表中的下一個服務。每當該命令執行時,它會獲取(當前)服務來處理URL壓 縮需求。如果在運行期間服務提供程序發生了變化,不會影響命令的使用,只要有此需求時有服務在就成。(如果你停止了所有服務提供程序,服務查找將返回 null,這將會打印出相應的錯誤信息——好的代碼應該確保程序能夠預防返回服務引用爲null的情況發生。)

服務跟蹤

除過每次查詢服務外,還可以用ServiceTracker來代替做這一工作。這就跳過了中間獲得ServiceReference的幾步,但是要求你在構造之後調用open,以便開始跟蹤服務。

對於ServiceReference,可以調用getService()獲得服務實例。而waitForService()則在服務不可用時阻塞一段時間(根據指定的timeout。如果timeout爲0,則永遠阻塞)。我們可以如下重新實現shorten命令:

  1. package com.infoq.shorten.command;  
  2.  
  3. import java.io.IOException;  
  4. import org.osgi.framework.BundleContext;  
  5. import org.osgi.util.tracker.ServiceTracker;  
  6. import com.infoq.shorten.IShorten;  
  7.  
  8. public class ShortenCommand {  
  9.     protected ServiceTracker tracker;  
  10.     public ShortenCommand(BundleContext context) {  
  11.         this.tracker = new ServiceTracker(context,  
  12.             IShorten.class.getName(),null);  
  13.         this.tracker.open();  
  14.     }  
  15.     protected String shorten(String url) throws IllegalArgumentException,  
  16.             IOException {  
  17.         try {  
  18.             IShorten shorten = (IShorten)  
  19.                 tracker.waitForService(1000);  
  20.             if (shorten == null)  
  21.                 return null;  
  22.             return shorten.shorten(url);  
  23.         } catch (InterruptedException e) {  
  24.             return null;  
  25.         }  
  26.     }  

使用Service Tracker的常見問題是在構造後忘記了調用open()。除此之外,還必須在MANIFEST.MF內部引入org.osgi.util.tracker包。

使用ServiceTracker來管理服務依賴通常被認爲是管理關係的好方法。在沒有使用服務的情況下,查找已輸出的服務稍微有點複雜:比 如,ServiceReference在其被解析爲一個服務之前突然變得不可用了。存在一個ServiceReference的原因是,相同實例能夠在多 個bundle間共享,而且它可以被用來基於某些標準(手工)過濾服務。而且,它還可以使用過濾器來限制可用服務的集合。

服務屬性和過濾器

當一個服務註冊時,可以將服務屬性一起註冊。大多情況下屬性可以爲null,但是也可以提供OSGi特定或關於URL的通用屬性。例如,我們想給服務分級 以便區分優先級。我們可以註冊Constants.SERVICE_RANKING(代表優先級的數值),作爲最初註冊過程的一部分。我們可能還想放一些 client想知道的元數據,比如服務的主頁在哪兒,該站點的條款鏈接。爲達此目的,我們需要修改activator:

  1. public class Activator implements BundleActivator {  
  2.     public void start(BundleContext context) {  
  3.         Hashtable properties = new Hashtable();  
  4.         properties.put(Constants.SERVICE_RANKING, 10);  
  5.         properties.put(Constants.SERVICE_VENDOR, "http://tr.im");  
  6.         properties.put("home.page""http://tr.im");  
  7.         properties.put("FAQ""http://tr.im/website/faqs");  
  8.         context.registerService(IShorten.class.getName(),  
  9.             new Trim(), properties);  
  10.     }  
  11. ...  

服務級別自動由ServiceTracker及其他對象來管理,但也可以用特定條件來過濾。Filter是由LDAP風格的過濾器改編而來的,其使用了一種前綴表示法(prefix notation)來 執行多個過濾。雖然多數情況下你想提供類的名字(Constants.OBJECTCLASS),但你也可以對值進行檢驗(包括限制連續變量的取值範 圍)。Filter是通過BundleContext創建的;如果你想跟蹤實現了IShorten接口的服務,並且定義一個FAQ,我們可以這樣做:

  1. ...  
  2. public class ShortenCommand  
  3.     public ShortenCommand(BundleContext context) {  
  4.         Filter filter = context.createFilter("(&" +  
  5.             "(objectClass=com.infoq.shorten.IShorten)" +  
  6.             "(FAQ=*))");  
  7.         this.tracker = new ServiceTracker(context,filter,null);  
  8.         this.tracker.open();  
  9.     }  
  10.     ...  

在定義服務時可以被過濾或可以設置的標準屬性包括:

◆service.ranking (Constants.SERVICE_RANKING) - 整數,可以區分服務優先級
◆service.id (Constants.SERVICE_ID) - 整數,在服務被註冊時由框架自動設置
◆service.vendor (Constants.SERVICE_VENDOR) - 字符串,表明服務出自誰手
◆service.pid (Constants.SERVICE_PID) - 字符串,代表服務的PID(persistent identifier)
◆service.description (Constants.SERVICE_DESCRIPTION) - 服務的描述
◆objectClass (Constants.OBJECTCLASS) - 接口列表,服務被註冊在哪些接口下

過濾器語法在OSGi核心規範的 3.2.7節 “Filter syntax”中有定義。最基本的,它允許如等於(=)、約等於(~=)、大於等於、小於等於以及子字符串比較等操作符。括號將過流器分組,並且可以結合 使用“&”、“|” 或“!”分別代表and、or和not。屬性名不是大小寫敏感的,值可能是(如果不用~=作比的話)。“*”是通配符,可用來支持子字符串匹配,比如 com.infoq.*.*。

總結

本文中,我們介紹瞭如何使用服務進行bundle間通信,以替代直接類引用的方法。服務可以讓模塊系統動態化,這樣就能應對在運行時服務的變化問題。我們 還接觸到了服務級別、屬性及過濾器,並使用標準服務跟蹤器來更容易的訪問服務並跟蹤變化的服務。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章