模块化编程和 Jigsaw 项目最新早期访问版本使用教程

阅读数:1885 2016 年 3 月 9 日

话题:Java语言 & 开发架构

软件可以看成是一个由相互交互的部件组成的系统。在 Java 中,人们普遍将那些组件中的每一个都打包成自己的 JAR 文件。理论上讲,一个部件包含三个属性:名称、供外界使用的公共 API 和对其他部件的依赖。这种跟图类似的模型有助于开发人员和工具剖视、分析和使用软件系统。

但是,那些属性在 Java 运行时内部并不存在,它是使用类路径来访问一大堆 JAR 文件,然后简单地将它们统统揉成一个巨大的泥团。JAR 文件之间的所有差别都完全丢失了,只剩下一个程序包的扁平集合,从而失去了进一步验证的能力。因此,有一些重要的问题是运行中的 JVM 无法回答的,例如,“这些都是必须的 JAR 文件吗?”,“恰恰就是这些 JAR 文件吗?”,“存在冲突吗?”,或者“是只使用了公共 API 吗?”

因此,一方面,关于如何将系统模块化以及系统各部件之间的依赖关系,已经存在一个结构良好的模型。另一方面,实际的运行时环境是一个单一的、几乎完全没有结构的命名空间。这种不匹配导致了大家甚为熟悉的JAR 地狱及对内部 API 的依赖,还导致了启动性能低下及脆弱的安全性

Jigsaw 项目将对编译器和运行时进行增强,以便向结构化模型靠拢。它的主要目标是可靠配置(通过声明依赖)和强封装(通过隐藏内部构件),两者的代理即是模块的概念。

模块简介

下面这段话援引自我重点推荐的、Oracle 首席架构师 Mark Reinhold 编写的设计概览“模块系统的状态”:

模块是一个命名的、自描述的代码和数据集合。其代码组织成一个包含类型(即 Java 类和接口)的程序包;其数据包含资源和其他类型的静态信息

为了控制代码如何引用其他模块的类型,模块会声明它编译和运行时所需要的其他模块。为了控制其他模块的代码引用其程序包里的类型的方式,模块会声明哪些程序包可以输出。

因此,与 JAR 文件相比,模块有一个可被 JVM 识别的名称,声明了它依赖的其他模块,定义了哪些程序包是其公共 API 的组成部分。

名称

模块可以随意命名,但必须不能冲突。为此,建议使用标准的程序包反向域名模式。虽然这不是强制的,但这通常意味着模块名是它所包含的程序包的前缀。

依赖 & 可读性

模块列出了它编译和运行时依赖的其他模块。下面这段话也是援引自“模块系统的状态”:

当一个模块直接依赖于另一个模块 [……] 那么第一个模块中的代码将能够引用第二个模块中的类型。我们因此可以说,第一个模块读取第二个模块,或者,等效的说法,第二个模块对第一个模块而言是可读的。

[…]

模块系统确保每个依赖都恰好有另一个模块满足这种依赖,任何两个模块都不能互相读,每个模块至多只能读一个定义了特定程序包的模块,定义了同名程序包的模块互不妨碍。

可读性概念是可靠配置的基础:违反任何条件,模块系统都会拒绝编译或运行代码;这是对脆弱的类路径模型的一个重大改进。

输出 & 可访问性

模块列出了它输出的程序包。一个模块中的类型只能被另一个模块中满足如下条件的代码访问:

  • 类型是公开的;
  • 包含的程序包由第一个模块输出;
  • 第二个模块读第一个模块。

这就是说,公开的不一定是真公开的。一个非输出程序包中的公开类型同一个输出程序包中的非公开类型一样,对外界而言都是隐藏的。因此,“public”现在甚至比程序包私有类型隐藏得都深,因为模块系统甚至不允许通过反射访问。就 Jigsaw 目前的实现而言,关于这一点,命令行标识是唯一的方式。

因此,可访问性建立在可读性和输出语句的基础上,是强封装的基础。所谓强封装,是指模块作者可以明确表达公开和支持模块 API 的哪些部分。

示例:创建我们的第一个模块

比如说,在我们的网络中,有一个监控微服务的应用程序。它周期性地同微服务通信,并使用它们的应答更新一个数据库表及一个简洁的 JavaFX UI。眼下,我们假设应用程序是作为一个独立的项目开发的,没有任何依赖。

现在,让我们切换到带来 Jigsaw 的 Java 9!(早期访问版本可以从 java.net 上下载——本文介绍的示例代码和命令都是针对 2015 年 12 月 22 日的 build 96 创建的。)虽然,Java 依赖分析器 jdeps 在 JDK 8 中就已经存在,但我们需要使用 JDK 9 版本,因为它知道模块。

首先需要注意的是,我们可以简单地忽略模块。除非代码依赖于内部 API 或者其他一些 JDK 实现细节(在这种情况下,代码可能遭到破坏),应用程序完全可以同使用 Java 8 一样编译和运行。只需要将工件(如果有的话还有它的依赖)添加到类路径并调用main方法。就这样,它就运行了!

为了将代码移入模块,我们必须为它创建一个模块描述符。这是一个名为module-info.java的源代码文件,位于源代码目录的根目录下:

复制代码
module com.infoq.monitor {
// 添加应用程序需要的模块
// 添加应用程序输出的程序包
}

现在,我们的应用程序是一个名为 com.infoq.monitor 的模块了。我们可以使用jdeps确定它依赖哪些模块:

复制代码
jdeps -module ServiceMonitor.jar

这个命令会列出应用程序用到的所有程序包,更重要的是,这些程序包的来源模块:java.basejava.loggingjava.sqljavafx.basejavafx.controlsjavafx.graphics

模块中已经包含了应用程序所依赖的程序包,我们现在可以考虑可能输出哪些程序包了。由于我们正在谈论的是一个独立应用程序,我们不会输出任何东西。

复制代码
module com.infoq.monitor {
requires java.base; // 后文会有进一步的介绍
requires java.logging;
requires java.sql;
requires javafx.base;
requires javafx.controls;
requires javafx.graphics;
// 不输出任何包
}

编译同不用 Jigsaw 一样,只是需要在源文件列表中包含module-info.java文件:

复制代码
javac -d classes/com.infoq.monitor ${source files}

由于所有的类都已经编译到classes/com.infoq.monitor目录下,所以我们从它们创建一个 JAR 文件:

复制代码
jar -c \
--file=mods/com.infoq.monitor.jar \
--main-class=com.infoq.monitor.Monitor \
${compiled class files}

新的--main-class标识用于指定包含应用程序入口的类。编译结果就是一个所谓的模块化 JAR 文件,下面我们会进一步讨论。

与旧模型形成鲜明对比的是,应用程序启动采用一个全新的指令序列。我们使用新的-mp开关指定查找模块的位置,使用-m指定我们想要启动的模块:

复制代码
java -mp mods -m com.infoq.monitor

为了更好地理解这一点,我们将探讨下编译器和虚拟机如何处理模块。

Java 和模块

模块种类

JDK 本身就是模块化的,包含大约 80 个平台模块(可以通过java -listmods查看)。那些模块将会标准化,Java 9 SE 专属的模块将以“java.”为前缀,JDK 专属的模块将以“jdk.”为前缀。

不是所有的 Java 环境都必须包含所有的平台模块。相反,Jigsaw 的其中一个目标就是可扩展平台,就是说可以轻松创建一个只包含所需模块的运行时。

所有的 Java 代码均依赖于 Object,而且几乎所有的代码都使用像线程和集合这样的基本特性。这些类型在java.base中提供,因此后者扮演了一个特殊的角色;它是唯一一个模块系统本来就知道的模块,由于所有的代码都依赖它,所以所有模块都会自动读它。

因此,在上面的例子中,我们不需要声明对java.base的依赖。

非平台模块称为应用程序模块,用于启动应用程序的模块(包含 main 方法的模块)称为初始化模块

模块化 JAR 文件

前面我们已经看到,Jigsaw 还是创建 JAR 文件,尽管有新的语义。如果它们包含一个 module-info.class 文件,那么它们就被称为模块化 JAR 文件,关于这一点,“模块系统的状态”一文是这样描述的:

除了它在根目录下包含一个 module-info.class 文件之外,模块化 JAR 文件在所有可能的方面都同普通的 JAR 文件类似。

模块化 JAR 文件既可以置于类路径中,也可以置于模块路径中(见下文)。这使得项目可以发布成一个单独的工件,让用户决定是采用旧有的应用程序构建方法,还是模块化方法。

模块路径

平台模块是当前使用的环境的组成部分,因此很容易获取。为了让编译器或虚拟机知道应用程序模块,我们必须使用-mp指定模块路径(就像上文所做的那样)。

当前环境中的平台模块同模块路径中的应用程序模块一起构成了可见模块空间。有了这个模块集合和一个包含其中的初始化模块,虚拟机就可以创建一个模块图。

模块图

从初始化应用程序模块开始,模块系统解析所有的传递依赖。解析结果是一副模块图,其中模块是节点,一个模块对另一个模块的依赖是一条有向边。

对于我们的例子,模块图是下面这个样子:

(点击放大图像)

其中,蓝色为平台模块,亮一些的为直接依赖模块,暗一些的为传递依赖模块。无所不在的 java.base 没有显示;记住,所有的模块都隐式依赖这个模块。

示例:划分模块

基于对编译器和虚拟机如何处理模块的理解,我们开始考虑如何将应用程序划分成模块。

我们的应用程序架构包含以下几个部分:

  • 同微服务通信,创建统计信息
  • 更新数据库
  • JavaFX 用户界面展示
  • 连接各个部分

我们接下来为每个部分创建一个模块:

  • com.infoq.monitor.stats
  • com.infoq.monitor.db
  • com.infoq.monitor.ui
  • com.infoq.monitor

Jigsaw 快速入门指南和 JDK 本身都建议为每个模块在项目源代码文件夹根目录下创建一个文件夹。在我们的示例中,目录结构如下:

复制代码
Service Monitor
└─ src
├─ com.infoq.monitor
│ ├─ com ...
│ └module-info.java
├─ com.infoq.monitor.db
│ ├─ com ...
│ └module-info.java
├─ com.infoq.monitor.stats
│ ├─ com ...
│ └module-info.java
└─ com.infoq.monitor.ui
├─ com ...
└module-info.java

这里的目录树经过了裁剪,但每个“com”目录代表同名的程序包,并且会包含更多的子目录以及最终的模块代码。

统计模块依赖 java.base(但正如我们所已经了解的那样,我们不必列出它),并使用 Java 内建的日志工具。它是一个公开的 API,包含在一个单独的程序包里,允许请求聚合和详细的统计信息。

复制代码
module com.infoq.monitor.stats {
requires java.logging;
exports com.infoq.monitor.stats.get;
}

再重申下可访问性原则:com.infoq.monitor.stats.get 中非公开的类型以及其他程序包中的所有类型对其他模块而言都是隐藏的。即使是输出的程序包,也只对读这个模块的模块可见。

我们的数据库模块也记录日志,而且明显需要 Java 的 SQL 特性。它的 API 包含一个简单的写入器:

复制代码
module com.infoq.monitor.db {
requires java.logging;
requires java.sql;
exports com.infoq.monitor.db.write;
}

很明显,用户界面需要包含用到的 JavaFX 特性的平台模块。它的 API 包含启动 JavaFX UI 的方法。这会返回一个使用了JavaFX 特性的模型。客户端可以更新这个模型,而 UI 会显示新的状态:

复制代码
module com.infoq.monitor.ui {
requires javafx.base;
requires javafx.controls;
requires javafx.graphics;
exports com.infoq.monitor.ui.launch;
exports com.infoq.monitor.ui.show;
}

现在,我们已经讨论完了实际的功能,可以将注意力转移到将所有这些部件连接在一起的主模块上了。该模块需要前面介绍过的三个模块以及 Java 的日志工具。由于 UI 的依赖模块需要使用 JavaFX 特性,所以它还依赖于 javafx.base。由于主模块不会被其他模块使用,所以没有输出 API。

复制代码
module com.infoq.monitor {
requires com.infoq.monitor.stats;
requires com.infoq.monitor.db;
requires com.infoq.monitor.ui;
requires javafx.base; // 为了更新 UI 模型
requires java.logging;
// 没有输出程序包
}

我们不得不显式声明需要 javafx.base 程序包,这有点笨拙,但是如果我们不这样做,那么我们就无法从 javafx.beans.property 调用任何代码。为此,Jigsaw 原型提供了隐式可读性的概念,我们将稍后介绍。

经过模块化,我们创建了一个完全不同的模块图:

(点击放大图像)

现在,让我们看下部分用于编译、打包和启动刚刚模块化了的应用程序的命令。对于那些除了 JDK 本身之外没有依赖的模块,下面就是我们编译和打包模块的方式:

复制代码
javac -d classes/com.infoq.monitor.stats ${source files}
jar -c \
--file=mods/com.infoq.monitor.stats.jar \
${compiled class files}

同以前一样,跟 Java 8 完全相同,我们编译模块的源代码,将结果文件写进 classes 文件夹下以模块名命名的子目录,并在 mods 目录创建一个 JAR 文件。这是一个模块化 JAR 文件,因为类文件中包含编译好的模块描述符module-info.class

更有趣的是,我们如何处理唯一依赖其他应用程序模块的模块:

复制代码
javac \
-mp mods \
-d classes/com.infoq.monitor \
${list of source files}
jar -c \
--file=mods/com.infoq.monitor.jar \
--main-class=com.infoq.monitor.Monitor \
${compiled class files}
java -mp mods -m com.infoq.monitor

编译器需要我们前面创建的应用程序模块,我们通过-mp modes指定模块路径以将其指向那里。打包和启动同以前一样,但是现在,目录mods包含了不只一个模块,而是四个。

JVM 将通过查找模块 com.infoq.monitor 启动,因为我们将其指定为初始化模块。当 JVM 找到这个模块,它会尝试解析可见模块(在这个例子中,包含四个应用程序模块和所有的平台模块)空间中的所有依赖,包括直接依赖和传递依赖。

如果 JVM 能够构建出一个有效的模块图,那么它最后会查找使用--main-class=com.infoq.monitor.Monitor指定的 main 方法,并启动应用程序。否则,它会失败并抛出异常,通知我们违反的条件,例如,缺少一个模块或者一个循环依赖。

隐式可读性

一个模块依赖于另一个模块有两种形式。

一种是内部消费的依赖,外界不知道它们的存在。以Guava为例,依赖于该项目某个模块的代码根本就不关心它内部是否使用了不可变列表。

这是最常见的情况,上文介绍的可读性已经涵盖这种情况,一个模块只能在声明了对另一个模块的依赖后才能访问该模块的 API。因此,如果一个模块依赖 Guava,那么其他模块对这个事实一无所知,如果它们自己不显式声明对 Guava 的依赖就无法访问它。

但是,还有一种情况,依赖没有完全封装,而是存在于模块之间的边界上。在那种情况下,一个模块依赖于另一个,并在自己公开的 API 中暴露了被依赖模块的类型。在 Guava 的例子中,一个模块暴露的方法可能需要或者返回一个不可变列表。

因此,想要调用依赖模块的代码也许只能使用被调用模块的类型。但是如果它没有同时读第二个模块,那么它就不能那样做。因此,为了能够使用依赖模块,客户模块全都不得不同时显式声明对第二个模块的依赖。确定并手工解决这样的隐藏依赖是一项乏味而容易出错的工作。

这就是隐式可读性的用途所在了:

[我们] 扩展了模块声明,以便模块可以将可读性授予另外的模块,将它所依赖的模块的可读性授予任何依赖它的模块。这种隐式可读性是通过在 requires 语句中包含 public 修饰符来表达的。

在一个模块的公开 API 使用不可变列表的例子中,通过声明公开依赖,模块将可读性授予 Guava 以及所有其他依赖 Guava 的模块。

示例:隐式可读性

让我们转到 UI 模块,它在其 API 中暴露了一个模型,而该模型使用了 javafx.base 模块的类型。现在,我们可以修改模块描述符,以便它可以公开声明需要那个模块:

复制代码
module com.infoq.monitor.ui {
// 将 javafx.base 暴露给依赖它的模块
requires public javafx.base;
requires javafx.controls;
requires javafx.graphics;
exports com.infoq.monitor.ui.launch;
exports com.infoq.monitor.ui.show;
}

我们的主模块现在可以隐式读 javafx.base,而不必显式依赖它,因为它依赖的 com.infoq.monitor.ui 模块已经输出了它:

复制代码
module com.infoq.monitor {
requires com.infoq.monitor.stats;
requires com.infoq.monitor.db;
requires com.infoq.monitor.ui;
// 我们不再需要 javafx.base 来更新 UI 模型了
requires java.logging;
// 无输出程序包
}

虽然com.infoq.monitorcom.infoq.monitor.stats的原因为此改变了,但这一事实没有变。因此,这既没有改变模块图,也没有改变编译和运行应用程序所需的命令。

超出模块边界

再次引用设计概览:

通常,如果一个模块输出了一个程序包,而后者所包含的一个类型在签名中引用了第二个模块中的程序包,那么第一个模块的声明中应该包含对第二个模块的 requires public 依赖。这将确保其他依赖于第一个模块的模块自动地就可以读第二个模块,由此,就可以访问那个模块输出程序包中的所有类型。

但是,我们可以将此运用到什么程度呢?例如,考虑下java.sql模块。它暴露了Driver接口,其中包含一个 public 方法getParentLogger(),返回值为 aLogger。由于该模块公开需要java.logging,所以任何使用 Java 的 SQL 特性的模块也都可以隐式访问日志 API。

考虑到这一点,让我们再次看下数据库模块:

复制代码
module com.infoq.monitor.db {
requires java.logging;
requires java.sql;
exports com.infoq.monitor.db.write;
}

理论上讲,需要java.logging的声明是必要的,可能看上去多余。那么该删掉它吗?

为了回答这个问题,我们必须看下com.infoq.monitor.db到底怎么使用java.logging。我们的模块也许只是为了能够调用Driver.getParentLogger()而读它,然后使用logger(例如,记录一条消息)做一些事情就够了。在这种情况下,我们的代码同java.logging的交互恰恰发生在它同java.sql包的Driver交互紧邻的地方。我们在前文中将其称为 com.infoq.monitor.db 和 java.sql 的边界。

或者,我们可能在com.infoq.monitor.db 中到处用到日志记录功能。那么,java.logging中的类型会出现在许多与Driver无关的地方,也就不能再视为仅限于com.infoq.monitor.dbjava.sql的边界。

由于 Jigsaw 是一项前沿技术,所以社区还有时间讨论这个主题,就推荐实践达成一致。我的观点是,如果一个模块不只是在同另一个模块的边界处使用,那么就应该显式地声明需求。这种方法使系统结构更清晰易懂,同时也经得起未来重构时模块声明的考验。因此,只要我们的数据库模块会独立于 SQL 模块使用日志记录,那么我就该保留依赖声明。

聚合模块

隐式可读性为所谓的聚合模块提供了可能,这种模块自己不包含任何代码,但为了方便使用聚合了若干其他模块。Jigsaw JDK 已经引入了这种机制,将“紧凑配置文件(compact profile)” 模块化,而暴露的那些模块恰恰就是程序包包含在配置文件中的模块。

以我们的微服务 monitor 为例,我们可以设想成一个聚合了统计、用户界面和数据模块的 API 模块,这样,主模块就只有一个依赖了:

复制代码
module com.infoq.monitor.api {
requires public com.infoq.monitor.stats;
requires public com.infoq.monitor.db;
requires public com.infoq.monitor.ui;
// 隐式可读性是不可传递的
// 因此我们必须显式列出'javafx.base'
requires public javafx.base
}
module com.infoq.monitor {
requires com.infoq.monitor.api;
requires java.logging;
// 没有输出程序包
}

这理论上是有用的,但在这个简单的例子中没有提供特别的好处。

服务

目前为止,我们已经探讨了在编译时确定和声明的依赖。如果依赖采用服务的形式,一个或多个模块提供一种功能,抽象成一个单独的类型,而其他服务消费该类型的实例,那么就可以实现松耦合。消费者可以使用模块系统发现服务提供者。这就实现了服务定位器模式,模块本身就扮演了定位器的角色。

一个服务就是一组提供一个整体功能的接口和(通常是抽象)类。它所包含的所有类型都必须能够从一个单独的类型(如一个接口)访问,以便用其加载服务。

服务提供模块包含一个服务的一个或多个实现。每个实现在模块描述符中有一个provides X with Y;子句,其中 X 为服务接口的完全限定名,Y 为实现类的完全限定名。Y 要有一个 public 无参数构造函数,以便模块系统能够对它进行初始化。

服务消费者模块读取服务模块,其描述符中包含一个uses X; 子句。然后,它会在运行时调用ServiceLoader.load(Class)获取一个服务接口加载器。该加载器是一个可迭代对象,包含了实现 X 以及由服务提供模块提供的所有类的实例。

当像这里描述的那样使用服务时,不仅实现了编译时松耦合(因为消费者没有声明对提供者的依赖),还实现了运行时松耦合,因为模块系统不会创建从消费者到提供者的读边。

另外一个有趣的方面是,一个服务的可用提供者集合是由模块路径定义的(亦即通常在启动时定义)。确切地说,那些提供服务实现的可见模块会在运行时通过服务加载器获取。因此,可以通过编辑系统模块路径并重启来影响系统的行为。

示例:创建和使用服务

在我们的例子中,我们有一个com.infoq.monitor.stats模块,我们已经讨论过,该模块用于连接运行在我们网络中的服务,并生成统计信息。对于单个模块而言,这听上去工作太多,但我们可以把它分割。

监视单个服务是一个标准任务,因此,我们为该任务创建一个 API,并将其放进新模块com.infoq.monitor.watch。该服务接口名为com.infoq.monitor.watch.Watcher

现在,我们可以自由创建一个或多个实现具体微服务的模块。我们将这些模块命名为com.infoq.monitor.watch.logincom.infoq.monitor.watch.shipping等等。它们的模块描述符如下:

复制代码
module com.infoq.monitor.watch.login {
// 该模块需要定义它所提供的服务;
// 该模块具备隐式可读性,因此本身就是可用的;
requires public com.infoq.monitor.watch;
provides com.infoq.monitor.watch.Watcher
with com.infoq.monitor.watch.login.LoginWatcher;
}

请注意,它们只提供了 Watcher 的实现,但没有输出程序包。

由于所有代码都是使用com.infoq.monitor.stats提供的微服务,我们现在必须确保它仍然能够使用那个功能,新的module-info.java如下:

复制代码
module com.infoq.monitor.stats {
requires java.logging;
requires com.infoq.monitor.watch;
// 我们必须声明依赖哪个服务
uses com.infoq.monitor.watch.Watcher;
exports com.infoq.monitor.stats.get;
}

现在,我们可以在其代码中的某个地方使用如下代码:

复制代码
List<Watcher> watchers = new ArrayList<>();
ServiceLoader.load(Watcher.class).forEach(watchers::add);

上述代码会生成一个列表,其中包含了由模块路径上的模块提供的每个 Watcher 实现的一个实例。从现在开始,代码就跟以前一样了,也就是连接服务,生成统计信息。

这里我们仅看下 com.infoq.monitor.stats 的模块图(因为其他所有的部分都没有变化),新版本的模块图如下:

(点击放大图像)

注意所有指向新模块的箭头;这是一个典型的依赖反转原则示例。

编译、打包和启动都同以前一样(除了现在模块比以前多了)。

迁移

截至目前,我们已经探讨了将一个完整应用程序及其所有依赖转换成模块的场景。但是,当 Jigsaw 第一次从其全新的程序包中删除时,那还不会很常见;大部分项目都会依赖一些尚不适合于模块系统的库,而且无法控制它们。

Jigsaw 团队已经直接解决了这个问题,提供了一种逐步向模块化迁移的方法。为此,他们引入了两种我们尚未讨论的模块。

模块种类 II

我们已经了解了平台模块和应用程序模块。它们都完全了解模块系统,模块描述符是它们的一个关键特征。由于它们有名字,我们称它们为命名模块

对于不了解模块系统的工件,还有其他两种模块。

在 Jigsaw 之前,从类路径中加载的所有类型最终都处于同一个空间,在这个空间里,它们彼此之间可以自由访问。这个非结构化的空间还会继续存在:每个类加载器会有一个唯一的未命名模块,它会将从类路径中加载的所有类型分配给该模块使用。

未命名模块可以读取其他所有模块,并输出所有的程序包。由于模块化不应该依赖于类路径中的随机内容,所以命名模块不能 require 未命名模块,因此也就无法读取它们(不借助反射的话)。

但是,没有模块描述符的工件仍然可以放在模块路径上。在这种情况下,模块系统会为它创建一个全功能的模块,我们称之为自动模块

自动模块的名称是根据工件的文件名生成的,它可以读取其他所有模块,并输出它们所有的全部程序包。由于模块系统很容易在启动时检查一个特定的自动模块是否在模块路径上,所以命名模块可以依赖它们, 并因此能够读取它们。

因此,在常见的单个应用程序类加载器中,应用程序可见模块空间由以下几个部分构成:

  • 包含在运行时中的命名平台模块;
  • 命名应用程序模块:一个命名应用程序模块对应模块路径上一个包含模块描述符的工件;
  • 自动模块:一个自动模块对应模块路径上一个不包含模块描述符的工件;
  • 一个包含类路径上所有工件(不管是否有模块描述符)的唯一的未命名模块(如果有多个应用程序类加载器,则会有多个未命名模块)。

迁移策略

这些类型的模块开辟了逐步向模块系统迁移的路径。(然而,需要注意的是,模块关系并不是唯一的障碍。)

前面已经讲过,整个应用程序,包含它所有的依赖,都可以放在类路径上。当出现一些妨碍迁移的问题时,这是一种重要的应对方法。

现在,我们看下这种方法为什么有效:类路径上的所有工件都会合成为一个未命名模块,其中的所有类型彼此可以自由访问。为了使用 Java 的公共 API,它们必须访问平台模块,由于未命名模块可以读取其他所有的可见模块,所以它们能够做到这一点。

自下而上迁移从无依赖的工件开始,它们可以迅速模块化。以此为基础,其他项目可以迁移到 Jigsaw。

如果项目已经迁移,那么客户可以将模块化 JAR 文件放在模块路径上,并通过名字引用它们。即使没有迁移,代码仍然来自类路径,那么它也可以访问已迁移的工件,因为未命名模块可以读取其他所有模块。或者,客户可以决定将模块化 JAR 文件放在类路径上。

这种方法最适合那些依赖少、依赖关系完善的库项目。但是,随着依赖数量的增加,项目可能不希望等着所有依赖模块化。对于大型 Java 应用程序而言尤其如此,它们可能会更喜欢采用另一种方法。

自上而下迁移从为项目的所有工件创建模块描述符开始。它们需要有一个名字,并且必须指定它们依赖哪些其他的内部工件以及它们需要输出的程序包。

当然,这个过程会遇到外部依赖。如果有可用的适合 Jigsaw 的版本存在,那最好。如果不存在,那么就是要采用自动模块的方式:项目工件 require 名称由 Jigsaw 根据工件文件名生成的模块,而工件会放在模块路径上。

对于直接依赖,这样做就够了,新应用程序的模块已经可以访问它们了。这些依赖可能会引入传递性依赖。但是,由于直接依赖已经转换成了自动模块,后者可以读取包括未命名模块在内的其他所有模块,所以它们的依赖可以放在类路径上。

对于大型项目,这种手动方法就不合用了,需要借助构建工具。Gradle 和 Maven 已经开始研发同 Jigsaw 相关的特性。

要了解更多有关迁移的细节,可以查看 Alex Buckley 和 Alan Bateman 在 JavaOne 大会上的所作的题为“高级模块化开发”的演讲,这两位都是 Oracle Jigsaw 团队的成员。

示例:迁移依赖

比方说,我们的数据库模型使用了 Guava,在路径libs下,我们有一个工件guava-19.0.jar。我们不能简单地把它放在类路径上,因为我们的应用程序已经恰当地模块化了。

Java 可以根据文件名guava-19.0.jar生成模块名guava。了解到这一点,我们就可以更新数据库模块的描述符:

复制代码
module com.infoq.monitor.db {
requires java.logging;
requires java.sql;
requires guava;
exports com.infoq.monitor.db.write;
}

我们需要在编译时将libs路径加到编译器的模块路径中:

复制代码
javac \
-mp libs \
-d classes/com.infoq.monitor.db \
${list of source files}

打包过程没有变化。如果我们像以前一样启动我们的应用程序,那么 JVM 会报告无法找到模块guava。要解决这个问题,需要将目录libs添加到模块路径上:

复制代码
java -mp mods:libs -m com.infoq.monitor

(注意:在 Windows 上,路径之间的分隔符是“;”而不是“:”)

下一步

我们已经探讨了 Jigsaw 的基本示例,并看到了它所提供的核心特性。除了等待 Java 9 的到来之外,我们其他还能干些的什么呢?

深入

学无止境,下面是我们在讨论中没有涉及的两个高级主题:

“模块系统的现状”这篇优秀的文章展示了如何将模块结合反射一起使用,其中包括在运行时增加读边的新概念以及同类加载器交互

新工具jlink可以用于创建仅包含一个平台模块特定集合的运行时镜像;这在Jigsaw 快速入门指南中有介绍,我在此强烈推荐。

此外,Jigsaw 团队在 JavaOne 2015 和 Devoxx BE 2015 大会上的演讲也涵盖了这些主题。我以前在这里进行过汇总。

观察

有关 Jigsaw 的所有内容都可以在该项目的 OpenJDK 网站上找到。有关 Jigsaw 项目的最新消息,可以从Jigsaw-Dev 邮件列表了解到。我也会继续在我的博客上探讨这个主题。

准备

前面已经提到,迁移到 Jigsaw 有点复杂。在项目准备的过程中,我们应该检查下它们是否依赖 Java 9 没有提供或已经删除的东西。

可以使用 Java 依赖分析工具jdeps分析内部 API 依赖这个重大的障碍(部分内部程序包介绍、针对WindowsUnix平台的官方文档),该工具在 JDK 8 中已经提供。另外,至少有三种面向 Maven 的 jdeps-plugins,分别由ApachePhilippe Marschall提供。后者允许项目逐步移除对内部 API 的依赖,而且能防止故态复萌。

如果你担心 Java 9 不提供某些特定的 API,那么你可以查看相应的 OpenJDK 项目邮件列表,因为他们会负责开发这些 API 的公共版本。

此外,我们应该明确项目中的关键依赖,并同那些团队核实一下,他们如何为 Java 9 做准备。

应用

Jigsaw 早期访问版本已经提供下载,可以用于试验性地编译和运行现有项目。遗憾的是,构建系统支持尚不完善,但相关工作正在推进中。

通过这种方式收集的信息和问题可以通过发布到 Jigsaw-Dev 邮件列表来向项目组反馈。下面这段话引自一个被大量提及的 JEP 的(差不多是)结束语:

从理论上确定这些变化的全部影响是不可能的。因此,我们必须依赖大量的内部、尤其是外部测试。[……] 如果对于开发人员、部署人员和终端用户而言,这些变化中的一部分是无法克服的,那么我们将研究减轻影响的方法。

此外,还有一个全球性的 Java 用户组AdoptOpenJDK,对于早期采用者而言,这是一个不错的联系方式。

关于作者

Nicolai Parlog是一名软件开发人员,同时也是一名 Java 爱好者。他不断地进行有关 Java 的阅读、思考和写作。编码既是他的谋生方式,也是他的兴趣所在。他是多个开源项目的长尾贡献者,并在CodeFX发表有关软件开发的博客。读者可以在Twitter上关注 Nicolai。

查看英文原文:

Programming with modularity and Project Jigsaw. A Tutorial Using the Latest Early Access Build