今天轉載JAVA模塊化系列的三篇文章。
在過去幾年,Java模塊化一直是一個活躍的話題。從JSR 277(現已廢止)到JSR 291,模塊化看起來是Java進化過程中的必經一環。即便是基於JVM的未來語言,比如Scala,也考慮了模塊化的問題。本文是關於模塊化Java系列文章中的第一篇,討論模塊化的含義,以及為什么要關注它。
什么是模塊化?
模塊化是個一般概念,這一概念也適用於軟件開發,可以讓軟件按模塊單獨開發,各模塊通常都用一個標准化的接口來進行通信。實際上,除了規模大小有區別外,面向對象語言中對象之間的關注點分離與模塊化的概念基本一致。通常,把系統划分外多個模塊有助於將耦合減至最低,讓代碼維護更加簡單。
Java語言並不是按照模塊化思想設計的(除了package,按照Java語言規范introduction一 節的介紹,package類似於Modula-3模塊),但是在Java社區依然有很多實際存在的模塊。任何一個Java類庫實際上都是一個模塊,無論其 是Log4J、Hibernate還是Tomcat。通常,開源和非開源的應用都會依賴於一個或多個外部類庫,而這種依賴關系又有可能傳遞到其他類庫上。
類庫也是模塊
類庫毫無疑問也是模塊。對於類庫來講,可能沒有一個單一接口與之通信,但往往卻有‘public’ API(可能被用到)和‘private’ package(文檔中說明了其用途)。此外,它們也有自己依賴的類庫(比如JMX或JMS)。這將引起自動依賴管理器引入許多並非必須的類庫:以Log4J-1.2.15為例,引入了超過10個依賴類庫(包括javax.mail
和javax.jms
),盡管這些類庫中有不少對於使用Log4J的程序來說根本不需要。
某些情況下,一個模塊的依賴可以是可選的;換句話說,該模塊可能有一個功能子集缺少依賴。在上面的例子中,如果JMS沒有出現在運行時 classpath中,那么通過JMS記錄日志的功能將不可用,但是其他功能還是可以使用的。(Java通過使用延遲鏈接——deferred linking來達到這一目的:直到要訪問一個類時才需要其出現,缺少的依賴可以通過ClassNotFoundException
來處理。其他一些平台的弱鏈接——weak linking概念也是做類似的運行時檢查。)
通常,模塊都附帶一個版本號。許多開源項目生成的發行版都是以類似log4j-1.2.15.jar的方式命名的。這樣開發者就可以在運行時通過手動方式來檢測特定開源類庫的版本。可是,程序編譯的時候可能使用了另一個不同版本的類庫:假定編譯時用log4j-1.2.3.jar
而運行時用log4j-1.2.15.jar
,程序在行為上依然能夠保持兼容。即使升級到下一個小版本,仍然是兼容的(這就是為什么log4j 1.3 的問題會導致一個新分支2.0產生,以表示兼容性被打破)。所有這些都是基於慣例而非運行時已知約束。
模塊化何時能派上用場?
作為一般概念,模塊化有助於將應用分解為不同的部件,各個部件可以單獨測試(和開發)。正如上面所提到的,大多數類庫都是模塊。那么,對於那些生產類庫提 供給別人使用的人來說,模塊化是一個非常重要的概念。通常,依賴信息是在構建工具(maven pom 或 ivy-module)里進行編碼並被明確記錄在類庫使用文檔中的。另外,高層類庫開發過程中需要修改較低層級類庫bug,以提供更好支持的情況並不少 見,即便低層類庫的最新版本已經對bug進行了修正。(可是有時候這種情況可能會導致出現一些微妙的問題。)
如果一個類庫是提供給他人使用的,那么它就已經是一個模塊了。但是世上鮮有“Hello World”這樣的類庫,也鮮有“Hello World”這樣的模塊。只有當應用足夠大時(或者是用一個模塊化構建系統進行構建時),把應用划分為不同部件的概念就派上用場了。
模塊化的好處之一是便於測試。一個小模塊(具有定義良好的API)通常比應用整體更好測試。在GUI應用中尤其如此,GUI自身可能不好測試,但是其調用的代碼卻是可測試的。
模塊化的另一個好處是便於進化。盡管系統整體有一個版本號,但實際上,其下有多個模塊及相應版本(不論開源與否,總有一些類庫——甚至是Java版本—— 是系統所依賴的)。這樣,每個模塊都可以自己的方式自由地進化。某些模塊進化得快些,另一些則會長期保持穩定(例如,Eclipse 3.5 的org.eclipse.core.boot
從2008年2月以來一直沒有改變過)。
模塊化也可給項目管理帶來方便。如果一個模塊公布的API可供其他模塊預先使用,那么各個模塊就可以由不同的團隊分別開發。這在大型項目中必定會發生,各個項目子團隊可以負責不同模塊的交付。
最后,將一個應用程序模塊化,可以幫助識別正在使用依賴類庫的哪個版本,以便協調大型項目中的類庫依賴。
運行時與編譯時
無論在編譯時還是運行時,Java的classpath都是扁平的。換句話說,應用程序可以看到classpath上的所有類,而不管其順序如何(如果沒 有重復,是這樣;否則,總是找最前面的)。這就使Java動態鏈接成為可能:一個處於classpath前面的已裝載類,不需要解析其所引用的可能處於 classpath后面的那些類,直到確實需要他們為止。
如果所使用的接口實現到運行時才能清楚,通常使用這種方法。例如,一個SQL工具可以依賴普通JDBC包來編譯,而運行時(可以有附加配置信息)可以實例化適當的JDBC驅動。這通常是在運行時將類名(實現了預定義的工廠接口或抽象類)提供給Class.forName
查找來實現。如果指定的類不存在(或者由於其他原因不能加載),則會產生一個錯誤。
因此,模塊的編譯時classpath可能會與運行時classpath有些微妙的差別。此外,每個模塊通常都是獨立編譯的(模塊A可能是用模塊C 1.1 來編譯的,而模塊B則可能是用模塊C 1.2 來編譯的),而另一方面,在運行時則是使用單一的路徑(在本例中,即可能是模塊C的1.1版本,也可能是1.2版本)。這就會導致依賴地獄(Dependency Hell),特別當它是這些依賴傳遞的末尾時更是這樣。不過,像Maven和Ivy這樣的構建系統可以讓模塊化特性對開發者是可見的,甚至對最終用戶也是可見的。
Java有一個非常好的底層特性,叫做ClassLoader, 它可以讓運行時路徑分得更開。通常情況下,所有類都是由系統ClassLoader裝載的;可是有些系統使用不同的ClassLoader將其運行時空間 進行了划分。Tomacat(或者其他Servlet引擎)就是一個很好的例子,每個Web應用都有一個ClassLoader。這樣Web應用就不必去 管(無論有意與否)在同一JVM中其他Web應用所定義的類。
這種方式下,每個Web應用都用自己的ClassLoader裝載類,這樣一個(本地)Web應用實現裝載的類不會與其他Web應用實現相沖突。但這就要 求對任何ClassLoader鏈,類空間都是一致的;這意味着在同一時刻,你的VM可以同時從兩個不同的Classloader中各自裝載一個Util.class
, 只要這兩個ClassLoader互相不可見。(這也是為什么Servlet引擎具有無需重啟即可重新部署的能力;扔掉了一個ClassLoader,你 也就扔掉了其引用類,讓老版本符合垃圾回收的條件——然后讓Servlet引擎創建一個新的ClassLoader並在運行時中重新部署應用類的新版本。)
再談模塊
構建一個模塊化系統實際上是把系統划分成(有可能)可重用模塊的過程,並使模塊間耦合最小化。同時,其也是一個減少模塊需求耦合的過程:例如,Eclipse IDE許多plugin對GUI和非GUI組件(如jdt.ui
和jdt.core
)的依賴是分開的,這樣就可以在IDE環境之外使用這些非GUI模塊(headless builds、分析及錯誤檢查等等)。
除了作為整體的rt.jar
之外,任何其他系統都可以被分解為不同的模塊。問題是這么做是否值得?畢竟,從頭構建一個模塊化系統比起把一個單模塊系統分割成多個模塊要容易得多。
之所以這樣,原因之一是跨越模塊邊界的類泄漏。例如,java.beans
包邏輯上不應該依賴於任何GUI代碼;可是Beans.instantiate()
所使用的java.beans.AppletInitializer
引用了Applet
,這必然導致對整個AWT的依賴。因此從技術上講java.beans
有依賴於AWT的選項,盡管常識告訴我們不應該有。如果核心java類庫從一開始就采用了模塊化方法來構建,那么這種錯誤早在API公布之前就發現了。
有些情況下,一個模塊看上去不能再被划分成子模塊了。可是,有時候相關功能保持在同一個模塊中是為了便於組織,當需要的時候還可以再進一步分解。例如,對重構的支持起初是Eclipse JDT的一部分,現在被抽出為一個模塊,以便其他語言(如CDT)利用其重構能力。
Plugins
許多系統都是通過plugin概念進行擴展的。在這種情況下,宿主系統定義了一套plugin必須遵循的API及plugin注入方式。許多應用(如Web瀏覽器、IDE及構建工具)通常都是通過提供帶有適當API的插件來對應用進行定制的。
有時候這些plugin受到限制或只有一些普通操作(音頻或視頻解碼),但是組織起來效果也非常不錯(例如,IDE的眾多plugin)。有時候,這些 plugin可以提供自己的plugin,以便進一步定制行為,使得系統具有更高可定制性。(可是,增加這些中間層級會使系統難以理解。)
這種plugin API成為各個plugin必須遵守的契約的一部分。這些plugin自己也是模塊,也面臨依賴鏈和版本問題。由於(特定)plugin API演化的復雜性,因此plugin自己也面臨這一問題(必須維持向后兼容性)。
Netscape plugin API成功的原因之一是其簡單性:只需實現少量的函數。只要宿主瀏覽器用適當的MIME類型將輸入重定向,plugin就可以處理其他事情。可是,更復雜的應用(如IDE)通常需要更緊密集成各個模塊,因此需要一個更復雜的API來推動。
Java模塊化的當前狀態
目前,Java領域存在許多模塊化系統和plugin體系。IDE是名氣最大的,IntelliJ、NetBeans和Eclipse都提供了其自己的 plugin系統作為其定制途徑。而且,構建系統(Ant、Maven)甚至終端用戶應用(Lotus Notes、Mac AppleScript應用)都有能夠擴展應用或系統核心功能的概念。
OSGi是Java領域里無可辯駁的最成熟的模塊系統,它與Java幾乎是如影相隨,最早出現於JSR 8,但是最新規范是JSR 291。 OSGi在JAR的MANIFEST.MF文件中定義了額外的元數據,用來指明每個包所要求的依賴。這就讓模塊能夠(在運行時)檢查其依賴是否滿足要求, 另外,可以讓每個模塊有自己的私有 classpath(因為每個模塊都有一個ClassLoader)。這可以讓dependency hell盡早被發現,但是不能完全避免。和JDBC一樣,OSGi也是規范(目前是4.2版),有多個開源(及商業)實現。因為模塊不需要依賴任何OSGi的特定代碼,許多開源類庫現在都將其元信息嵌入到manifest中,以便OSGi運行時使用。有些程序包沒有這么做,也可以用bnd這樣的工具,它可以處理一個已有的JAR文件並為其產生合適的默認元信息。自2004年Eclipse 3.0 從專有plugin系統切換到OSGi之后,許多其他專有內核系統(JBoss、WebSphere、Weblogic)也都隨之將其運行時轉向基於OSGi內核。
最近創建的Jigsaw項目是為了模塊化JDK自身。盡管其是JDK內部的一部分,並且很可能在其他SE 7 實現中不被支持,但是在該JDK之外使用Jigsaw並無限制。盡管仍在開發當中,Jigsaw還很可能成為前面提到的JSR 294的參考實現。最低要求SE 7(加上目前還沒有Java 7的事實)說明了Jigsaw仍在開發中,而且運行在Java 6或更低版本上的系統基本上是用不上了。
為了鼓勵采用標准模塊化格式,JSR 294專家組目前正在討論簡單模塊系統提議:在這一提議中,Java類庫(來自Maven庫及Apache.org)的開發者能夠提供讓Jigsaw和OSGi系統都能使用的元信息。結合對Java語言的微小變動(最值得關注的是增加的module
關鍵字),這一信息可以在編譯時由高級編譯器產生。運行時系統(如Jigsaw或OSGi)可以使用這些信息來校驗所安裝的模塊及其依賴。
總結
本文討論了模塊化的一般概念,以及在Java系統中是如何實現的。由於編譯時和運行時路徑可能不同,有可能會產生不一致的類庫需求,從而導致依賴地獄。然 而,plugin API允許裝載多種代碼,但其必須遵循宿主的依賴處理規則,這又增加了發生不一致的可能性。為了防止這種情況出現,像OSGi這樣的運行時模塊化系統可以 在決定應用是否能被正確啟動之前就校驗各項要求,而不是在運行時不知不覺發生錯誤。
最后,有人在正在進行中的JSR 294的郵件列表中提出,要為Java語言創建一個模塊系統,其可以完全在Java語言規范中被定義,以便Java開發者可以產生帶有編碼依賴信息的標定過版本的模塊,該模塊以后可以用於任何模塊系統。
模塊化是大型Java系統的一個重要特征。在這些項目中構建腳本和項目通常被划分為多個模塊,以便改進構建過程,但是在運行時卻很少考慮划分模塊的問題。
在“模塊化Java”系列文章的第二篇里,我們將討論靜態模塊化(static modularity)。內容包括如何創建bundle、將其安裝到OSG引擎以及怎樣建立bundle之間的版本依賴。在下一篇文章中,我們將討論動態模塊化(dynamic modularity)並展示bundle如何對其他bundle作出響應。
在上篇文章《模塊化Java簡介》 中講到,Java在開發時把package作為模塊化單元,部署時把JAR文件作為模塊化單元。可是盡管像Maven這樣的構建工具能夠在編譯時保證 package和JAR的特定組合,但這些依賴在運行時classpath下仍可能出現不一致的情況。為了解決這一問題,模塊可以聲明其依賴需求,這樣, 在運行時就可以在執行之前進行依賴檢查。
OSGi是一個Java的運行時動態模塊系統。OSGi規范描述了OSGi運行時的工作行為方式;當前版本是OSGi R4.2(Infoq曾經報導過)。
一個OSGi模塊(也稱為bundle)就是一個普通的JAR文件,但在其MANIFEST.MF中帶有附加信息。一個bundle的manifest必須至少包含如下內容:
- Bundle-ManifestVersion:對OSGi R4 bundle來說必須是2(OSGi R3 bundle則默認為1)
- Bundle-SymbolicName:bundle的文本標識符,通常以反向域名的形式出現,如com.infoq,並且往往對應了包含其中的package
- Bundle-Version:major.minor.micro.qualifier形式的版本號,前三個元素是數字(缺省是0),qualifier則是文本(缺省是空字符串)
創建一個bundle
最簡單的bundle必須在manifest文件中包含如下內容:
Bundle-ManifestVersion: 2 Bundle-SymbolicName: com.infoq.minimal Bundle-Version: 1.0.0
創建bundle並沒有什么可稀奇的,那么讓我們創建一個帶activator的bundle吧。下面是OSGi特定的代碼片段,在bundle啟動時被調用,有點像是bundle的main方法。
package com.infoq; import org.osgi.framework.*; public class ExampleActivator implements BundleActivator { public void start(BundleContext context) { System.out.println("Started"); } public void stop(BundleContext context) { System.out.println("Stopped"); } }
為了讓OSGi知道哪個類是activator,我們需要在manifest中加入額外的信息項:
Bundle-Activator: com.infoq.ExampleActivator Import-Package: org.osgi.framework
Bundle-Activator聲明了在bundle啟動時要實例化並調用其start()方法的類;類似的,在bundle停止時將調用該類的stop()方法。
那么Import-Package又是干什么的?每個bundle都需要在manifest中定義其依賴,以便在運行時判斷所有必需代碼是否可用。在本例 中,ExampleActivator依賴於org.osgi.framework包中的BundleContext;如果我們不在manifext中聲 明該依賴,在運行時就會碰到NoClassDefFoundError錯誤。
下載OSGi引擎
要編譯並測試我們的bundle,需要一個OSGi引擎。對OSGi R4.2,下面羅列了幾個可用的開源引擎。你也可以下載Reference API來編譯(這樣可以確保沒有用到任何平台特定特性);可是,要運行bundle,還是需要一個OSGi引擎。以下引擎都可供選擇:
Equinox | |
---|---|
許可 | Eclipse Public License |
文獻 | http://www.eclipse.org/equinox/ |
下載 | org.eclipse.osgi_3.5.0.v20090520.jar |
評注 | 該 要啟動console,在命令行輸入 |
框架 | org.eclipse.osgi_3.5.0.v20090520.jar |
Felix | |
許可 | Apache License |
文獻 | http://felix.apache.org/ |
下載 | Felix Framework Distribution 2.0.0 |
評注 | 這是所見遵守規范最嚴格的OSGi引擎,還被用在GlassFish及許多其他開源產品中。運行時需要在命令行輸入java -jar bin/felix.jar 而不是 java -jar felix.jar ,因為啟動時它要從當前目錄查找bundles 路徑。 |
框架 | bin/felix.jar |
Knopflerfish | |
許可 | Knopflerfish License (BSD-esque) |
文獻 | http://www.knopflerfish.org/ |
下載 | knopflerfish_fullbin_osgi_2.3.3.jar |
評注 | 該JAR是一個自解壓zip文件;剛開始你必須運行java -jar 進行解壓。不要下載“bin_osgi ”,它無法啟動。 |
框架 | knopflerfish.org/osgi/framework.jar |
另外還有更小的定位於嵌入設備的OSGi R3運行時可用(比如Concierge),但本系列文章只關注OSGi R4。
編譯並運行bundle
獲得framework.jar之后,把OSGi加入classpath並編譯上面的例子,然后將其打包成JAR:
javac -cp framework.jar com/infoq/*/*.java
jar cfm example.jar MANIFEST.MF com
每種引擎都有shell,命令也相似(但不相同)。為了便於練習,讓我們看看如何獲得這些引擎並使之運行、安裝和啟/停bundle。
一旦引擎啟動並運行起來,你就可以安裝bundle(由 file:// URL來定位)了,然后可以使用安裝bundle所返回的bundle id,啟動或停止該bundle。
Equinox | Felix | Knopflerfish | |
---|---|---|---|
啟動應用 | java -jar org.eclipse.osgi_*.jar -console |
java -jar bin/felix.jar |
java -jar framework.jar -xargs minimal.xargs |
幫助 | help |
||
列表 | ss |
ps |
bundles |
安裝 | install file:///path/to/example.jar |
||
啟動 | start id |
||
更新 | update id |
||
停止 | stop id |
||
卸載 | uninstall id |
||
退出 | exit |
shutdown |
盡管所有的shell工作起來大都一樣,但命令之間還是有容易混淆的細微差別。有兩個統一console的項目(Pax Shell,Posh)和運行器(Pax Runner)可以利用;OSGi RFC 132則是一個正在進行中的提案,試圖標准化command shell。另外,Apache Karaf可以運行在Equinox或Felix之上,提供統一的shell以及其他特性。盡管使用這些項目或工具進行實際部署是可取的,但本系列文章還是關注於普通的OSGi框架實現。
如果你啟動了OSGi框架,你應該能夠安裝上面所講的com.infoq.minimal-1.0.0.jar(你還可以用鏈接地址及install命令直接從網站上進行安裝)。每次安裝bundle,引擎都會打印出該bundle的數字ID。
在安裝好bundle之前不可能知道bundle的ID是多少,這取決於系統中其它bundle的ID分配情況;但是你可以使用適當的命令羅列出已安裝的bundle將其找出來。
依賴
迄今為止,我們只有一個bundle。模塊化的一個好處是可以把系統分解為多個小模塊,在此過程中,減小應用的復雜性。從某種程度上,Java的 package已經做到了這一點:例如,一個common包有獨立的client和server包,他們都依賴於該common包。但是Jetty最近的例子(client意外地依賴於server)表明做到這一點並不總是很容易。實際上,有些由OSGi給項目帶來的好處純粹就是強制模塊間的模塊化約束。
模塊化的另一個好處是把'public'包從非public包中分離出來。Java的編譯時系統允許隱藏非public類(在特定包中是可見的),但是不支持更大程度的靈活性。然而在OSGi模塊中,你可以選擇哪些包是exported(輸出)的,這就意味着沒有輸出的包對其他模塊是不可見的。
讓我們繼續開發一個功能,用來初始化URI Templates(與Restlet中 使用的一樣)。因為該功能可重用,我們想將其放在一個單獨模塊中,讓使用它的客戶端依賴於它。(通常,bundle不適合這么細粒度的用法,但是其可用於 說明工作原理)。該功能將根據一個模板(比如http://www.amazon.{tld}/dp/{isbn}/)和一個包含有 tld=com,isbn=1411609255的Map,產生出URLhttp://www.amazon.com/dp/1411609255/(這么做的一個原因是,如果Amazon URL模式發生了變化,我們能夠改變該模板,盡管好的URI是不會改變的)。
為了提供一個在不同實現之間切換的簡單方法,我們將提供一個接口和一個工廠。這會讓我們看到在提供功能的同時實現是怎樣對client隱藏的。代碼(對應幾個源文件)如下:
package com.infoq.templater.api; import java.util.*; public interface ITemplater { public String template(String uri, Map data); } // --- package com.infoq.templater.api; import com.infoq.templater.internal.*; public class TemplaterFactory { public static ITemplater getTemplater() { return new Templater(); } } // --- package com.infoq.templater.internal; import com.infoq.templater.api.*; import java.util.*; public class Templater implements ITemplater { public String template(String uri, Map data) { String[] elements = uri.split("\\{|\\}"); StringBuffer buf = new StringBuffer(); for(int i=0;i
該實現隱藏在com.infoq.templater.internal包中,而public API則位於com.infoq.templater.api包中。這就給了我們巨大的靈活性,如果需要,以后可以修改實現以提供更加有效的手 段。(internal包名是約定俗成,你可以起其他名字)。
為了讓其他bundle能夠訪問該public API,我們需要將其export(輸出)。我們的manifest如下:
Bundle-ManifestVersion: 2 Bundle-SymbolicName: com.infoq.templater Bundle-Version: 1.0.0 Export-Package: com.infoq.templater.api
創建一個client bundle
我們現在可以創建一個使用templater的client。利用上面的例子創建一個activator,其start()方法如下:
package com.infoq.amazon; import org.osgi.framework.*; import com.infoq.templater.api.*; import java.util.*; public class Client implements BundleActivator { public void start(BundleContext context) { Map data = new HashMap(); data.put("tld", "co.uk"); // or "com" or "de" or ... data.put("isbn", "1411609255"); // or "1586033115" or ... System.out.println( "Starting\n" + TemplaterFactory.getTemplater().template( "http://www.amazon.{tld}/dp/{isbn}/", data)); } public void stop(BundleContext context) { } }
我們需要在manifest中顯式輸入templater API,否則我們的bundle無法編譯。用Import-Package或Require-Bundle都可指定依賴。前者可以讓我們單獨輸入包;后者 則將隱式輸入該bundle中所有輸出包。(多個包及bundles可以用逗號分開)。
Bundle-ManifestVersion: 2 Bundle-SymbolicName: com.infoq.amazon Bundle-Version: 1.0.0 Bundle-Activator: com.infoq.amazon.Client Import-Package: org.osgi.frameworkRequire-Bundle: com.infoq.templater
注意在前面的例子中,我們已經使用了Import-Package來輸入org.osgi.framework。在這個例子中,我們將演示 Require-Bundle的用法,其使用了Bundle-SymbolicName。當然,用Import-Package: org.osgi.framework, com.infoq.templater.api可以達到相同的效果。
不管如何聲明依賴於templater的bundle,我們都只能訪問單一的輸出包com.infoq.templater。盡管client可以通過 TemplaterFactory.getTemplater()來訪問templater,但是我們不能直接從internal包中訪問該類。這樣我們 在未來就可以在不影響client的情況下改變templater類的實現。
測試該系統
任何OSGi應用實際上都是一組bundle。在本例中,我們需要編譯並把bundle打包為JAR(如前面所述),啟動OSGi引擎,安裝兩個bundle。下面是在Equinox中進行操作的例子:
java -jar org.eclipse.osgi_* -console osgi> install file:///tmp/com.infoq.templater-1.0.0.jar Bundle id is 1 osgi> install file:///tmp/com.infoq.amazon-1.0.0.jar Bundle id is 2 osgi> start 2 Starting http://www.amazon.co.uk/dp/1411609255
Amazon client bundle現在已經啟動了;當其啟動時,它先用(硬編碼的)給定值為我們初始化URI template。然后在該bundle啟動過程中打印出來以確定其已正常工作。當然,一個真正的系統不會這么死板;但是Templater服務可以用於 任何其他應用(例如,產生一個基於Web的應用中的鏈接)。將來,我們將能夠在OSGi環境中查看Web應用。
帶版本的依賴
本文最后要指出的是目前我們所談的依賴都沒有版本;更確切的說,我們可以使用任意版本。兩個bundle整體及各個包都可以有版本,增大minor號通常 意味着增加了新特性(但保持向后兼容)。以org.osgi.framework包為例,OSGi R4.1中是1.4.0,OSGi R4.2中是1.5.0。(順便提一句,這是bundle版本和銷售版本保持分離的很好理由,Scala語言已經這么做了)。
要聲明依賴處於一個特定版本,我們必須在Import-Package或Require-Bundle中來表達。比如,我們可以指定Require- Bundle: com.infoq.templater;bundle-version="1.0.0"以表示工作所需的最低版本為1.0.0。類似的,我們可以用 Import-Package: com.infoq.templater.api;version="1.0.0"做相同的事情 —— 但是要記住package與bundle版本是完全分開的。如果你不指定版本,缺省為0.0.0,因此,除非指定了相應的Export-Package: com.infoq.templater.api;version="1.0.0"否則該輸入不會被解析。
還可以指定一個版本范圍。例如,按慣例OSGi版本的major號增大表示向后兼容發生改變,因此我們可能想將其限制在1.x的范圍內。我們可以通過 (bundle-)version="[1.0,2.0)"的方式來表達這一依賴約束。該例中,[表示‘包含’,而)表示‘不包含’。換句話說,‘從 1.0到2.0但不包括2.0’。實際上,將一個依賴約束表達為‘1.0’與“[1.0,∞)”意思是一樣的——換句話說,比1.0版高都可以。
盡管這些內容超出了本文的范圍,但是在一個OSGi系統中,一個bundle同時有兩個版本也是可能的。如果你有一個老client依賴於1.0版本 API,同時又一個新client依賴於2.0版本API,這就非常有用了。只要每個bundle的依賴是一致的(換句話說,一個bundle不能直接或 間接同時輸入1.0和2.0)那么應用程序將工作良好。作為讀者練習,你可以創建一個使用泛型的2.0版的Templater API,然后讓一個client依賴於1.x,而另一個依賴於2.x。
總結
在本文中,我們探討了開源OSGi引擎Equinox、Felix和Knopflerfish,並且創建了兩個有依賴關系的bundle。我們還談到了帶版本的依賴。截止目前,模塊化還是靜態的;我們還沒有涉及到OSGi的動態本質。在下一篇文章中我們將涉及這一內容。
本文是“模塊化Java”系列文章的第三篇,我們將討論動態模塊化。內容涉及如何解析bundle類、bundle如何變化、以及bundle之間如何通信。
在前一篇文章《模塊化Java:靜態模塊化》中,我們討論了如何構建Java模塊並將其作為一個單獨的JAR進行部署。文中的例子給出了一個client和一個server bundle(兩者在同一個VM中),client通過工廠方法找到server。在該例子中,工廠實例化了一個已知類,當然也可以使用反射來獲取一個服 務實現;Spring就是大量運用這種技術把spring對象綁定在一起的。
在我們討論動態服務之前,有必要回顧一下類路徑,因為標准Java代碼和模塊化Java代碼的區別之一就是依賴在運行時是如何綁定的。在此之后,我們還將簡單討論一下類的垃圾回收;如果你對此已非常熟悉,則可以跳過這部分內容。
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.infoq.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和實現如下:
Bundle-ManifestVersion: 2 Bundle-Name: Shorten Bundle-SymbolicName: com.infoq.shorten Bundle-Version: 1.0.0 Export-Package: com.infoq.shorten --- package com.infoq.shorten; public interface IShorten { 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為參數(要縮寫的東西)並返回新的壓縮過的文本:
package com.infoq.shorten.tinyurl; import java.io.BufferedReader; import java.io.InputStreamReader; import java.net.URL; import com.infoq.shorten.IShorten; public class TinyURL implements IShorten { private static final String lookup = "http://tinyurl.com/api-create.php?url="; public String shorten(String url) throws IOException { String line = new BufferedReader( new InputStreamReader( new URL(lookup + url).openStream())).readLine(); if(line == null) throw new IllegalArgumentException( "Could not shorten " + url); return line; } }
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里以便找到該實現。代碼如下:
Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: TinyURL Bundle-SymbolicName: com.infoq.shorten.tinyurl Bundle-Version: 1.0.0 Import-Package: com.infoq.shorten,org.osgi.framework Bundle-Activator: com.infoq.shorten.tinyurl.Activator --- package com.infoq.shorten.tinyurl; import org.osgi.framework.BundleActivator; import org.osgi.framework.BundleContext; import com.infoq.shorten.IShorten; public class Activator implements BundleActivator { public void start(BundleContext context) { context.registerService(IShorten.class.getName(), new TinyURL(),null); } public void stop(BundleContext context) { } }
盡管registerService()方法接收一個字符串作為其第一個參數,而且用"com.infoq.shorten.IShorten"也是可以的,但是最好還是用class.class.getName()這種形式,這樣如果你重構了包或改變了類名,在編譯時就可發現問題。如果用字符串,進行了錯誤的重構,那么只有在運行時你才能知道問題所在。
registerService()的第二個參數是實例本身。之所以將其與第一個參數分開,是因為你可以將同一個服務實例輸出給多個服務接口(如果需要帶有版本的API,這就有用了,你可以進化接口了)。另外,一個bundle輸出同一類型的多個服務也是有可能的。
最后一個參數是服務屬性(service properties)。允許你給服務加上額外元數據注解,比如標注優先級以表明該服務相對於其他服務的重要性,或者調用者關心的其他信息(比如功能描述和廠商)。
只要該bundle一啟動,縮寫服務就可用了。當bundle停止,框架將自動取消服務注冊。如果我們想要自己取消注冊(比方說,對錯誤代碼和網絡接口不可用所作出的響應)也很容易(用context.unregisterService())。
使用服務
一旦服務起來並運行之后,我們就可以用client訪問它了。如果運行的是Equinox,你可以用services命令羅列所有已安裝的服務,以及它們是由誰注冊的:
{com.infoq.shorten.IShorten}={service.id=27} Registered by bundle: com.infoq.shorten.trim-1.0.0 [1] No bundles using service. {com.infoq.shorten.IShorten}={service.id=28} Registered by bundle: com.infoq.shorten.tinyurl-1.0.0 [2] No bundles using service.
在調用服務處理URL之前,client需要解析服務。我們需要獲得一個服務引用,它可以讓我們查看服務自身內部的屬性,然后利用其來獲得我們感興趣的服務。可是,我們需要能夠重復處理相同及不同的URL,以便我們可以把它集成到Equinox或Felix的shell里。實現如下:
package com.infoq.shorten.command; import org.osgi.framework.BundleContext; import org.osgi.framework.ServiceReference; import com.infoq.shorten.IShorten; public class ShortenCommand { protected BundleContext context; public ShortenCommand(BundleContext context) { this.context = context; } protected String shorten(String url) throws IllegalArgumentException, IOException { ServiceReference ref = context.getServiceReference(IShorten.class.getName()); if(ref == null) return null; IShorten shorten = (IShorten) context.getService(ref); if(shorten == null) return null; return shorten.shorten(url); } }
當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,之后你就可以使用該命令了:
java -jar org.eclipse.osgi_* -console -noExit osgi> install file:///tmp/com.infoq.shorten-1.0.0.jar Bundle id is 1 osgi> install file:///tmp/com.infoq.shorten.command-1.0.0.jar Bundle id is 2 osgi> install file:///tmp/com.infoq.shorten.tinyurl-1.0.0.jar Bundle id is 3 osgi> install file:///tmp/com.infoq.shorten.trim-1.0.0.jar Bundle id is 4 osgi> start 1 2 3 4 osgi> shorten http://www.infoq.com http://tinyurl.com/yr2jrn osgi> stop 3 osgi> shorten http://www.infoq.com 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命令:
package com.infoq.shorten.command; import java.io.IOException; import org.osgi.framework.BundleContext; import org.osgi.util.tracker.ServiceTracker; import com.infoq.shorten.IShorten; public class ShortenCommand { protected ServiceTracker tracker; public ShortenCommand(BundleContext context) { this.tracker = new ServiceTracker(context, IShorten.class.getName(),null); this.tracker.open(); } protected String shorten(String url) throws IllegalArgumentException, IOException { try { IShorten shorten = (IShorten) tracker.waitForService(1000); if (shorten == null) return null; return shorten.shorten(url); } catch (InterruptedException e) { return null; } } }
使用Service Tracker的常見問題是在構造后忘記了調用open()。除此之外,還必須在MANIFEST.MF內部引入org.osgi.util.tracker包。
使用ServiceTracker來管理服務依賴通常被認為是管理關系的好方法。在沒有使用服務的情況下,查找已輸出的服務稍微有點復雜:比 如,ServiceReference在其被解析為一個服務之前突然變得不可用了。存在一個ServiceReference的原因是,相同實例能夠在多 個bundle間共享,而且它可以被用來基於某些標准(手工)過濾服務。而且,它還可以使用過濾器來限制可用服務的集合。
服務屬性和過濾器
當一個服務注冊時,可以將服務屬性一起注冊。大多情況下屬性可以為null,但是也可以提供OSGi特定或關於URL的通用屬性。例如,我們想給服務分級 以便區分優先級。我們可以注冊Constants.SERVICE_RANKING(代表優先級的數值),作為最初注冊過程的一部分。我們可能還想放一些 client想知道的元數據,比如服務的主頁在哪兒,該站點的條款鏈接。為達此目的,我們需要修改activator:
public class Activator implements BundleActivator { public void start(BundleContext context) { Hashtable properties = new Hashtable(); properties.put(Constants.SERVICE_RANKING, 10); properties.put(Constants.SERVICE_VENDOR, "http://tr.im"); properties.put("home.page", "http://tr.im"); properties.put("FAQ", "http://tr.im/website/faqs"); context.registerService(IShorten.class.getName(), new Trim(), properties); } ... }
服務級別自動由ServiceTracker及其他對象來管理,但也可以用特定條件來過濾。Filter是由LDAP風格的過濾器改編而來的,其使用了一種前綴表示法(prefix notation)來 執行多個過濾。雖然多數情況下你想提供類的名字(Constants.OBJECTCLASS),但你也可以對值進行檢驗(包括限制連續變量的取值范 圍)。Filter是通過BundleContext創建的;如果你想跟蹤實現了IShorten接口的服務,並且定義一個FAQ,我們可以這樣做:
... public class ShortenCommand public ShortenCommand(BundleContext context) { Filter filter = context.createFilter("(&" + "(objectClass=com.infoq.shorten.IShorten)" + "(FAQ=*))"); this.tracker = new ServiceTracker(context,filter,null); this.tracker.open(); } ... }
在定義服務時可以被過濾或可以設置的標准屬性包括:
- 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間通信,以替代直接類引用的方法。服務可以讓模塊系統動態化,這樣就能應對在運行時服務的變化問題。我們 還接觸到了服務級別、屬性及過濾器,並使用標准服務跟蹤器來更容易的訪問服務並跟蹤變化的服務。我們將在下一部分介紹如何用聲明式服務使得服務的編寫更加容易。