JAVA模块化

今天转载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(文档中说明了其用途)。此外,它们也有自己依赖的类库(比如JMXJMS)。这将引起自动依赖管理器引入许多并非必须的类库:以Log4J-1.2.15为例,引入了超过10个依赖类库(包括javax.mailjavax.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),特别当它是这些依赖传递的末尾时更是这样。不过,像MavenIvy这样的构建系统可以让模块化特性对开发者是可见的,甚至对最终用户也是可见的。

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.uijdt.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.2Infoq曾经报导过)。

一个OSGi模块(也称为bundle)就是一个普通的JAR文件,但在其MANIFEST.MF中带有附加信息。一个bundle的manifest必须至少包含如下内容:

  • Bundle-ManifestVersion:对OSGi R4 bundle来说必须是2(OSGi R3 bundle则默认为1)
  • Bundle-SymbolicName:bundle的文本标识符,通常以反向域名的形式出现,如com.infoq,并且往往对应了包含其中的package
  • Bundle-Versionmajor.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
评注

org.eclipse.osgi bundle包含了框架、运行时和shell,是全部合一的。它的文件名也最长,用tab补全(或改名为equinox.jar)可以解决这一问题。

要启动console,在命令行输入java -jar 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 ShellPosh)和运行器(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"做相同的事情 —— 但是要记住packagebundle版本是完全分开的。如果你不指定版本,缺省为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())。然而,在运行时也可以使用多个类加载器;像JettyTomcat这样的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.lybit.lytr.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.tinyurlcom.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间通信,以替代直接类引用的方法。服务可以让模块系统动态化,这样就能应对在运行时服务的变化问题。我们 还接触到了服务级别、属性及过滤器,并使用标准服务跟踪器来更容易的访问服务并跟踪变化的服务。我们将在下一部分介绍如何用声明式服务使得服务的编写更加容易。

上一篇:PHP交友网站源码、门户社交聊天网站源码,多国语言婚恋交友网站


下一篇:(转)ant 使用指南