在过去几年,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 开发者可以产生带有编码依赖信息的标定过版本的模块,该模块以后可以用于任何模块系统。
查看英文原文: Modular Java: What Is It? 。
感谢张龙对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。
评论