在过去几年,Java模块化一直是一个活跃的话题。从JSR 277(现已废止)到JSR 291,模块化看起来是Java进化过程中的必经一环。即便是基于JVM的未来语言,比如Scala,也考虑了模块化的问题。本文是关于模块化Java系列文章中的第一篇,讨论模块化的含义,以及为什么要关注它。
51CTO编辑推荐:OSGi入门与实践全攻略
什么是模块化?
模块化是个一般概念,这一概念也适用于软件开发,可以让软件按模块单独开发,各模块通常都用一个标准化的接口来进行通信。实际上,除了规模大小有区 别外,面向对象语言中对象之间的关注点分离与模块化的概念基本一致。通常,把系统划分外多个模块有助于将耦合减至最低,让代码维护更加简单。
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开发者可以产生带有编码依赖信息的标定过版本的模块,该模块以后可以用于任何模块系统。