Java-模块系统-全-

Java 模块系统(全)

原文:The Java Module System

译者:飞龙

协议:CC BY-NC-SA 4.0

第一部分

欢迎来到模块化

Java 9 将模块化提升为一等概念。但什么是模块?它们解决了哪些问题,你如何从中受益?以及一等概念意味着什么?

你正在阅读的这本书将回答所有这些问题以及更多。它教你如何定义、构建和运行模块,它们对现有项目有什么影响,以及它们提供了哪些好处。

所有这些都在适当的时候。这本书的这一部分首先解释了模块化的含义,为什么它迫切需要,以及模块系统的目标(第一章)。第二章将你投入深水区,展示了定义、构建和运行模块的代码,然后第三章到第五章详细探讨了这三个步骤。第三章尤其重要,因为它介绍了模块系统背后的基本概念和机制。

书的第二个部分讨论了 Java 9 对现有应用程序带来的挑战,第三部分介绍了更高级的功能。

1

谜团的第一个部分

本章涵盖

  • 模块化及其如何塑造系统

  • Java 无法强制执行模块化

  • 新的模块系统如何旨在解决这些问题

我们都遇到过软件部署后拒绝按我们想要的方式工作的情形。可能有无数的可能原因,但有一类问题如此令人讨厌,以至于它获得了一个特别亲切的名字:JAR 地狱。JAR 地狱的经典方面是行为不端的依赖项:一些可能缺失,但似乎为了弥补这一点,其他可能存在多次,可能在不同版本中。这是导致崩溃或更糟的是,微妙地破坏运行应用程序的可靠方法。

JAR 地狱的根本问题是我们将 JAR 视为具有身份和彼此之间关系的工件,而 Java 将 JAR 视为没有任何有意义属性的简单类文件容器。这种差异导致了问题。

一个例子是 JAR 之间缺乏有意义的封装:所有公共类型都可以被同一应用程序中的所有代码自由访问。这使得无意中依赖库中维护者认为是实现细节且从未为公共使用而完善的类型变得容易。他们可能将这些类型隐藏在名为 internalimpl 的包中,但这并不能阻止我们导入它们。

然后,当维护者更改这些内部组件时,我们的代码就会崩溃。或者,如果我们对库的社区有足够的影响力,维护者可能被迫保留他们认为内部且未触及的代码,防止重构和代码演变。缺乏封装导致可维护性降低——不仅对库,也对应用程序。

对于日常开发来说不太相关,但对于整个生态系统来说更糟糕的是,管理对安全关键代码的访问变得困难。在 Java 开发工具包(JDK)中,这导致了一系列漏洞,其中一些在 Oracle 收购 Sun 后导致了 Java 8 的发布延迟。

这些以及其他问题已经困扰 Java 开发者超过 20 年,解决方案也讨论了近 20 年。Java 9 是第一个将内置到语言中的版本:自 2008 年以来在 Project Jigsaw 的框架下开发的 Java 平台模块系统(JPMS)。它允许开发者通过将元信息附加到 JARs 上来创建模块,从而使它们不仅仅是容器。从 Java 9 开始,编译器和运行时理解模块的身份和关系,因此可以解决诸如缺失或重复依赖以及缺乏封装等问题。

但 JPMS 不仅仅是临时修补。它带来了一系列我们可以用它来开发更美观、可维护的软件的出色功能。可能最大的好处是它将每个个体开发者和整个社区直接面对模块化的基本概念。更有知识的开发者、更模块化的库、更好的工具支持——我们可以期待一个将模块化作为一等公民的 Java 世界带来这些和更多。

我认识到许多开发者在升级时可能会跳过多个 Java 版本。例如,直接从 Java 8 跳到 Java 11 是很常见的。我会指出 Java 9、10 或 11 之间的差异,当它们出现时。书中大部分内容从 Java 9 版本开始对所有 Java 版本都是相同的。在某些情况下,我用 Java 9+作为 Java 9 或更高版本的简称。

这章从第 1.1 节开始,探讨模块化的本质以及我们通常如何感知软件系统的结构。关键在于,在特定的抽象级别(JARs)上,JVM 并不像我们那样看待事物(第 1.2 节)。相反,它抹去了我们精心创建的结构!这种阻抗不匹配会导致真正的问题,正如我们将在第 1.3 节中讨论的那样。模块系统被创建出来是为了将工件转换为模块(第 1.4 节)并解决由阻抗不匹配引起的问题(第 1.5 节)。

1.1 模块化究竟是什么?

你如何看待软件?作为代码行?作为比特和字节?UML 图?Maven POMs?

我不是在寻找定义,而是在寻找直觉。花点时间想想你最喜欢的项目(或者你被支付去工作的项目):它感觉如何?你是如何可视化的?

1.1.1 将软件作为图可视化

我认为我正在工作的代码库是由相互作用的各个部分组成的系统。(是的,这是正式的。)每个部分有三个基本属性:名称、对其他部分的依赖以及它提供给其他部分的功能。

这在抽象的每个层面上都是正确的。在非常低的层面上,一个部分映射到一个单独的方法,其中它的名称是方法的名称,它的依赖项是它调用的方法,它的特性是它触发的返回值或状态变化。在非常高的层面上,一个部分对应于一个服务(有人提到微服务吗?)或者甚至是一个完整的应用程序。

想象一个结账服务:作为电子商务的一部分,它让用户购买他们挑选的商品。为了做到这一点,它需要调用登录和购物车服务。我们再次有三个属性:名称、依赖项和功能。很容易使用这些信息来绘制图 1.1 中所示的图。

图 1.1 如果将结账服务及其依赖项记录下来,它们自然形成一个小的图,显示了它们的名称、依赖项和功能。

我们可以在不同的抽象级别上感知部分。在方法和整个应用程序的极端之间,我们可以将它们映射到类、包和 JAR 文件。它们也有名称、依赖项和功能。

这个视角有趣的地方在于它可以用来可视化和分析一个系统。如果我们想象,甚至绘制,每个我们心中所想的部件的节点,然后根据它们的依赖关系用边将它们连接起来,我们就得到了一个图。

这种映射如此自然,以至于电子商务示例已经做到了这一点,你可能都没有注意到。看看其他常见的软件系统可视化方式,如图图 1.2 所示,图无处不在。

图 1.2 在软件开发中,图无处不在。它们以各种形状和形式出现:例如,UML 图(左),Maven 依赖树(中),以及微服务连接图(右)。

类图是图。构建工具的依赖输出结构类似于树(如果你使用 Gradle 或 Maven,尝试gradle dependenciesmvn dependency:tree),这是一种特殊的图。你有没有见过那些疯狂的微服务图,你根本看不懂?那些也是图。

这些图看起来不同,取决于我们是在谈论编译时还是运行时依赖,我们是否只看一个抽象级别,或者将它们混合,我们是否检查系统的整个生命周期或一个单一时刻,以及许多其他可能的区分。其中一些差异将在以后变得重要,但现在我们不需要深入探讨。现在,任何无数可能的图都可以——只需想象你最熟悉的那一个。

1.1.2 设计原则的影响

将系统作为图来可视化是分析其架构的常见方式。许多良好的软件设计原则直接影响了其外观。

以一个原则为例,即分离关注点。遵循它,我们努力创建软件,其中每个部分都专注于一项任务(如“登录用户”或“绘制地图”)。通常,任务由更小的任务组成(如“加载用户”和“验证密码”以登录用户),实现这些任务的组件也应该分离。这导致了一个图表,其中单个部分形成小的集群,执行清晰分离的任务。

相反,如果关注点没有很好地分离,图表将没有明显的结构,看起来一切似乎都连接到其他一切。正如你在图 1.3 中看到的,区分这两种情况很容易。

图片

图 1.3 两个系统的架构以图表形式表示。节点可以是 JAR 文件或类,边是它们之间的依赖关系。但细节并不重要:只需快速浏览就能回答是否存在良好的关注点分离问题。

影响图表的一个原则的另一个例子是依赖倒置。在运行时,高级代码总是调用低级代码,但一个设计得当的系统在编译时反转这些依赖关系:高级代码依赖于接口,而低级代码实现它们,从而将依赖关系向上反转到接口。查看图表的右侧变体(见图 1.4),你可以轻松地找到这些反转。

图片

图 1.4 高级代码依赖于低级代码的系统与使用接口来反转依赖关系向上的系统(右侧)创建的图表(左侧)不同。这种反转使得识别和理解系统中的有意义组件变得更加容易。

关注点分离和依赖倒置等原则的目标是解耦图表。如果我们忽略它们,系统就会变得混乱,没有东西可以改变,而不会潜在地破坏看似无关的东西。如果我们遵循它们,系统就可以很好地组织。

1.1.3 什么是模块化

良好软件设计的原则引导我们走向解耦的系统。有趣的是,尽管可维护的系统是目标,但大多数原则都是通过允许我们专注于单个部分的路径来引导我们达到那里。原则关注点不在于整个代码库,而在于单个元素,因为最终它们的特性决定了它们构成的系统的属性。

我们已经探讨了关注点分离和依赖倒置如何提供两个积极的特性:专注于单一任务和依赖于接口,而不是实现。系统各部分的最理想特性可以总结如下。

基本信息 每个模块,我之前称之为部分,都有明确的职责和它所实现的明确合同。它是自包含的,对客户端来说是透明的,并且只要另一个模块实现了相同的合同,就可以用不同的模块替换它。它的少量依赖是 API,而不是实现。

由这样的模块构建的系统更容易适应变化,并且根据依赖的实现方式,在启动时甚至运行时可能更加灵活。这正是模块化的全部意义:作为精心设计模块的涌现属性,实现可维护性和灵活性。

1.2 Java 9 之前的模块擦除

你已经看到了交互部分图如何与几个很好的属性相连接,这些属性通常总结为模块化。但最终,这些只是想法——谈论软件的方式。图只是代码行,在 Java 的情况下,最终会被编译成字节码指令,并由 Java 虚拟机(JVM)执行。如果语言、编译器和 JVM(我将粗略且不正确地总结为 Java)能像我们一样看待事物,那将是极好的。

通常情况下,它们确实是这样的!如果你设计一个类或接口,那么你给它起的名字就是 Java 用来识别它的。你定义为其 API 的方法正是其他代码可以调用的——使用你定义的确切方法名称和参数类型。它的依赖关系明显可见,要么是导入语句,要么是完全限定的类名,编译器和 JVM 将使用这些名称的类来满足它们。

例如,让我们看看接口 Future,它表示一个可能已完成或尚未完成的计算的结果。类型的功能并不重要,因为我们只对它的依赖感兴趣:

public interface Future<V> { boolean cancel(boolean mayInterruptIfRunning); boolean isCancelled(); boolean isDone(); V get() throws InterruptedException, ExecutionException; V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException; }

通过查看 Future 声明的所有方法,很容易列举出依赖关系:

  • InterruptedException

  • ExecutionException

  • TimeUnit

  • TimeoutException

将同样的分析应用于刚刚确定类型,我们可以在图 1.5 中创建依赖图。图的精确形式在这里并不重要。重要的是,当我们谈论类型时心中所想的依赖图与 Java 隐式为它创建的依赖图是相同的。

图 1.5 Java 为任何给定类型操作依赖图与我们对类型依赖的认识相一致。此图显示了接口 Future 在包 java.util.concurrentjava.lang 中的依赖关系。

由于 Java 具有强类型和静态类型的特点,它会在立即告诉你如果有什么东西出错了。一个类的名称不合法?你的某个依赖项丢失了?一个方法的可视性改变了,现在调用者看不到它?Java 会告诉你——在编译时的编译器,以及在执行时的 JVM。

可以通过反射绕过编译时检查(参见附录 B 以获取快速介绍)。因此,它被认为是一个锋利且可能危险的工具,仅用于特殊场合。我们现在将忽略它,但将在后面的章节中回到它。

作为 Java 对我们依赖感知与我们不同的例子,让我们看看服务或应用级别。这超出了 Java 的范围:它不知道应用程序的名称,不能告诉你没有“GitHab”服务或“Oracel”数据库(哦,不),也不知道你更改了你的服务 API 并破坏了你的客户端。它没有映射到应用程序或服务的协作的结构。而且这很好,因为 Java 在单个应用程序的层面上运行。

但一个抽象级别显然在 Java 的范围内,尽管在 Java 9 之前,它得到了非常糟糕的支持——糟糕到模块化努力实际上被取消,导致了所谓的模块擦除。这个级别是处理工件,或者用 Java 的话说是 JAR 文件。

如果在这个级别上模块化了应用程序,它由几个 JAR 文件组成。即使它没有,它也依赖于库,这些库可能有自己的依赖项。将这些记录下来,你将得到已经熟悉的图,但这次是针对 JAR 文件,而不是类。

例如,让我们考虑一个名为 ServiceMonitor 的应用程序。在不深入细节的情况下,它的行为如下:它检查网络上其他服务的可用性并汇总统计数据。这些数据被写入数据库并通过 REST API 提供。

应用程序的作者创建了四个 JAR 文件:

  • 观察者—观察其他服务并检查可用性

  • 统计学—从可用性数据中创建统计数据

  • 持久化—使用 hibernate 读取和写入统计数据到数据库

  • 监控—触发数据收集,并将数据通过统计管道传输到持久化存储;使用 spark 实现 REST API

每个 JAR 都有自己的依赖项,所有这些都可以在图 1.6 中看到。

图 1.6 对于任何应用程序,你都可以为其工件绘制一个依赖图。在这里,ServiceMonitor 应用程序被拆分为四个 JAR 文件,它们之间相互依赖,同时也依赖于第三方库。

图表包括我们之前讨论的所有内容:JAR 文件有名称,它们相互依赖,并且通过提供其他 JAR 文件可以调用的公共类和方法,每个 JAR 文件都提供了特定的功能。

在启动应用程序时,你必须列出所有你想要使用的 JAR 文件到类路径上:

$ java --classpath observer.jar:statistics.jar:persistence.jar:monitor.jar org.codefx.monitor.Monitor

使用--classpath选项明确列出所需的 JAR 文件(这是-cp-classpath的一个新替代方案,也可以与 javac 一起使用)。

重要信息 这就是事情变得糟糕的地方——至少,在 Java 9 之前。JVM 在启动时并不知道你的类。每次它遇到对未知类的引用,从命令行指定的主类开始,它都会遍历类路径上的所有 JAR 文件,寻找具有该完全限定名称的类。如果找到了,它就会将这个类加载到一个包含所有类的巨大集合中,然后完成。正如你所见,JVM 中没有与 JAR 文件对应的运行时概念。

没有运行时表示,JAR 文件失去了它们的身份。尽管它们有文件名,但 JVM 并不太关心它们。如果异常信息能指向发生问题的 JAR 文件,或者如果 JVM 能命名一个缺失的依赖项,那岂不是很好?

谈论依赖关系——这些也变得不可见。在类级别上操作,JVM 没有关于 JAR 文件之间依赖的概念。忽略包含类的组件也意味着那些组件的封装是不可能的。确实,每个公共类都对其他所有类可见。

名称、显式依赖关系、明确定义的 API——编译器或 JVM 对我们所重视的模块中的任何事物都不太关心。这抹去了模块结构,将那个精心设计的图变成了一个巨大的泥球,如图 1.7 所示。这并非没有后果。

图 1.7 Java 的编译器或虚拟机都没有关于组件或它们之间依赖的概念。相反,JAR 文件被视为简单的容器,类从这个容器中加载到一个单一的命名空间中。最终,这些类会进入一种原始汤的状态,其中每个公共类型都可以被其他任何类型访问。

1.3 Java 9 之前的复杂性

正如你所见,Java 9 之前的版本缺乏支持跨组件模块化的概念。尽管这会引发问题,但显然它们并不是不可逾越的障碍(否则我们不会使用 Java)。但当这些问题出现时,通常是在大型应用程序中,它们可能很难解决,甚至无法解决。

如我在本章开头提到的,最可能影响应用程序开发者的复杂性通常被亲切地称为 JAR 地狱;但它们并不是唯一的。安全和维护问题,对于 JDK 和库开发者来说是一个更大的问题,也是其后果。

我相信你已经看到了很多这些复杂情况,在本节的其余部分,我们将逐一探讨它们。如果你不熟悉所有这些,请不要担心——相反,如果你还没有处理过它们,你应该觉得自己很幸运。如果你熟悉 JAR 地狱和相关问题,可以自由跳转到第 1.4 节,该节介绍了模块系统。

如果你因为这些问题似乎永无止境而感到沮丧,请放松——会有一种宣泄:第 1.5 节讨论了模块系统如何克服这些缺点的大部分。

1.3.1 JAR 之间的未表达依赖

你的应用程序是否曾经因为 NoClassDefFoundError 而崩溃?当 JVM 无法找到当前正在执行的代码所依赖的类时,就会发生这种情况。找到依赖的代码很容易(查看堆栈跟踪就会揭示它),通常也不需要做更多的工作(缺失类的名称通常会给出线索),但确定依赖项为何不存在可能很困难。然而,考虑到工件依赖图,问题就出现了,为什么我们只在运行时才发现某些东西缺失。

重要信息:原因很简单:JAR 无法以 JVM 能够理解的方式表达它依赖于哪些其他 JAR。需要一个外部实体来识别和满足这些依赖。

在构建工具获得识别和检索依赖的能力之前,那个外部实体就是我们。我们必须扫描文档以查找依赖项,找到正确的项目,下载 JAR 文件,并将它们添加到项目中。可选依赖项进一步复杂了过程,其中 JAR 可能仅在需要使用某些功能时才需要另一个 JAR。

为了使应用程序工作,它可能只需要几个库。但每个库反过来可能又需要几个其他库,依此类推。随着未表达依赖问题复杂性的增加,它变得指数级地更加费时和容易出错。

重要信息:构建工具如 Maven 和 Gradle 大大解决了这个问题。它们擅长使依赖关系明确,以便它们可以沿着传递依赖树的多条边追踪每个所需的 JAR。然而,让 JVM 理解工件依赖的概念将提高鲁棒性和可移植性。

1.3.2 使用相同名称的阴影类

有时,类路径上的不同 JAR 包可能包含具有相同完全限定名的类。这可能是由于多种原因:

  • 可能存在同一库的两个不同版本。

  • 一个 JAR 可能包含其自己的依赖项——这被称为胖 JAR 或超级 JAR——但其中一些也作为独立的 JAR 被拉入,因为其他工件依赖于它们。

  • 图书馆可能已被重命名或拆分,其某些类型可能无意中添加到类路径中两次。

定义:阴影

因为类将从类路径上包含它的第一个 JAR 文件中加载,这导致所有同名的其他类不可用——这被称为阴影。

如果变体在语义上不同,这可能导致从几乎察觉不到的异常行为到破坏性的错误。更糟糕的是,问题表现出来的形式可能看起来是非确定性的。它取决于 JAR 文件的搜索顺序,这可能在不同的环境中有所不同:例如,在你的 IDE(如 IntelliJ、Eclipse 或 NetBeans)和代码最终运行的生成机器之间。

以 Google 广泛使用的 Guava 库为例,它包含一个实用类 com.google.common.collect.Iterators。从 Guava 版本 19 到版本 20,emptyIterator() 方法被删除。如图 1.8 所示,如果两个版本都出现在类路径上,并且版本 20 首先出现,那么任何依赖于 Iterators 的代码都将使用新版本,从而导致无法调用 19 的 Iterators::emptyIterator。即使包含该方法的类在类路径上,它实际上也是不可见的。

阴影通常是由于意外发生的。但也可以故意使用这种行为来覆盖第三方库中的特定类,从而修补库。尽管构建工具可能会减少意外发生的可能性,但它们通常无法防止这种情况发生。

图片

图 1.8 类路径中可能包含同一库的两个不同版本(顶部)或具有共同类型集合的两个库(底部)。在这两种情况下,某些类型都出现了一次以上。只有类路径扫描期间遇到的第一个变体被加载(它覆盖了所有其他变体),因此 JAR 文件的扫描顺序决定了哪个代码运行。

1.3.3 同一项目的不同版本之间的冲突

版本冲突是任何大型软件项目的噩梦。一旦依赖项的数量不再是单个数字,冲突发生的可能性会以惊人的速度趋近于 1。

定义:版本冲突

当两个必需的库依赖于不同、不兼容的第三方库版本时,就会发生版本冲突。

如果两个版本都存在于类路径上,行为将是不可预测的。由于阴影,存在于两个版本中的类将只从其中一个加载。更糟糕的是,如果访问了存在于一个版本但不在另一个版本中的类,该类也将被加载。调用库的代码可能会发现两个版本的混合。

另一方面,如果其中一个版本缺失,程序很可能无法正确运行,因为需要两个版本,并且假设它们不兼容,这意味着它们不能相互替代(参见图 1.9)。与缺失的依赖项一样,这表现为意外的行为或NoClassDefFoundError

图片

图 1.9 相同库冲突版本的传递依赖通常无法解决——必须消除一个依赖项。在这里,RichFaces的老版本依赖于与应用程序想要使用的 Guava 不同的版本。不幸的是,Guava 16 删除了RichFaces所依赖的 API。

继续从关于阴影的章节中的 Guava 示例,想象一些代码依赖于com.google.common.io.InputSupplier,这是一个在 19 版本中存在但在 20 版本中删除的类。JVM 首先扫描 Guava 20,在找不到该类后,从 Guava 19 中加载它。突然,两个 Guava 版本混合在一起运行了!作为一个结束动作,想象InputSupplier调用Iterators::emptyIterator。你认为——调试那个会有多有趣?

必要信息 对于这个问题,没有不涉及现有模块系统或手动调整类加载器的技术解决方案。构建工具通常能够检测这种场景。它们可能会发出警告,并且通常通过选择最新版本等简单机制来解决。

1.3.4 复杂类加载

我们在第 1.2 节中对类加载机制的检查并不完整。描述的行为是默认行为,其中所有应用程序类都由同一个类加载器加载。但开发者可以自由地添加额外的类加载器,从一个委托到另一个以解决我们在这里讨论的一些问题。

这通常是通过容器如组件系统和 Web 服务器来完成的。理想情况下,这种隐式使用应该对应用程序开发者隐藏;但我们知道,所有抽象都有漏洞。在某些情况下,开发者可能明确添加类加载器来实现功能:例如,允许用户通过加载新类来扩展应用程序,或者能够使用同一依赖项的冲突版本。

无论多个类加载器如何进入画面,它们都需要你更深入地研究这个主题。并且它们可以迅速导致一个复杂的委托机制,表现出意外且难以理解的行为。

1.3.5 JAR 之间的弱封装

Java 的可见性修饰符非常适合在同一个包中的类之间实现封装。但跨包边界,类型的可见性只有一种:public

正如你所见,类加载器将所有加载的包折叠成一个巨大的泥球——结果是所有公共类对所有其他类都是可见的。由于这种弱封装,无法创建在整个 JAR 内部可见但外部不可见的函数。

这使得正确模块化系统变得困难。如果某些功能需要由模块的不同部分(如库或系统的子项目)使用,但不应该对外部可见,唯一的方法是将它们全部放入一个包中并使用包可见性。在一种预防性的服从行为中,你抹去了代码的结构,而不是将这项任务留给 JVM。即使在包可见性解决了这个问题的情况下,仍然有反射可以绕过它。

弱封装允许一个组件的客户突破其内部结构(参见 图 1.10)。这可能是意外发生的,如果 IDE 建议从文档标记为内部包的包中导入类。更常见的是,这是为了克服似乎没有其他解决方案的问题(有时是这样,有时不是)。但代价是高昂的!

图片

图 1.10 Eclipse JGit 的维护者并没有打算让 org.eclipse.jgit.internal 中的类型供公共使用。不幸的是,由于 Java 没有关于 JAR 内部的概念,维护者无法阻止任何 com.company.Type 编译时与之冲突。即使它只有包可见性,也可以通过反射访问它。

现在,客户端的代码与组件的实现细节耦合在一起。这使得客户端的更新变得风险很大,如果维护者决定考虑这种耦合,将阻碍这些内部组件的更改。这可能会发展到减缓或甚至阻止组件的有意义的发展。

如果这听起来像是一个边缘情况,那就不是了。最臭名昭著的例子是 sun.misc.Unsafe,这是一个 JDK 内部类,允许我们做一些疯狂的事情(按照 Java 标准),比如直接分配和释放内存。许多关键的 Java 库和框架,如 Netty、PowerMock、Neo4J、Apache Hadoop 和 Hazelcast 都在使用它。由于许多应用程序依赖于这些库,它们也依赖于这些内部组件。因此,尽管 Unsafe 并非有意或设计成这样,但它已成为基础设施的一个关键部分。

另一个例子是 JUnit 4。许多工具,尤其是 IDE,都有各种使开发者测试更容易的出色功能。但由于 JUnit 4 的 API 不足以实现所有这些功能,工具会突破其内部结构。这种耦合大大减缓了 JUnit 4 的发展,最终成为完全重新开始使用 JUnit 5 的一个重要原因。

1.3.6 安全检查必须手工制作

在包边界上弱封装的直接后果是,与安全相关的功能暴露给了同一环境中运行的所有代码。这意味着恶意代码可以访问关键功能,而唯一对抗这种做法的方法是在关键执行路径上手动实现安全检查。

自 Java 1.1 以来,这是通过在每个进入安全相关代码的代码路径上调用SecurityManager::checkPackageAccess(检查调用代码是否允许访问被调用包)来实现的。或者更确切地说,应该在每条这样的路径上调用。忘记这些调用导致了过去困扰 Java 的一些漏洞,尤其是在从 Java 7 过渡到 8 的过程中。

当然,可以争论说,与安全相关的代码应该被双重、三重或四重检查。但人非圣贤,要求我们在模块边界手动插入安全检查反而比一个高度自动化的变体风险更高。

1.3.7 启动性能不佳

你是否曾经想过为什么许多 Java 应用程序,尤其是使用像 Spring 这样的强大框架的 Web 后端,加载时间如此之长?

定义:慢启动

正如你之前看到的,JVM 会按需懒加载类。最常见的情况是,许多类在启动时立即被访问(而不是在应用程序运行一段时间后),Java 运行时加载它们需要一段时间。

一个原因是类加载器不知道一个类来自哪个 JAR,因此它必须对类路径上的所有 JAR 进行线性扫描。同样,识别类路径上所有特定注解的出现需要检查所有类。

1.3.8 刚硬的 Java 运行时

这并不是 JVM 大杂烩方法的真正后果,但既然我在抱怨,我就把它说出来。

定义:刚硬运行时

在 Java 8 之前,没有安装 JRE 子集的方法。所有 Java 安装都支持 XML、SQL 和 Swing 等,而许多用例并不需要这些。

虽然这对中等计算设备(如台式电脑和笔记本电脑)可能影响不大,但对于像路由器、机顶盒、汽车以及 Java 被使用的所有其他角落和缝隙,这显然很重要。随着容器化的当前趋势,它对服务器也变得相关,减少镜像的大小可以降低成本。

Java 8 引入了紧凑配置文件,它定义了 Java SE 的三个子集。它们缓解了问题,但并没有解决这个问题。紧凑配置文件是固定的,因此无法覆盖所有当前和未来对部分 JRE 的需求。

1.4 模块系统的鸟瞰图

我们刚刚讨论了很多问题。Java 平台模块系统是如何解决这些问题的?主要思想相当简单!

ESSENTIAL INFO 模块是 JPMS 的基本构建块(惊喜)。像 JAR 一样,它们是类型和资源的容器;但与 JAR 不同,它们具有额外的特性。这些是最基本的特性:

  • 一个名称,最好是全局唯一的

  • 对其他模块的依赖声明

  • 一个明确定义的 API,由导出包组成

1.4.1 每件事都是模块

有不同类型的模块,第 3.1.4 节对它们进行了分类,但现在快速看一下它们是有意义的。在 Project Jigsaw 的工作中,OpenJDK 被拆分成了大约 100 个模块,所谓的平台模块。其中大约 30 个以 java.* 开头;它们是标准化的模块,每个 JVM 必须包含(图 1.11 展示了其中的一些)。

图片

图 1.11 平台模块的选择。箭头显示它们的依赖关系,但为了使图形更简单,一些依赖关系没有被表示:聚合器模块 java.se 直接依赖于每个模块,每个模块直接依赖于 java.base。

这些是一些比较重要的模块:

  • java.base — 没有它就没有 JVM 程序可以运行的模块。包含 java.langjava.util 等包。

  • java.desktop — 不仅针对那些勇敢的桌面 UI 开发者。包含抽象窗口工具包 (AWT;包 java.awt.*)、Swing(包 javax.swing.*)和更多 API,其中还包括 JavaBeans(包 java.beans.*)。

  • java.logging — 包含 java.util.logging 包。

  • java.rmi — 远程方法调用 (RMI)。

  • java.xml — 包含大部分 XML API 的词汇:Java API for XML Processing (JAXP)、Streaming API for XML (StAX)、Simple API for XML (SAX) 和文档对象模型 (DOM)。

  • java.xml.bind — Java Architecture for XML Binding (JAXB)。

  • java.sql — Java 数据库连接 (JDBC)。

  • java.sql.rowset — JDBC RowSet API。

  • java.se — 引用组成核心 Java SE API 的模块。(这是一个所谓的聚合器模块;参见第 11.1.5 节。)

  • java.se.ee — 引用组成完整 Java SE API 的模块(另一个聚合器)。

然后是 JavaFX。它的高级架构优于 AWT 和 Swing 的一个明显迹象是,它不仅足够地从 JDK 的其余部分解耦以获得自己的模块,实际上还被分成了七个:绑定、图形、控件、网页视图、FXML、媒体和 Swing 互操作。所有这些模块名称都以 javafx.* 开头。

最后,大约有 60 个以 jdk 开头的模块。它们包含 API 实现、内部实用工具、工具(如编译器、JAR、Java 依赖分析工具 [JDeps] 和 Java Shell 工具 [JShell])等。它们可能因 JVM 实现而异,因此使用它们类似于使用 sun. 包中的代码:不是一个未来证明的选择,但有时是唯一可用的选项。

您可以通过运行 java --list-modules 来查看包含在 JDK 或 JRE 中的所有模块列表。要获取单个模块的详细信息,请执行 java --describe-module ${module-name}.${module-name} 是一个占位符,不是有效的语法——请将其替换为您选择的模块。)

平台模块打包到 JMOD 文件中,这是一个专门为此目的创建的新格式。但 JDK 之外的外部代码也可以创建模块。在这种情况下,它们是模块化 JAR:包含一个新结构,模块描述符,它定义了模块的名称、依赖关系和导出。最后,还有模块系统从尚未转换为模块的 JAR 文件中动态创建的模块。

重要信息 这导致模块系统的基本方面:一切都是模块!(或者更准确地说,无论类型和资源如何呈现给编译器或虚拟机,它们最终都会进入一个模块。)模块是模块系统的核心,也是本书的核心。其他所有内容最终都可以追溯到它们及其名称、它们的依赖关系声明以及它们导出的 API。

1.5 您的第一个模块

JDK 的模块化是很好,但您的代码呢?它如何最终进入模块中?这相当简单。

您唯一需要做的是在源文件夹中添加一个名为 module-info.java 的文件,一个模块声明,并填写您的模块名称、对其他模块的依赖关系以及构成其公共 API 的包:

module my.xml.app { requires java.base; requires java.xml; exports my.xml.api; }

您将看到,要求 java.base 实际上并不是必需的。

看起来 my.xml.app 模块使用了平台模块 java.base 和 java.xml 并导出一个包 com.example.xml。到目前为止,一切顺利。现在,将 module-info.java 与所有其他源文件一起编译成 .class 文件,并将其打包到 JAR 中。(编译器和 jar 工具将自动完成正确的事情。)Et voilà,您已经创建了您的第一个模块。

1.5.1 模块系统的作用

让我们启动 XML 应用程序并观察模块系统的作用。为此,请执行以下命令:

java --module-path mods --module my.xml.app

模块系统从这里开始。它采取了许多步骤来改善您在 1.2 和 1.3 节中看到的泥球状结构:

  1. 自举

  2. 验证所有必需的模块是否存在

  3. 构建应用程序架构的内部表示

  4. 启动初始模块的 main 方法

  5. 在应用程序运行期间保持活跃,以保护模块内部

图 1.12 捕获了所有步骤。但让我们不要急于求成,依次研究每个步骤。

图 1.12 Java 平台模块系统 (JPMS) 在实际应用中的工作情况。它的大部分工作在启动时完成:在(1)引导启动之后,它(2)确保在构建模块图时所有模块都存在,然后在(3)将控制权交给运行的应用程序之前。在运行时,它(4)强制执行每个模块的内部结构得到保护。

加载基础模块

模块系统只是代码,你已经了解到一切皆模块,那么哪个包含了 JPMS?那就是 java.base,基础模块。在一个相当复杂的鸡生蛋问题中,模块系统和基础模块互相引导启动。

基础模块也是 JPMS 构建的模块图中的第一个节点。这正是它接下来要做的。

模块解析:构建表示应用程序的图

你发出的命令以 --module my.xml.app 结尾。这告诉模块系统 my.xml.app 是应用程序的主模块,并且依赖关系解析需要从这里开始。但 JPMS 在哪里可以找到这个模块?这就是 --module-path mods 的作用。它告诉模块系统它可以在文件夹 mods 中找到应用程序模块,因此 JPMS 忠实地在那里寻找 my.xml.app 模块。

文件夹不包含模块,它们包含 JAR 文件。因此模块系统扫描 mods 中的所有 JAR 文件,并查找它们的模块描述符。在示例中,mods 包含 my.xml.app.jar,其描述符声称它包含一个名为 my.xml.app 的模块。这正是模块系统一直在寻找的!JPMS 创建 my.xml.app 的内部表示并将其添加到模块图中——到目前为止,还没有连接到其他任何东西。

模块系统找到了初始模块。接下来是什么?寻找它的依赖。my.xml.app 的描述符表明它需要模块 java.base 和 java.xml。JPMS 在哪里可以找到这些模块?

第一个,java.base,已经为人所知,因此模块系统可以从 my.xml.app 添加一个连接到 java.base ——图中的第一条边。接下来是 java.xml。它以 java 开头,这告诉模块系统它是一个平台模块;因此 JPMS 不在模块路径中搜索它,而是在其自己的模块存储中搜索。JPMS 在那里找到了 java.xml 并将其添加到图中,通过 my.xml.app 与其建立连接。

现在图中有三个节点,但只有两个被解析。java.xml 的依赖关系仍然未知,因此 JPMS 接下来检查它们。尽管如此,它除了 java.base 之外没有其他依赖,因此模块解析结束。从 my.xml.app 和无处不在的基础模块开始,这个过程构建了一个包含三个节点的简单图。

如果 JPMS 找不到所需的模块,或者遇到任何歧义(例如,两个包含具有相同名称的模块的 JAR 文件),它将带有一个信息性错误消息退出。这意味着你可以在启动时发现问题,否则这些问题可能会在未来某个任意时刻导致正在运行的应用程序崩溃。

启动初始模块

这个过程又是如何开始的呢?啊,是的,是通过以--module my.xml.app结尾的命令。模块系统完成了其核心功能之一——验证所有必需依赖项的存在——现在可以交出控制权给应用程序。

初始模块my.xml.app不仅是模块解析开始的模块,它还必须包含一个public static void main(String[])方法。但在启动应用程序时,你不必指定包含该方法的类。我跳过了这一步,但你打包.class文件到 JAR 时很勤奋,并在那时指定了主类。这些信息被嵌入到模块描述符中,现在 JPMS 可以从中读取它。

由于你没有指定主类就使用了--module my.xml.app,模块系统期望在模块描述符中找到该信息。幸运的是,它确实找到了,并且在该类上调用main方法。应用程序启动了,但 JPMS 的工作还没有结束!

保护模块内部

即使应用程序成功启动,模块系统也需要保持活跃以履行其第二个基本功能:保护模块内部。还记得my.xml.app模块声明中的exports my.xml.api那一行吗?这就是它和其他类似的地方发挥作用的地方。

每当模块首次访问另一个模块中的类型时,JPMS 都会验证以下三个要求是否得到满足:

  • 被访问的类型必须是公开的。

  • 拥有该类型的模块必须导出包含它的包。

  • 在模块图中,访问模块必须连接到拥有模块。

my.xml.app首次使用javax.xml.XMLConstants(例如)时,模块系统会检查XMLConstants是否是公开的(✔),java.xml是否导出javax.xml(✔),以及my.xml.app是否在模块图中连接到java.xml(✔)。由于所有三个条件都满足,my.xml.app就可以使用XMLConstants进行操作。

这种行为修复了 Java 在工件关系上采用泥球方法的一个关键缺陷:无法区分工件内部的代码和可以公开使用的代码。有了exports,模块可以清楚地定义其 API 的哪些部分是公开的,哪些是内部的,并且可以依赖模块系统来执行其决策。

更复杂的例子

作为一个不那么平凡的例子,图 1.13 展示了在 1.2 节中引入的 ServiceMonitor 应用程序的模块图。它的四个 JAR 文件——monitor、observer、statistics 和 persistence,以及它的两个依赖项——spark 和 hibernate——都被转换成了模块。像 java.xml 和 java.base 这样的 JDK 模块也因为应用程序依赖于它们而可见。

我发现与图 1.6 的比较很引人注目,该图描述了 ServiceMonitor 的 JAR 文件之间的依赖关系。图 1.6 显示了我们在工件级别上组织应用程序的理解,而图 1.13 显示了模块系统如何看待它。它们如此相似证明了模块系统可以很好地用来表达应用程序的架构。

图 1.13 ServiceMonitor 应用程序的模块图与图 1.6 中的架构图非常相似。该图显示了包含应用程序代码的四个模块、实现其功能集所使用的两个库以及涉及的 JDK 模块。箭头表示它们之间的依赖关系。每个模块列出了它导出的一些包。

1.5.2 您的非模块化项目将大致上运行良好

对于现有项目,尤其是拥有大型代码库的项目,开发者会对迁移路径感兴趣。尽管其他模块系统通常是“要么在要么不在”,意味着为了使用它们,一切都必须是模块,但这对于 JPMS 来说并不是一个选项。为了保持向后兼容性,在 Java 8 或更早版本上从类路径上运行的应用程序必须在 Java 9 上做同样的事情。因此,非模块化应用程序必须在模块化的 JDK 之上运行,这意味着模块系统必须处理这种情况。

它确实做到了。我之前已经顺便提到,模块系统处理了尚未转换为模块的 JAR 文件。这正是由于向后兼容性的原因。尽管迁移到模块系统是有益的,但它不是强制性的。

因此,用于指定编译器和 JVM 的 JAR 文件或普通.class文件的类路径,在 Java 8 和之前的工作方式相同。甚至类路径上的模块表现得就像非模块化 JAR 文件一样。基本假设是类路径负责访问想要转换为 1.3 节中讨论的泥球状结构的工件。

与此同时,一个新的概念被创造出来:模块路径。在这里,基本假设是它将所有工件视为模块。有趣的是,即使是普通的 JAR 文件也是如此。

重要的信息:类路径和模块路径的共存以及它们对普通和模块化工件的处理是大型应用程序逐步迁移到模块系统的关键。第八章将深入探讨这个重要主题。

模块系统的一个重要方面,尤其是对遗留项目来说,是兼容性。JPMS 在底层进行了许多更改,尽管几乎所有的更改在严格意义上都是向后兼容的,但其中一些与现有代码库的交互非常糟糕。例如:

  • 依赖于 JDK 内部 API(例如sun.*包中的那些)会导致编译时错误和运行时警告。

  • JEE API 必须手动解析。

  • 包含相同包中类的不同工件可能会引起问题。

  • 紧凑配置文件、扩展机制、endorsed-standards-override 机制以及类似功能已被移除。

  • 运行时图像布局发生了很大变化。

  • 应用程序类加载器不再是URLClassLoader

最后,无论一个应用程序是否模块化,在 Java 9 或更高版本上运行可能会破坏它。第六章和第七章专门用于识别和克服最常见的挑战。

在这个阶段,你可能会有这样的疑问:

  • Maven、Gradle 和其他工具不是已经管理依赖项了吗?

  • 关于开放服务网关倡议(OSGi),为什么我不直接使用它呢?

  • 在大家都编写微服务的时候,模块系统不是过度了吗?

你提出的问题很有道理。没有一项技术是孤立的,值得从整体上审视 Java 生态系统,并检查现有工具和方法与模块系统的关系以及它们未来的可能关系。我在第 15.3 节中这样做;你已经知道了解读它所需的一切,所以如果你不能放下这些问题,为什么不现在就读读它呢?

第 1.5 节描述了模块系统想要实现的高级目标,第二章展示了模块化应用程序可能的样子的一个更长的示例。第三章、第四章和第五章详细探讨了从头开始编写、编译、打包和运行此类应用程序的方法。本书的第二部分在第三部分转向模块系统的先进功能之前讨论了兼容性和迁移。

1.6 模块系统目标

从本质上讲,Java 平台模块系统是为了教 Java 了解工件之间的依赖关系图而开发的。想法是,如果 Java 停止擦除模块结构,那么这种擦除的大部分丑陋后果也会消失。

首先,这应该会减轻当前状况造成的许多痛点。但不仅如此,它引入了大多数未使用过其他模块系统的开发者所不熟悉的能力,可以进一步提高软件的模块化。这在更具体的意义上意味着什么?

在我们到来之前,重要的是要注意,模块系统的所有目标对各种项目来说并不同等重要。许多主要受益于像 JDK 这样的大型、长期项目,JPMS 主要是为这些项目开发的。大多数目标不会对日常编码产生巨大影响,例如,Java 8 中的 lambda 表达式或 Java 10 中的var。然而,它们将改变项目开发和部署的方式——这是我们每天都在做的事情(对吗?)。

在模块系统的目标中,有两个特别重要:可靠配置和强封装。我们将比其他目标更详细地研究它们。

1.6.1 可靠配置:不留任何 JAR 文件

正如你在 1.4.3 节中观察模块系统运行时所见,各个模块声明它们对其他模块的依赖,JPMS 分析这些依赖。尽管我们只看了 JVM 启动,但相同的机制在编译时间和链接时间(是的,这是新的;见第十四章)也在发挥作用。因此,当依赖项缺失或冲突时,这些操作可以快速失败。与只有当第一个类需要时才在启动时发现依赖项缺失的事实相比,这是一个很大的优势。

在 Java 9 之前,具有相同类的 JAR 文件不会被识别为冲突。相反,运行时会选择一个任意的类,从而覆盖其他类,这导致了 1.3.2 节中描述的复杂性。从 Java 9 开始,编译器和 JVM 识别这一点以及许多其他可能导致早期问题的歧义。

定义:可靠配置

两者结合,使得系统的配置比以前更加可靠,因为只有良好形成的启动配置才能通过这些测试。如果它们通过了,JVM 可以将概念依赖图转换为模块图,用运行系统的结构化视图替换泥球,就像我们可能拥有的那样。

1.6.2 强封装:使模块内部代码不可访问

模块系统的另一个关键目标是使模块能够强封装其内部结构,并仅导出特定的功能。

一个对模块私有的类应该以与私有字段对类私有的相同方式私有。换句话说,模块边界应该不仅决定类和接口的可见性,还决定它们的可访问性。

——马克·雷诺尔德,《Jigsaw 项目:聚焦大局》(mreinhold.org/blog/jigsaw-focus)

为了实现这一目标,编译器和 JVM 在模块边界上强制执行严格的访问规则:仅允许访问导出包中公共类型(即字段和方法)的公共成员。其他类型对模块外部的代码不可访问——甚至不能通过反射访问。最后,我们可以强封装库的内部实现,并确保应用程序不会意外地依赖于实现细节。

这也适用于 JDK,如前所述,它已被转换为模块。因此,模块系统阻止了对 JDK 内部 API 的访问,这意味着以sun.com.sun.开头的包。不幸的是,许多广泛使用的框架和库,如 Spring、Hibernate 和 Mockito,都使用了这样的内部 API,因此如果模块系统如此严格,许多应用程序在 Java 9 上将会崩溃。为了给开发者迁移时间,Java 更加宽容:编译器和 JVM 有命令行开关允许访问内部 API;在 Java 9 到 11 中,默认允许运行时访问(更多内容请参阅第 7.1 节)。

为了防止代码意外地依赖于间接依赖中的类型,这些类型可能会从一个运行到下一个运行而发生变化,情况甚至更为严格:一般来说,一个模块只能访问它作为依赖项所要求的模块的类型。(一些高级特性会故意违反该规则。)

1.6.3 自动化安全与改进的可维护性

模块内部 API 的强封装可以极大地提高安全性和可维护性。它有助于安全性,因为关键代码有效地隐藏了不需要使用它的代码。它还使维护更容易,因为模块的公共 API 可以更容易地保持小规模。

随意使用 Java SE 平台实现内部的 API 既是一个安全风险,也是一个维护负担。所提议的规范提供的强封装将允许实现 Java SE 平台组件防止对其内部 API 的访问。

——Java 规范请求(JSR)376

1.6.4 改进的启动性能

当知道一个类只能引用几个其他特定组件中的类,而不是运行时加载的任何类时,现有的优化技术可以更有效地使用。

当知道一个类只能引用几个其他特定组件中的类,而不是运行时加载的任何类时,许多提前时间、整个程序优化技术可以更有效。

——JSR 376

还可以通过注解来索引类和接口,这样就可以在不进行完整的类路径扫描的情况下找到这些类型。这尚未在 Java 9 中实现,但可能会在未来的版本中实现。

1.6.5 可扩展的 Java 平台

明确定义依赖关系的模块的一个美好后果是,可以轻松确定 JDK 的运行子集。例如,服务器应用程序不使用 AWT、Swing 或 JavaFX,因此可以在没有这些功能的情况下运行 JDK。新的工具 jlink(参见第十四章)使得创建仅包含应用程序所需模块的运行时镜像成为可能。我们甚至可以包括库和应用程序模块,从而创建一个不需要在宿主系统上安装 Java 的自包含程序。

定义:可扩展平台

由于 JDK 已经模块化,我们可以挑选所需的特性并创建仅包含所需模块的 JRE。

这将保持 Java 在小型设备和容器中的关键玩家地位。

1.6.6 非目标

不幸的是,模块系统并非万能的灵丹妙药,一些有趣的用例并未得到覆盖。首先,JPMS 没有版本的概念。你不能给模块指定版本或要求依赖项的版本。话虽如此,将此类信息嵌入模块描述符并使用反射 API 访问它是可能的,但这只是为开发人员和工具提供的元信息——模块系统不会处理它。

JPMS 不“看到”版本也意味着它不会区分同一模块的两个不同版本。相反,并且与可靠配置的目标一致,它将这种情况视为经典的歧义——同一模块出现两次——并拒绝编译或启动。有关模块版本的信息,请参阅第十三章。

JPMS 没有提供从集中式仓库搜索或下载现有模块或发布新模块的机制。这项任务已经足够由现有的构建工具来覆盖。

JPMS 的目标也不是模拟一个动态模块图,其中单个工件可以在运行时出现或消失。然而,在高级特性之上(参见第 12.4 节),可以实施这样的系统。

1.7 新旧技能

我描述了许多承诺,本书的其余部分解释了 Java 平台模块系统如何旨在实现这些承诺。但不要误解,这些好处并非免费!要在模块系统之上构建应用程序,你将不得不比以前更深入地思考工件和依赖关系,并将更多的这些想法编码到代码中。某些在 Java 9 中曾经有效的东西将不再有效,使用某些框架将需要比以前更多的努力。

你可以将这看作与静态强类型语言相比,动态语言在编写代码时需要更多的工作——至少在编写代码的时候是这样。所有那些类型和泛型——难道你就不想到处使用 Object 和类型转换吗?当然,你可以这样做,但你愿意为了在编写代码时节省一些脑力而放弃类型系统提供的安全性吗?我不这么认为。

1.7.1 你将学到什么

新技能是必需的!幸运的是,这本书教授了这些技能。当一切都说完,你已经掌握了以下章节中阐述的机制时,无论是新应用还是现有应用都不会让你感到困难。

第一部分,特别是第三章到第五章,介绍了模块系统的基础。除了实用技能外,它们还教授了底层机制,以帮助你更深入地理解。之后,你将能够通过封装模块的内部结构和表达其依赖关系来描述模块及其关系。使用javacjarjava,你可以编译、打包和运行模块以及它们形成的应用程序。

书的第二部分基于基础知识,并将其扩展到涵盖更复杂的使用案例。对于现有应用,你将能够分析与 Java 9 到 11 可能存在的兼容性问题,并使用它提供的各种功能创建迁移路径到模块系统。为此,以及实现不那么直接的模块关系,你可以使用高级功能,如限定导出、开放模块和服务以及扩展的反射 API。使用jlink,你可以创建针对特定用例优化的精简 JRE,或者带有自己的 JRE 的自包含应用程序镜像。最后,你将看到更大的图景,包括模块系统如何与类加载、反射和容器交互。

1.7.2 你应该知道的内容

当谈到技能要求时,JPMS 有一个有趣的特性。它所做的许多事情都是全新的,并且在其模块声明中带有自己的语法分区。如果你有基本的 Java 技能,学习这一点相对容易。所以如果你知道代码是按类型、包和最终 JAR 组织;可见性修饰符,尤其是public,是如何跨越它们的;以及javacjarjava的作用,并且对如何使用它们有一个大致的了解,那么你就具备了理解第一部分以及第三部分中介绍的大多数更高级特性的所有知识。

但要真正理解模块系统解决的问题以及欣赏它提出的解决方案,需要的不仅仅是这些。熟悉以下内容以及与大型应用工作的经验,使你更容易理解模块系统特性的动机及其优点和缺点:

  • JVM 以及尤其是类加载器是如何运行的

  • 机制带来的麻烦(想想 JAR 地狱)

  • 更高级的 Java API,如服务加载器和反射 API

  • 构建工具如 Maven 和 Gradle 以及它们如何构建项目

  • 如何模块化软件系统

但无论你多么博学,你可能会遇到一些参考资料或解释,它们与你所知的内容不相关。对于一个像 Java 这样庞大的生态系统来说,这是很自然的,每个人在转角处都会学到新的东西(相信我,我亲身体验过)。所以,永远不要绝望!如果一些无关紧要的内容没有帮助,那么你很可能仅通过查看代码就能理解技术细节。

在背景着色完成后,是时候动手学***MS 基础知识了。我建议你继续阅读第二章,这一章贯穿了第一部分的其余内容,展示了定义、构建和运行模块化 JAR 的代码。它还介绍了本书其余部分出现的演示应用程序。如果你更愿意先学习底层理论,可以跳到第三章,该章节教授模块系统的基本机制。如果你担心你的项目与 Java 9 的兼容性,第六章和第七章详细介绍了这一点,但如果没有很好地掌握基础知识,这些章节将难以理解。

摘要

  • 一个软件系统可以被看作是一个图,它通常显示了系统的(不)期望属性。

  • 在 JAR 级别,Java 过去对那个图没有任何理解。这导致了各种问题,其中包括 JAR 地狱、手动安全性和维护性差。

  • Java 平台模块系统旨在使 Java 理解 JAR 图,这为语言带来了工件级别的模块化。最重要的目标包括可靠的配置、强大的封装以及改进的安全性、可维护性和性能。

  • 这是通过引入模块实现的:基本上,是带有额外描述符的 JAR 文件。编译器和运行时将解释描述的信息,以便构建工件依赖关系图并提供承诺的好处。

2

模块化应用程序的结构

本章涵盖

  • 布局模块化应用程序的源代码

  • 创建模块声明

  • 编译模块

  • 运行模块化应用程序

本章向您介绍了创建模块化应用程序的整体工作流程,但它并没有对这些主题进行详细解释。第三章、第四章和第五章会详细解释这些主题——它们深入探讨了这些主题。但是,对于像模块系统这样包罗万象的主题,很容易只见树木不见森林。这就是为什么本章向您展示了整体图景。它通过展示一个简单的模块化应用程序,如何定义和编译其模块,以及应用程序是如何执行的,给您一个不同部分如何拼凑在一起的印象。

这意味着我会让你跳入深水区:接下来的内容可能并不立即明了。但如果你对某些内容感到困惑,请不要担心——它们很快就会被详细解释。当你完成这本书的第一部分后,示例中的所有内容都会变得完全清晰。所以,请折起这些页面,因为你可能需要回头查阅它们。

2.1 节解释了假设的应用程序做什么,它由哪些类型组成,以及它们的职责。模块系统在 2.2 节中发挥作用,讨论了如何组织文件和文件夹,描述模块,以及编译和运行应用程序。这次简短的接触将展示模块系统的许多核心机制,以及一些基本功能不足以模块化复杂应用程序的实例——这些内容将在 2.3 节中讨论。您可以在www.manning.com/books/the-java-module-systemgithub.com/CodeFX-org/demo-jpms-monitor上找到应用程序。主分支包含 2.2 节中描述的变体。

2.1 介绍 ServiceMonitor

要看到模块系统在行动中的样子,你需要一个可以应用它的示例项目。项目具体做什么并不十分重要,所以不必担心它的细节。

让我们想象一个由服务组成的网络,这些服务相互协作以取悦用户——可能是一个社交网络或视频平台。您希望监控这些服务以确定系统的健康状况,并在问题发生时及时发现(而不是在客户报告时)。这正是示例应用的作用所在。

示例应用程序被称为 ServiceMonitor。它联系单个服务,收集和汇总诊断数据,并通过 REST 提供这些数据。

注意:您可能还记得 1.2 节或图 1.10 中的应用程序,当时它被分割成四个不同的 JAR 文件。我们最终会达到更详细的模块化,但这将在 2.2 节中探讨。在那样做之前,让我们思考一下如何在单个工件(让我们称其为单体方法)中实现这样一个系统。如果它与第一章的内容不完全一致,请不要在意——新章节,新细节。

恰好,这些服务已经收集了你想要的数据,所以 ServiceMonitor 需要做的只是定期查询它们。这是ServiceObserver实现的工作。一旦你有了以DiagnosticDataPoint形式存在的诊断数据,它就可以被喂给Statistician,将其汇总为Statistics。这些统计数据随后被存储在StatisticsRepository中,并通过 REST 提供。Monitor类将一切联系在一起。

图 2.1 展示了这些类型之间的关系。为了更好地理解这是如何工作的,让我们看看代码,从ServiceObserver接口开始。

列表 2.1 ServiceObserver接口

public interface ServiceObserver { DiagnosticDataPoint gatherDataFromService(); }

图片

图 2.1 构成 ServiceMonitor 应用程序的类。两个 ServiceObserver 实现使用 Alpha 和 Beta API 查询服务并返回诊断数据,这些数据由 Statistician 聚合到 Statistics 中。统计数据由存储库存储和加载,并通过 REST API 公开。Monitor 协调所有这些。

看起来很简单,但不幸的是,并非所有服务都公开相同的 REST API。目前使用了两代:Alpha 和 Beta。这就是为什么 ServiceObserver 是一个具有两个实现的接口(参见 图 2.2):每个实现连接到不同的 API 代,并确保通过相同的接口将数据公开给应用程序。

图片

图 2.2 被观察的服务使用两种不同的 API 代来公开诊断数据。相应地,ServiceObserver 接口有两个实现。

Statistician 没有自己独立的状态——它只提供两种方法,要么创建一个新的 Statistics 实例,要么将现有的统计数据和新数据点组合成更新的统计数据。

列表 2.2 Statistician

public class Statistician { public Statistics emptyStatistics() { return Statistics.empty(); } public Statistics compute( Statistics currentStats, Iterable<DiagnosticDataPoint> dataPoints) { Statistics finalStats = currentStats; for (DiagnosticDataPoint dataPoint : dataPoints) finalStats = finalStats.merge(dataPoint); return finalStats; } }

StatisticsRepository 没有做什么特别的事情——它只加载和存储统计数据。无论是通过序列化、JSON 文件还是后端数据库来完成,对于这个例子来说都是无关紧要的。

列表 2.3 StatisticsRepository

public class StatisticsRepository { public Optional<Statistics> load() { /* ... */ } public void store(Statistics statistics) { /* ... */ } }

这使得你拥有一个收集数据点的类型,另一个将它们转换为统计数据的类型,以及一个存储统计数据的类型。所缺少的是一个通过定期轮询数据并将其推送到统计学家再推送到存储库来将它们联系在一起的类型。这就是 Monitor 的作用所在。以下列表展示了其字段和 updateStatistics() 方法,该方法实现了其核心职责。(确保任务定期运行的代码被省略。)

列表 2.4 Monitor 类及其 updateStatistics() 方法

public class Monitor { private final List<ServiceObserver> serviceObservers; private final Statistician statistician; private final StatisticsRepository repository; private Statistics currentStatistics; // [...] private void updateStatistics() { List<DiagnosticDataPoint> newData = serviceObservers .stream() .map(ServiceObserver::gatherDataFromService) .collect(toList()); Statistics newStatistics = statistician .compute(currentStatistics, newData); currentStatistics = newStatistics; repository.store(newStatistics); } // [...] }

Monitor将最新的统计数据存储在currentStatistics字段(类型为Statistics)中。

在请求时,公开 REST API 的MonitorServer会要求监控器提供统计数据——无论是从内存中还是从持久化中——然后提取请求的部分并返回它们。

列表 2.5 MonitorServer

public class MonitorServer { private final Supplier<Statistics> statistics; public MonitorServer(Supplier<Statistics> statistics) { this.statistics = statistics; } // [...] private Statistics getStatistics() { return statistics.get(); } // [...] }

一个值得注意的细节是,尽管MonitorServer调用了Monitor,但它并不依赖于它。这是因为MonitorServer没有获取到监控器的引用,而是获取到一个数据提供者,该提供者将调用转发给监控器。原因很简单:Monitor协调整个应用程序,这使得它成为一个内部有很多操作的类。我不想仅仅为了调用一个 getter 而将 REST API 与这样一个重量级的对象耦合起来。在 Java 8 之前,我可能会创建一个专门的接口来获取统计数据并让Monitor实现它;但自从 Java 8 以来,lambda 表达式和现有的函数式接口使得临时解耦变得更加容易。

总的来说,你最终会得到这些类型:

  • DiagnosticDataPoint—在时间间隔内服务的可用性数据。

  • ServiceObserver—服务观察的接口,返回DiagnosticDataPoint

  • AlphaServiceObserverBetaServiceObserver—每个都观察服务的不同变体。

  • Statistician—从DiagnosticDataPoint计算统计数据。

  • Statistics—保存计算出的统计数据。

  • StatisticsRepository—存储和检索统计数据。

  • MonitorServer—响应统计数据的 REST 调用。

  • Monitor将所有内容串联起来。

2.2 服务监控器的模块化

如果你要将服务监控器应用程序按照前面描述的那样作为一个真实的项目来实现,那么充分利用模块系统就像是用大锤砸核桃一样。但这是一个例子,它在这里是为了展示模块化项目的结构,所以你会像处理一个更大的项目一样来构建它。

谈到结构,我们先从将应用程序切割成模块开始,然后再讨论源代码如何在文件系统中布局。接下来是最有趣的步骤:如何声明和编译模块以及运行应用程序。

2.3 将 ServiceMonitor 分割成模块

将应用程序模块化的最常见方式是通过关注点的分离。ServiceMonitor 具有以下内容,相关类型在括号中给出:

  • 从服务中收集数据(ServiceObserver, DiagnosticDataPoint

  • 将数据聚合到统计信息中(Statistician, Statistics

  • 持久化统计信息(StatisticsRepository

  • 通过 REST API(MonitorServer)公开统计信息

但不仅领域逻辑生成需求。还有技术性的需求:

  • 数据收集必须隐藏在 API 之后。

  • Alpha 和 Beta 服务各自需要该 API 的单独实现(AlphaServiceObserverBetaServiceObserver)。

  • 所有关注点都必须由(Monitor)进行协调。

重要信息 这导致了以下具有所述公开可见类型的模块:

  • monitor.observer (ServiceObserver, DiagnosticDataPoint)

  • monitor.observer.alpha (AlphaServiceObserver)

  • monitor.observer.beta (BetaServiceObserver)

  • monitor.statistics (Statistician, Statistics)

  • monitor.persistence (StatisticsRepository)

  • monitor.rest (MonitorServer) * monitor (Monitor)

将这些模块叠加到图 2.3 中的类图上,很容易看到模块依赖出现。

图片

图 2.3 服务监控应用程序的模块(粗体)覆盖在类结构(常规)之上。注意模块边界之间的类依赖如何确定模块依赖。

2.4 在目录结构中布局文件

图 2.4 显示了应用程序的目录结构。每个模块都将是一个独立的项目,这意味着每个都可以有自己的目录结构。但是,没有必要使事情复杂化,所以你会使用相同的结构。如果你参与过不同的项目或者使用过 Maven、Gradle 或其他构建工具,你会认出这是默认的结构。

图片

图 2.4 ServiceMonitor 应用程序的每个模块都是其自己的项目,具有众所周知的目录结构。新的是mods文件夹,它收集构建后的模块 JAR 文件,以及每个项目的根源目录中的模块声明module-info.java文件。

首先要注意的是mods文件夹。稍后,当你创建模块时,它们将在这里结束。第 4.1 节将更详细地介绍目录结构。

然后是稍微不寻常的libs文件夹,其中包含第三方依赖项。在实际项目中,你可能不需要它,因为你的构建工具管理依赖项。但你会手动编译和启动,将所有依赖项放在一个地方可以极大地简化这个过程。所以这并不是一个推荐,甚至不是一个要求——这只是简化。

另一个不常见的事情是 module-info.java。它被称为模块声明,负责定义模块的属性。这使得它位于模块系统的中心,因此也在本书中,尤其是第 3.1 节。尽管如此,我们将在下一节快速浏览它。

2.5 声明和描述模块

重要的是每个模块都有一个模块声明。按照惯例,这是一个位于项目根源文件夹中的 module-info.java 文件。编译器会从这个文件创建一个模块描述符,module-info.class。当编译后的代码被打包成 JAR 文件时,描述符必须位于模块系统的根目录,以便模块系统能够识别和处理它。

如第 2.2.1 节所述,应用程序由七个模块组成,因此必须有七个模块声明。列表 2.6 显示了所有这些声明。即使目前还不知道任何细节,你也能窥见正在进行的事情。

module the.name { } 块定义了一个模块。名称通常遵循包命名约定:它应该通过反转域名来保证全局唯一性,全部小写,并且部分之间用点分隔(更多内容请参阅第 3.1.3 节——我仅使用较短的名称是为了使它们更适合本书)。在模块块内部,requires 指令表达了模块之间的依赖关系,而 exports 指令通过命名要导出公共类型的包来定义每个模块的公共 API。

列表 2.6 所有 ServiceMonitor 模块的声明

module monitor.observer { exports monitor.observer; } module monitor.observer.alpha { requires monitor.observer; exports monitor.observer.alpha; } module monitor.observer.beta { requires monitor.observer; exports monitor.observer.beta; } module monitor.statistics { requires monitor.observer; exports monitor.statistics; } module monitor.persistence { requires monitor.statistics; requires hibernate.jpa; exports monitor.persistence; exports monitor.persistence.entity; } module monitor.rest { requires spark.core; requires monitor.statistics; exports monitor.rest; } module monitor { requires monitor.observer; requires monitor.observer.alpha; requires monitor.observer.beta; requires monitor.statistics; requires monitor.persistence; requires monitor.rest; }

2.5.1 在其他模块上声明依赖

重要的是 requires 指令包含一个模块名称,并告诉 JVM 声明模块依赖于指令中给出的模块。

你可以看到观察者实现依赖于观察者 API,这立即就很有道理。统计模块也依赖于观察者 API,因为 Statistician::compute 使用了 DiagnosticDataPoint 类型,它是 API 的一部分。

类似地,持久化模块需要统计信息,因此它依赖于统计模块。它还依赖于 Hibernate,因为它使用 Hibernate 与数据库通信。

然后是 monitor.rest,它也依赖于统计模块,因为它处理统计信息。除此之外,它使用 Spark 微框架创建 REST 端点。在 2.2.1 节中模块化应用时,我特别指出MonitorServer不依赖于Monitor。这现在很有用,因为它意味着 monitor.rest 不依赖于 monitor;这很好,因为 monitor 依赖于 monitor.rest,而模块系统禁止声明循环依赖。最后,monitor 依赖于所有其他模块,因为它创建了大多数实例,并将结果从一个模块传递到另一个模块。

2.5.2 定义模块的公共 API

ESSENTIAL INFO 一个exports指令包含一个包名,并通知 JVM,依赖于声明该包的其他模块可以查看该包中的公共类型。

大多数模块导出单个包:包含在模块确定时列出的类型的包。你可能已经注意到,包名总是以模块名作为前缀——通常它们是相同的。这不是强制的,但由于模块和包名都遵循反向域名命名方案,所以这是常见的情况。

持久化模块是唯一导出多个包的模块。除了包含其核心功能(StatisticsRepository)的monitor.persistence之外,它还导出monitor.persistence.entity。该包定义了一组被注解的类,Hibernate 可以通过这些注解理解如何存储和加载它们(这些通常被称为实体)。这意味着 Hibernate 必须访问它们,这反过来又意味着模块必须导出包含它们的包。(如果你依赖于 Hibernate 对私有字段或构造函数进行反射,导出是不够的——请参阅第 12.2 节以获取解决方案。)

另一个例外是 monitor,它没有导出任何包。这很有道理,因为它就像蜘蛛网中的蜘蛛一样,位于模块图的中心,协调执行流程。因此,它没有自己的 API,其他人可能想要调用。主模块——通常和合适地包含程序的主方法——不导出任何包是典型的。

2.5.3 使用模块图可视化 SERVICEMONITOR

在模块的依赖和导出定义得井井有条之后,让我们来看看图 2.5 中得到的模块图。虽然它看起来就像是对图 2.3 的一个清理版本,但它远不止于此!图 2.3 是一个应用架构师可能在白板上绘制的图表。它显示了模块及其关系,但那些只是你的想象产物——对编译器或虚拟机来说无关紧要。图 2.5 另一方面,则是模块系统对你架构的解释。

图片

图 2.5 应用程序的模块图,显示了模块及其导出的包以及它们之间的依赖关系。与图 2.3 不同,这不仅仅是一个架构图;这是模块系统看待应用程序的方式。

两个图中的模块方面看起来如此相似,以至于几乎可以互换,这意味着你可以在代码中相当精确地表达你对应用程序架构的愿景:即在模块声明中。这不是很酷吗?

编写代码与之前

你可能会想知道,编写代码在 Java 9 之前和之后会有什么不同。答案是大多数情况下不会有变化。尽管如此,一些细节已经发生了变化,为了让你做好准备,第六章和第七章将详细介绍这些变化。

除了正确模块化项目,偶尔需要考虑将类放在哪个包中,或者是否需要修改依赖项或导出项之外,日常工作中对领域建模和解决手头问题的努力将保持不变。有了 IDE 的支持,修改依赖项或导出项就像管理包导入一样简单。

尽管如此,如何组织更大的代码库的大局工作将变得更加容易。添加依赖项,尤其是那些已经间接存在的依赖项,变得更加明确,因此在结对编程、代码审查或架构审查期间更容易讨论——这将确保感知到的和实际的架构不会轻易分离。像服务(见第十章)和聚合模块(见第 11.1.5 节)这样的突出功能将增强模块化工具箱,如果使用得当,将导致更好的设计。

2.6 编译和打包模块

现在你已经将项目整洁地组织到模块特定的文件夹中,创建了模块声明,并编写了代码,你就可以构建(稍后运行)应用程序了。要构建它,你需要创建模块工件,这是一个两步的过程:编译和打包。

在编译时,你需要让编译器知道声明中引用的模块在哪里。对于 Java 模块的依赖项来说,这是微不足道的,因为编译器知道它们在哪里(在运行时环境的 libs/modules 文件中)。

重要信息 为了让你的模块被发现,你必须使用模块路径,这是一个与类路径平行的概念,但正如其名称所暗示的,它期望的是模块化的 JAR 文件而不是普通的 JAR 文件。当编译器搜索引用的模块时,它将被扫描。为了定义模块路径,javac有一个新的选项:--module-path,或简写为-p。(同样的思路也适用于使用 JVM 启动应用程序。因此,相同的选项--module-path-p也被添加到java中,它们的功能完全相同。)

你选择了mods文件夹来存储你的模块,这意味着两件事:

  • 模块路径将包含mods

  • 打包的工件将创建在mods中。

一些模块有外部依赖项:持久化模块需要 Hibernate (hibernate.jpa),而 REST 模块需要 Spark (spark.core)。目前,最容易的假设是它们的工件也已经模块化 JAR,并且你或工具将它们及其依赖项放置在 mods 文件夹中。

如果你将普通 JAR 放置在模块路径上,将模块化 JAR 放置在类路径上,或者混合使用,会发生什么?如果依赖项尚未模块化但你仍然想使用它,你能做什么?所有这些都属于迁移故事的一部分,并在第八章中进行了介绍。

mods 中拥有所有先决条件后,你可以编译和打包模块。你从 monitor.observer 开始,它没有依赖项。它不包含任何新内容——使用较旧的 Java 版本执行这些命令将产生完全相同的结果:

$ javac -d monitor.observer/target/classes${source-files}$ jar --create --file mods/monitor.observer.jar-C monitor.observer/target/classes .

编译目标文件夹

列出或查找所有源文件:在这种情况下,monitor.observer/src/main/java/monitor/observer/DiagnosticDataPoint.javamonitor.observer/src/main/java/monitor/observer/ServiceObserver.java

mods 中命名新的 JAR 文件

编译后的源文件

monitor.alpha 模块确实有一个依赖项,因此你必须使用模块路径来告诉编译器在哪里可以找到所需的工件。当然,使用 jar 打包不会受到影响:

$ javac --module-path mods-d monitor.observer.alpha/target/classes ${source-files} $ jar --create --file mods/monitor.observer.alpha.jar -C monitor.observer.alpha/target/classes .

javac 将在其中搜索代码所依赖的模块的文件夹

大多数其他模块的工作方式几乎相同。一个例外是 monitor.rest,它在 libs 文件夹中有第三方依赖项,因此你需要将其添加到模块路径中:

$ javac --module-path mods:libs-d monitor.rest/target/classes ${source-files}

该模块在两个文件夹中有依赖项,因此两个文件夹都被添加到模块路径中。

另一个例外是 monitor。你利用这个机会通知模块系统它有一个 main 方法,可以用作应用程序的入口点:

$ javac --module-path mods -d monitor/target/classes ${source-files} $ jar --create --file mods/monitor.jar --main-class monitor.Monitor-C monitor/target/classes .

包含应用程序主方法的类

图 2.6 展示了你最终得到的结果。这些 JAR 文件就像普通的 JAR 文件一样,只有一个例外:每个都包含一个模块描述符 module-info.class,将其标记为模块化 JAR。

图 2.6 所有应用程序模块,编译并打包在mods中,准备启动。

2.7 运行 ServiceMonitor

将所有模块编译到mods文件夹后,你终于可以启动应用程序了。正如你在下面的单行命令中可以看到的,这就是你在模块声明中投入的一些辛勤工作得到回报的地方:

$ java --module-path mods:libs ①``--module monitor

Java 搜索模块的文件夹

要启动的模块名称

必要信息你所需要做的就是调用java,指定模块路径,这样java就知道在哪里找到你的应用程序所包含的工件,并告诉它要启动哪个模块。解决所有依赖项,确保没有冲突或模糊情况,以及仅使用正确的一组模块来启动,都由模块系统处理。

2.8 扩展模块化代码库

当然,没有任何软件项目是永远完成的(除非它已经死亡),所以变化是不可避免的。例如,如果你想添加另一个观察者实现,会发生什么?通常你会采取以下步骤:

  1. 开发子项目。

  2. 确保它能构建。

  3. 在现有代码中使用它。

这正是你现在要做的。对于新模块,模块系统如果添加了声明就会感到满意。

module monitor.observer.gamma { requires monitor.observer; exports monitor.observer.gamma; }

你就像编译和打包其他模块一样编译和打包新模块:

$ javac --module-path mods -d monitor.observer.gamma/target/classes ${source-files} $ jar --create --file mods/monitor.observer.gamma.jar -C monitor.observer.gamma/target/classes .

然后你可以将其作为依赖项添加到现有的代码中:

module monitor { requires monitor.observer; requires monitor.observer.alpha; requires monitor.observer.beta; requires monitor.observer.gamma; requires monitor.statistics; requires monitor.persistence; requires monitor.rest; }

你就完成了。假设构建过程负责编译和打包,你实际上只需要添加或编辑模块声明。这也适用于删除或重构模块:除了你通常需要投入的工作之外,你还需要稍微思考一下这如何影响你的模块图,并相应地更新声明。

2.9 剖析:模块系统的影响

总体来说,这进行得相当顺利,不是吗?在探索以下章节的细节之前,让我们花点时间看看模块系统承诺的两个好处以及一些你可以通过更高级的功能来平滑处理的一些粗糙边缘。

2.9.1 模块系统为你做了什么

在 1.5 节中讨论模块系统的目标时,我们提到了两个最重要的目标:可靠配置和强封装。现在你已经构建了一些更具体的东西,我们可以重新审视这些目标,看看它们将如何帮助你交付健壮、可维护的软件。

可靠配置

如果 mods 中缺少依赖项会发生什么?如果两个依赖项需要同一项目的不同版本——比如 Log4j 或 Guava——会发生什么?如果两个模块意外或故意导出相同的类型,会发生什么?

使用类路径时,这些问题会在运行时显现:一些会导致应用程序崩溃,而其他问题可能更为微妙,甚至可以说是狡猾,并可能导致应用程序行为损坏。在模块系统中,许多这些不可靠的情况,尤其是我刚才提到的那些,会在更早的时候被发现。编译器或 JVM 会通过详细的错误信息终止,并给你机会修复错误。

例如,如果你启动应用程序但 monitor.statistics 缺失,你会收到以下消息:

> 初始化引导层时发生错误 > java.lang.module.FindException: > 没有找到模块 monitor.statistics, > 它被 monitor 所需要

类似地,这是在模块路径上存在两个 SLF4J 版本时启动 ServiceMonitor 应用程序的结果:

> 初始化引导层时发生错误 > java.lang.module.FindException: > 在 mods 中找到两个版本的模块 org.slf4j.api > (org.slf4j.api-1.7.25.jar 和 org.slf4j.api-1.7.7.jar)

你也不再可能意外地依赖于你的依赖项的依赖项。Hibernate 使用 SLF4J,这意味着当你的应用程序启动时,库总是存在的。但一旦你开始从 SLF4J 导入类型(你不需要在任何模块声明中导入),编译器就会阻止你,并通知你正在使用你未显式依赖的模块中的代码:

> monitor.persistence/src/main/java/.../StatisticsRepository.java:4: > 错误:无法看到包 org.slf4j > (包 org.slf4j 在模块 org.slf4j.api 中声明, > 但模块 monitor.persistence 没有读取它)

即使你试图欺骗编译器,模块系统在启动时也会执行相同的检查。

强封装

现在,让我们从模块的用户转变为模块的维护者。想象一下在 monitor.observer.alpha 中对代码进行重构,可能是为了修复一个错误或提高性能。在发布新版本后,你发现你破坏了 monitor 中的某些代码,使应用程序变得不稳定。如果你更改了公共 API,那就是你的责任。

但是,如果你在一个被标记为不支持的类型中更改了内部实现细节,会发生什么呢?也许这个类型必须公开,因为你想要在两个包中使用它,或者可能是监控器的作者通过反射访问了它。在这种情况下,你无法阻止用户依赖你的实现。

通过模块系统,你可以避免这种情况。你已经做到了!只有你导出的包中的类型是可见的。其余的都是安全的——甚至从反射中也是安全的。

注意:如果你想知道在真正需要打破模块的情况下可以做什么,请参阅第 7.1 节和第 12.2.2 节。

2.9.2 模块系统还能为你做什么

虽然 ServiceMonitor 的模块化进行得相当顺利,但还有一些粗糙的边缘我们需要讨论。你现在无法做任何事情,但本书第三部分的先进功能将使你能够平滑它们。本节为你预览了即将发生的事情。

标记必需的模块依赖项

模块monitor.observer.alphamonitor.observer.beta声明了对monitor.observer的依赖,这是有意义的,因为它们实现了后者公开的ServiceObserver接口。它们还返回DiagnosticDataPoint实例,它属于同一个模块。

这对任何使用实现模块的代码都有有趣的后果:

ServiceObserver observer = new AlphaServiceObserver("some://service/url"); DiagnosticDataPoint data = observer.gatherDataFromService();

包含这些行的模块需要依赖monitor.observer;否则,它将无法看到ServiceObserverDiagnosticDataPoint类型。对于不依赖monitor.observer的客户端来说,整个monitor.observer.alpha模块几乎毫无用处。

如果一个模块只有在客户端记得显式依赖另一个模块时才能使用,这会很笨拙。幸运的是,有一个解决办法!你将在第 11.1 节中了解到隐式可读性。

解耦 API 的实现和消费者

考虑到monitor.observer与实现模块之间的关系,以及monitor.observer.alphamonitor.observer.beta,还有一些其他的事情需要考虑。为什么监控器必须知道实现细节呢?

目前,监控器需要实例化具体的类,但从那时起,它只通过接口与它们交互。依赖整个模块来调用一个构造函数似乎有些过度。实际上,每当一个废弃的ServiceObserver实现被淘汰或引入新的实现时,你都需要更新监控器的模块依赖,然后重新编译、打包和重新部署工件。

为了实现 API 实现和消费者之间的松散耦合,其中消费者如 monitor 不需要依赖实现如 monitor.observer.alpha 和 monitor.observer.beta,模块系统提供了服务。它们在第十章中进行了讨论。

使导出更具有针对性

记得持久化模块是如何导出包含由 Hibernate 注解用于使用的对象的数据传输对象的包吗?

module monitor.persistence { requires monitor.statistics; requires hibernate.jpa; exports monitor.persistence; exports monitor.persistence.entity; }

这看起来并不合适——只有 Hibernate 需要看到那些实体。但现在其他依赖于 monitor.persistence 的模块,如 monitor,也能看到它们。

再次强调,高级模块系统功能可以满足你的需求。合格导出允许模块仅向选定的模块导出包。第 11.3 节介绍了这一机制。

仅使包对反射可用

即使仅向选定的模块导出包也可能过多:

  • 你可能针对 API(如 Java 持久化 API [JPA])编译你的模块,而不是针对具体实现(例如,Hibernate),因此你可能会在合格导出中谨慎提及实现模块。

  • 你可能使用的是基于反射的工具(如 Hibernate 或 Guice),它仅在运行时通过反射访问你的代码,所以为什么要在编译时使其可访问呢?

  • 你可能依赖于对私有成员的反射(当配置为字段注入时,Hibernate 会这样做),这在导出包中是不起作用的。

第 12.2 节提供了一个解决方案,介绍了开放模块和开放包。它们仅在运行时使包可用。作为交换,它们允许反射私有成员,这是基于反射的工具通常需要的。类似于导出,还有合格开放,你可以使用它将包仅向选定的模块开放。

如果你例如使用 Hibernate 作为 JPA 提供者,你可能已经努力防止模块声明中直接依赖 Hibernate。在这种情况下,将依赖项硬编码到模块声明中可能不是你期待的事情。第 12.3.5 节详细讨论了这种情况。

2.9.3 允许可选依赖

项目中包含仅在特定依赖项存在于运行中的应用程序中才执行的代码并不罕见。例如,模块 monitor.statistics 可能包含使用一个花哨的统计库的代码,由于可能是许可问题,当 ServiceMonitor 启动时并不总是存在。另一个例子是,只有当存在第三个依赖项时,某些功能对用户才有吸引力——比如,如果与某些断言库一起使用,可能是一个与框架合作的测试框架。

在这种情况下,根据前面讨论的内容,必须在模块声明中要求依赖项。这迫使它在编译时存在,以便成功编译。不幸的是,相同的 requires 指令意味着依赖项也必须在启动时存在,否则 JVM 将拒绝运行应用程序。

这并不令人满意。但正如预期的那样,模块系统提供了一个出路:可选依赖项,它们必须在编译时存在,但在运行时不要求。它们在第 11.2 节中进行了讨论。在讨论了所有这些和其他高级功能之后,第 15.1 节展示了使用其中大多数功能的 ServiceMonitor 的一个变体。

定义、构建和运行模块化应用程序的三个步骤各自有自己的章节:第三章、第四章和第五章,分别。所有这些都很重要,但第三章尤其重要,因为它介绍了模块系统背后的基本概念和机制。

摘要

  • 当你对应用程序进行模块化时,你可以从模块边界跨模块的类型依赖中推断出模块依赖。这使得创建初始模块依赖图变得简单直接。

  • 多模块项目的目录结构可以与 Java 9 之前的情况相似,因此现有的工具和方法将继续工作。

  • 模块声明——项目根目录中的 module-info.java 文件——是模块系统对编码带来的最明显变化。它命名了模块并声明了依赖项以及公共 API。除此之外,代码的编写方式几乎没有任何变化。

  • 命令 javacjarjava 已更新以支持模块化。最明显且相关的变化是模块路径(命令行选项 --module-path-p)。它与类路径平行,但用于模块。

3

定义模块及其属性

本章节涵盖

  • 模块是什么,以及模块声明如何定义它们

  • 区分不同类型的模块

  • 模块的可读性和可访问性

  • 理解模块路径

  • 使用模块解析构建模块图

我们已经讨论了很多关于模块的内容。它们不仅是模块化应用程序的核心构建块,也是对模块系统真正理解的核心。因此,深入了解它们是什么以及它们的属性如何塑造程序的行为是很重要的。

在定义、构建和运行模块的三个基本步骤中,本章探讨了第一个(关于其他两个步骤,请参阅第四章和第五章)。本章详细解释了什么是模块以及模块的声明如何定义其名称、依赖项和 API(第 3.1 节)。一些来自 JDK 的示例让你对从 Java 9 开始将要探索的模块景观有一个初步的了解,并帮助你分类模块类型。

我们还讨论了模块系统(以及由此扩展的编译器和运行时)如何与模块交互(第 3.2 节和第 3.3 节)。最后但同样重要的是,我们检查了模块路径以及模块系统如何解析依赖关系并从它们构建一个图(第 3.4 节)。

如果您想跟随代码编写,请查看 ServiceMonitor 的主分支。它包含本章中展示的大多数模块声明。到本章结束时,您将知道如何定义模块的名称、依赖关系和 API,以及模块系统如何根据这些信息表现。您将理解模块系统可能抛出的错误消息,并能够分析和修复其根本原因。

指示牌

本章为后续内容奠定了基础,因此本书的其余部分都与它相连。为了使这些联系显而易见,本章包含大量的前瞻性引用。如果您觉得它们烦人,可以忽略它们——当您打开本章查找某些内容时,它们将变得重要。

3.1 模块:模块化应用程序的构建块

在所有关于模块的高谈阔论之后,是时候动手实践了。我们首先将查看您可能遇到的两种文件格式中的模块(JMODs 和模块化 JARs),然后转向如何声明模块的属性。为了在本书的其余部分更容易地进行讨论,我们将对不同的模块类型进行分类。

3.1.1 与 JDK 一起提供的 JAVA 模块(JMODS)

在 Project Jigsaw 的工作过程中,Java 代码库被分割成大约 100 个模块,这些模块以新的格式 JMOD 提供的。它是故意不明确的,以便比使用 JAR 格式(本质上是一个 ZIP 文件)更激进地进化。它被保留用于 JDK,因此我们不会深入讨论它。

虽然我们不应该创建 JMODs,但我们可以检查它们。要查看 JRE 或 JDK 中的模块,请调用 java --list-modules。信息来自优化后的模块存储,即运行时安装的 libs 文件夹中的 modules 文件。JDK(而非 JRE)还包含在 jmods 文件夹中的原始模块;并且新的 jmod 工具,您可以在 jmods 旁边的 bin 文件夹中找到,可以使用 describe 操作输出它们的属性。

以下代码片段展示了检查 JMOD 文件的一个示例。在这里,jmod 用于描述 Linux 机器上的 java.sql,其中 JDK 9 安装在 *opt*jdk-9。像大多数 Java 模块一样,java.sql 使用了模块系统的一些高级功能,因此并非所有细节都会在章节结束时变得清晰:

$ jmod describe *opt*jdk-9/jmods/java.sql.jmod > java.sql@9.0.4 ①``> exports java.sql > exports javax.sql > exports javax.transaction.xa > requires java.base mandated ③``> requires java.logging transitive > requires java.xml transitive > uses java.sql.Driver ⑤``> platform linux-amd64

模块的版本以简单字符串的形式记录在文件中:这里,9.0.4。

包含在 java.sql 中并向其他模块公开的包(导出在第 3.1.3 节中介绍)

需求指令声明了依赖关系。术语“强制”源于 java.base 是一个特殊情况(参见第 3.1.4 节)。

使用隐式可读性(参见第 11.1 节)的依赖项

使用指令与服务相关联(参见第十章,特别是第 10.1.1 节)。

模块是为特定的操作系统和硬件架构构建的。

3.1.2 模块 JAR:自建模块

我们不应该创建 JMOD,那么我们如何交付我们创建的模块呢?这正是模块 JAR 发挥作用的地方。

定义:模块 JAR 和模块描述符

模块 JAR 只是一个普通的 JAR,除了一个小的细节。它的根目录包含一个模块描述符:一个module-info.class文件。(本书将没有模块描述符的 JAR 称为普通 JAR,但这不是一个官方术语。)

模块描述符包含模块系统创建模块运行时表示所需的所有信息。单个模块的所有属性都表示在这个文件中;因此,本书中讨论的许多功能也有其对应项。在下一节中,我们将介绍如何从源文件创建这样的描述符,并将其包含在 JAR 中,以便开发人员和工具可以创建模块。

尽管模块描述符允许模块 JAR 不仅仅是类文件存档,但以这种方式使用它并不是强制性的。客户端可以选择将其作为简单的 JAR 使用,忽略所有模块相关属性,只需将其放置在类路径上。这对于现有项目的增量模块化至关重要。(第 8.2 节介绍了未命名的模块。)

3.1.3 模块声明:定义模块属性

因此,模块描述符module-info.class是您将任何旧 JAR 转换为模块所需的所有内容。然而,这引发了一个问题,那就是如何创建描述符。正如文件扩展名.class所暗示的,它是编译源文件的结果。

定义:模块声明

模块描述符是从模块声明编译而来的。按照惯例,这是一个位于项目根源文件夹中的module-info.java文件。声明是模块的核心元素,因此是模块系统。

声明与描述

你可能会担心你会混淆模块声明和模块描述符这两个术语。如果你这样做,通常不是什么大问题。前者是源代码,后者是字节码,但它们只是相同想法的不同形式:定义模块属性的东西。上下文通常只留下一个选项,所以通常很清楚指的是哪种形式。

如果这还不能满足你,并且你总是想做到正确无误,我可以通过分享我的记忆法来帮助你:按字典顺序来说,声明在描述符之前,这很整洁,因为从时间顺序来说,你首先有源代码,然后是字节码。这两种排序是一致的:首先是声明/源代码,然后是描述符/字节码。

模块声明决定了模块在模块系统中的身份和行为。后续章节中介绍的大多数功能在模块声明中都有相应的指令,将在适当的时候介绍。现在,让我们看看 JAR 文件缺少的三个基本属性:一个名字、显式的依赖关系和封装的内部结构。

基本信息 这是定义这三个属性的简单 module-info.java 文件的结构:

module ${module-name} { requires ${module-name}; exports ${package-name}; }

当然,${module-name}${package-name} 需要替换为实际的模块和包名称。

以 ServiceMonitor 的 monitor.statistics 模块的描述符为例:

module monitor.statistics { requires monitor.observer; exports monitor.statistics; }

你可以很容易地识别我刚才描述的结构:module 关键字后面跟着模块的名称,主体包含 requiresexports 指令。接下来的几节将探讨声明这三个属性的具体细节。

新的关键字?

你可能会想知道,在后续章节中提到的新的关键字 modulerequiresexports 等对于已经将这些术语用作字段、参数、变量和其他命名实体的名称的代码意味着什么。幸运的是,没有必要担心。这些是受限关键字,意味着它们只在语法期望它们的位置上作为关键字使用。所以,虽然你不能有一个名为 package 的变量或一个名为 byte 的模块,但你可以有一个名为 module 的变量甚至模块。

模块命名

JAR 文件缺少的最基本属性是一个编译器和 JVM 可以用来识别它们的名字。因此,这是模块最突出的特征。你将有机会,甚至有义务给你的每个模块起一个名字。

基本信息 除了 module 关键字之外,声明从给模块命名开始。这必须是一个标识符,这意味着它必须遵循与例如包名称相同的规则。模块名称通常是小写的,并以点分层结构。

命名模块将相当自然,因为你在日常生活中使用的多数工具已经让你命名项目了。但是,尽管将项目名称作为寻找模块名称的跳板是有意义的,但选择时仍然很重要!

正如你在 3.2 节中看到的,模块系统在很大程度上依赖于模块的名称。特别是冲突或演变的名称会引起问题,因此确保名称是

  • 全局唯一

  • 稳定

实现这一点的最好方法是使用通常用于包的逆向域名命名方案。结合标识符的限制,这通常导致模块的名称是其包含的包的前缀。这不是强制性的,但这是一个很好的迹象,表明两者都是经过深思熟虑选择的。

保持模块名称和包名前缀同步强调,模块名称更改(这会暗示包名更改)是可能的最严重的破坏性更改之一。为了稳定起见,这应该是一个非常罕见的事件。

例如,以下描述符命名了模块 monitor.statistics(为了使名称简洁,组成 ServiceMonitor 应用程序的模块不遵循反向域名命名方案):

module monitor.statistics { // requires and exports truncated }

所有其他属性都定义在模块名称后面的花括号内。没有特定的顺序要求,但通常是在定义依赖关系之前先定义导出。

通过requires指令声明依赖关系

在 JARs 中,我们缺少声明依赖的能力。使用 JARs 时,我们永远不知道它们需要哪些其他工件才能正常运行,我们依赖于构建工具或文档来确定这一点。在模块系统中,依赖关系必须明确声明。(参见图 3.1 了解这是如何实现的。)

图 3.1 能够表达模块之间的依赖关系为 JVM 引入了一个新的抽象层,JVM 可以对此进行推理。没有它们(左侧),它只能看到类型之间的依赖关系;但是有了它们(右侧),它看到的是类似于我们倾向于的工件之间的依赖关系。

定义:依赖关系

依赖关系通过requires指令声明,该指令由关键字后跟模块名称组成。该指令表明声明的模块依赖于命名的模块,并在编译和运行时需要它。

monitor.statistics 模块在编译时和运行时依赖于 monitor.observer,这是通过requires指令声明的:

module monitor.statistics { requires monitor.observer; // exports truncated }

如果一个依赖关系通过requires指令声明,当模块系统找不到具有该确切名称的模块时,它将抛出一个错误。如果缺少模块,编译以及启动应用程序都将失败(参见 3.2 节)。

导出包以定义模块的 API

最后是导出,它定义了一个模块的公共 API。在这里,你可以选择哪些包包含应该对模块外部可用的类型,哪些包仅用于内部使用。

定义:导出包

关键字 exports 后跟模块包含的包的名称。只有导出包才可以在模块外部使用;所有其他包都严格封装在模块内部(见第 3.3 节)。

模块 monitor.statistics 导出一个同名的包:

module monitor.statistics { requires monitor.observer; exports monitor.statistics; }

注意,尽管我们喜欢认为它们是层级结构的,但包并不是层级结构的!包 java.util 不包含 java.util.concurrent;因此,导出前者不会暴露后者中的任何类型。这与导入一致,其中 import java.util.* 将导入 java.util 中的所有类型,但不会从 java.util.concurrent 中导入(见图 3.2)。

图 3.2 我们喜欢将包视为层级结构,其中 org.junitpioneer 包含 extensionvintage(左)。但这并不是事实!Java 只关心完整的包名,看不到两者之间的任何关系(右)。在导出包时必须考虑这一点。例如,exports org.junitpioneer 不会导出 jupitervintage 中的任何类型。

这意味着如果模块想要导出两个包,它总是需要两个 exports 指令。模块系统也不提供通配符如 exports java.util.* 来简化这个过程——公开 API 应该是一个故意的行动。

EXAMPLE MODULE DECLARATIONS

为了让你熟悉,让我们看看一些实际的模块声明。最基础的模块是 java.base,因为它包含 java.lang.Object 类,没有这个类,任何 Java 程序都无法运行。这是所有依赖的终结者:所有其他模块都需要它,但它不需要其他任何东西。对 java.base 的依赖如此基本,以至于模块甚至不需要声明它,因为模块系统会自动填充它(下一节将详细介绍)。尽管它没有依赖,但它导出了 116 个包,所以我只会展示一个高度截断的版本:

module java.base { exports java.lang; exports java.math; exports java.nio; exports java.util; // 许多许多其他导出 // 精美特性的使用被截断 }

一个更简单的模块是 java.logging,它公开了 java.util.logging 包:

module java.logging { exports java.util.logging; }

要查看需要另一个模块的模块,让我们转向 java.rmi。它创建日志消息,因此依赖于 java.logging 来完成这个任务。它公开的 API 可以在 java.rmi 和其他带有该前缀的包中找到:

module java.rmi { requires java.logging; exports java.rmi; // 其他 `java.rmi.*` 包的导出 // 精美特性的使用被截断 }

要了解更多示例,请翻回第 2.2.3 节,特别是声明 ServiceMonitor 应用程序模块的代码。

3.1.4 模块的多种类型

想想你现在正在工作的应用程序。它很可能由多个 JAR 文件组成,这些 JAR 文件在未来某个时刻可能会全部成为模块。但它们并不是构成应用程序的唯一元素。JDK 也被分割成模块,它们也将成为你考虑的一部分。但是等等,还有更多!在这个模块集中,一些具有特定特征的模块需要特别指出。

定义:模块类型

为了避免不可解的混乱,以下术语识别了某些角色,并使讨论模块化景观更加清晰。现在是时候坐下来学习它们了。如果你一次记不住所有这些术语,请不要担心;将页面书签,并在遇到不确定如何解释的术语时返回(或直接查看书籍的索引)。

  • 应用程序模块 — 非 JDK 模块;Java 开发者为他们自己的项目创建的模块,无论是库、框架还是应用程序。这些模块位于模块路径上。目前,它们将是模块化 JAR 文件(见第 3.1.2 节)。

  • 初始模块 — 应用模块,编译从这里开始(对于javac)或者包含main方法(对于java)。第 5.1.1 节展示了如何使用java命令启动应用程序时指定它。编译器也使用这个概念:如第 4.3.5 节所述,它定义了编译从哪个模块开始。

  • 根模块 — JPMS 开始解析依赖的地方(这个过程在第 3.4.1 节中详细解释)。除了包含主类或要编译的代码外,初始模块也是一个根模块。在书中进一步遇到的复杂情况下,可能需要定义除初始模块之外的根模块(如第 3.4.3 节所述)。

  • 平台模块 — 构成 JDK 的模块。这些模块由 Java SE 平台规范(以 java.为前缀)以及 JDK 特定模块(以 jdk.为前缀)定义。如第 3.1.1 节所述,它们以优化形式存储在运行时的libs目录中的modules文件中。

  • 孵化器模块 — 非标准平台模块,其名称总是以 jdk.incubator 开头。它们包含可能从冒险的开发者测试中受益的实验性 API。

  • 系统模块 — 除了从平台模块的子集中创建运行时镜像外,jlink还可以包括应用程序模块。这种镜像中找到的平台和应用程序模块统称为其系统模块。要列出它们,请使用镜像的bin目录中的java命令并调用java --list-modules

  • 可观察模块——当前运行时的所有平台模块以及命令行上指定的所有应用程序模块;JPMS 可以使用这些模块来满足依赖项。总而言之,这些模块构成了可观察模块的宇宙。

  • 基础模块——应用程序和平台模块之间的区别只是为了使通信更容易。对于模块系统来说,所有模块都是相同的,除了一个:平台模块 java.base,所谓的基模块,扮演着特定的角色。

平台模块和大多数应用程序模块都有模块描述符,这些描述符是由模块创建者提供的。其他模块存在吗?是的:

  • 明确模块——由模块创建者提供模块描述符的平台模块和大多数应用程序模块。

  • 自动模块——没有模块描述符的命名模块(提示:模块路径上的普通 JAR 文件)。这些是由运行时创建的应用程序模块,而不是由开发者创建的。

  • 命名模块——明确模块和自动模块的集合。这些模块有一个名称,要么由描述符定义,要么由 JPMS 推断。

  • 无名模块——未命名的模块(提示:类路径内容)因此不是明确的。

自动模块和无名模块在将应用程序迁移到模块系统的背景下变得相关——这是第八章深入讨论的主题。为了更好地理解这些类型的模块如何相互关联,请参阅图 3.3。

图片

图 3.3 大多数类型的模块,组织在一个方便的图表中。与 JDK 一起提供的模块称为平台模块,基模块位于中心。然后是应用程序模块,其中必须有一个是初始模块,它包含应用程序的main方法。(根、系统和孵化模块未显示。)

为了将这些术语应用到例子中,让我们转向第二章中我们探讨的 ServiceMonitor 应用程序。它由七个模块(monitor、monitor.observer、monitor.rest 等等)以及外部依赖项 Spark 和 Hibernate 及其传递依赖项组成。

当它启动时,包含其七个模块及其依赖项的文件夹在命令行上指定。与运行应用程序的 JRE 或 JDK 中的平台模块一起,它们构成了可观察模块的宇宙。这是模块系统将尝试满足所有依赖项的模块池。

ServiceMonitor 的模块以及构成其依赖关系的 Hibernate 和 Spark 都是应用程序模块。因为它包含main方法,所以监控器是初始模块——不需要其他根模块。程序直接依赖的唯一平台模块是基础模块 java.base,但 Hibernate 和 Spark 会引入进一步的模块,如 java.sql 和 java.xml。因为这是一个全新的应用程序,并且所有依赖关系都被假定为模块化,这不是一个迁移场景;因此,没有涉及自动或未命名的模块。

现在你已经知道了存在哪些类型的模块以及如何声明它们,是时候探索 Java 如何处理这些信息了。

3.2 可读性:连接各个部分

模块是原子构建块:交互工件图中的节点。但没有节点之间的边,就无法形成图!这就是可读性发挥作用的地方,基于此,模块系统将在节点之间创建连接。

定义:可读性边

当模块客户在其声明中需要模块 bar 时,则在运行时客户将读取 bar,或者相反,bar 将由客户可读(参见图 3.4)。两个模块之间的连接称为可读性边,或简称为读取边。

图片

图 3.4 模块客户在其描述符中需要模块 bar(1)。基于此,模块系统将允许客户在运行时读取 bar(2)。

而“客户需要 bar”和“客户依赖于 bar”这样的短语反映了客户和 bar 之间的静态、编译时关系,而可读性则是其更动态的运行时对应物。为什么它更动态?requires指令是读取边的原始发起者,但绝非唯一。其他还有命令行选项(参见第 3.4.4 节中的--add-reads)和反射 API(参见第 12.3.4 节),都可以用来添加更多;最终,这并不重要。无论读取边是如何产生的,它们的效果总是相同的:它们是可靠配置和可访问性的基础(参见第 3.3 节)。

3.2.1 实现可靠的配置

如第 1.5.1 节所述,可靠的配置旨在确保 Java 程序编译或启动时使用的特定工件配置能够维持程序运行,而不会出现虚假的运行时错误。为此,它执行了一些检查(在模块解析过程中,该过程在第 3.4.1 节中解释)。

基本信息 模块系统检查可观察模块的宇宙是否包含所有必需的依赖项,包括直接和间接依赖项,如果缺少任何内容,则会报告错误。绝对不能有歧义:没有两个工件可以声称它们是同一个模块。这在存在同一模块的两个版本的情况下尤其有趣——因为模块系统没有版本的概念(见第十三章),它将此视为重复的模块。因此,如果遇到这种情况,它会报告错误。模块之间不能存在静态依赖循环。在运行时,模块之间相互访问是可能的,甚至是必要的(例如,考虑使用 Spring 注解的代码和 Spring 对该代码的反射),但这些不能是编译依赖项(Spring 显然没有针对它所反射的代码进行编译)。包应该有唯一的来源,因此没有两个模块必须包含同一包中的类型。如果它们这样做,这被称为拆分包,模块系统将拒绝编译或启动此类配置。这在迁移的上下文中尤其有趣,因为一些现有的库和框架故意拆分包(见第 7.2 节)。

当然,这种验证并不是无懈可击的,问题可能隐藏足够长的时间,以至于导致正在运行的应用程序崩溃。例如,如果模块的错误版本最终出现在正确的位置,应用程序将启动(所有必需的模块都存在),但稍后,例如,当类或方法缺失时,将崩溃。

由于模块系统是为了在编译时间和运行时展示一致的行为而开发的,因此可以通过基于相同的工件进行编译和启动来进一步最小化这些错误。(在示例中,针对具有错误版本的模块的编译将失败。)

3.2.2 尝试不可靠的配置

让我们尝试破坏一些东西!模块系统检测到哪些不可靠的配置?为了调查,我们将转向第二章中引入的 ServiceMonitor 应用程序。

缺少依赖项

考虑 monitor.observer.alpha 及其声明:

module monitor.observer.alpha { requires monitor.observer; exports monitor.observer.alpha; }

这就是尝试编译时缺少 monitor.observer 监视器的情况:

> monitor.observer.alpha/src/main/java/module-info.java:2: > error: 模块未找到: monitor.observer > requires monitor.observer > ^ > 1 error

如果模块在编译时存在,但在到达发射台的过程中丢失,JVM 将使用以下错误退出:

> 初始化引导层时发生错误 > java.lang.module.FindException: > 模块 monitor.observer 未找到, > 由 monitor.observer.alpha 所需

虽然在启动时强制所有传递性所需的模块存在是有意义的,但对于编译器来说则不然。因此,如果缺少间接依赖项,编译器既不会发出警告也不会报错,如下面的示例所示。

这些是 monitor.persistence 和 monitor.statistics 的模块声明:

module monitor.persistence { requires monitor.statistics; exports monitor.persistence; } module monitor.statistics { requires monitor.observer; exports monitor.statistics; }

很明显,monitor.persistence 并不直接需要 monitor.observer,因此即使 monitor.observer 不在模块路径上,monitor.persistence 的编译也能成功。

如果启动应用程序时缺少传递性依赖项,则无法工作。即使初始模块不直接依赖于它,其他模块可能会,因此它将被报告为缺失。ServiceMonitor 仓库中的 break-missing-transitive-dependency 分支创建了一个配置,其中缺失的模块会导致错误信息。

重复模块

由于模块通过名称相互引用,任何两个模块声称具有相同名称的情况都是模糊的。选择哪个是正确的非常依赖于上下文,并且不是模块系统通常可以决定的。因此,为了避免可能的不良决策,它根本不做出任何决定,而是产生一个错误。快速失败允许开发者在问题造成更多问题之前注意到并修复它。

这是模块系统在尝试编译模块路径上具有两个版本的 monitor.observer.beta 时产生的编译错误:

> 错误:应用程序模块路径上的重复模块 > module in monitor.observer.beta > 1 error

注意,编译器无法将错误链接到编译下的任何文件,因为它们不是问题的原因。相反,模块路径上的工件导致了错误。

当错误直到 JVM 启动时才被发现,它会提供一个更精确的消息,列出 JAR 文件名:

> 初始化引导层时发生错误 > java.lang.module.FindException: > 在 mods 中找到两个版本的模块 monitor.observer.beta > (monitor.observer.beta.jar 和 monitor.observer.gamma.jar)

正如我们在第 1.5.6 节中讨论的,并在第 13.1 节中进一步探讨的那样,模块系统没有版本的概念,因此在这种情况下,将发生相同的错误。我认为,绝大多数重复模块错误将是由模块路径上存在相同模块的多个版本引起的。

重要信息 模糊性检查仅应用于单个模块路径条目!(这句话可能会让你感到困惑——我将在第 3.4.1 节中解释我的意思,但我想在这里提一下,以免遗漏这个重要的事实。)

如果模块实际上并不需要,模块系统也会抛出重复模块错误。只要模块路径包含它就足够了!其中两个原因是服务和可选依赖项,这些将在第十章和第 11.2 节中介绍。ServiceMonitor 分支break-duplicate-modules-even-if-unrequired即使不需要也会因为重复模块而创建错误信息。

DEPENDENCY CYCLES

意外创建循环依赖并不难,但让编译器通过它却很难。甚至很难向编译器展示它们。为了做到这一点,你必须解决“先有鸡还是先有蛋”的问题,即如果两个项目相互依赖,那么不可能在没有另一个项目的情况下编译其中一个。如果你尝试这样做,你会遇到缺失的依赖项并得到相应的错误。

一种绕过这个问题的方法是同时编译两个模块,从鸡和蛋同时开始,换句话说;第 4.3 节解释了如何做到这一点。简单来说,如果在编译的模块之间存在循环依赖,模块系统会识别出来并导致编译错误。如果 monitor.persistence 和 monitor.statistics 相互依赖,它看起来是这样的:

> monitor.statistics/src/main/java/module-info.java:3: > 错误:涉及 monitor.persistence 的循环依赖 > requires monitor.persistence; > ^ > 1 个错误

另一种方法是逐步建立循环依赖,而不是一次性建立,在已经构建了有效配置之后。让我们再次转向 monitor.persistence 和 monitor.statistics:

module monitor.persistence { requires monitor.statistics; exports monitor.persistence; } module monitor.statistics { requires monitor.observer; exports monitor.statistics; }

这个配置没有问题,编译时没有出现任何问题。现在开始有点复杂了:编译模块并保留 JAR 文件。然后更改 monitor.statistics 模块的声明,使其需要 monitor.persistence,这会创建一个循环依赖(在这个例子中,这种改变并没有太多意义,但在更复杂的应用程序中通常会有意义):

module monitor.statistics { requires monitor.observer; requires monitor.persistence; exports monitor.statistics; }

下一步是仅编译已更改的 monitor.statistics,同时使用模块路径上的已编译模块。这必须包括 monitor.persistence,因为统计模块现在依赖于它。反过来,持久化模块仍然声明它对 monitor.statistics 的依赖,这是依赖循环的第二部分。不幸的是,对于这次黑客攻击,模块系统识别出这个循环并导致与之前相同的编译错误。

将“偷壳游戏”提升到下一个层次最终欺骗了编译器。在这个场景中,两个完全无关的模块——让我们选择 monitor.persistence 和 monitor.rest——被编译成模块化的 JAR 文件。然后是魔术表演:

添加一个依赖关系,例如从持久化到 rest,并且更改后的持久化针对原始模块集进行编译。这是因为原始的 rest 不依赖于持久化。

添加第二个依赖关系,即从 rest 到持久化,但 rest 也针对包括尚未依赖它的持久化版本在内的原始模块集进行编译。因此,它也可以被编译。

感到困惑?查看图 3.5 以获得另一个视角。

图 3.5 将依赖关系循环传递给编译器并不容易。这里是通过选择两个无关的模块,持久化和 rest(两者都依赖于统计),然后从其中一个模块添加依赖到另一个模块来完成的。编译 rest 时,必须针对旧的持久化版本,这样循环就不会显示,并且编译可以通过。在最后一步,两个原始模块都可以用它们之间具有循环依赖关系的新编译版本来替换。

现在 monitor.persistence 和 monitor.rest 的版本相互依赖。要使这种情况在现实生活中发生,编译过程——可能由构建工具管理——必须处于严重混乱状态(但这并非闻所未闻)。幸运的是,模块系统支持你,并在使用这种配置启动 JVM 时报告错误:

> 初始化引导层时发生错误 > java.lang.module.FindException: > 检测到循环: > monitor.persistence > -> monitor.rest > -> monitor.persistence

所有示例都显示了两个工件之间的循环依赖关系,但模块系统可以检测到所有长度的循环。这样做是好事!更改代码总是存在破坏上游功能的风险,这意味着使用正在更改的代码的其他代码——无论是直接还是间接的。

如果依赖关系只有一个方向,那么更改影响的代码量是有限的。另一方面,如果依赖关系可以形成循环,那么循环中的所有代码以及依赖于它的所有代码都可能受到影响。尤其是如果循环很大,这可能会迅速变成所有代码都受到影响,我相信你也会同意你想避免这种情况。模块系统并不是唯一帮助你在这里的工具——你的构建工具也是如此,它也会对依赖关系循环感到不满。

拆分包

当两个模块包含同一包中的类型时,会发生拆分包的情况。例如,回想一下,monitor.statistics 模块在monitor.statistics包中包含一个名为Statistician的类。现在让我们假设 monitor 模块包含一个简单的回退实现,SimpleStatistician,为了促进一致性,它位于 monitor 自己的monitor.statistics包中。

当尝试编译 monitor 时,你会得到以下错误:

> monitor/src/main/java/monitor/statistics/SimpleStatistician.java:1: > 错误:另一个模块中存在相同的包:monitor.statistics > package monitor.statistics; > ^ > 1 错误

重要信息 有趣的是,编译器只有在编译的模块可以访问另一个模块中的分割包时才会显示错误。这意味着分割包必须被导出。

为了尝试这一点,让我们走一条不同的路线:SimpleStatistician已经不存在了,这次是monitor.statistics创建了分割包。为了尝试重用一些实用方法,它在monitor包中创建了一个Utils类。它没有意愿与其他模块共享这个类,因此它继续只导出monitor.statistics包。

编译monitor.statistics没有错误,这是有道理的,因为它不需要monitor,因此对分割包一无所知。当编译monitor的时候,情况变得有趣。它依赖于monitor.statistics,并且两者都包含monitor包中的类型。但是,正如我刚才提到的,因为monitor.statistics没有导出包,编译才能正常工作。

太好了!现在到了启动的时候:

> 初始化引导层时发生错误 > java.lang.reflect.LayerInstantiationException: > 模块监控器在模块 monitor.statistics 和模块 monitor 中存在

这并不顺利。模块系统在启动时检查分割包,并且这里无论它们是否导出都无关紧要:没有两个模块可以包含同一包中的类型。正如你在第 7.2 节中看到的,当将代码迁移到 Java 9 时,这可能会变成一个问题。

ServiceMonitor 仓库在break-split-package-compilationbreak-split-package-launch分支中展示了编译和运行时分割包问题的示例。

模块化的死亡钻石

图片

图 3.6 如果一个模块更改了其名称(这里,从 jackson 更改为 johnson),依赖于它的项目(这里,通过 frame 和 border 的 app)可能会面临模块化的死亡钻石:它们依赖于同一个项目,但通过两个不同的名称。

分割包和缺失依赖项的特别狡猾的混合体是模块化的死亡钻石(见图 3.6)。假设一个模块在两个版本之间更改了名称:你的一个依赖项通过其旧名称需要它,另一个依赖项通过其新名称需要它。现在你需要相同的代码出现在两个不同的模块名称下,但 JPMS 不会允许这种情况发生。

你会遇到以下情况之一:

  • 一个模块化的 JAR 文件,它只能作为一个具有一个名称的模块出现,因此将触发错误,因为一个依赖项无法满足

  • 两个具有不同名称但相同包的模块化 JAR 文件,这将导致你刚刚观察到的分割包错误

重要的是要避免这种情况!如果你正在向公共仓库发布工件,你应该仔细考虑是否需要重命名你的模块。如果是的话,你可能还希望更改包名,这样人们就可以同时使用旧模块和新模块。如果你作为用户最终处于这种情况,你可能很幸运地通过创建聚合模块(见 11.1.5 节)或编辑模块描述符(见 9.3.3 节)来摆脱困境。

3.3 可访问性:定义公共 API

在模块和读取边已经就位的情况下,你知道模块系统是如何构建你心中所想的图的。为了防止这个图表现得像你想要逃离的泥球,还有一个额外的要求:隐藏模块内部结构,以便外部代码无法访问它。这就是可访问性的作用所在。

定义:可访问性

如果以下所有条件都得到满足,模块 bar 中的类型Drink对模块 customer 中的代码是可访问的(参见图 3.7):

  • Drink 是公共的。

  • Drink 属于 bar 导出的包。

  • customer 读取 bar。

对于可访问类型的成员(意味着它的字段、方法和嵌套类),通常的可见性规则适用:公共成员是完全可访问的,受保护成员仅限于继承类。技术上讲,包私有成员在同一包中是可访问的,但正如你在上一节中看到的,由于模块间不允许分割包的规则,这并不有用。

图片

图 3.7 模块 bar 在一个导出包中包含一个公共类型Drink(1)。模块 customer 读取模块 bar(3),因此 customer 中的代码访问Drink的所有要求都得到了满足。想知道如果某些要求没有得到满足会发生什么?请查看 3.3.3 节。

注意:可访问性的定义包括想要访问类型的模块。从这个意义上说,一个类型永远不会“可访问”,而只能是“对特定模块可访问”。尽管如此,当没有其他模块可见时,人们通常使用相同的术语,并说如果一个类型是公共的且在导出包中,那么它是可访问的。然后任何模块都可以通过读取包含它的模块来自由访问该类型。

要理解可访问性如何塑造模块的公共 API,首先理解这个术语很重要:什么是公共 API?

定义:公共 API

用非技术术语来说,模块的公共 API 是所有不能在不引起使用它的代码编译错误的情况下更改的内容。(一般来说,这个术语还包括运行时行为的规范,但由于模块系统不在这个维度上操作,我在本书中会忽略它。)更技术地说,模块的公共 API 包括以下内容:

  • 所有导出包中的公共类型名称

  • 公共和受保护字段的名称和类型名称

  • 所有公共和受保护方法的名称、参数类型名称和返回类型名称(称为方法签名)

如果你觉得我突然谈论名称很奇怪,想想你在保持包外依赖代码编译的同时可以在类型中更改什么。私有和包可见字段?当然可以!私有和包可见方法?当然可以。需要保持不变的是其他代码可能编译的名称:类型的名称、公共方法的签名等等。

检查公共 API 的定义,可以清楚地看出,模块系统从 Java 9 开始改变了包(必须导出)和类型(必须公共)层面的东西。另一方面,在类型内部,没有变化,类型在 Java 8、Java 9 及以后的公共 API 是相同的。

3.3.1 实现强封装

重要信息 如果一个类型不可访问,就无法以该类型特有的任何方式与之交互:无法实例化它,访问其字段,调用方法或使用嵌套类。“特定于该类型”这个短语有点不寻常——它是什么意思?如果它们在可访问的超类型(如类型实现的接口)或最终是Object中定义,则可以与类型的成员交互。这与 Java 9 之前的版本非常相似,当时可以使用非公共接口实现来使用,但只能通过该接口。

例如,考虑一个高性能库 superfast,它有已知 Java 集合的自定义实现。让我们关注一个假设的SuperfastHashMap类,它实现了 Java 的Map接口并且不可访问(可能它在导出的包中是包可见的,也可能整个包都没有导出)。

如果超级快速模块之外的代码获得一个SuperfastHashMap实例(可能来自工厂),那么它只能将其用作Map。它不能将其分配给SuperfastHashMap类型的变量,也不能在它上面调用superfastGet(即使该方法为公共)但定义在可访问的超类型(如MapObject)上的所有内容都没有问题。(参见图 3.8。)

图片

图 3.8 不可访问的类型SuperfastHashMap实现了可访问的Map接口。超级快速模块之外的代码,如果得到一个实例,可以将其用作MapObject,但永远不能以特定于该类型的方式使用:例如,通过调用superfastGet。超级快速模块中的代码不受访问限制,可以像通常一样使用该类型:例如,创建实例并返回它们。

可访问性规则使得在强封装模块内部的同时,能够暴露精心选择的特性,确保外部代码不能依赖于实现细节。有趣的是,这包括反射,如果它跨越模块边界使用,也无法绕过这些规则!(我们将在本章的其余部分讨论反射——如果你需要复习基础知识,请参阅附录 B。)

可能你会想知道像 Spring、Guice、Hibernate 等基于反射的库在未来的工作方式,或者如果代码绝对需要,它将如何突破到一个模块中。有几种方式可以提供或获取访问权限:

  • 正规导出(参见第 3.1 节)

  • 有资格的导出(参见第 11.3 节)

  • 开放模块和开放包(参见第 12.2 节)

  • 命令行选项(总结在第 7.1 节)

第十二章将更深入地探讨反射。

但让我们回到三个作为访问前提条件(公共类型、导出包、读取模块)的条件。它们有一些有趣的后果。

重要的信息首先,public不再公开。通过查看类型,无法再判断它是否将在模块外部可见——为此,需要检查module-info.java或信任 IDE 突出显示导出的包或类型。如果没有requires指令,模块中的所有类型对外部都是不可访问的。封装是新的默认设置!

这三个条件还意味着你也不再可能意外地依赖于传递依赖。让我们看看原因。

3.3.2 封装传递依赖

没有模块系统,使用一个依赖项引入的 JAR 文件中的类型(但未声明为依赖项)是可能的。一旦项目以这种方式使用类型,构建配置就不再反映实际的依赖项集合,这可能导致从无知的架构决策到运行时错误的各种问题。

例如,假设一个项目正在使用 Spring,它依赖于 OkHttp。编写使用 OkHttp 类型代码就像让 IDE 添加它所建议的导入语句一样简单。代码将能够编译和运行,因为构建工具将确保 Spring 及其所有依赖项,包括 OkHttp,始终可用。这使得声明对 OkHttp 的依赖变得不必要,因此很容易被遗忘。(参见图 3.9。)

作为结果,项目的依赖项分析可能会提供误导性的结果,基于这些结果可能会做出有问题的决策。OkHttp 的版本也不是固定的,完全取决于 Spring 使用什么。如果该版本被更新,依赖于 OkHttp 的代码将默默地运行在不同的版本上,这实际上会带来程序在运行时出现行为异常或崩溃的真实风险。

图片

图 3.9 没有模块时,很容易意外地依赖于传递依赖,如本例所示,其中应用程序依赖于 OkHttp,它被 Spring 拉入。另一方面,使用模块时,必须使用requires指令声明依赖项才能访问它们。应用程序不需要 OkHttp,因此无法访问它。

由于模块系统要求访问模块必须读取被访问的模块,这种情况现在不再发生。除非项目通过使用requires指令声明其对 OkHttp 的依赖,否则模块系统不允许它访问 OkHttp 的类。这样,它迫使你保持配置更新。

注意,模块具有通过一个称为隐式可读性的功能将它们的依赖项传递给依赖于它们的模块的能力。有关详细信息,请参阅第 11.11 节。

3.3.3 封装争斗

就像我们对可读性所做的那样,让我们打破规则!但在我们这样做之前,我想展示一个遵循所有规则且能正常工作的场景。再次强调,它基于第二章中引入的 ServiceMonitor 应用程序。

为了这些示例的目的,假设模块monitor.observer包含在其包monitor.observer中的类DisconnectedServiceObserver。它所做的不重要:重要的是它实现了ServiceObserver接口,它有一个不需要任何参数的构造函数,并且监控模块使用它。

模块monitor.observer导出monitor.observerDisconnectedServiceObserver是公开的。这满足了前两个可访问性要求,因此其他模块如果读取monitor.observer,则可以访问它。模块monitor也满足这个先决条件,因为它在其模块声明中需要monitor.observer。综合(图 3.10 和列表 3.1),所有要求都得到满足,monitor 中的代码可以访问DisconnectedServiceObserver。相应地,编译和执行没有错误。让我们玩弄细节,看看模块系统如何反应。

图 3.10 DisconnectedServiceObserver是公开的(1)并且位于由monitor.observer导出的包中(2)。因为 monitor 模块读取monitor.observer(3),所以其中的代码可以使用DisconnectedServiceObserver

列表 3.1 DisconnectedServiceObserver,可由 monitor 访问

// --- TYPE DisconnectedServiceObserver --- package monitor.observer; public class DisconnectedServiceObserver // implements ServiceObserver { // class body truncated } // --- MODULE DECLARATION monitor.observer --- module monitor.observer { exports monitor.observer; // } // --- MODULE DECLARATION monitor --- module monitor { requires monitor.observer; // // 其他 requires 指令省略 }

公开的 monitor.observer.DisconnectedServiceObserver

模块 monitor.observer 导出包 monitor.observer。

模块 monitor 需要并最终读取 monitor.observer。

类型非公开

如果将DisconnectedServiceObserver设置为包可见,monitor 的编译将失败。更确切地说,导入导致了第一个错误:

> monitor/src/main/java/monitor/Monitor.java:4: 错误: > monitor.observer.DisconnectedServiceObserver 在 monitor.observer 中不是公开的;不能从包外访问 > import monitor.observer.DisconnectedServiceObserver; > ^

在 Java 9 之前,从另一个包访问包可见的类型也是不可能的,因此错误信息并不新鲜——即使没有模块系统,你也会得到相同的错误。

同样,如果你通过在将DisconnectedServiceObserver设置为包可见后重新编译 monitor.observer 模块并启动整个应用程序来绕过编译器检查,错误与没有模块系统时相同:

> 异常发生在主线程中 java.lang.IllegalAccessError: > 无法从类 monitor.Monitor 访问类 monitor.observer.DisconnectedServiceObserver

在 Java 9 之前,可以使用反射 API 在运行时访问类型,这是强封装所阻止的。考虑以下代码:

Constructor<?> constructor = Class.forName("monitor.observer.DisconnectedServiceObserver").getDeclaredConstructor(); constructor.setAccessible(true); ServiceObserver observer = (ServiceObserver) constructor.newInstance();

在 Java 8 及之前版本中,无论DisconnectedServiceObserver是公开的还是包可见的,这都适用。在 Java 9 及以后的版本中,模块系统会阻止访问,如果DisconnectedServiceObserver是包可见的,调用setAccessible会导致异常:

> 异常发生在主线程中 java.lang.reflect.InaccessibleObjectException: > 无法使 monitor.observer.DisconnectedServiceObserver() 可访问:模块 monitor.observer 不对模块 monitor "打开" monitor.observer

ServiceMonitor 仓库的分支break-reflection-over-internals展示了这里的行为。monitor.observer 没有打开 monitor.observer 的抱怨指向了该问题的解决方案——这是第 12.2 节探讨的内容。

包未导出

接下来是要求之一,即包含访问类型的包必须导出。为了试验这一点,让我们再次将DisconnectedServiceObserver设置为公开,但将其移动到另一个包 monitor.observer.dis,该包 monitor.observer 没有导出。monitor 中的导入更新到新包:

> monitor/src/main/java/monitor/Monitor.java:4: 错误: > 包 monitor.observer.dis 不存在 > import monitor.observer.dis.DisconnectedServiceObserver; > ^ > (包 monitor.observer.dis 在模块 monitor.observer 中声明,该模块没有导出它)

这相当直接。

要查看运行时在这种情况下表现如何,你需要绕过编译器检查。为此,编辑 monitor.observer 以导出 monitor.observer.dis,编译所有模块,然后再次编译 monitor.observer 而不进行该导出。你可以像以前一样启动应用程序并引发运行时错误:

> 线程 "main" 中发生异常 java.lang.IllegalAccessError: > 类 monitor.Monitor(在模块 monitor 中)无法访问类 > monitor.observer.dis.DisconnectedServiceObserver(在模块 > monitor.observer 中),因为模块 monitor.observer 没有将 > monitor.observer.dis 导出给模块 monitor

就像编译器一样,运行时相当健谈,并解释了问题所在。当你尝试使构造函数可访问时,这也适用于反射 API,这样你就可以创建 DisconnectedServiceObserver 的实例:

> 线程 "main" 中发生异常 java.lang.reflect.InaccessibleObjectException: > 无法使 public monitor.observer.dis.DisconnectedServiceObserver() 可访问:> 模块 monitor.observer 没有对模块 monitor 导出 monitor.observer.dis

如果你仔细观察,你会看到运行时和反射 API 都提到了将包导出到模块。这被称为合格导出(在第 11.3 节中解释)。

模块未读取

列表中的最后一个要求是,导出模块必须被访问类型的模块读取。从 monitor 的模块声明中移除 requires monitor.observer 指令会导致预期的编译时错误:

> monitor/src/main/java/monitor/Monitor.java:3: 错误:> 包 monitor.observer 不可见 > 导入 monitor.observer.DiagnosticDataPoint; > ^ > (包 monitor.observer 在模块 monitor.observer 中声明,但模块 monitor 没有读取它)

要查看运行时对缺少 requires 指令的反应,首先使用一个正常配置编译整个应用程序,这意味着监控读取 monitor.observer。然后从 monitor 的 module-info.java 中移除 requires 指令,并仅重新编译该文件。这样,模块的代码将使用一个仍然需要 monitor.observer 的模块声明进行编译,但运行时将看到一个声称没有这种要求的模块描述。正如预期的那样,结果是运行时错误:

> 线程 "main" 中发生异常 java.lang.IllegalAccessError: > 类 monitor.Monitor(在模块 monitor 中)无法访问类 > monitor.observer.DisconnectedServiceObserver(在模块 > monitor.observer 中),因为模块 monitor 没有读取模块 > monitor.observer

再次,错误信息非常明确。

最后,让我们转向反射。你可以使用相同的编译技巧创建一个不读取 monitor.observer 的监控模块。并且当 DisconnectedServiceObserver 不是公共的,但你仍然想创建一个实例时,可以重用之前的反射代码。

当然,运行这些模块一起失败,对吧?是的,它会失败,但不是你预期的那个方式:

> 异常发生在主线程 "main" 中 java.lang.IllegalAccessError: > 类 monitor.Monitor (在模块 monitor) 无法访问类 > monitor.observer.ServiceObserver (在模块 monitor.observer) > 因为模块 monitor 不读取模块 monitor.observer

为什么错误信息会抱怨 ServiceObserver?因为该类型也在 monitor.observer 中,而 monitor 已经不再读取。让我们将反射代码更改为仅使用 Object

Constructor<?> constructor = Class.forName("monitor.observer.DisconnectedServiceObserver").getDeclaredConstructor(); constructor.setAccessible(true); Object observer = constructor.newInstance();

运行这个命令——它成功了!但你可能会问,缺失的读取边是什么?答案是简单但一开始有点令人惊讶:反射 API 会自动填充它。第 12.3.1 节探讨了背后的原因。

3.4 模块路径:让 Java 了解模块

你现在知道如何定义模块及其基本属性。但仍然有点不清楚的是,你是如何告诉编译器和运行时关于它们的。第四章探讨了从源到 JAR 的模块构建,你很快就会遇到需要引用编译中代码所依赖的现有模块的情况。第五章的情况相同,其中运行时需要了解应用程序模块,以便你可以启动其中一个。

在 Java 9 之前,你会使用类路径,其上包含普通的 JAR 文件(参见附录 A 以快速回顾类路径),来告知编译器和运行时在哪里找到工件。它们在寻找编译或执行期间所需的单个类型时会搜索它。

相反,模块系统承诺不对类型进行操作,而是高于它们一个层级,并管理模块。这种方法的表达方式之一是一个与类路径平行的全新概念,但它期望模块而不是裸类型或普通 JAR 文件。

定义:模块路径

模块路径是一个列表,其元素是包含工件或目录的工件。根据操作系统,模块路径元素由 :(基于 Unix)或 ;(Windows)分隔。它由模块系统用于定位在平台模块中找不到的必需模块。javacjava 以及其他与模块相关的命令都可以处理它——命令行选项是 --module-path-p

列表 3.2 展示了 ServiceMonitor 应用程序的 monitor 模块如何进行编译、打包和启动。它使用 --module-path 指向目录 mods,你假设它包含所有必需的依赖项作为模块 JAR 文件。有关编译、打包和启动的详细信息,请参阅第 4.2、4.5 和 5.1 节。

列表 3.2 编译、打包和启动 monitor

$ javac --module-path mods -d monitor/target/classes ${source-files} $ jar --create --file mods/monitor.jar --main-class monitor.Main -C monitor/target/classes . $ java --module-path mods:libs --module monitor

包含直接依赖项作为模块的目录

列出或查找所有源文件

在 mods 中为新 JAR 文件命名的名称

包含直接和传递依赖项的目录

重要的信息:重要的是要明确,只有模块路径将工件作为模块处理。有了这个知识,你可以更精确地了解构成可观察模块宇宙的内容。在第 3.1.4 节中,它被定义为如下:当前运行时的所有平台模块以及命令行上指定的所有应用程序模块被称为可观察的,它们共同构成了可观察模块的宇宙。

“命令行上指定的模块”这个短语有点含糊。现在你知道它们是可以找到在模块路径上的工件。

注意,我说的是工件,而不是模块!不仅模块化 JAR 文件,即使是普通 JAR 文件,当放置在模块路径上时,也会被转换为模块,并成为可观察模块宇宙的一部分。这种多少有些令人惊讶的行为是迁移故事的一部分,在这里讨论它可能会偏离我们对模块路径的探索,所以让我将解释推迟到第 8.3 节。现在我想提到的是,与模块路径将每个工件解释为模块对称的是,类路径将所有工件视为普通 JAR 文件,无论它们是否包含模块描述符。

注解处理器

如果你正在使用注解处理器,你一直将它们与应用程序的工件一起放置在类路径上。Java 9 建议根据关注点进行分离,并使用--class-path--module-path为应用程序 JAR 文件,以及--processor-path--processor-module-path为处理器 JAR 文件。对于非模块化 JAR 文件,应用程序和处理器路径之间的区别是可选的:将所有内容放置在类路径上是有效的,但对于模块来说则是强制性的;模块路径上的处理器不会被使用。

因为模块路径被多个工具使用,尤其是编译器和虚拟机,所以从一般概念上考虑这个概念是有意义的。除非另有说明,否则所描述的机制在所有环境中都按相同的方式工作。

3.4.1 模块解析:分析并验证应用程序的结构

在模块路径上调用javacjava并传递多个模块后会发生什么?这就是模块系统开始检查启动配置的时候,这意味着检查模块及其声明的依赖项以验证其可靠性。

这个过程必须从某个地方开始,因此模块系统的首要任务是决定根模块集。有几种方法可以将模块设置为根,我们将在适当的时候讨论所有这些方法,但最突出的是指定初始模块。对于编译器来说,这要么是编译下的模块(如果模块声明在源文件中),要么是使用--module指定的模块(如果使用了模块源路径)。在启动虚拟机的情况下,只有--module选项保留。

接下来,模块系统解决依赖关系。它检查根模块的声明,以查看它们依赖于哪些其他模块,并尝试使用可观察的模块来满足每个依赖项。然后它继续对那些模块执行相同的操作,以此类推。这个过程会一直持续,直到初始模块的所有传递依赖项都得到满足,或者配置被识别为不可靠。

解决服务和可选依赖项

模块解析的两个方面为之前讨论的过程增加了一些内容:

  • 服务(参见第十章,特别是 10.1.2 节)

  • 可选依赖项(参见第 11.2 节,特别是 11.2.3 节)

我在这里不会深入讨论它们,因为你们缺少先决条件,但我想要提及它们,这样你们就知道还有更多内容即将到来。简单来说,它们不会取消我描述的任何内容——它们只是添加了一些细节。

有关不可靠配置的必要信息,第 3.2.2 节探讨了在此阶段可能发生的问题类型以及模块系统如何对这些问题做出反应。需要补充的一个值得注意的细节是:如果模块路径由多个条目(目录或单个 JAR 文件)组成,则在这些条目之间不会应用模糊性检查!每个单独的条目必须只包含一个模块;但如果多个不同的条目包含相同的模块,则第一个(在模块路径上命名的顺序中)被选中——它遮蔽了其他模块。

证明模块可以在文件夹之间复制的最简单方法是从一个准备就绪且所有模块都在一个文件夹中的项目(比如说,mods)中选择。然后创建整个文件夹的副本(比如说,mods-copy),并将它们都放置在模块路径上:

$ java --module-path mods:mods-copy:libs --module monitor

所有模块在每个文件夹中只出现一次,但应用程序仍然会启动。

现在考虑一下,构建工具通常创建一个模块路径,其中列出每个依赖项。这意味着只要构建工具处于控制状态,例如在编译和测试期间,就不会在所有依赖项之间应用模糊性检查。

我认为这是不幸的,因为它违背了可靠配置的一部分承诺。另一方面,它也有一个优点,那就是只要你把你的模块放在第一位,你就可以故意用你更喜欢版本的模块来覆盖模块。但请记住,与类路径时代不同,不同的 JAR 文件永远不会“混合”。如果模块系统选择一个模块作为包的来源,它将在这个 JAR 中查找该包的所有类,而不会在其他 JAR 中查找(这与第 3.2.2 节和第 7.2 节中讨论的拆分包有关)。

接下来,让我们假设所有模块都已解析。如果没有发现错误,模块系统保证每个必需的模块都存在。或者更确切地说,具有正确名称的模块存在。

在这个阶段没有进行额外的检查,所以如果模块依赖于例如 com.google.common(Google Guava 库的模块名称)并且找到了具有该名称的空模块,模块系统就会满意。但缺失的类型仍然会在以后造成麻烦,以编译时或运行时错误的形式出现。虽然空模块不太可能,但具有不同版本且缺少几个类型的模块并不令人难以置信。尽管如此,可靠的配置将大大减少在执行过程中出现的 NoClassDefFoundError 的数量。

3.4.2 模块图:应用程序结构的表示

本书的第一章标题之一是“将软件作为图可视化”(第 1.1.1 节)。接下来的段落解释了开发者和工具通常如何将代码视为图,尤其是将工件之间的依赖视为图。第一章的其余部分说明了 Java 如何将它们视为仅包含类型的容器,随后将这些类型滚成一个泥球,以及这种不匹配是如何成为困扰生态系统的几个难题的根源。

模块系统承诺通过使 Java 的感知与你的感知一致来解决这些问题。所有这些都指向一个启示:模块系统也看到了一个工件图。所以,这就是:模块图!

定义:模块图

在模块图中,模块(作为节点)根据它们的依赖关系(通过有向边)连接。边是可读性的基础(在第 3.2 节中描述)。图在模块解析期间构建,并在运行时通过反射 API(在第 12.4.2 节中解释)提供。

图 3.11 展示了模块解析如何为简化的 ServiceMonitor 应用程序创建模块图。尽管如此,你不必把一切交给 JPMS。通过正确的命令行选项,你可以向图中添加更多模块和读取边;我们将在下一节中探讨这一点。

图片 1 图片 2

图 3.11 模块解析为简化版 ServiceMonitor 应用程序构建模块图。在每一步中,解析一个模块,意味着它在可观察模块的宇宙中,并且它的依赖关系被添加到模块图中。逐步解析所有传递依赖,最终从应用程序模块过渡到平台模块。

3.4.3 向图中添加模块

重要的是要注意,在解析过程中没有进入模块图的模块,在编译或执行期间也不可用。对于所有应用程序代码都在模块中的情况,这通常无关紧要。毕竟,遵循可读性和可访问性的规则,即使这些模块可用,它们的类型也是不可访问的,因为没有人会阅读这些模块。但是,在更高级的功能使用场景中,这可能会作为编译时或运行时错误出现,甚至作为不符合预期行为的应用程序。

各种用例可能导致模块无法进入图中的情况。其中之一是反射。它可以用来在一个模块中调用另一个模块中的代码,而不需要显式地依赖于它。但是没有这种依赖,被依赖的模块可能无法进入图。

假设存在一个替代的统计模块,名为 monitor.statistics.fancy,它不能在每个服务的部署中都存在于模块路径上。(原因无关紧要,但让我们假设一个许可证阻止了 fancy 代码被用于“邪恶”目的。作为邪恶的幕后黑手,我们偶尔想这么做。)因此,该模块可能有时存在,有时不存在,因此没有其他模块可以要求它,因为如果模块缺失,应用程序将无法启动。

应用程序如何处理这种情况?依赖于 fancy 统计库的代码可以使用反射来检查库是否存在,并且只有在它存在时才调用它。但是根据你刚刚学到的,这种情况永远不会发生!由于必要性,fancy 统计模块不被任何其他模块所依赖,因此不会出现在模块图中,这意味着它永远无法被调用。对于书中出现的这些和其他场景,模块系统提供了一个解决方案。

定义:--add-modules

javacjava上的--add-modules ${modules}选项接受一个以逗号分隔的模块名称列表,并将它们定义为初始模块之外的根模块。(如 3.4.1 节所述,根模块形成初始模块集,通过解析它们的依赖关系构建模块图。)这使用户能够将模块(及其依赖关系)添加到模块图中,否则这些模块不会显示,因为初始模块既不直接也不间接依赖于它们。

--add-modules 选项有三个特殊值:ALL-DEFAULTALL-SYSTEMALL-MODULE-PATH。前两个仅在运行时工作,用于本书不讨论的边缘情况。最后一个可能很有用:使用它,模块路径上的所有模块都成为根模块,因此它们都进入模块图。

在 ServiceMonitor 应用程序具有对 monitor.statistics.fancy 的可选依赖的情况下,你必须确保模块出现在包含它的部署的模块图中。在这种情况下,你会使用 --add-modules monitor.statistics.fancy 使其成为根模块,这将导致模块系统将其及其依赖项添加到模块图中:

$ java --module-path mods:libs --add-modules monitor.statistics.fancy --module monitor

图 3.12 从 图 3.10 中简化后的 ServiceMonitor 应用程序模块图,其中定义了额外的根模块 monitor.statistics.fancy,使用 --add-modules 选项。监控模块及其任何依赖都没有依赖于它,因此如果没有该选项,它就不会出现在模块图中。

你可以在 图 3.12 中看到生成的模块图。

--add-modules 选项的一个特别重要的用例是 JEE 模块,正如第 6.1 节所解释的,在从类路径运行应用程序时默认不会解析这些模块。因为你可以向图中添加模块,所以自然会想知道你是否也可以移除它们。答案是肯定的,但有点复杂:选项 --limit-modules 就朝这个方向前进,第 5.3.4 节展示了它是如何工作的。

不幸的是,无法让模块系统知道一个特定的依赖项不会得到满足,而你对此是可以接受的。这将允许你排除(传递性)你不需要的依赖项。根据我在典型的 Maven POM 文件中看到的排除数量,这是常见的,但是,遗憾的是,模块系统的严格性不允许这样做。

3.4.4 向图中添加边

当一个模块被显式添加时,它在模块图中是独立的,没有任何传入的读取边。如果对其的访问完全是反射性的,那没关系,因为反射 API 会隐式添加一个读取边。但对于常规访问,例如从其中导入类型时,可访问性规则要求可读性。

定义:--add-reads

编译时和运行时选项 --add-reads${module}=${targets}${module} 向逗号分隔的列表 ${targets} 中的所有模块添加读取边。这允许 ${module} 访问那些模块导出的所有公共类型,即使它没有提及它们的 requires 指令。如果 ${targets} 包含 ALL-UNNAMED,则 ${module} 可以读取类路径内容(这有点抽象——有关详细信息,请参阅第 8.2 节)。

回到 monitor.statistics.fancy,你可以使用 add-reads 允许 monitor.statistics 读取它:

$ java --module-path mods:libs --add-modules monitor.statistics.fancy --add-reads monitor.statistics=monitor.statistics.fancy --module monitor

结果模块图与图 3.12 中的相同,除了虚线现在被适当的读取边替换。在第 8.3.2 节的末尾有一个案例,其中--add-reads … =ALL-UNNAMED挽救了局面。

3.4.5 可访问性是一个持续的努力

一旦模块系统解决了所有依赖项,构建了模块图,并在模块之间建立了可读性,它将通过检查第 3.3 节定义的可访问性规则来保持活跃。如果这些规则被违反,将导致编译时或运行时错误,如第 3.3.3 节所示。如果你遇到模块系统的问题,并且无法从错误信息中判断出了什么问题,请参阅第 5.3 节以获取有关如何调试这种情况的建议。

如果你感兴趣了解更多关于构建和运行模块化应用程序的信息,例如你的绿色田野项目,第四章和第五章将更深入地探讨这一点。或者,你也可以在第六章和第七章中查看模块系统对你现有项目的影响。你也为深入了解并检查高级特性做好了准备,尤其是第十章和第十一章。

注意:你已经达到了一个里程碑!你现在已经理解了模块是如何定义的,哪些机制作用于这个定义,以及它们有什么影响——一般来说,Java 是如何与模块一起工作的。

摘要

  • 模块有两种形式:

  • 随 Java 运行时一起提供的模块是平台模块。它们被合并到运行时libs目录中的modules文件中。JDK 还以原始形式将它们作为 JMOD 文件存储在jmods目录中。只有java.base,即基本模块,是模块系统明确知道的。

  • 库、框架和应用开发者创建模块化的 JAR 文件,这些是包含模块描述符module-info.class的普通 JAR 文件。这些被称为应用程序模块,其中包含main方法的那个是初始模块。

  • 模块描述符是从模块声明module-info.java编译而来的,开发人员(和工具)可以编辑它。它是模块系统工作的核心,并定义了一个模块的属性:

  • 它的名字,由于反向域名命名方案,应该是全局唯一的

  • 它的依赖项,这些依赖项通过requires指令以名称引用其他模块

  • 它的 API,这是通过exports指令导出选定的包来定义的

  • 依赖声明和模块系统从这些声明中创建的可读性边缘是可靠配置的基础。这是通过确保,在众多事情中,所有模块都恰好出现一次,并且它们之间不存在依赖循环来实现的。这允许你更早地捕捉到可能破坏应用程序或导致崩溃的问题。

  • 可读性边缘和包导出共同构成了强封装的基础。在这里,模块系统确保只有导出包中的公共类型可访问,并且仅限于读取导出模块的模块。这防止了意外依赖传递依赖,并确保外部代码不能轻易依赖于您设计为模块内部类型的类型。

  • 反射也受到可访问性限制!与基于反射的框架(如 Spring、Guice 或 Hibernate)交互需要做更多的工作。

模块路径(选项--module-path-p)由文件或目录组成,使 JAR 文件对模块系统可用,模块系统将它们表示为模块。使用它而不是类路径,使编译器或 JVM 意识到你的项目工件。

指定在模块路径上的应用程序模块和包含在运行时中的平台模块构成了可观察模块的宇宙。在解析过程中,宇宙会搜索模块,从根模块开始,因此所有必需的模块必须位于模块路径上或在运行时中。

模块解析验证配置的可靠性(所有依赖项都存在,没有歧义等,如第 3.2 节所述)并生成模块图——这是模块系统中对您所看到的工件依赖关系的紧密表示。只有进入模块图的模块在运行时才可用。

4

从源文件构建模块到 JAR

本章涵盖

  • 项目目录结构

  • 从单个模块编译源文件到类文件

  • 同时编译多个模块

  • 将类文件打包到模块 JAR 中

能够按照第三章所述定义模块是一项很好的技能,但如果没有知道如何将这些源文件转换为模块化工件(JAR 文件)以便分发和执行,这些技能有什么用呢?本章探讨了构建模块的过程,从组织源文件,到将它们编译成类文件,最终将这些文件打包成可以分发和执行的模块化 JAR 文件。第五章专注于运行和调试模块化应用程序。

有时我们会查看命令行上可用的javacjar命令。你可能想知道——IDE 和其他工具不会为你使用它们吗?很可能,是的,但即使抛开总是了解这些工具如何施展魔法的论点,还有一个更重要的理由要了解这些命令:它们是进入模块系统核心的最直接途径。我们将使用它们来探索其内部和外部功能,完成后,你可以使用任何提供这些功能的工具。

在本章中,我们将首先探讨项目文件在磁盘上的组织方式(第 4.1 节)。这看起来可能微不足道,但有一个新的建议正在流传,值得深入研究。在按照第三章所述布局源文件并声明模块后,我们将转向编译它们。这可以一次编译一个模块(第 4.2 节)或同时编译多个模块(第 4.3 节)。最后一节讨论了如何将类文件打包成模块化的 JAR 文件。要查看一些实际的构建脚本,请查看 ServiceMonitor 的master分支。

到本章结束时,你将能够组织、编译和打包你的源代码和模块声明。生成的模块化 JAR 文件可以部署或发送给任何使用 Java 9 或更高版本并准备充分利用模块的人。

4.1 在目录结构中组织你的项目

一个真实的项目由许多不同类型的文件组成。显然,源文件是最重要的,但它们只是众多类型中的一种——其他还包括测试源文件、资源、构建脚本或项目描述、文档、源代码控制信息等等。任何项目都必须选择一个目录结构来组织这些文件,并且确保它不会与模块系统的特性冲突。

如果你一直在关注 Project Jigsaw 下的模块系统发展,并研究了官方的快速入门指南或早期的教程,你可能已经注意到它们使用了一种特定的目录结构。让我们看看这个建议,以确定它是否应该成为新的约定,并将其与 Maven 和 Gradle 等工具隐含理解的标准默认约定进行对比。

4.1.1 新提议——新的约定?

在早期关于模块系统的出版物中,项目目录通常包含一个src目录,其中每个属于项目的模块都有自己的子目录,包含项目的源文件。如果项目需要不仅仅是源文件,建议将这些相关内容组织成与src目录并行的树状结构,旁边有如testbuild这样的文件夹。这导致了一个concern/module的层次结构,如图 4.1 所示[part0017.html#filepos510835]。

图片

图 4.1 这种结构有顶级目录classesmodssrctest-src。各个模块的源文件位于srctest-src目录下的子目录中,这些子目录以模块的名称命名。

认识到这种单 src 结构的本质很重要:它是特定项目(JDK)的结构以及入门材料中使用的建议。由于其倾向于将单个模块的文件分散在平行的树中,我不建议除了最小的项目或经过细致检查后认为这种结构更优的项目外,使用这种结构。否则,我建议使用已建立的默认结构,我们将在下一节讨论。

4.1.2 建立的目录结构

大多数由多个子项目(我们现在称之为模块)组成的工程更喜欢使用独立的根目录,其中每个目录包含单个模块的源代码、测试、资源和之前提到的所有其他内容。它们使用 module/concern 层次结构,这正是已建立的项目结构所提供的。

默认目录结构,如 Maven 和 Gradle 等工具隐式理解的那样,实现了这种层次结构(参见图 4.2)。首先,默认结构为每个模块提供自己的目录树。在这个树中,src 目录包含生产代码和资源(分别在 main/javamain/resources 中),以及测试代码和资源(分别在 test/javatest/resources 中)。

按这种方式组织项目结构并不是强制要求。抛开为偏离目录配置构建工具的额外工作,以及多模块编译的特定情况(在第 4.3 节中介绍),所有结构都是同等有效的,应根据项目本身的优点来选择。

图 4.2

图 4.2 这种结构为每个模块都有一个顶级目录。模块可以根据自己的需求组织自己的文件。在这里,monitor.observer 使用 Maven 和 Gradle 项目中常用的目录结构。

说了这么多,本书中的示例使用这种默认结构,只有一个例外:如果所有模块 JAR 文件最终都位于同一目录下,使用命令行会更方便,因此 ServiceMonitor 应用程序的树结构有一个顶层的 mods 文件夹,其中包含创建的模块。

4.1.3 模块声明的位置

无论源文件如何组织,模块声明必须命名为 module-info.java。否则,编译器会产生类似于以下错误的错误,尝试编译 monitor-observer-info.java

> monitor.observer/src/main/java/monitor-observer-info.java:1: > 错误:模块声明应在名为 module-info.java 的文件中 > module monitor.observer { > ^ > 1 个错误

虽然不是严格必要的,但声明应位于根源目录中。否则,如第 4.3.2 节所述使用模块源路径将无法正常工作,因为模块系统无法定位描述符。结果,它无法识别模块,导致“找不到模块”错误。

为了尝试这一点,将 monitor.observer 的描述符移动到不同的目录,并编译 monitor。正如你所看到的,这导致了一个错误,即 monitor 所需的模块 monitor.observer 找不到:

> ./monitor/src/main/java/module-info.java:2: > 错误:找不到模块:monitor.observer > requires monitor.observer; > ^ > 1 错误

4.2 编译单个模块

一旦项目文件以目录结构排列,编写了一些代码,创建了模块声明,就到了编译源文件的时候。但它将是一个类型集合还是一个闪亮的模块?因为前者没有变化,所以我们将在探索编译器如何区分这两种情况之前,先关注后者。

4.2.1 编译模块化代码

本节重点介绍在一个所有依赖都已模块化的世界中编译单个模块的过程。只有当源文件中包含module-info.java声明时,才能编译模块,所以让我们假设这种情况成立。

除了在模块路径上操作并检查可读性和可访问性之外,编译器还增加了一个处理模块声明的能力。编译模块声明的结果是一个模块描述符,一个名为module-info.class的文件。像其他.class文件一样,它包含字节码,可以被像 ASM 和 Apache 的字节码工程库(BCEL)这样的工具分析和操作。

除了使用模块路径而不是类路径之外,编译的工作方式与 Java 9 之前完全相同。编译器将编译所有给定的文件,并生成一个与输出目录中指定的-d选项匹配的包层次结构的目录结构。

图 4.3 显示了使用默认目录结构的 monitor.observer 模块的布局。要编译它,你需要创建一个类似于 Java 9 之前的javac调用:

  • --module-path选项指示编译器指向包含所需应用程序模块的目录。

  • -d选项确定编译的目标目录;它与 Java 9 之前的工作方式相同。

  • 列出或find monitor.observer/src/main/java/中的所有源文件,包括module-info.java(由${source-files}表示)。

图 4.3 monitor.observer 模块的目录结构,其中src目录已展开

在 ServiceMonitor 应用程序的根目录(即包含 monitor.observer 的目录)中,你需要执行以下命令:

$ javac --module-path mods -d monitor.observer/target/classes ${source-files}

展开查看src目录,然后查看target/classes,图 4.4 显示了预期的结果。

图 4.4 monitor.observer 模块的目录结构,其中target目录已展开

4.2.2 模块化或非模块化?

Java 平台模块系统旨在创建并最终运行模块,但这绝对不是强制性的。仍然可以构建普通的 JAR 文件,这引发了如何区分这两种情况的问题。编译器如何知道是创建一个模块还是一堆类型?

重要信息:如第 3.1.2 节所述,模块化 JAR 文件只不过是一个带有模块描述符module-info.class的普通 JAR 文件,该描述符是从模块声明module-info.java编译而来的。因此,编译器使用源文件列表中是否存在module-info.java来编译,以区分它是否在模块上工作。这就是为什么没有--create-module或类似的编译器选项。

编译模块和仅编译类型之间有什么区别?这归结于可读性,如第 3.2 节所述。如果包含模块声明的代码被编译

  • 它必须要求其依赖项能够访问这些依赖项导出的类型

  • 必须存在所需的依赖

相反,如果编译非模块化代码,由于缺少模块声明,不会表达任何依赖。在这种情况下,模块系统允许正在编译的代码读取所有模块以及它在类路径上找到的所有内容。第 8.2 节详细介绍了这种类路径模式。

与可读性不同,第 3.3 节中描述的可访问性规则适用于两种情况。无论代码是作为模块还是作为一堆源文件编译,它都会在访问其他模块中的类型时受到规则的约束。这尤其与 JDK 内部类有关,无论是非导出包中的公共类还是非公共类,因为无论代码如何编译,它们都是不可访问的。图 4.5 显示了可读性和可访问性之间的区别。

图片

图 4.5 比较非模块化代码(左)与模块化代码(右)的编译。可读性规则略有不同,而可访问性规则是相同的。

关于编译器错误的说明

让我们以 ServiceMonitor 应用程序为例。它的子项目 monitor 包含源文件Main.javaMonitor.javamodule-info.java

如果你在文件列表中包含了模块声明,javac 将开始编译一个模块,并验证所有对应用程序和平台模块的依赖是否已在描述符中声明。如果你省略了它,编译器将回退到仅识别类型之间的依赖,如图 3.1 所示。

但无论监控器是否作为模块编译,如果它使用了 JDK 模块或其他应用程序模块没有公开的类型,结果将是相同的:编译错误。

显然,编译一个模块比编译类型需要克服更多的障碍。那么,为什么要这样做呢?再次,我回到与在静态类型语言中编写代码的比较。作为 Java 开发者,我们通常认为静态类型是值得额外前期成本的,因为作为交换,我们得到了快速和可靠的一致性检查。它们不能防止所有错误,但确实可以防止很多错误。

同样的情况也适用于这里:使用模块系统编译模块比创建普通的 JAR 文件需要更多的努力,但作为交换,我们得到了减少运行时错误可能性的检查。我们用编译时的努力换取运行时的安全性——这是我会每天都会做的交易。

4.3 编译多个模块

如此描述的编译单个模块是直接的,编译所有七个 ServiceMonitor 模块也是类似的。但是,有必要逐个编译模块吗?或者,换一种说法,有没有不这样做的原因?对后者的回答是肯定的,一些细节可能使得一次性编译多个模块更可取:

  • 努力程度——虽然编译单个模块很简单,但编译多个模块所需的工作量会迅速增加。而且,反复几乎重复相同的命令,只有细微的变化,这无疑感觉是多余的。除非你在尝试 Java 9,否则你很少会手动这样做。但你的工具的开发者也应该被考虑在内。

  • 性能——在我的系统上,编译单个模块描述符大约需要半秒钟,编译 ServiceMonitor 应用程序的所有模块大约需要四秒钟。考虑到涉及的源文件不到 20 个,而更大项目的完整构建所需时间更短,这有点多。从逻辑上讲,我支付了启动编译器七次(为七个模块)的代价。

  • 弱循环依赖——尽管模块系统禁止使用 requires 指令来创建循环依赖,但还有其他方式让模块相互引用(现在就相信我)被认为是可接受的。尽管依赖是循环的,但它们可以被认为是弱的,因为如果缺少正确的依赖,你只会得到一个警告。尽管如此,无警告的编译还是值得一些努力的,为了达到这个目标,两个模块必须一起编译。

有关一次性编译多个模块的几个原因,编译器能够做到这一点是件好事!

4.3.1 原始方法

一次性编译多个模块是如何工作的?你能列出几个模块的源文件,让编译器自己处理吗?不行的:

$ javac --module-path mods:libs -d classes monitor/src/main/java/module-info.java monitor.rest/src/main/java/module-info.java > monitor.rest/src/main/java/module-info.java:1: > error: too many module declarations found > module monitor.rest { > ^ > 1 error

显然,编译器更喜欢一次处理一个模块。这也是有道理的,因为如前所述,它通过明确定义的模块边界来强制执行可读性和可访问性。如果许多不同模块的源文件混合在编译文件列表中,它们从何而来?编译器需要以某种方式知道一个模块在哪里结束,下一个模块在哪里开始。

4.3.2 模块源路径:通知编译器关于项目结构

从默认的单模块模式中解脱出来的方法是使用一个命令行选项来通知编译器关于项目的目录结构。编译器支持多模块编译,它可以在一次构建中构建多个模块。命令行选项 --module-source-path ${path} 用于启用此模式并指出包含模块的目录结构。所有其他编译器选项都按常规工作。

这听起来很简单,但还有一些重要的细节需要考虑。不过,在这样做之前,让我们先从一个简单的例子开始。

假设一下,ServiceMonitor 应用程序使用了第 4.1.1 节中定义的单个 src 结构,所有模块源目录都在 src 之下(参见 图 4.6)。然后你可以使用 --module-source-path src 来指向包含所有模块源代码的 src 文件夹,并告诉它一次性编译它找到的所有内容。

图片

图 4.6 如果项目只有一个 src 目录,并且每个模块的根源目录位于其下,那么模块源路径的使用最为简单。

与单模块构建一样,模块路径用于指向包含所需应用程序模块的目录——在这种情况下,这些是外部依赖,因为所有 ServiceMonitor 模块目前都在编译中。-d 选项与单模块构建中的用法相同,你仍然需要在 src 中列出所有源文件,包括所有模块声明。

组合起来,这是以下命令:

$ javac --module-path mods:libs --module-source-path src -d classes ${source-files}

查看 classes 目录可以看到每个模块都有一个目录,每个目录都包含该模块的类文件,包括模块描述符。整洁。

但并非总是那么简单。如果项目不使用单 src 结构,这该如何应用?这就是模块源路径的一个巧妙细节发挥作用的地方。

4.3.3 模块名称的通配符

模块源路径可以包含一个星号 (*)。虽然它通常被解释为通配符,在路径中通常表示“星号之前的目录中的任何内容”,但这在这里不是这种情况。相反,星号作为一个标记,指示模块名称在路径上的位置。星号之后路径的其余部分必须指向包含模块包的目录。

这样,编译器可以将源文件路径与模块源路径匹配,并推断出源文件属于哪个模块。为了使这工作,每个源文件都必须匹配模块源路径。

这可能看起来很复杂,但一个例子会澄清。让我们回到 4.1.2 节中结构化的 ServiceMonitor 应用程序,其中每个模块都有包含源文件的公共src/main/java目录。从项目的顶级目录开始,这些是某些源文件的相对路径:

  • monitor/src/main/java/monitor/Monitor.java

  • monitor/src/main/java/monitor/Main.java

  • monitor/src/main/java/module-info.java

  • monitor.rest/src/main/java/monitor/rest/MonitorServer.java

  • monitor.rest/src/main/java/module-info.java

  • monitor.persistence/src/main/java/monitor/persistence/StatisticsRepository.java

  • monitor.persistence/src/main/java/module-info.java

这样,共享结构就非常明显了:所有路径都遵循模式${modules}/src/main/java/${packages}/${sources}

回顾一下模块源路径的使用方法,你可以看到${modules}必须替换为*,并且你必须省略包目录,留下*/src/main/java。不幸的是,它现在还不工作,因为编译器不接受星号作为第一个字符——你必须用.填充它。现在,多模块编译工作得像魔法一样:

$ javac --module-path mods:libs --module-source-path "./*/src/main/java" -d classes ${source-files}

与之前一样,所有类文件最终都会放在classes目录下的模块特定子目录中。根据你对星号是模块名称标记的了解,你可以将这些路径总结为-d classes/*。不幸的是,-d选项并不理解这个标记,你不能用它来构建输出路径,如./*/target/classes。真遗憾。

你可能会想知道星号是如何与第一个例子中--module-source-path src的使用相关的。毕竟,在那里你没有指定模块名称将出现在哪里,编译器能够推断出来。乍一看可能看似不一致,但这其实是为了使简单情况的使用更加简单。

如果模块源路径不包含星号,编译器会静默地将其添加为最后一个路径元素。所以你实际上已经指定了src/*作为模块源路径,这与该示例中的目录结构相匹配。

如果所有模块都使用相同的目录结构,那么编译多个模块应该可以覆盖大多数情况。对于设置更复杂的那些,我们需要另一种技术。

4.3.4 多个模块源路径条目

有可能单个模块源路径不足以满足需求。也许不同的模块有不同的目录结构,或者某些模块的源文件分布在多个目录中。在这种情况下,你可以指定多个模块源路径条目,以确保每个源文件都匹配一个路径。

JDK 是一个复杂的项目,具有非平凡的目录结构。图 4.7 仅展示了其中的一小部分——在所有级别上还有许多更多的目录。

图 4.7 对 JDK 源目录的有限视角。注意 src 下的模块目录是如何进一步细分的。实际上源文件的根目录是位于更下方的 classes 目录。

假设你位于 jdk 目录中,并想要为 UNIX 构建项目,一个跨越所有模块和正确源文件夹的模块源路径会是什么样子?UNIX 源的路径是 src/java.desktop/unix/classes 或更一般地,src/${module}/unix/classes。同样,对于共享源,它是 src/${module}/share/classes。将这两个路径组合起来,你得到

--module-source-path "src/*/unix/classes":"src/*/share/classes"

为了减少冗余,模块源路径允许你使用 {dir1,dir2} 定义替代路径。如果你只需要统一路径元素名称的不同路径,你可以统一这些路径。使用替代路径,你可以将 shareunix 中的源路径统一如下:

--module-source-path "src/*/{share,unix}/classes"

4.3.5 设置初始模块

在为多模块编译设置好一切之后,又出现了一种可能性:只需命名即可编译单个模块及其依赖项。你为什么想要这样做呢?因为这样做不再需要你明确列出要编译的源文件!

如果设置了模块源路径,--module 选项允许你编译单个模块及其传递依赖项,而无需明确列出源文件。模块源路径用于确定哪些源文件属于指定的模块,并且依赖项是根据其声明来解决的。

编译 monitor.rest 及其依赖项现在变得简单。和以前一样,你使用 --module-path mods:libs 来指定依赖项的位置,并使用 -d classes 来定义输出文件夹。通过 --module-source-path "./*/src/main/java",你通知编译器你的项目目录结构;并且通过 --module monitor.rest,你命令它从编译 monitor.rest 开始:

$ javac --module-path mods:libs --module-source-path "./*/src/main/java" -d classes --module monitor.rest

如果 classes 之前是空的,现在它包含了 monitor.rest(指定模块)、monitor.statistics(直接依赖)和 monitor.observer(间接依赖)的类文件。

列表 2.3、2.4 和 2.5 展示了如何逐步编译 ServiceMonitor 应用程序。掌握了如何使用多模块编译的知识,它也可以像以下这样轻松完成:

$ javac --module-path mods:libs --module-source-path "./*/src/main/java" -d classes --module monitor

由于初始模块监控器依赖于所有其他模块,因此所有模块都会被构建。与逐步方法不同,类文件不会放入*/target/classes,而是放入classes/*(使用*作为模块名称的占位符)。

除了使命令更容易阅读外,--module-source-path--module的组合也在更高层次上操作。与列出单个源文件相比,它清楚地说明了编译特定模块的意图。我喜欢这一点。

尽管如此,有两个缺点:

  • 编译后的类文件不能重新分发到更深层次的目录结构中,而是全部位于相同的目录下(在最近的例子中,是classes目录)。如果构建过程的后续阶段依赖于这些文件的精确位置,就必须采取额外的准备步骤,这可能会抵消最初使用模块源路径的优势。

  • 如果使用--module(而不是列出所有模块的源文件)启动编译,编译器将应用可能导致意外结果的优化。其中之一是未使用代码检测:没有从初始模块间接引用的类不会被编译,如果通过服务解耦,甚至整个模块可能从输出中缺失(见第十章)。

4.3.6 是否值得?

多模块编译是否有回报?我列出了三个理由来鼓励其使用,因此有必要回到它们:

  • 努力程度——一旦你掌握了如何构建模块源路径,编译多个模块的工作量就会大大减少。这明确包括构建特定模块及其依赖项,这也会变得更容易。同时,构建工具通常逐个编译项目,将它们配置为一次性编译可能会增加复杂性,尤其是如果还需要采取进一步步骤将类文件分发到特定模块的目录中。

  • 性能——使用多模块编译,ServiceMonitor 应用程序的构建时间不到一秒,这比逐步构建七个模块快四倍。但这是一个相当极端的例子,因为每个模块只包含两到三个类。相对而言,启动编译器七次的开销很大;但从绝对意义上来说,这仅仅减少了三秒钟。考虑到任何适度规模项目的构建时间,减少几秒钟几乎不值得使构建更加复杂。

  • 弱循环依赖——在这种情况下,如果构建过程中应该没有警告,那么就没有办法绕过多模块编译。

多模块编译是可选的,其好处并不足以推荐它作为默认实践。尤其是如果你的工具不支持无缝集成,设置它可能不值得付出努力。这是一个典型的“视情况而定”的情况。不过,我必须说,我喜欢它在更高层次上操作:模块而不是仅仅类型。

4.4 编译器选项

模块系统带来了许多新的命令行选项,本书中对此进行了详细解释。为了确保你可以轻松找到它们,表 4.1 列出了所有与编译器相关的选项。查看docs.oracle.com/javase/9/tools/javac.htm以获取官方编译器文档。

表 4.1 所有模块相关编译器(javac命令)选项的字母顺序表。描述基于文档,引用指向本书中解释如何使用选项的章节。

选项 描述 参考
--add-exports 允许模块导出额外的包 11.3.4
--add-modules 除了初始模块外,还定义根模块 3.4.3
--add-reads 在模块之间添加读取边 3.4.4
--limit-modules 限制可观察模块的宇宙 5.3.5
--module, -m 设置初始模块 4.3.5
--module-path, -p 指定查找应用程序模块的位置 3.4
--module-source-path 传达项目的目录结构 4.3.2
--module-version 指定编译中模块的版本 13.2.1
--patch-module 在编译过程中用类扩展现有模块 7.2.4
--processor-module-path 指定查找注解处理器模块的位置 4.2.1
--system 覆盖系统模块的位置
--upgrade-module-path 定义可升级模块的位置 6.1.3

新的--release选项

你是否曾经使用过-source-target选项来编译你的代码,以便在较旧的 Java 版本上运行,但看到它在运行时崩溃,因为一个方法调用失败,出现了一个看似无法解释的错误?也许你忘记指定了-bootclasspath

没有这个选项,编译器会创建 JVM 可以理解的字节码(好),但它会链接到当前版本的核心库 API(不好)。这可能导致调用在旧 JDK 版本中不存在的类型或方法,从而引起运行时错误。

从 Java 9 开始,编译器通过--release选项防止了这种常见的操作错误,该选项将所有三个选项设置为正确的值。

4.5 打包模块化 JAR

在从想法到运行代码的过程中,编码和编译之后的下一步是将类文件打包成一个模块。正如 3.1.2 节所解释的,这应该生成一个模块化的 JAR 文件,它就像一个普通的 JAR 文件一样,但包含模块的描述符module-info.class。因此,你期望可信的jar工具负责打包。创建一个模块化的 JAR 文件(在这种情况下为 monitor.observer)就是这么简单:

$ jar --create --file mods/monitor.observer.jar -C monitor.observer/target/classes .

将新的命令行别名放在一边,这个调用与 Java 9 之前完全相同。有趣且隐含的细节是,因为 monitor.observer/target/classes 包含一个 module-info.class,所以生成的 monitor.observer.jar 也将包含它,使其成为一个模块化 JAR。

虽然 jar 工具的工作方式与之前相似,但有一些与模块相关的细节和新增功能,例如定义模块的入口点,我们应该看看。

注意:JAR 不是唯一用于交付 Java 字节码的格式。JEE 还与 WAR 和 EAR 文件一起工作。尽管如此,在规范更新以接受模块之前,无法创建模块化的 WAR 或 EAR。

4.5.1 JAR 的快速回顾

为了确保我们都在同一页面上,让我们快速看一下 jar 是如何用于打包存档的。正如我刚才指出的,如果包含的文件列表中包含模块描述符 module-info.class,则结果是一个模块化 JAR。

让我们以打包 monitor.observer 的命令为例。结果是 mods 中的 module.observer.jar,它包含 monitor.observer/target/classes 及其子目录中的所有类文件。因为 classes 包含一个模块描述符,所以 JAR 也将包含它,因此无需额外努力就是一个模块化 JAR:

$ jar --create ①``--file mods/monitor.observer.jar ②``-C monitor.observer/target/classes .

此操作模式表示创建一个存档(另一种选择是 -c)。

要创建的存档文件名(另一种选择是 -f)

-C 使 jar 变为指定的文件夹,点号 (.) 告诉它包含文件夹中的所有源文件。

在打包模块时,您应该考虑使用 --module-version 记录模块的版本。第 13.2.1 节解释了如何进行操作。

4.5.2 分析 JAR

当与 JAR 一起工作时,了解分析您所创建内容的方法很有帮助。特别是,了解 JAR 包含的文件以及其模块描述符的内容非常重要。幸运的是,jar 有选项可以做到这两点。

列出 JAR 的内容

最明显的事情是查看 JAR 的内容,这可以通过 --list 实现。以下片段显示了上一节创建的 monitor.observer.jar 的内容。它包含一个 META-INF 文件夹,我们不会深入探讨,因为它已经存在多年,并且与模块系统无关。还有一个模块描述符,以及 monitor.observer 包中的 DiagnosticDataPointServiceObserver 类。没有什么特别或意外的:

$ jar --list --file mods/monitor.observer.jar > META-INF/ > META-INF/MANIFEST.MF > module-info.class > monitor/ > monitor/observer/ > monitor/observer/DiagnosticDataPoint.class > monitor/observer/ServiceObserver.class

这不是一个新命令——它只是因为新的别名而看起来不同:--list–t的简写,--file-f的简写。在 Java 9 之前,jar -t -f some.jar会做同样的事情。

检查模块描述符

模块描述符是一个类文件,因此由字节码组成。这使得使用工具查看其内容成为必要。幸运的是,jar可以使用--describe-module(或-d)来做这件事。检查monitor.observer.jar,你会发现它是一个名为monitor.observer的模块,导出同名的包并需要基础模块:

$ jar --describe-module --file mods/monitor.observer.jar > monitor.observer jar:.../monitor.observer.jar/!module-info.class > exports monitor.observer > requires java.base mandated

(如果你想知道mandated是什么意思,记得从 3.1.4 节中了解到,每个模块隐式地需要基础模块,这意味着必须存在java.base。)

4.5.3 定义入口点

要启动一个 Java 应用程序,必须知道入口点,它是包含public static void main(String[])方法的类之一。包含该方法的类可以在应用程序启动时在命令行上指定,或者记录在随 JAR 文件一起提供的清单文件中。如果你不知道这些选项是如何工作的,不必担心,因为 Java 9 增加了一个第三种方式,即使用模块的方式。

当使用jar将类文件打包到归档中时,你可以使用--main-class ${class}定义主类,其中${class}是包含main方法的类的完全限定名(意味着包名后跟一个点和类名)。它将被记录在模块描述符中,并在模块是启动应用程序的初始模块时默认用作主类(有关详细信息,请参阅 5.1 节)。

注意:如果你习惯于设置清单的Main-Class条目来创建可执行 JAR 文件,你将很高兴地听到jar --main-class也会设置它。

ServiceMonitor 应用程序在monitor.Main中有一个单一的入口点。你可以使用--main-class monitor.Main来记录在打包过程中的情况:

$ jar --create --file mods/monitor.jar --main-class monitor.Main -C monitor/target/classes .

使用--describe-module,你可以看到主类已被记录在描述符中:

$ jar --describe-module --file mods/monitor.jar > monitor jar:.../monitor.jar/!module-info.class # requires and contains truncated > main-class monitor.Main

很有趣的是,jar工具既没有验证你声称存在此类的能力,也没有责任。没有检查它是否存在或是否包含合适的main方法。如果出现问题,现在不会发生错误,但启动模块将失败。

4.5.4 归档器选项

我们只探索了 jar 提供的最重要选项。其他一些选项在不同的上下文中变得有趣,并在相关章节中解释。为了确保您可以轻松找到它们,表 4.2 列出了与模块系统相关的选项。访问 docs.oracle.com/javase/9/tools/jar.htm 获取官方 jar 文档。

表 4.2 所有模块相关归档器(jar 命令)选项的字母顺序表。描述基于文档,参考信息指向本书中解释如何使用选项的章节。

选项 描述 参考信息
--hash-modules 记录依赖模块的哈希值
--describe-module, -d 显示模块的名称、依赖项、导出、包等 4.5.2
--main-class 应用程序入口点 4.5.3
--module-path, -p 指定查找应用程序模块以记录哈希值的位置 3.4
--module-version 指定编译中模块的版本 13.2.1
--release 创建包含不同 Java 版本字节码的多版本 JAR 文件 附录 E
--update 更新现有归档,例如通过添加更多类文件 9.3.3

摘要

  • 确保选择满足项目要求的目录结构。如果有疑问,请坚持使用构建系统的默认结构。

  • 编译一个模块的所有源代码(包括声明)的 javac 命令与 Java 9 之前相同,除了它使用模块路径而不是类路径。

  • 模块源路径(--module-source-path)通知编译器项目是如何结构的。这使编译器操作从处理类型提升到处理模块,允许您使用简单选项(--module-m)编译所选模块及其所有依赖项,而不是列出源文件。

  • 模块化 JAR 文件只是带有模块描述符 module-info.class 的 JAR 文件。jar 工具对它们处理得和其他类文件一样好,因此将它们全部打包到 JAR 文件中不需要任何新选项。

  • 可选地,jar 允许指定模块的入口点(使用 --main-class),这是具有 main 方法的类。这使得启动模块变得简单。

5

运行和调试模块化应用程序

本章涵盖

  • 通过指定初始模块启动模块化应用程序

  • 从模块中加载资源

  • 验证模块、模块集和模块图

  • 减少和列出可观察模块的宇宙

  • 使用日志调试模块化应用程序

如第三章和第四章所述,将模块定义、编译并打包成模块化 JAR 文件后,终于到了使用java命令启动 JVM 和运行应用程序的时候了。这为我们提供了讨论与运行时相关的概念的机会:如何从模块中加载资源(第 5.2 节)。然而,迟早会遇到问题,因此第 5.3 节还将探讨使用各种命令行选项调试模块配置。

到本章结束时,你将能够启动由模块组成的程序。除此之外,你将深刻理解模块系统如何处理给定的配置,以及如何通过日志和其他诊断工具来观察这一点。

这也完成了本书的第一部分,它教授了你编写、编译和运行简单模块化应用程序所需的一切知识。它为第二部分和第三部分将要探讨的更高级功能奠定了基础,其中最重要的是那些支持逐步迁移到模块系统的功能。

5.1 使用模块启动 JVM

在定义模块依赖关系和 API、创建模块化 JAR 文件并将它们放置在模块路径上之后,使用模块化应用程序启动 JVM 竟然如此简单。你只需要指定初始模块,也许还需要指定主类。

java命令有一个--module ${module}选项,它指定了初始模块${module}。模块解析从这里开始,并且这也是将启动具有public static void main方法的 main 类的模块。

特定的类要么由初始模块的描述符定义,要么通过在模块名称后附加一个斜杠和完全限定的类名(见 5.1.1 节)来使用--module ${module}/${class}指定。

对于 ServiceMonitor 应用程序,所有准备工作最终都集中在你已经看到的调用上,该调用以 monitor 作为初始模块启动 JVM:

$ java --module-path mods:libs --module monitor

如第 3.4 节所述,--module-path mods:libs通知模块系统modslibs目录包含 ServiceMonitor 的应用程序模块。选项--module monitor将 monitor 定义为初始模块,因此模块系统将解析 monitor 的所有依赖关系,并构建如前所述的模块图。然后,它将启动在第 4.5.3 节打包期间在模块描述符中设置的 main 类:monitor.Main

5.1.1 指定主类

--module选项也可以用来定义应用程序的主类。为此,初始模块的名称后面跟着一个正斜杠和完全限定的类名(包名后跟一个点和类名)。

在这里,你明确地定义了应用程序是通过在监控器的类monitor.Main中调用main方法来启动的:

$ java --module-path mods:libs --module monitor/monitor.Main

在命令行上指定主类覆盖了模块描述符中定义的内容。这意味着应用程序仍然可以有多个入口点,就像没有模块系统一样。如果其中一个是合理的默认值,那么将其嵌入到模块描述符中是有意义的,如第 4.5.3 节所述。

如果监控器将 monitor.Main 定义为其主类,但出于某种原因你不想使用它,你可以轻松地覆盖它。使用以下命令,应用程序通过调用监控器的 some.other.MainClass 来启动,忽略监控器描述符中定义的任何内容:

$ java --module-path mods:libs --module monitor/some.other.MainClass

为了使这起作用,初始模块必须包含指定的类。由于 monitor 和 some.other.MainClass 的情况并非如此,执行你刚才看到的命令会导致错误:

> 错误:无法找到或加载主类 > some.other.MainClass 在模块 monitor 中

5.1.2 如果初始模块和主模块不是同一个

如果你想用作初始模块的模块不包含应用程序的主类,你该怎么办?首先,这似乎是一个奇怪的问题;但是,嘿,软件开发充满了这些问题,所以这并不意味着它不会发生。

例如,想象一个可以以几种模式(数据输入、评估、管理)启动的桌面应用程序,并且在启动时通过选择正确的主类来选择模式。由于应用程序复杂,它由许多模块组成,每种模式都有自己的模块(data.entry、data.evaluation、administration)。每种模式的模块也包含相应的入口点。在最上面是 app,它依赖于所有应用程序的模块。(图 5.1 显示了模块图。)

为了启动此应用程序,你希望使用 --module app 并然后指定来自其他模块中的一个主类——但这可能吗?为了解决这个问题,我们需要为涉及的两个模块定义一些术语:

  • 那是依赖于应用程序所需的所有模块(我将称之为所有)的模块。我会称它为 all。

  • 然后是包含你想要启动的主类的模块——我将称之为 main。

图片

图 5.1 桌面应用程序的模块图,其中应用程序位于顶部,包含入口点的三个模块位于下方

到目前为止,这两个模块总是相同的,所以你将模块名称传递给 --module,使其成为初始模块。如果这两个是独立的模块,你会怎么做?

问题的关键在于模块系统坚持主类的来源。没有方法可以欺骗它去搜索除了初始模块之外的任何模块。因此,你必须选择主模块作为初始模块,并将其传递给 --module

根据假设,这并不能正确解决所有依赖项,那么你如何确保所有依赖项都被考虑在内?在这个时候,第 3.4.3 节中引入的--add-modules选项就派上用场了。使用它,你可以定义所有作为额外的根模块,并让模块系统解决其依赖项:

$ java --module-path mods --add-modules all --module main

对于桌面应用程序,这意味着你始终使用--add-modules app选项来确保图形包含所有必需的模块,然后选择所需模式的模块作为主模块。例如:

$ java --module-path mods --add-modules app --module data.entry

顺便说一句,如果你想知道为什么各种模式的模块不会依赖于所有必需的模块,至少有三个答案:

  • 应用程序可能通过服务解耦,如第十章所示,其中 app 是消费者。

  • 模式模块可能有一些可选依赖项,如第 11.2 节所述,而 app 确保它们都存在。

  • 我确实说过这是一个奇怪的情况,记得吗?

5.1.3 将参数传递给应用程序

将参数传递给应用程序与之前一样简单。JVM 将初始模块之后的所有内容放入一个字符串数组中(按空格分割),并将其传递给main方法。

假设你这样调用 ServiceMonitor。你认为会传递给Main::main什么?(小心,这是一个陷阱问题!)

$ java --module-path mods:libs --module monitor --add-modules monitor.rest opt arg

这是一个陷阱问题,因为--add-modules monitor.rest看起来像是模块系统应该负责的事情。如果这个选项放在正确的位置,即在--module之前,那么它就会是正确的。但现状是,这个选项在--module之后,这使得 JVM 将其解释为应用程序的选项,并将其传递下去。

为了演示,让我们扩展Main::main以打印参数:

public static void main(String[] args) { for (String arg : args) { System.out.print(arg + " / "); } // [...] }

确实,你得到输出--add-modules *monitor.rest* opt / arg

请注意,确保将--module作为你希望 JVM 处理的最后一个选项,并将所有应用程序选项放在其后。

5.2 从模块中加载资源

第 3.3 节详细介绍了模块系统的可访问性规则如何在模块边界之间提供强大的封装。尽管它只讨论了类型,但在运行时通常也需要访问资源。这些资源可能是配置、翻译、媒体文件,在某些情况下甚至是原始的 .class 文件。通常,代码会从与项目一起发货的 JAR 文件中加载这些资源。由于 JPMS 将模块化 JAR 转换为模块,这些模块声称强封装其内部内容,我们需要探讨这如何影响资源加载。在深入探讨这一点之前,在接下来的几节中,我将简要回顾过去资源是如何加载的,并指出 Java 9+ 引入的变化。然后我们将更仔细地研究跨模块边界加载包资源。

提示:本书中多次提及资源访问的话题:第 6.3 节解释了如何访问 JDK 资源,第 8.2.1 节深入探讨了非模块化资源的访问。为了实际演示如何加载资源,请查看 ServiceMonitor 的 feature-resources 分支。

5.2.1 在 JAVA 9 之前的资源加载

在 Java 9 之前的版本中,没有 JAR 之间的边界,每个类都可以访问类路径上的所有资源。这甚至比类型更糟糕,因为至少它们可以使用包可见性来隐藏自己在一个包中。对于资源来说,这种情况并不存在。

重要的信息:要加载一个资源,你可以在 ClassClassLoader 上调用 getResourcegetResourceAsStream。从概念上讲,这些方法几乎相同:你将资源文件的名称作为 String 传递给它们,如果找到,它们会返回一个 URLInputStream;否则返回 null。为了不让事情比必要的更复杂,我们将坚持使用 Class::getResource

列表 5.1 展示了如何加载各种资源。只要所有类和资源都在类路径上,它们在哪个 JAR 中并不重要。图 5.2 显示了一个包含所有加载资源的单个 JAR 文件——如果它在类路径上,每次调用 Class::getResource 都会返回一个 URL 实例。

列表 5.1 加载资源:所有成功,因为它们在类路径上

Class<?> anchor = Class .forName("monitor.resources.opened.Anchor") ①``URL pack = anchor.getResource("file.txt"); ②``URL root = anchor.getResource("/file.txt"); ③``URL meta = anchor.getResource("*META-INF*file.txt"); ④``URL bytecode = anchor.getResource("Anchor.class");

要调用 Class::getResource,你首先需要一个 Class 实例——其他两个 Anchor 类同样适用。

相对于包含 Anchor 的包解析

由于以“/”开头,因此从 JAR 的根目录解析为绝对路径。

使用绝对路径访问 META-INF。

加载锚点的字节码

图片

图 5.2 JAR monitor.persistence 包含一些资源——巧合的是,正好是列表 5.1 需要的那些。

5.2.2 Java 9 及以后版本的资源加载

你可能会想知道为什么列表 5.1 提供了如此多的不同示例。有些与模块一起工作,但有些则不行,我想依次讨论它们。在我们到达那里之前,让我们先考虑 Java 9 中的各种资源 API:

  • Class上的方法是从模块中加载资源的好方法——我们将稍后探讨它们的行为。

  • ClassLoader上的方法在模块方面有不同的行为,并且通常不那么有用,我们不会讨论它们。如果你想使用它们,请查看它们的 Javadoc。

  • 新的类java.lang.Module,我们将在 12.3.3 节中深入探讨,它也有getResourcegetResourceAsStream方法。它们的行为与Class上的方法非常相似。

在这个问题解决之后,我们可以转向使用工作马力的Class::getResource来从模块中加载列表 5.1 中的各种资源。第一个重要的观察是,在同一个模块内,每次调用都返回一个URL实例,这意味着所有资源都被找到。这适用于模块封装的任何包。当涉及到跨模块边界加载资源时,事情就有些不同了:

  • 包中的资源默认是封装的(详细信息请参阅 5.2.3 节)。

  • 来自 JAR 根目录或名称无法映射到包的文件夹(例如,由于包含破折号而无法映射的META-INF)的资源永远不会被封装。

  • .class文件永远不会被封装。

  • 如果资源被封装,getResource调用将返回null

大多数形式的访问不被封装的原因归结为迁移的便利性。Java 生态系统中的许多关键和广泛使用的工具和框架依赖于 JAR 根目录或META-INF文件夹中的配置(例如,JPA 实现)或扫描.class文件(例如,以定位注解类)。如果所有资源默认都被封装,那么这些工具默认情况下将无法与模块一起工作。

同时,资源强封装的好处远不如类型重要,因此决定只封装包中的资源。让我们看看如何解决这个问题。

5.2.3 在模块边界之间加载包资源

每当Class::getResource或其等效方法被要求加载资源时,它会检查路径是否符合包名。简单来说,如果从路径中删除文件名,然后将所有/替换为.,得到一个有效的包名,则资源将从包中加载。

让我们从列表 5.1 中选取一些行作为例子。调用anchor.getResource("file.txt")告诉 JVM 从anchor类相对位置加载资源file.txt。因为该类在一个包中——在这个例子中是monitor.resources.opened——所以资源是从该包中加载的。

一个反例是 anchor.getResource("*META-INF*file.txt")。前面的斜杠表示绝对路径(因此,anchor 在哪个包中无关紧要),尝试将其转换为包名将得到 META-INF。这在 Java 中是无效的,因此资源不会从包中加载。

打开一个包

理解 JVM 如何确定资源是否在包中非常重要,因为如果资源在包中,它会被强封装。此外,exports子句并不提供对资源的访问。因为getResource绑定到反射 API,需要一个不同的机制。

我们之前还没有讨论过,但当你想要提供对资源的访问时,你寻找的是opens子句。在语法上,它与exports完全相同,但它只提供对包的反射访问,这使得它非常适合这种用例。

关于opens还有很多东西要学习,第 12.2 节详细讨论了它,但你需要知道的是,它提供了对其他情况下封装的包中资源的访问。让我们尝试一下,并在列表 5.1 中加载的资源周围构建一个名为monitor.resources的模块。以下是模块声明:

module monitor.resources { exports monitor.resources.exported; opens monitor.resources.opened; }

与图 5.2 进行比较,你可以看到在其三个包中,一个是封装的,一个是导出的,一个是开放的。如果你运行列表 5.2 中的代码,你能期待什么?

这取决于运行代码的模块。如果是monitor.resources,调用将通过,因为封装只在模块边界之间操作。如果任何其他模块运行代码,只有monitor.resources.opened包会被提供给它进行反射。因此,getResource将只为opened返回非null URL,而为从closedexported加载资源将返回null

来自列表 5.1 的其他调用——getResource("Anchor.class")getResource("/file.txt")getResource("*META-INF*file.txt")——将会通过,因为它们加载的是字节码或不在包中的资源。如第 5.2.2 节所述,这些资源没有被封装。

列表 5.2 从具有不同可访问性的包中加载资源

URL closed = Class .forName("monitor.resources.closed.Anchor") .getResource("file.txt");URL exported = Class .forName("monitor.resources.exported.Anchor") .getResource("file.txt");URL opened = Class .forName("monitor.resources.opened.Anchor") .getResource("file.txt");

无法从封装的包中加载资源

无法从导出的包中加载资源

成功从打开的包中加载资源

总结来说,如果你想在模块的包中提供对资源的访问,你必须打开它。

打开包以提供对资源的访问会邀请其他代码依赖你的模块的内部结构。为了避免这种情况,考虑在你的公共 API 中公开一个类型,它可以被分配加载资源的任务。这样,你就可以根据需要自由地重新排列内部资源,而不会破坏其他模块。

提示:如果你想要避免依赖包含资源的模块,你可以创建一个服务。第十章介绍了服务,使用它们来访问资源将非常直接,如果不是因为需要处理名称,那么它将是简单的。幸运的是,对于这个费时的过程有出色的文档,所以在这里我不会重复它。查看ResourceBundleProvider的 Javadoc,但请确保你至少阅读了 Java 10 版本——它与 Java 9 的工作方式相同,但文档更清晰:mng.bz/G28M

5.3 调试模块和模块化应用程序

模块化系统解决了一个复杂的问题,并设定了雄心勃勃的目标。我认为它在使简单情况易于使用方面做得很好,但让我们不要自欺欺人:它是一个复杂的机械装置,事情可能会出错——尤其是在你进入本书的以下两个部分时,这些部分探讨了迁移到模块化系统及其更高级的功能。在这种情况下,深入了解模块化系统的内部工作原理可能会有所帮助。幸运的是,它提供了一些方法来实现这一点:

  • 分析和验证模块

  • 测试构建模块图

  • 检查可观察模块的宇宙

  • 在解析过程中排除模块

  • 记录模块化系统行为

在以下各节中,我将依次介绍它们。

5.3.1 分析单个模块

你已经看到jmod describe显示了 JMOD 的模块属性(第 3.1.1 节)以及jar --describe-module为 JARs 执行了类似的工作(第 4.5.2 节)。这些都是检查单个工件的好方法。通向同一目的地的不同路径是通过java --describe-module实现的。跟在模块名称后面,此选项会打印出对应工件的路经以及模块的描述符。模块化系统不做其他任何事情,也不解析模块或启动应用程序。

因此,虽然jmod describejar --describe-module操作于工件,但java --describe操作于模块。根据情况,一个或另一个可能更方便,但最终它们的输出是相似的。

再次转向 ServiceMonitor,你可以使用--describe-module来查看其模块以及平台模块的描述:

$ java --module-path mods --describe-module monitor.observer > monitor.observer file:...monitor.observer.jar > 导出 monitor.observer > 需要 java.base 强制 $ java --module-path mods --describe-module java.sql > java.sql@9.0.4 > 导出 java.sql > 导出 javax.sql > 导出 javax.transaction.xa > 需要 java.base 强制 > 需要 java.logging 传递 > 需要 java.xml 传递 > 使用 java.sql.Driver

5.3.2 验证模块集

查看单个模块对于分析已知问题很有帮助。但未知问题怎么办?模块路径是否有重复模块?是否有模块拆分包?

java选项--validate-modules扫描模块路径以查找错误。它报告重复模块和拆分包,但不构建模块图,因此无法发现缺失模块或依赖循环。执行检查后,java退出。

对于这个例子,我创建了一个包含monitor.observer包的模块 monitor.rest,就像模块 monitor.observer 一样。这是验证这些模块的结果:

$ java --module-path mods --validate-modules # 截断标准化的 Java 模块 # 截断非标准化的 JDK 模块 > file:.../monitor.rest.jar monitor.rest > file:.../monitor.observer.beta.jar monitor.observer.beta > file:.../spark.core.jar spark.core > file:.../monitor.statistics.jar monitor.statistics > file:.../monitor.jar monitor > file:.../monitor.observer.jar monitor.observer > 包含 monitor.observer 与模块 monitor.rest 冲突 > file:.../monitor.persistence.jar monitor.persistence > file:.../monitor.observer.alpha.jar monitor.observer.alpha > file:.../hibernate.jpa.jar hibernate.jpa

输出首先列出所有 JDK 模块,它们没有错误,然后继续列出应用程序模块。它列出了扫描的 JAR 文件以及其中发现的模块,以及 monitor.rest 和 monitor.observer 之间的拆分包。

5.3.3 验证模块图

使用--dry-run选项,JVM 执行完整的模块解析,包括构建模块图和断言可靠的配置,但然后在执行主方法之前停止。这可能听起来不太有用,但我发现它很有用。在包含错误并因此阻止应用程序启动的命令中使用--dry-run不会改变任何东西。但当你最终正确时,命令会退出,你将回到命令行。这使得你可以快速尝试命令行选项,直到它们正确,而无需不断启动和终止应用程序。

作为有缺陷命令的一个例子,让我们尝试在没有模块路径的情况下启动 ServiceMonitor。正如预期的那样,它失败了,因为没有地方去搜索应用程序模块,模块系统找不到初始模块 monitor:

$ java --module monitor > Error occurred during initialization of boot layer > java.lang.module.FindException: > Module monitor not found

在混合中使用--dry-run不会改变任何东西:

$ java --dry-run --module monitor > Error occurred during initialization of boot layer > java.lang.module.FindException: > Module monitor not found

现在来一个应该能工作的命令:

$ java --module-path mods:libs --dry-run --module monitor

这导致——没有任何结果。命令是正确的,模块系统是满意的,因此在模块解析后没有消息就退出了。

从 5.1.2 节中记住,即使看起来顺序上不令人满意,--dry-run也必须放在--module之前。并且对专家的一个备注:如果你正在使用自定义类加载器、自定义安全管理者或代理,即使使用--dry-run它们也会被启动。

5.3.4 列出可观察模块和依赖项

你在 3.1.1 节中使用了--list-modules选项,其中列出了当前运行时中的所有平台模块,使用java --list-modules。有了对模块系统如何工作的更好理解,我可以告诉你,它不仅仅如此。

列出可观察模块的宇宙

选项--list-modules列出了可观察模块的宇宙。模块系统不做其他任何事情,也不解析模块或启动应用程序。

如 3.1.4 节所述,可观察模块的宇宙包括平台模块(运行时中的那些)和应用模块(模块路径上的那些)。在解析过程中,模块从这个集合中选取来构建模块图。应用程序永远不会包含未用--list-modules列出的模块。(但请注意,许多可观察模块可能不会进入图,因为它们不是任何根模块所必需的——甚至不是间接必需的。)

当调用java --list-modules时,你要求 JVM 列出所有可观察模块。因为你没有指定模块路径,所以只会打印出运行时的平台模块。

让我们看看一个不那么简单的问题,并列出 ServiceMonitor 应用程序的modslibs文件夹中的模块:

$ java --module-path mods:libs --list-modules > spark.core # truncated Spark dependencies # truncated standardized Java modules # truncated non-standardized JDK modules > monitor > monitor.observer > monitor.observer.alpha > monitor.observer.beta > monitor.persistence > monitor.rest > monitor.statistics > hibernate.jpa # truncated Hibernate dependencies

如果在一个常规 JDK 安装上执行,输出会非常庞大,因为它列出了大约 100 个平台模块。它还总是包含模块路径上的所有模块。这些一起对于查看可以从哪些模块构建模块图是有用的,但它们也使得难以看清整体。不过,有一种方法可以将输出限制到一个合理的子集,我们将在下一节中探讨。

列出传递依赖项

在那长长的可观察模块列表中,有一个有趣的子集是初始模块的传递依赖。幸运的是,你可以使用 --limit-modules 选项将列表缩减到仅包含这些内容。我稍后会解释它具体是如何工作的——现在,请相信我说,结合 --list-modules,你可以用它来打印任何给定模块的所有传递依赖的列表。

这里有一些关于平台模块的实验:

$ java --limit-modules java.xml --list-modules > java.base > java.xml $ java --limit-modules java.sql --list-modules > java.base > java.logging > java.sql > java.xml $ java --limit-modules java.desktop --list-modules > java.base > java.datatransfer > java.desktop > java.prefs > java.xml

你可以看到,java.xml 只依赖于 java.base,SQL 模块使用了日志和 XML 功能,而且即使包含所有 AWT、Swing、一些媒体 API 和 JavaBeans API 的 java.desktop,其依赖项也出奇地少(尽管原因并不令人满意——它是一个包含大量功能的巨大模块)。

你也可以使用这种方法来检查应用程序模块。一旦应用程序的模块数量超过几个,这尤其有用,因为那时很难记住所有模块。

让我们再次看看 ServiceMonitor 并检查其一些模块的依赖关系:

$ java --module-path mods:libs --limit-modules monitor.statistics --list-modules > java.base > monitor.observer > monitor.statistics $ java --module-path mods:libs --limit-modules monitor.rest --list-modules > spark.core # Spark 的依赖被截断 > java.base > monitor.observer > monitor.rest > monitor.statistics

--limit-modules--list-modules 的组合显示,monitor.statistics 只依赖于 monitor.observer(以及无处不在的基础模块),而 monitor.rest 则拉入了 Spark 的所有依赖。

现在是时候看看 --limit-modules 参数是如何工作的了。

5.3.5 在解析过程中排除模块

你刚刚使用 --limit-modules 来缩减 --list-modules 的输出。这是怎么工作的?鉴于 --list-modules 打印出所有可观察模块的宇宙,--limit-modules 显然限制了它。而且因为你可以用它来查看一个模块的所有传递依赖,这些依赖必须被评估。结合这两个观察结果,基本上就定义了这个选项。

选项 --limit-modules ${modules} 接受一个以逗号分隔的模块名称列表。它将可观察模块的宇宙限制为指定的模块及其传递依赖项。如果与 --add-modules(见第 3.4.3 节)或 --module(见第 5.1 节)一起使用 --limit-modules,则指定的模块变为可观察的,但它们的依赖项不会!

逐步来说,这是模块系统评估选项的方式:

  1. --limit-modules 指定的模块开始,JPMS 确定它们的所有传递依赖项。这符合第 3.2.1 节中描述的可靠配置要求。

  2. 如果使用了 --add-modules--module,JPMS 会添加指定的模块(但不是它们的依赖项)。

  3. JPMS 使用生成的集合作为任何进一步步骤(如列出模块或启动应用程序)的可观察模块的宇宙。

--limit-modules 参数进行一些实验应该可以清楚地说明它是如何工作的。让我们首先列出 monitor.rest 的所有传递依赖项:

$ java --module-path mods:libs --limit-modules monitor.rest --list-modules > java.base # 为了使输出更简洁 # 我省略了文件路径 > monitor.observer > monitor.rest > monitor.statistics > spark.core

你可以翻回到 图 2.4 来验证这些确实是正确的依赖项。现在,你认为如果你尝试启动应用程序会发生什么?为了做到这一点,你必须将 --list-modules 替换为 --module monitor

$ java --module-path mods:libs --limit-modules monitor.rest --module monitor > 初始化引导层时发生错误 > java.lang.module.FindException: > 模块 monitor.persistence 未找到, > 由 monitor 需要

这个结果展示了 --limit-modules 的工作的两个方面:

  • 使用 --module 指定的初始模块变为可观察的(否则异常会抱怨 monitor 未找到)。

  • 初始模块的任何依赖项都不会变为可观察的(否则应用程序会启动)。

对于 --add-modules 也应该是这样,所以当你添加 add-modules monitor.persistence 时,你可以期待看到什么?

  • 因为 monitor.persistence 现在是可观察的,所以那个特定的错误应该会消失。

  • 因为它的依赖项 hibernate.jpa 不可观察,你可以预期会有关于这个的错误。

让我们试试:

$ java --module-path mods:libs --limit-modules monitor.rest --add-modules monitor.persistence --module monitor > 初始化引导层时发生错误 > java.lang.module.FindException: > 模块 monitor.observer.alpha 未找到, > 由 monitor 需要

这个特定的情况在 图 5.3 中展示。

图 5.3 --limit-modules 选项在模块解析之前被评估。

真糟糕——观察者的实现也缺失了,所以你永远无法了解 Hibernate。幸运的是,这并不是你不能通过更多的--add-modules解决的问题:

$ java --module-path mods:libs --limit-modules monitor.rest --add-modules monitor.persistence, monitor.observer.alpha,monitor.observer.beta --module monitor > Error occurred during initialization of boot layer > java.lang.module.FindException: > Module hibernate.jpa not found, > required by monitor.persistence

就这样吧!

在上一节中,你使用了计算出的宇宙来列出所有引用的模块,从而有效地打印出某个模块的所有传递依赖项。但这并不是--limit-modules的唯一用例。当我们讨论第十章中的服务时,会有更多内容出现(参见 10.1.2 节关于限制服务提供者的内容)。

5.3.6 使用日志消息观察模块系统

最后但同样重要的是,我们来到了调试的神奇子弹:日志消息。每当系统出现异常行为,在明显的地方(无论这些地方在哪里,对于特定的异常行为而言)找不到任何可操作的问题时,就是时候转向日志了。

一旦你到达这里,很可能是你正在处理一个相对罕见的问题。对于这些情况,了解如何提取日志消息和相关信息,以及日志在最佳情况下应该看起来像什么(即一切正常的情况)是很有帮助的。本节不会展示如何修复具体问题——相反,它为你提供了自己完成这些事情的工具。

模块系统将日志消息记录到两种不同的机制中(因为,嘿,为什么不呢?),一种更简单,一种更复杂:

  • 解析器的诊断消息

  • 统一 JVM 日志

我们将探讨两种方法,先从更简单的变体开始。

模块解析期间的诊断消息

使用--show-module-resolution选项,模块系统会在模块解析期间打印消息。以下是在使用该选项启动 ServiceMonitor 应用程序时的输出。它确定了根模块(在这种情况下只有一个),以及作为依赖项加载的模块以及哪个依赖项:

$ java --module-path mods:libs --show-module-resolution ①``--limit-modules monitor ②``--dry-run --module monitor # 对于每个模块,文件被列出;# 我为了简洁性移除了它,但可能会有帮助 > root monitor > monitor 需要 monitor.observer > monitor 需要 monitor.rest > monitor 需要 monitor.persistence > monitor 需要 monitor.observer.alpha > monitor 需要 monitor.observer.beta > monitor 需要 monitor.statistics > monitor.rest 需要 spark.core > monitor.rest 需要 monitor.statistics > monitor.persistence 需要 hibernate.jpa > monitor.persistence 需要 monitor.statistics > monitor.observer.alpha 需要 monitor.observer > monitor.observer.beta 需要 monitor.observer > monitor.statistics 需要 monitor.observer # Spark 依赖项截断 # Hibernate 依赖项截断

激活模块解析的消息

由于在服务引入时原因变得清晰,需要限制可观察模块的宇宙,否则会解析出许多意外的模块。

你只想看到解析器消息,因此不需要启动应用程序。

从模块系统中提取解析器的诊断消息相对简单但不可定制。现在是时候转向更复杂和强大的机制了。

使用统一日志查看 JPMS

Java 9 带来了统一的日志架构,该架构通过相同的机制将 JVM 生成的许多消息管道化。附录 C 介绍了它并解释了如何配置它。如果你以前从未这样做过,现在应该看看。我在这里等你。

太好了——你回来了。有了对日志机制和配置的理解,你可以更深入地了解模块系统是如何工作的。以下所有实验都是使用已知的命令启动 ServiceMonitor 应用程序,使用 --dry-run 防止实际执行:

$ java --module-path mods:libs --dry-run --module monitor

碎片将仅显示除该命令外还使用的 -Xlog 配置,以定义输出。为了减少噪音并集中注意力,我移除了所有标签并手动编辑了消息,只显示最重要的部分——实际的日志包含更多信息。

遵循附录 C 中的建议,我查看了 -Xlog:help 并看到了 module 标签,这看起来很有希望。我将其用作 module* 以获取所有带有该标签的消息:

# -Xlog:module* # 截断许多模块 > java.base 位置: jrt:/java.base > jdk.compiler 位置: jrt:/jdk.compiler > spark.core 位置: file://... > monitor.persistence 位置: file://... > monitor.observer 位置: file://... > monitor 位置: file://... > monitor.rest 位置: file://... > Phase2 初始化,0.0977682 秒

在这里,模块系统会告知它加载了哪些模块。这些都是所有涉及的平台模块以及 monitor.*模块及其依赖项。要获取更多详细信息,让我们包括调试信息:

# -Xlog:module*=debug # 啊!大约 1500 行日志消息

这个输出有点令人眼花缭乱,但当你一步一步地过一遍,它并不复杂。此外,你还有机会看到模块系统如何工作的细节。所以,让我们来做吧!

模块系统首先处理的是,有趣的是,未命名的模块。这仍然在很大程度上是一个谜——参见第 8.2 节。接下来是基本模块——如第 3.1.4 节所述,所有其他模块都依赖于它,因此定义它很早是有意义的:

> 为引导加载器记录未命名的模块 > java.base 位置:jrt:/java.base > 模块定义:java.base

然后开始创建所有可观察的模块:

> jdk.compiler 位置:jrt:/jdk.compiler > 创建模块:jdk.compiler > jdk.localedata 位置:jrt:/jdk.localedata > 创建模块:jdk.localedata > monitor.observer.alpha 位置:file://... > 创建模块:monitor.observer.alpha # 创建了许多其他模块

在所有模块创建完成后,模块系统会处理它们的描述符,添加读取边和定义中的包导出:

> 将模块 java.xml 的读取添加到模块 java.base > 包 com/sun/org/apache/xpath/internal/functions 位于模块 java.xml > 导出至模块 java.xml.crypto > 包 javax/xml/datatype 位于模块 java.xml > 导出至所有未命名的模块 > 包 org/w3c/dom 位于模块 java.xml > 导出至所有未命名的模块 > 将模块 monitor.statistics 的读取添加到模块 monitor.observer > 将模块 monitor.statistics 的读取添加到模块 java.base > 包 monitor/statistics 位于模块 monitor.statistics > 导出至所有未命名的模块

你可以看到它将包导出表述为"导出至模块 ...",有时值甚至不是所有未命名的模块。这是怎么回事?第 11.3 节会深入探讨这一点——这里我们只需认识到包导出被处理了。

那就是全部了!最后一条消息是你之前见过的,它出现在中止 dry run 之前:

> Phase2 初始化,0.1048592 秒

如果你进一步进入矩阵并将日志级别设置为trace,你会看到几千条消息,但并没有令人瞩目的发现等待着你。你只是看到,随着每个类的加载,模块系统会记录它属于哪个包和模块,在最终定义包之前。一旦完成,相应的模块就会被创建。

如果你移除--dry-run并执行应用程序,你不会得到更多信息。在debug模式下,不会创建新的消息;而在trace模式下,你只会看到如何将一些嵌套类分配给现有的包。

注意:如果您想知道,所有这些都在单个线程中发生。您可以通过使用-Xlog:module*=debug:stdout:tid打印线程 ID 来验证这一点,它显示所有模块相关操作的相同 ID。

现在您知道了如何配置日志以及日志应该是什么样子。这些知识可以成为一个很好的诊断工具。当模块化应用程序没有按预期工作且其他方法未能提供有助于解决问题的分析时,它非常有用。

5.4 虚拟机选项

就像编译器和归档器一样,虚拟机获得了一些新的命令行选项,这些选项与模块系统交互。为了您的方便,表 5.1 列出了它们。您可以在docs.oracle.com/javase/9/tools/java.htm找到官方文档。

表 5.1 所有模块相关虚拟机(java命令)选项的字母顺序列表。描述基于文档,参考指向本书中解释如何使用选项的章节。

选项 描述 参考
--add-exports 允许模块导出额外的包 11.3.4
--add-modules 除了初始模块外,还定义根模块 3.4.3
--add-opens 使模块开放额外的包 12.2.2
--add-reads 在模块之间添加读取边 3.4.4
--describe-module, -d 显示模块的名称、依赖项、导出、包等 5.3.1
--dry-run 启动虚拟机但在调用main方法之前退出 5.3.3
--illegal-access 配置如何处理从类路径到 JDK 内部 API 的访问 7.1.4
--limit-modules 限制可观察模块的宇宙 5.3.5
--list-modules 列出所有可观察模块 5.3.4
--module, -m 设置初始模块并启动其主类 5.1
--module-path, -p 指定查找应用程序模块的位置 3.4
--patch-module 在编译过程中扩展现有模块的类 7.2.4
--show-module-resolution 在模块解析期间打印消息 5.3.6
--upgrade-module-path 定义可升级模块的位置 6.1.3
--validate-modules 扫描模块路径以查找错误 5.3.2

除了能够在命令行上使用这些选项之外,您还可以在可执行 JAR 的清单中指定其中的一些,在java命令选择的特定环境变量中定义它们,或者将它们放入您提供给启动 JVM 的参数文件中。第 9.1.4 节解释了所有这些。

你已经达到了第一部分的第二个里程碑和结论。你现在对模块系统的基本原理非常熟悉。如果你有机会,花些时间练习你所学的知识——也许创建自己的演示或尝试使用 ServiceMonitor (github.com/CodeFX-org/demo-jpms-monitor)。接下来要阅读的内容取决于你是否有一个想要迁移到 Java 9+ 并可能模块化(见第二部分)的项目,或者你是否更感兴趣于了解模块系统还能为你做些什么(见第三部分)。

摘要

  • 初始模块通过 --module 定义。如果它定义了主类,则无需更多操作即可启动应用程序;否则,在正斜杠之后将完全限定类名附加到模块名称之后。

  • 确保在 –module 之前列出所有 JVM 选项,否则它们将被视为应用程序选项,不会影响模块系统。

  • 可以使用 --list-modules 列出可观察的模块。如果你需要调试问题并查看哪些模块可用于解析,这会很有用。

  • 如果使用 --limit-modules,可观察的模块宇宙仅由指定的模块及其传递依赖组成,从而减少了在解析期间可用的模块。与 --list-modules 结合使用,这是一个确定模块传递依赖的好方法。

  • 选项 --add-modules 可以用来定义除初始模块之外的其他根模块。如果一个模块不是必需的(例如,因为它仅通过反射访问),则必须使用 --add-modules 来确保它成为模块图的一部分。

  • 选项 --dry-run 启动 JVM 并让模块系统处理配置(模块路径、初始模块等)并构建模块图,但在调用主方法之前退出。这让你可以在不启动应用程序的情况下验证配置。

  • 模块系统记录了各种消息,可以使用简单的 --show-module-resolution 或更复杂的 -Xlog:module* 打印它们。它们让你分析模块系统如何构建模块图,这有助于故障排除。

  • 从模块加载资源的工作方式与从 JAR 文件加载它们非常相似。唯一的例外是那些不是 .class 文件且位于不同模块包中的资源(例如,与 JAR 的根目录或 META-INF 文件夹不同)。这些资源默认被封装,因此不可访问。

  • 一个模块可以使用 opens 指令来提供对包的反射访问,这暴露了其中定位的资源,并允许其他模块加载它们。不幸的是,这种解决方案会邀请其他代码依赖于模块的内部结构。

  • 在加载资源时,默认使用 Class 或新类型 java.lang.Module 上的 getResourcegetResourceAsStream 方法。ClassLoader 上的这些方法通常具有更少有用的行为。

第二部分

适应现实世界的项目

本书第一部分探讨了模块系统的基本知识以及如何编译、打包和运行模块化应用程序。除了教授相关机制外,它还展示了未来的 Java 项目将如何组织和发展。

但对于现有的项目呢?我相信你们希望看到它们在 Java 9 或更高版本上运行,也许甚至作为模块。这部分将介绍如何实现这一点。

第一步,让项目在 Java 9+上编译和运行,对于任何不想在 Java 8 的生命周期结束后继续使用或支付支持费用的代码库来说是强制性的。第二步,将项目的工件转换为模块化 JAR 文件,是可选的,并且可以逐步完成。

第六章和第七章专门介绍迁移到 Java 9。它们都是关于让你的非模块化、基于类路径的项目在新版本上工作(而不创建任何模块)。第八章介绍了允许你逐步模块化项目的功能。第九章提供了一些战略建议,教你如何使用第六章至第八章中学到的知识迁移和模块化你的项目。

我建议按照这个顺序阅读这些章节,但如果你更喜欢只在需要时研究技术细节,你可以从第九章开始。或者,你也可以先了解你可能会遇到的最大挑战:对 JEE 模块的依赖(第 6.1 节)和对 JDK 内部的依赖(第 7.1 节)。

本书本部分没有针对所有内容提供具体的示例。在github.com/CodeFX-org/demo-java-9-migration的存储库中包含了一个 ServiceMonitor 应用程序的变体,其中包含一些需要修复才能在 Java 9+上运行的问题。试试看吧!

6

移动到 Java 9 或更高版本时的兼容性挑战

本章涵盖

  • 为什么 JEE 模块被弃用且默认不解析

  • 编译和运行依赖于 JEE 模块的代码

  • 为什么URLClassLoader的转换失败

  • 理解新的 JDK 运行时图像布局

  • 替换已删除的扩展机制、支持标准覆盖机制和引导类路径选项

本章和第七章讨论了将现有代码库迁移到 Java 9 及更高版本时的兼容性挑战。你目前不会创建任何模块;这些章节是关于在最新版本上构建和运行现有项目。

为什么移动到 Java 9+需要两整个章节?难道你不能安装最新的 JDK 并期望一切都能正常工作吗?Java 不是应该向后兼容的吗?是的——如果你的项目及其依赖项只依赖于非弃用、标准化、有文档记录的行为。但这只是一个大前提,而且结果证明,在没有任何强制措施的情况下,更广泛的 Java 社区已经偏离了这条道路。

正如你将在本章中看到的,模块系统弃用了一些 Java 特性,移除了其他特性,并更改了一些内部结构:

  • 包含 JEE API 的模块已弃用,需要手动解决(第 6.1 节)。

  • 应用程序类加载器(也称为系统类加载器)不再是URLClassLoader,这破坏了一些类型转换(第 6.2 节)。

  • Java 运行时图像(JRE 和 JDK)的目录布局已彻底翻新(第 6.3 节)

  • 一些机制,如紧凑配置文件和 endorsed-standards 覆盖机制已被移除(第 6.4 节)。

  • 还有一些小的变化,比如不再允许单下划线作为标识符(第 6.5 节)。

但这还不是全部。第七章讨论了两个更多挑战(内部 API 和分割包)。它们各自有自己的章节,因为在你迁移项目后,你可能会再次遇到它们与非 JDK 模块一起。

这些变化一起破坏了一些库、框架、工具、技术和可能也是你的代码,因此不幸的是,更新到 Java 9+并不总是容易的任务。一般来说,项目越大、越老,它需要的工作就越多。然而,这通常是值得的投资,因为它是一个偿还技术债务并使代码库变得更好的机会。

到本章和下一章结束时,你将了解更新到 Java 9、10、11 甚至更晚版本的挑战。给定一个应用程序,你将能够对需要做什么做出明智的猜测;并且假设所有依赖项都配合,你将能够在最新版本上使其工作。你还将为第九章做好准备,该章节讨论了迁移到 Java 9 及以后的策略。

关于类路径

第八章详细讨论了非模块化代码如何在模块化的 JDK 上运行。目前你只需要知道以下内容:

  • 类路径仍然完全有效。在迁移到 Java 9+的过程中,你将继续使用它而不是模块路径。

  • 即使如此,模块系统仍然在发挥作用:例如,关于模块解析。

  • 类路径上的代码将自动读取大多数模块(但并非全部:请参阅第 6.1 节),因此它们在编译时或运行时无需额外配置即可使用。

6.1 与 JEE 模块一起工作

Java SE 中的许多代码都与 Java EE / Jakarta EE(我将其缩写为 JEE)相关:CORBA 就是其中之一,Java Architecture for XML Binding (JAXB)和 Java API for XML Web Services (JAX-WS)也是如此。这些和其他 API 最终出现在表 6.1 中显示的六个模块中。这可能会是一个微不足道的旁白和故事的结尾,但不幸的是并非如此。当你尝试编译或运行依赖于这些模块中的类的代码时,模块系统会声称这些模块在图中缺失。

在 Java 9 中,对于使用 java.xml.bind 模块中的JAXBException的类,存在一个编译错误:

> 错误:包 javax.xml.bind 不可见 > import javax.xml.bind.JAXBException; > ^ > (包 javax.xml.bind 在模块 java.xml.bind 中声明, > 该模块不在模块图中) > 1 个错误

如果你通过了编译器,但忘记了调整运行时,你会得到一个NoClassDefFoundError错误:

> 线程"main"中的异常 java.lang.NoClassDefFoundError: > javax/xml/bind/JAXBException > at monitor.Main.main(Main.java:27) > 原因:ClassNotFoundException: > javax.xml.bind.JAXBException > at java.base/BuiltinClassLoader.loadClass > (BuiltinClassLoader.java:582) > at java.base/ClassLoaders$AppClassLoader.loadClass > (ClassLoaders.java:185) > at java.base/ClassLoader.loadClass > (ClassLoader.java:496) > ... 更多

发生了什么?为什么在类路径上的代码中没有正确标准化的 Java API,对此能做些什么?

表 6.1 六个 JEE 模块。描述引用了文档。

模块名称 描述
java.activation 定义了 JavaBeans 激活框架(JAF)API javax.activation
java.corba 定义了 Open Management Group(OMG)CORBA API 的 Java 绑定和 RMI-IIOP API javax.activity, javax.rmi, javax.rmi.CORBA, org.omg.*
java.transaction 定义了 Java 事务 API(JTA)的一个子集,以支持 CORBA 互操作 javax.transaction
java.xml.bind 定义了 JAXB API javax.xml.bind.*
java.xml.ws 定义了 JAX-WS 和 Web 服务元数据 API javax.jws, javax.jws.soap, javax.xml.soap, javax.xml.ws.*
java.xml.ws.annotation 定义了 Common Annotations API 的一个子集,以支持在 Java SE 平台上运行的程序 javax.annotation

6.1.1 为什么 JEE 模块是特殊的?

Java SE 包含一些由认可的规范和独立技术组成的包。这些技术是在 Java 社区进程(JCP)之外开发的,通常因为它们依赖于由其他机构管理的标准。例如,由万维网联盟(W3C)和 Web 超文本应用技术工作组(WHATWG)开发的文档对象模型(DOM),以及简单 XML API(SAX)。如果你感兴趣,你可以在mng.bz/8Ek7找到它们的列表和它们所在的包。其中许多不均衡地落入表 6.1 中列出的 JEE 模块:java.corba、java.xml.bind 和 java.xml.ws。

从历史上看,Java 运行时环境(JRE)附带这些技术的实现,但准备让用户独立于 JRE 进行升级。这可以通过认可的规范覆盖机制(见第 6.5.3 节)来完成。

类似地,应用服务器通常通过提供自己的实现来扩展或升级 CORBA、JAXB 或 JAX-WS API,以及 JavaBeans 激活框架(在 java.activation 中)或 JTA(在 java.transaction 中)。最后,java.xml.ws.annotation 包含了javax.annotation包。它通常被各种 JSR 305 实现扩展,这些实现最著名的是它们与null相关的注解。

在所有这些扩展或替换 Java 附带 API 的案例中,技巧是使用完全相同的包和类名,这样类就会从外部 JAR 加载而不是内置的。在模块系统的术语中,这被称为拆分包:相同的包在不同的模块或模块和类路径之间拆分。

拆分包的终结

在 Java 9+及以后的版本中,拆分包不再有效。我们将在第 7.2 节中详细探讨这个问题——现在只需知道,与 Java 一起分发的包中的类路径上的类实际上是不可见的:

  • 如果 Java 包含一个具有相同完全限定名的类,那么将加载那个类。

  • 如果 Java 内置的包版本不包含所需的类,结果将是前面展示的编译错误或NoClassDefFoundError。而且这无论类是否存在于类路径上都会发生。

这是一个适用于所有模块的所有包的通用机制:将它们在模块和类路径之间拆分使得类路径部分不可见。使六个 JEE 模块特殊的是,与其他模块不同,通常使用拆分包方法来扩展或升级它们。

为了使应用服务器和像 JSR 305 实现这样的库在没有大量配置的情况下工作,做出了一些权衡:对于类路径上的代码,Java 9 和 10 默认不解析 JEE 模块,这意味着它们不会进入模块图,因此不可用(有关未解析的模块,请参阅第 3.4.3 节;有关类路径场景的详细信息,请参阅第 8.2.2 节)。

这对于带有这些 JEE API 自己实现的程序来说效果很好,但对于依赖于 JDK 变体的程序来说就不那么好了。没有进一步的配置,使用这些六个模块中的类型在类路径上的代码将无法编译和运行。

为了消除这种复杂性并正确地将 Java SE 与 JEE 分开,这些模块在 Java 9 中被弃用,在 Java 11 中被移除。随着它们的移除,命令行工具如wsgenxjc也不再随 JDK 一起提供。

6.1.2 手动解析 JEE 模块

如果由于缺少 JEE API 而导致编译或运行时错误,或者如果 JDeps 分析(见附录 D)显示你依赖于 JEE 模块,你会怎么做?有三个答案:

  • 如果你的应用程序在应用程序服务器中运行,它可能提供了这些 API 的实现,在这种情况下,你可能不会遇到运行时错误。根据你的配置,你可能需要修复构建错误,不过——其他两种解决方案都应该能解决这个问题。

  • 选择该 API 的第三方实现,并将其作为依赖项添加到你的项目中。由于 JEE 模块默认不解析,该实现将在编译和运行时无问题地使用。

  • 在 Java 9 和 10 中,如第 3.4.3 节所述,使用 --add-modules 添加平台模块。由于 Java 11 中移除了 JEE 模块,这在那里将不起作用。

该节开头提供的示例尝试使用 java.xml.bind 模块中的 JAXBException。以下是使用 --add-modules 使该模块可用于编译的方法:

$ javac --classpath ${jars} --add-modules java.xml.bind -d ${output-dir} ${source-files}

当代码编译和打包时,你需要再次添加模块以执行:

$ java --classpath ${jars} --add-modules java.xml.bind ${main-class}

如果你依赖于几个 JEE API,添加 java.se.ee 模块可能比添加每个单独的模块更容易。它使得所有六个 EE 模块都可用,这简化了一些事情。(它是如何使它们可用的?请阅读第 11.1.5 节中关于聚合模块的内容。)

重要信息:而不是使用 --add-modules,我强烈建议认真考虑将所需的 API 的第三方实现作为常规项目依赖项添加。第 9.1.4 节讨论了使用命令行选项的缺点,所以在走这条路之前请务必阅读。而且由于 Java 11 中移除了 JEE 模块,迟早你需要第三方实现。

手动添加 JEE 模块的努力仅适用于非模块化代码。一旦它被模块化,EE 模块就不再特殊:你可以像要求任何其他模块一样要求它们,并且它们将像任何其他模块一样被解析——至少,直到它们被移除。

第三方 JEE 实现

比较和讨论各种 JEE API 的第三方实现将远离模块系统,因此在这里我不会这么做。有关替代方案列表,请参阅 JEP 320 (openjdk.java.net/jeps/320) 或 Stack Overflow (mng.bz/0p29)。

6.1.3 添加 JEE 模块的第三方实现

可能你一直在使用受支持的规范覆盖机制来更新规范和独立技术。在这种情况下,你可能想知道在模块时代它发生了什么。正如你可能猜到的,它被移除并由新事物取代了。

编译器和运行时都提供了--upgrade-module-path选项,该选项接受一个目录列表,格式类似于模块路径的格式。当模块系统创建模块图时,它会搜索这些目录以查找工件,并使用它们来替换可升级的模块。六个 JEE 模块始终可升级:

  • java.activation

  • java.corba

  • java.transaction

  • java.xml.bind

  • java.xml.ws

  • java.xml.ws.annotation

JDK 供应商可能会使更多模块可升级。例如,在 Oracle JDK 上,这适用于 java.jnlp。此外,使用jlink链接到映像中的应用程序模块始终可升级——有关更多信息,请参阅第 14.2.1 节。

升级模块路径上的 JAR 文件不必是模块化的。如果它们缺少模块描述符,它们将被转换为自动模块(请参阅第 8.3 节),并且仍然可以替换 Java 模块。

6.2 将其转换为URLClassLoader

在 Java 9 或更高版本上运行项目时,您可能会遇到类似于以下示例中的类转换异常。在这里,JVM 抱怨它无法将jdk.internal.loader.ClassLoaders.AppClassLoader实例转换为URLClassLoader

> 线程 "main" 中发生异常 java.lang.ClassCastException: > java.base/jdk.internal.loader.ClassLoaders$AppClassLoader ①``> 无法转换为 java.base/java.net.URLClassLoader > 在 monitor.Main.getClassPathContent(Main.java:46) > 在 monitor.Main.main(Main.java:28)

getClass()返回的类加载器是AppClassLoader

AppClassLoader不扩展URLClassLoader,因此转换失败。

这是什么新类型,为什么它会破坏代码?让我们找出答案!在这个过程中,您将了解 Java 9 如何更改类加载行为以改进启动性能。因此,即使您的项目没有遇到这个问题,这也是加深 Java 知识的好机会。

6.2.1 应用程序类加载器,过去和现在

在所有 Java 版本中,应用程序类加载器(通常称为系统类加载器)是 JVM 用于运行应用程序的三个类加载器之一。它加载不需要任何特殊权限的 JDK 类以及所有应用程序类(除非应用程序使用自己的类加载器,在这种情况下,以下内容不适用)。

您可以通过调用ClassLoader.getSystemClassLoader()或在一个实例上调用getClass().getClassLoader()来访问应用程序类加载器。这两种方法都承诺给您一个ClassLoader类型的实例。在 Java 8 及之前,应用程序类加载器是URLClassLoader,它是ClassLoader的子类型;由于URLClassLoader提供了一些有用的方法,因此通常将其实例转换为它。您可以在列表 6.1 中看到一个示例。

没有模块作为 JAR 的运行时表示,URLClassLoader 没有办法知道在哪个工件中查找一个类;因此,每当需要加载一个类时,URLClassLoader 会扫描类路径上的每个工件,直到找到它所寻找的内容(参见图 6.1)。这显然非常低效。

列表 6.1 将应用程序类加载器转换为 URLClassLoader

private String getClassPathContent() { URLClassLoader loader = (URLClassLoader) this.getClass().getClassLoader(); ①``return Arrays.stream(loader.getURLs()) .map(URL::toString) .collect(joining(", ")); }

获取应用程序类加载器并将其转换为 URLClassLoader

getURLs 不存在于 ClassLoader 中,这就是进行类型转换的原因。

图片

图 6.1 没有模块(顶部),通过扫描类路径上的所有工件来加载特定的类。有模块(底部),类加载器知道一个包来自哪个模块化 JAR,并直接从那里加载。

现在让我们转向 Java 9+。随着 JAR 在运行时得到了适当的表示,类加载行为可以得到改善:当需要加载一个类时,会识别它所属的包,并使用它来确定一个特定的模块化 JAR。只有那个 JAR 才会被扫描以查找类(参见图 6.1)。这依赖于这样一个假设,即没有两个模块化 JAR 包含同一包中的类型——如果它们确实包含,则称为拆分包,模块系统会抛出一个错误,如第 7.2 节所述。

新类型 AppClassLoader 和其同样新的超类型 BuiltinClassLoader 实现了新的行为,并且从 Java 9 开始,应用程序类加载器是一个 AppClassLoader。这意味着偶尔的 (URLClassLoader) getClass().getClassLoader() 序列将不再成功执行。如果你想了解更多关于 Java 9+ 中类加载器结构和关系的信息,请参阅第 12.4.1 节。

6.2.2 没有使用 URLCLASSLOADER

如果你在一个你依赖的项目中遇到 URLClassLoader 的类型转换,并且没有 Java 9+-兼容的版本可以更新,你除了以下几种方法外别无选择:

  • 为项目打开一个问题,或者贡献一个修复方案。

  • 在本地分叉或修补项目。

  • 等待。

如果迫不得已,你可以切换到另一个库或框架,如果它有在 Java 9+ 上运行良好的版本。

如果你自己的代码进行了类型转换,你可以(并且必须)对此采取措施。不幸的是,你可能不得不放弃一些功能。你很可能将 URLClassLoader 转换为使用其特定的 API,尽管 ClassLoader 已经有所增加,但它不能完全替代 URLClassLoader。不过,看看它可能做你想做的事情。

如果你只是需要查看应用程序启动时使用的类路径,请检查系统属性java.class.path。如果你已经使用URLClassLoader通过将 JAR 文件附加到类路径来动态加载用户提供的代码(例如,作为插件基础设施的一部分),那么你必须找到一种新的方法来做这件事,因为使用 Java 9 及以后版本的应用程序类加载器无法做到这一点。

相反,考虑创建一个新的类加载器——它还有一个额外的好处,就是你将能够丢弃新类,因为它们没有被加载到应用程序类加载器中。如果你至少编译了 Java 9,层可能是一个更好的解决方案(参见第 12.4 节)。

你可能会想调查AppClassLoader并使用它的能力,如果它满足你的需求。一般来说,不要这样做!依赖AppClassLoader很丑陋,因为它是一个私有内部类,所以你必须使用反射来调用它。依赖其公共超类型BuiltinClassLoader也不推荐。

如包名jdk.internal.loader所暗示的,它是一个内部 API;并且因为该包是在 Java 9 中添加的,所以默认情况下不可用,所以你必须使用--add-exports甚至--add-opens(有关详细信息,请参阅第 7.1 节)。这不仅使代码和构建过程复杂化,还使你面临未来 Java 更新中可能出现的兼容性问题——例如,当这些类被重构时。所以除非绝对必要以实现关键功能,否则不要这样做。

6.2.3 寻找麻烦的转换

检查这些转换的代码很简单:通过全文搜索“(URLClassLoader)”应该就能找到,并且很少出现误报(包含括号以仅找到转换)。至于在依赖项中找到它们,我不知道有任何工具能让这个过程变得舒适。我猜可能需要一个构建工具的魔法(以将所有依赖项的源 JAR 文件放在一个地方),命令行魔法(以访问所有.java文件及其文件内容),以及另一个全文搜索才能做到这一点。

6.3 更新运行时图像目录布局

JDK 和 JRE 的目录结构是逐步演化的,在超过 20 年的时间里,它们积累了灰尘。不随时间重新组织它们的一个原因当然是向后兼容性。正如对看似每个细节一样,一些代码依赖于它们的特定布局。两个例子:

  • 一些工具,尤其是 IDE,依赖于rt.jar(构成核心 Java 运行时的类)、tools.jar(工具和实用程序的类)和src.zip(JDK 源代码)的确切位置。

  • 存在着通过推测运行中的 JRE 有一个包含它们的bin子目录来搜索 Java 命令(如javacjarjavadoc)的代码——如果 JRE 是 JDK 安装的一部分,这是真的,因为其中包含一个包含这些命令的bin文件夹和一个相邻的jre文件夹。

然后是模块系统,它打破了使这两个示例成为可能的基本假设:

  • JDK 代码现在已模块化,因此应该以单个模块的形式交付,而不是像 rt.jartools.jar 这样的单体 JAR。

  • 使用模块化的 Java 代码库和像 jlink 这样的工具,可以从任何一组模块创建运行时图像。

从 Java 11 开始,不再有独立的 JRE 软件包。运行程序需要 JDK 或由 jlink 创建的软件包。

由于模块化系统将带来一些破坏性变化,因此决定彻底重新组织运行时图像目录结构。您可以在图 6.2 中看到这些变化的结果。总体而言,新的布局要简单得多:

  • 一个单独的 bin 目录,没有重复的二进制文件

  • 一个单独的 lib 目录

  • 一个单独的目录,conf,用于包含所有配置文件

图 6.2 比较 JDK 8 和 9 的目录结构。新的结构要干净得多。

这些变化的直接后果是您需要更新您的开发工具,因为旧版本可能无法与 9 版本及以后的 JDK 安装一起使用。根据项目情况,可能有必要搜索代码中在 JDK/JRE 文件夹中查找二进制文件、属性文件或其他内容的代码。

系统资源获取的 URL 已更改,例如来自 ClasLoader::getSystemResource,它曾经是这样的形式,其中 ${path} 是类似 java/lang/String.class 的内容:

jar:file:${java-home}/lib/rt.jar!${path}

它现在看起来是这样的:

jrt:/${module}/${path}

所有创建或消耗此类 URL 的 JDK API 都在新的方案上操作,但非 JDK 代码必须更新以生成这些 URL,以适应 Java 9+。

此外,Class::getResource*ClassLoader::getResource* 方法不再读取 JDK 内部资源。相反,要访问模块内部资源,请使用 Module::getResourceAsStream 或创建一个如下所示的 JRT 文件系统:

FileSystem fs = FileSystems.getFileSystem(URI.create("jrt:/")); fs.getPath("java.base", "java/lang/String.class"));

有关如何访问资源的更多详细信息,请参阅第 5.2 节。

6.4 选择、替换和扩展平台

在编译代码或启动 JVM 时,曾经有各种方式来指定构成 JDK 平台的类。您可以选择 JDK 的一个子集,用另一个技术(如 JAXB)替换特定技术,添加几个类,或者选择一个完全不同的平台版本来编译或启动。模块系统使一些这些功能变得过时,并以更现代的方法重新实现了其他功能;而且无论 JPMS 如何,Java 9 版本还移除了一些功能。

如果您依赖于本节中讨论的其中一个或多个功能,您将不得不做一些工作来保持您的项目运行。没有人喜欢被迫重做没有引起任何明显问题的东西,但回顾这些功能(其中大部分我从未使用过),我只能说没有它们,JDK 内部变得多么简单。

6.4.1 紧凑型配置文件不再需要

如第 1.5.5 节所述,模块系统的一个目标允许用户创建仅包含所需模块的运行时镜像。这对于存储空间有限的设备以及虚拟化环境尤其有趣,因为两者都关注较小的运行时镜像。当模块系统不会与 Java 8 一起发布变得明显时,这曾是计划的一部分,紧凑型配置文件被创建为一个临时解决方案。

三个紧凑型配置文件定义了 Java SE 8 API 和 JREs 的子集,仅包含支持这些 API 子集所需的类。在选择了与您的应用程序需求相匹配的配置文件后,您将使用javac选项-profile来针对它进行编译(以确保您保持在所选子集内),然后运行匹配变体的字节码。

在模块系统的作用下,可以使用jlink创建出更加灵活的运行时镜像(参见第 14.1 节),紧凑型配置文件就不再需要了。因此,Java 9+编译器在编译 Java 8 时将仅接受-profile选项。要针对特定模块的选择进行编译,您可以使用--limit-modules选项,如第 5.3.5 节所述。

这些是您需要以获得与三个紧凑型配置文件相同的 API 的模块:

  • 对于紧凑 1 配置文件——java.base、java.logging 和 java.scripting

  • 对于紧凑 2 配置文件——紧凑 1 的类加上 java.rmi、java.sql 和 java.xml

  • 对于紧凑 3 配置文件——紧凑 2 的类加上 java.compiler、java.instrument、java.management、java.naming、java.prefs、java.security.jgss、java.security.sasl、java.sql.rowset 和 java.xml.crypto

而不是依赖于固定的选择,我建议采用不同的方法。使用jlink创建仅包含您需要的平台模块的镜像(参见第 14.1 节);如果您的应用程序及其依赖项完全模块化,甚至可以包含您的应用程序模块(参见第 14.2 节)。

6.4.2 扩展机制已移除

在 Java 9 之前,扩展机制允许我们在不将它们放在类路径上的情况下向 JDK 添加类。它从各种目录中加载它们:从由系统属性java.ext.dirs命名的目录中,从 JRE 中的lib/ext,或从特定平台的全局系统目录中。Java 9 删除了此功能,如果 JRE 目录存在或系统属性已设置,编译器和运行时将退出并显示错误。

替代方案如下:

  • javajavac选项--patch-module将内容注入到模块中(参见第 7.2.4 节)。

  • javajavac 选项 --upgrade-module-path 用另一个模块替换可升级的平台模块(参见第 6.1.3 节)。

  • 扩展的工件可以放置在类路径上。

6.4.3 已移除 ENDORSED STANDARDS OVERRIDE MECHANISM

在 Java 9 之前,endorsed standards override mechanism 允许我们用自定义实现替换某些 API。它从系统属性 java.endorsed.dirs 或 JRE 中的 lib/endorsed 目录命名的目录中加载它们。Java 9 移除了这个特性,如果 JRE 目录存在或系统属性被设置,编译器和运行时会因为错误而退出。替代方案与扩展机制(第 6.4.2 节)相同。

6.4.4 已移除某些 BOOT CLASS PATH 选项

已移除 -Xbootclasspath-Xbootclasspath/p 选项。请使用以下选项代替:

  • javac 选项 --system 指定系统模块的替代来源。

  • javac 选项 --release 指定了一个替代的平台版本。

  • javajavac 选项 --patch-module 将内容注入初始模块图中的模块。

6.4.5 不再为 Java 5 进行编译

Java 编译器可以处理来自各种 Java 语言版本的源代码(例如,使用 -source 指定的 Java 7)并且可以同样为各种 JVM 版本生成字节码(例如,为 Java 8,使用 -target 指定)。Java 以前遵循“一加三回”的政策,这意味着 javac 9 支持 Java 9(显然)以及 8、7 和 6。

javac 8 中设置 -source 5-target 5 会引发弃用警告,并且不再由 javac 9 支持。类似地,在 Java 9 中设置 -source 6-target 6 也会产生相同的警告。现在每六个月就会有一个新版本发布,这项政策不再适用。Java 10、11 和 12 可以很好地编译 Java 6。

注意:编译器可以识别和处理所有之前 JDK 的字节码——它只是不再为 6 版本之前的版本生成字节码。

6.4.6 已移除 JRE 版本选择

在 Java 9 之前,您可以在 java(或相应的清单条目)上使用 -version:N 选项来使用版本 N 的 JRE 启动应用程序。在 Java 9 中,该功能已被移除:Java 启动器会因为命令行选项而退出并显示错误,对于清单条目会打印警告,而其他方面则忽略它。如果您一直依赖这个功能,以下是 Java 文档对该功能的说明:

现代应用程序通常通过 Java Web Start (JNLP)、本地操作系统打包系统或活动安装程序进行部署。这些技术有自己的方法来管理所需的 JRE,通过查找或下载并更新所需的 JRE,按需进行。这使得启动器的启动时 JRE 版本选择变得过时。

看起来文档认为使用-version:N的应用程序不够现代——这真是个无礼的说法。开个玩笑,如果你的应用程序依赖于这个特性,你除了让它在没有-version:N的情况下工作外别无选择;例如,通过将其与它运行得最好的 JRE 捆绑在一起。

6.5 导致重大失败的小事

除了模块系统带来的更大挑战外,还有一些变化,通常与 JPMS 无关,虽然规模较小,但同样会带来麻烦:

  • 版本字符串的新格式

  • 移除一些 JDK 和 JRE 工具

  • 单下划线不再是有效的标识符

  • Java 网络启动协议(JNLP)语法更新

  • JVM 选项的移除

我不想让你等太久,但我也不想遗漏那些会阻止你的迁移的事情。所以我会快速地解决这些问题。

6.5.1 新版本字符串

经过 20 多年的发展,Java 终于正式接受它不再处于 1.x 版本。是时候了。从现在起,系统属性java.version及其同族java.runtime.versionjava.vm.versionjava.specification.versionjava.vm.specification.version不再以1.x开头,而是以x开头。同样,java -version返回x,所以在 Java 9 中你得到9.something

版本字符串格式

新版本字符串的确切格式仍在变动中。在 Java 9 中,你得到9.${MINOR}.${SECURITY}.${PATCH},其中${SECURITY}有一个特性,即当发布新的次要版本时,它不会重置为零——你可以通过查看这个数字来判断哪个版本包含更多的安全补丁。

在 Java 10 及以后版本,你得到${FEATURE}.${INTERIM}.${UPDATE}.${PATCH},其中${FEATURE}10开始,每六个月随着每个功能发布而增加。${INTERIM}的作用与${MINOR}类似,但由于新计划中没有计划发布次要版本,它被假定为始终为0

一个不幸的副作用是,版本嗅探代码可能会突然停止报告正确的结果,这可能导致程序出现奇怪的行为。全文搜索相关的系统属性应该能找到这样的代码。

至于更新它,如果你愿意将项目的需求提升到 Java 9+,你可以避免系统属性的探测和解析,而是使用新的Runtime.Version类型,这要容易得多:

``Version version = Runtime.version(); // 在 Java 10 及以后版本,使用 version.feature() switch (version.major()) { case 9: System.out.println("Modularity"); break; case 10: System.out.println("Local-Variable Type Inference"); break; case 11: System.out.println("Pattern Matching (we hope)"); break; } $`

6.5.2 工具外流

JDK 积累了很多工具,随着时间的推移,一些变得不再需要或被其他工具取代。一些被包含在 Java 9 的春季大扫除中:

  • JavaDB 不再包含。它曾是 Apache Derby DB,您可以从db.apache.org下载。

  • VisualVM 不再与 JDK 捆绑,并在github.com/oracle/visualvm成为独立项目。

  • hprof代理库已被移除。替代其功能的工具是jcmdjmap和 Java 飞行记录器。

  • jhat堆可视化器已被移除。

  • java-rmi.exejava-rmi.cgi启动器已被移除。作为替代,请使用 servlet 通过 HTTP 代理 RMI。

  • native2ascii工具用于将基于 UTF-8 的属性资源包转换为 ISO-8859-1。然而,Java 9+支持基于 UTF-8 的包,因此该工具变得多余并被移除。

此外,所有与 JEE 相关的命令行工具,如wsgenxjc,在 Java 11 中也不再可用,因为它们与其包含的模块一起被移除(有关 JEE 模块的详细信息,请参阅第 6.1 节)。

6.5.3 最小的事情

下面可能是导致您的 Java 9 构建失败的最小问题:Java 8 已弃用单下划线_作为标识符,在 Java 9 中使用它作为标识符时会导致编译错误。这样做是为了回收下划线作为可能的关键字;未来的 Java 版本将赋予它特殊含义。

另一个问题:Thread.stop(Throwable)现在会抛出UnsupportedOperationException。其他stop重载仍然可以工作,但使用它们被高度不建议。

JNLP 语法已更新,以符合 XML 规范,并“为了消除不一致性,简化代码维护,并增强安全性”。我不会列出更改——您可以在mng.bz/dnfM找到它们。

每个 Java 版本都会移除一些已弃用的 JVM 选项,Java 9 也不例外。它特别关注垃圾回收,其中一些组合不再受支持(DefNew + CMSParNew + SerialOldIncremental CMS),一些配置被移除(-Xincgc-XX:+CMSIncrementalMode-XX:+UseCMSCompactAtFullCollection-XX:+CMSFullGCsBeforeCompaction-XX:+UseCMSCollectionPassing)或已弃用(-XX:+UseParNewGC)。Java 10 随后移除了-Xoss-Xsqnopause-Xoptimize-Xboundthreads-Xusealtsigs

6.5.4 JAVA 9,10 和 11 中的新弃用功能

最后,这里是一个非详尽的列表,列出了 Java 9,10 和 11 中弃用的功能:

  • java.applet包中的 Applet API,以及appletviewer工具和 Java 浏览器插件

  • Java Web Start,JNLP,以及javaws工具

  • 并发标记清除(Concurrent Mark Sweep,CMS)垃圾回收器

  • 通过-Xprof激活的 HotSpot FlatProfiler

  • policytool安全工具

Java 10 和 11 已经实施了一些弃用:

  • Java 10 移除了 FlatProfiler 和policytool

  • Java 11 移除了 Applet API 和 Web Start。

更多信息,以及详细信息和建议的替代方案,请查看发布说明(Java 9:mng.bz/GLkN;Java 10:mng.bz/zLeV)和标记为删除的已弃用代码列表(Java 9:mng.bz/YX9e;Java 10:mng.bz/qRoU)。

摘要

  • JEE 模块在 Java 9 中被弃用,在 Java 11 中被移除。您需要尽早找到一个满足您要求的第三方依赖项。

  • 在 Java 9 和 10 中,默认情况下不会解析这些模块,这可能导致编译时和运行时错误。要修复此问题,您可以使用实现相同 API 的第三方依赖项,或者使用 --add-modules 使 JEE 模块可用。

  • 应用程序类加载器不再是 URLClassLoader 类型,因此像 (URLClassLoader) getClass().getClassLoader() 这样的代码将失败。解决方案是仅依赖 ClassLoader API,即使这意味着必须删除某些功能(推荐);创建一个动态加载新代码的层(推荐);或者侵入类加载器内部并使用 BuiltinClassLoader 或甚至 AppClassLoader(不推荐)。

  • 运行时镜像的目录结构已更改,您可能需要更新您的工具,特别是 IDE,以与 Java 9 及以后的版本一起工作。也需要更新在 JDK/JRE 目录中四处游荡的代码或为系统资源手工创建 URL。

  • 移除了一些修改构成平台的类集合的机制。对于大多数机制,模块系统提供了替代方案:

  • 而不是使用紧凑配置文件,请使用 jlink 创建运行时镜像,并使用 --limit-modules 配置编译。

  • 而不是使用扩展机制或认可标准机制,请使用 --patch-module--upgrade-module-path 或类路径。

  • 而不是使用 -Xbootclasspath 选项,请使用 --system--release--patch-module

  • 现在不再可能为 Java 5 编译,也不能使用 -version:N 选项以 Java 版本 N 启动应用程序。

  • Java 的命令行工具和系统属性 java.version 报告的版本为 9.${MINOR}.${SECURITY}.${PATCH}(在 Java 9 中)或为 ${FEATURE}.${INTERIM}.${UPDATE}.${PATCH}(在 Java 10 及以后),这意味着在 Java X 中它们以 X 开头而不是 1.x。一个新的 API Runtime.Version 使得解析该属性变得不再必要。

  • 以下工具已被移除:

  • 在 Java 9 中:JavaDB、VisualVM、hprofjhatjava-rmi.exejava-rmi.cginative2ascii

  • 在 Java 10 中:policytool

  • 在 Java 11 中:idljorbdschemagenservertooltnameservwsgenwsimportxjc

  • 单下划线不再是有效的标识符。

  • JNLP 语法已更新以符合 XML 规范,因此您可能需要更新您的 JNLP 文件。

  • 每个 Java 版本都会删除已弃用的 JVM 命令行选项,这可能会破坏您的某些脚本。

  • Java 9 废弃了 Applet 技术和 Java Web Start,Java 11 则移除了它们。

7

在 Java 9 或更高版本上运行时的反复挑战

本章涵盖

  • 区分标准化的、受支持的以及内部的 JDK API

  • 使用 JDeps 查找对 JDK 内部 API 的依赖

  • 编译和运行依赖于内部 API 的代码

  • 为什么分割包会使类不可见

  • 修复分割包

第六章讨论了在将项目迁移到 Java 9+ 时可能会遇到的一些问题。一旦完成这些,除非你选择使用 Java 9 之前的依赖项,否则你将不会再次遇到这些问题。本章探讨了两个你可能仍然需要解决的问题:

  • 依赖于内部 API 会导致编译错误(第 7.1 节)。这适用于 JDK 内部 API,例如来自 sun.* 包的类,也适用于你依赖的库或框架内部的代码。

  • 在工件之间分割包会导致编译时和运行时错误(第 7.2 节)。同样,这可能会发生在你的代码和 JDK 模块之间,以及任何其他两个工件之间:例如,你的代码和第三方依赖项。

就像我们之前讨论的问题一样,当你的项目需要在 Java 9+ 上运行时,你也必须解决这两个问题,但这并不仅限于此:即使迁移之后,在编写代码或引入新依赖项时,你偶尔也会遇到这些问题。无论涉及的是哪种类型的模块,对模块内部和分割包的依赖都会引起麻烦。你遇到这些问题的可能性与使用类路径代码和平台模块(迁移场景)一样,与使用应用程序模块(你已经在 Java 9 或更高版本上运行并使用模块的场景)一样。

本章展示了如何打破模块的封装以及如何修复包分割,无论这些情况发生在何种背景下。与第六章一起,这为你准备在迁移过程中可能出现的几乎所有问题。

关于类路径

如果你在第六章中没有阅读到注意事项,我想在这里重复一遍:

  • 类路径仍然完全有效,在迁移到 Java 9+ 的过程中,你将继续使用它而不是模块路径。

  • 即使如此,模块系统仍然在发挥作用,尤其是在强封装方面。

  • 类路径上的代码将自动读取大多数模块(但不是全部;请参阅第 6.1 节),因此它们在编译时或运行时无需额外配置即可可用。

7.1 内部 API 的封装

模块系统最大的卖点之一是强封装。正如第 3.3 节深入解释的那样,我们终于可以确保只有受支持的 API 对外部代码可访问,同时隐藏实现细节。

内部 API 的不可访问性适用于与 JDK 一起分发的平台模块,其中只有 java.*javax.* 包得到完全支持。例如,当您尝试编译一个在现在已封装的包 com.sun.java.swing.plaf.nimbus 中对 NimbusLookAndFeel 有静态依赖(意味着导入或完全限定的类名,而不是反射访问)的类时,就会发生这种情况:

> 错误:包 com.sun.java.swing.plaf.nimbus 不可见 > 导入 com.sun.java.swing.plaf.nimbus.NimbusLookAndFeel; > ^ > (包 com.sun.java.swing.plaf.nimbus 已在模块 java.desktop 中声明,但没有导出它) > 1 个错误

令人惊讶的是,许多库和框架,甚至应用代码(通常是更重要的部分),都使用了来自 sun.*com.sun.* 包的类,其中大多数在 Java 9 及以后版本中无法访问。在本节中,我将向您展示如何找到这样的依赖项以及如何处理它们。

但为什么要讨论这个呢?如果内部 API 不可访问,那就没有什么可讨论的,对吧?好吧,是时候让您知道一些事情了:它们并不是完全不可访问。在运行时,一切都将继续工作,直到下一个主要的 Java 版本(尽管您可能会收到一些不期望的警告消息);并且通过控制命令行,任何包都可以在编译时被访问。(我想我听到了一声松了一口气——那是你吗?)

第 9.1.4 节讨论了使用命令行选项配置模块系统的更广泛影响;在这里,我们专注于解决当前问题。我们将区分静态和反射以及编译时和运行时访问(第 7.1.3 和 7.1.4 节),因为存在一些关键差异。但在我们深入探讨之前,您需要确切了解内部 API 的构成以及 Java 依赖分析工具(JDeps)如何帮助您在项目和依赖项中找到有问题的代码。

提示:如果您不确定反射是如何工作的,请参阅附录 B,其中提供了简要介绍。此外,在本节中,我们专注于对 JDK 的反射访问;对于模块化世界中反射的更一般性观点,请参阅第十二章。

当您完成本节后,您将能够轻松地打开模块以利用维护者不希望您使用的 API。更重要的是,您将能够评估该策略的利弊,以便您可以在是否值得走这条路方面做出明智的决定。

7.1.1 内部 API 的显微镜下

哪些 API 是内部的?一般来说,每个既不是公共的也不是在导出包中的类——这个规则完全适用于应用模块。至于 JDK,答案并不那么简单。在标准化、受支持和内部 API 的历史复杂情况之上,Java 9+ 通过对某些 API 进行特殊处理并删除其他 API,增加了一层复杂性。让我们一步一步地解开这个情况。

JDK API 的三种类型:标准化、支持和内部

从历史的角度来看,Java 运行时环境(JRE)有三种类型的 API:

  • java.*javax.* 包中找到的公共类是标准化的,并且在所有 JRE 中完全支持。仅使用这些类可以编写最可移植的代码。

  • 一些 com.sun.*jdk.* 包以及它们包含的一些类被标记为 jdk.Exported 注解,在这种情况下,它们由 Oracle 支持,但并不一定存在于非 Oracle JRE 中。这些绑定将代码绑定到特定的 JRE。

  • 大多数 com.sun.* 包以及所有 sun.* 包以及所有非公共类都是内部的,并且可以在不同的版本和 JRE 之间更改。依赖这些是最不稳定的,因为这样的代码理论上可能在任何小版本更新中停止工作。

在 Java 9+ 和模块系统的作用下,这三种类型的 API——标准化、支持和内部——仍然存在。一个模块是否导出包是一个关键指标,但显然不足以划分三个类别。另一个指标是模块的名称。如您在 3.1.4 节中回忆的那样,平台模块分为由 Java 规范定义的(以 java.* 前缀)和 JDK 特定的(以 jdk.* 前缀):

  • 由 java.* 模块导出的包中找到的公共类(这些可以是 java.*javax.* 包)是标准化的。

  • 由 jdk.* 模块导出的包中找到的公共类不是标准化的,但在 Oracle 和 OpenJDK 的 JDK 上得到支持。

  • 所有其他类都是内部 API。

从 Java 8 到 Java 9+,哪些具体的类被标准化、支持或内部化几乎没有变化。因此,com.sun.* 中的许多类和 sun.* 中的所有类都像之前一样是内部 API。区别在于模块系统将这种约定转化为一个积极执行的区分。图 7.1 展示了内部 API 未导出的分割点。

图片

图 7.1 在 Java 8(左侧),包名和很少看到的 @jdk.Exported 注解决定了 API 是否标准化、支持或内部化。从 Java 9 开始(右侧),模块名和导出指令扮演了这一角色。

jdk.* 模块不是标准化的只是一个约定,模块系统对此一无所知。因此,尽管依赖它们的导出 API 可能并不明智,但 JPMS 不会封装它们,我们讨论的任何命令行选项都不是必要的。在这里,当我提到内部 API 时,我指的是模块系统使其不可访问的类,因为类不是公共的或包没有导出。

对于臭名昭著的 sun.misc.Unsafe

如你所想,最初的想法是封装 Java 9 之前的所有内部 API。当 2015 年更大的 Java 社区意识到这一点时,引起了骚动。尽管平均 Java 开发者可能只偶尔使用内部 API,但许多最知名的库和框架经常这样做,它们的一些最关键功能依赖于它。

这种情况的典型代表是sun.misc.Unsafe类,根据其包名,显然是内部的。它提供了 Java 中不常见的功能,并且正如类名所暗示的,是不安全的。(说到表达性的名称!)也许最好的例子是直接内存访问,这是 JDK 偶尔必须执行的操作。

但它超出了 JDK 的范围。由于Unsafe易于使用,一些库,尤其是那些专注于高性能的库,开始使用它;随着时间的推移,生态系统中很大一部分直接或间接地依赖于它。这种类和其他类似类被封装的前景导致了社区的强烈反响。

随后,Project Jigsaw 团队决定允许更平滑的迁移路径。对现有内部 API 及其在 JDK 之外的使用的调查产生了以下结果:

  • 大多数受影响的 API 很少或从未使用。

  • 一些受影响的 API 偶尔使用,但在 Java 9 之前已经存在标准化替代方案。一个典型的例子是sun.misc中的BASE64Encoder/BASE64Decoder对,它可以被java.util.Base64替代。

  • 一些受影响的 API 偶尔使用,但提供关键功能,没有替代方案。这就是sun.misc.Unsafe可以找到的地方。

决定封装前两种类型,但至少在下一个主要 Java 版本中保持第三种类型可访问。但从各自的模块导出它们会令人困惑,因为这会使它们看起来像是受支持或甚至标准化的 API,而它们绝对不是。还有什么比创建一个合适命名的模块更能说明这一点呢?

在 Java 9 之前没有替代方案的临界 API 由模块 jdk.unsupported 导出。正如其名称所暗示的,它是 JDK 特定的(仅保证在 Oracle JDK 和 OpenJDK 上存在)且不受支持(内容可能在下一个版本中更改)。在 Java 9 到 11 中,它包含以下类:

  • 来自sun.misc的:SignalSignalHandlerUnsafe

  • 来自sun.reflect的:ReflectionReflectionFactory

  • 来自com.sun.nio.file的:ExtendedCopyOptionExtendedOpenOptionExtendedWatchEventModifierSensitivityWatchEventModifier

如果你的代码或依赖项依赖于这些类(第 7.1.2 节展示了如何找出),那么尽管在 Java 9 之前它们是内部 API,但你目前不需要做任何事情来继续使用它们。随着它们功能的标准替代方案(如变量句柄,它替代了Unsafe的一部分)的发布,它们将被封装。我强烈建议你仔细检查对这些类的使用,并为它们最终消失做好准备。

已移除的 API

尽管一些内部 API 在接下来的几年内仍然可用,大多数已经被封装,但有一些遭遇了更严厉的命运,被移除或重命名。这打破了使用它们的代码,超出了任何过渡期和命令行选项的范围。以下是它们:

  • sun.miscsun.reflect 中不属于 jdk.unsupported 部分的所有内容:例如,sun.misc.BASE64Encodersun.misc.BASE64Decodersun.misc.Cleanersun.misc.Service

  • com.sun.image.codec.jpegsun.awt.image.codec

  • com.apple.concurrent

  • com.sun.security.auth.callback.DialogCallbackHandler

  • java.util.logging.LogManagerjava.util.jar.Pack200.Packerjava.util.jar.Pack200.Unpacker 上的 addPropertyChangeListenerremovePropertyChangeListener 方法(在 Java 8 中被弃用)

  • 参数或返回类型来自 java.awt.peerjava.awt.dnd.peer 的方法(这些包从未标准化,并且在 Java 9 及以后的版本中是内部的)

大多数这些类和包都有替代方案,你可以使用 JDeps 来了解它们。

7.1.2 使用 JDeps 分析依赖关系

既然我们已经讨论了标准化、受支持和内部 API 之间的区别以及 jdk.unsupported 的特殊情况,现在是时候将这方面的知识应用到实际项目中。为了与 Java 9+兼容,你需要找出它所依赖的内部 API。

仅通过查看项目的代码库是不够的——如果它所依赖的库和框架出现问题,你就会遇到麻烦,因此你需要分析它们。这听起来像是一项可怕的手动工作,需要筛选大量代码以查找对这些 API 的引用。幸运的是,没有必要这样做。

自 Java 8 以来,JDK 附带命令行 Java 依赖分析工具(JDeps)。它分析 Java 字节码,即 .class 文件和 JARs,并记录类之间所有静态声明的依赖关系,然后可以进行过滤或聚合。这是一个用于可视化探索我一直在谈论的各种依赖图的 neat 工具。附录 D 提供了 JDeps 入门指南;如果你从未使用过 JDeps,你可能想阅读它。尽管如此,理解这一节并不是严格必要的。

在内部 API 的背景下,有一个特性特别有趣:选项 --jdk-internals 使得 JDeps 列出所有引用的 JARs 所依赖的内部 API,包括由 jdk.unsupported 导出的 API。输出包含以下内容:

  • 分析的 JAR 文件和包含问题 API 的模块

  • 涉及的具体类

  • 这种依赖关系成为问题的原因

我将使用 JDeps 对 Scaffold Hunter 进行分析,Scaffold Hunter 是“一个基于 Java 的开源工具,用于数据集的可视化分析。”以下命令分析内部依赖关系:

$ jdeps --jdk-internals ①``-R --class-path 'libs/*' ②``scaffold-hunter-2.6.3.jar

告知 JDeps 分析内部 API 的使用

递归分析所有依赖

从应用程序 JAR 文件开始

输出以提及拆分包开始,我们将在第 7.2 节中探讨。然后报告有问题的依赖关系,其中一些将在下面展示。输出详细,提供了检查相关代码或分别在项目中打开问题的所有所需信息:

> batik-codec.jar -> JDK 已删除的内部 API ①``> JPEGImageWriter -> com.sun.image.codec.jpeg.JPEGCodec ②``> JDK 内部 API (JDK 已删除的内部 API) > JPEGImageWriter -> com.sun.image.codec.jpeg.JPEGEncodeParam > JDK 内部 API (JDK 已删除的内部 API) > JPEGImageWriter -> com.sun.image.codec.jpeg.JPEGImageEncoder > JDK 内部 API (JDK 已删除的内部 API) # [...] > guava-18.0.jar -> jdk.unsupported ④``> Striped64 -> sun.misc.Unsafe > JDK 内部 API (jdk.unsupported) > Striped64$1 -> sun.misc.Unsafe > JDK 内部 API (jdk.unsupported) > Striped64$Cell -> sun.misc.Unsafe > JDK 内部 API (jdk.unsupported) # [...] > scaffold-hunter-2.6.3.jar -> java.desktop > SteppedComboBox -> com.sun.java.swing.plaf.windows.WindowsComboBoxUI > JDK 内部 API (java.desktop) > SteppedComboBox$1 -> com.sun.java.swing.plaf.windows.WindowsComboBoxUI > JDK 内部 API (java.desktop)

batik-codec 依赖于已删除的 API。

JPEGImageWriter(我已截断包名)依赖于几个不同的类。

说明问题是什么

Guava 依赖于 jdk.unsupported。

Striped64 依赖于 sun.misc.Unsafe,以及其两个内部类也是如此。

Scaffold Hunter 依赖于 java.desktop 内部的类。

JDeps 以以下注释结束,提供了有关一些发现问题的有用背景信息和建议:

> 警告:JDK 内部 API 不受支持,且仅限于 JDK 实现的私有 API,可能会被移除或以不兼容的方式更改,从而破坏您的应用程序。请修改您的代码以消除对任何 JDK 内部 API 的依赖。有关 JDK 内部 API 替换的最新更新,请查看:> https://wiki.openjdk.java.net/display/JDK8/Java+Dependency+Analysis+Tool > > JDK 内部 API 建议替换 > ---------------- --------------------- > com.sun.image.codec.jpeg.JPEGCodec 使用 javax.imageio @since 1.4 > com.sun.image.codec.jpeg.JPEGDecodeParam 使用 javax.imageio @since 1.4 > com.sun.image.codec.jpeg.JPEGEncodeParam 使用 javax.imageio @since 1.4 > com.sun.image.codec.jpeg.JPEGImageDecoder 使用 javax.imageio @since 1.4 > com.sun.image.codec.jpeg.JPEGImageEncoder 使用 javax.imageio @since 1.4 > com.sun.image.codec.jpeg.JPEGQTable 使用 javax.imageio @since 1.4 > com.sun.image.codec.jpeg.TruncatedFileException > 使用 javax.imageio @since 1.4 > sun.misc.Unsafe 查看 JEP 260 > sun.reflect.ReflectionFactory 查看 JEP 260

7.1.3 编译针对内部 API

强封装的目的在于模块系统默认不允许您使用内部 API。这会影响从 Java 9 开始的任何 Java 版本的编译和运行时行为。在这里,我们讨论编译——第 7.1.4 节讨论了运行时行为。最初,强封装主要与平台模块相关,但随着您的依赖项模块化,您将看到它们代码周围存在相同的障碍。

然而,有时您可能处于必须使用非导出包中的公共类来解决当前问题的境地。幸运的是,即使在模块系统存在的情况下,这也是可能的。(我在说显而易见的事情,但我想指出,这仅是您代码的问题,因为您的依赖项已经编译——它们仍将受到强封装的影响,但仅限于运行时。)

导出到模块

选项 --add-exports ${module}/${package}=${reading-module},在 javajavac 命令中可用,将 ${module} 的 ${package} 导出给 \({reading-module}。因此,\){reading-module} 中的代码可以访问 ${package} 中的所有公共类型,但其他模块则不能。

当将 ${reading-module} 设置为 ALL-UNNAMED 时,类路径上的所有代码都可以访问该包。在迁移到 Java 9+ 时,您将始终使用该占位符——只有当您自己的代码在模块中运行时,您才能将导出限制为特定模块。

到目前为止,导出始终是无目标的,因此能够导出到特定模块是一个新的方面。此功能也适用于模块描述符,如第 11.3 节所述。此外,我在 ALL-UNNAMED 的含义上有点含糊。它与未命名的模块相关,第 8.2 节详细讨论了这一点,但就目前而言,“类路径上的所有代码”是一个很好的近似。

让我们回到导致以下编译错误的代码:

> 错误:包 com.sun.java.swing.plaf.nimbus 不可见 > 导入 com.sun.java.swing.plaf.nimbus.NimbusLookAndFeel; > ^ > (包 com.sun.java.swing.plaf.nimbus 在模块 java.desktop 中声明,但没有导出它) > 1 个错误

在这里,某个类(由于它与问题无关,我已经从输出中省略)从封装的包com.sun.java.swing.plaf.nimbus中导入了NimbusLookAndFeel。注意错误信息如何指出具体问题,包括包含该类的模块。

这在 Java 9 中显然不能直接工作,但如果您想继续使用它呢?那么您可能是在犯一个错误,因为javax.swing.plaf.nimbus中有一个标准化的替代方案;在 Java 10 中,只有这个版本保留了下来,因为内部版本已被移除。但为了这个示例,让我们假设您仍然想使用内部版本——也许是为了与无法更改的遗留代码进行交互。

要成功编译针对com.sun.java.swing.plaf.nimbus.NimbusLookAndFeel的代码,您只需在编译器命令中添加--add-exports java.desktop/com.sun.java.swing.plaf.nimbus=ALL-UNNAMED即可。如果您手动这样做,它将类似于以下内容(所有占位符都必须替换为具体值):

$ javac --add-exports java.desktop/com.sun.java.swing.plaf.nimbus=ALL-UNNAMED --class-path ${dependencies} -d ${target-folder} ${source-files}

使用构建工具时,您需要在构建描述符中放置该选项的某个位置。请查阅您工具的文档以了解如何为编译器添加命令行选项。

这样,代码可以愉快地编译针对封装类。但重要的是要意识到,您只是将问题推迟到了运行时!在命令行上添加此导出只改变了一次编译——没有将任何信息放入生成的字节码中,以允许该类在执行期间访问该包。您仍然需要弄清楚如何在运行时使其工作。

7.1.4 对内部 API 的执行

我提到,至少在 Java 9、10 和 11 中,JDK 内部依赖项在运行时仍然可用。结合我之前告诉您的所有内容,这应该有点令人惊讶。在整个书中,我一直强调强封装的好处,并说它和可见性修饰符一样重要——那么为什么在运行时不强制执行呢?

就像许多其他 Java 怪癖一样,这个怪癖源于对向后兼容性的承诺:强封装 JDK 内部会破坏许多应用程序。即使只是过时的 Nimbus 外观和感觉的使用,应用程序也会崩溃。有多少最终用户或 IT 部门会在遗留应用程序停止工作后安装 Java 9+?有多少团队会在很少用户有 Java 9+可用的情况下针对 Java 9+进行开发?

为了确保模块系统不会将生态系统分成“Java 9 之前”和“Java 9 之后”,决定授予类路径上的代码对 JDK 内部 API 的非法访问,直到至少 Java 11。每个这些方面都是经过深思熟虑的:

  • 类路径上的代码 … ——从模块路径运行代码表示它已为模块系统做好准备,在这种情况下,没有必要做出例外。因此,它仅限于类路径代码。

  • …到 JDK-internal APIs ——从兼容性的角度来看,没有理由授予应用程序模块访问权限,因为它们在 Java 9 之前不存在。因此,异常仅限于平台模块。

  • …至少 Java 11——如果异常是永久的,那么更新麻烦代码的动力将会大大降低。

正如你在第六章中看到的,这并不能解决应用程序在 Java 9、10 或 11 上执行时可能遇到的所有问题,但它更有可能成功运行。

管理对 JDK 内部 API 的全面非法访问

为了成功迁移,了解对 JDK 内部 API 的全面非法访问背后的细节很重要;但探索它会使你对模块系统的心理模型更加复杂。保持大局意识有助于:强封装在编译时和运行时禁止访问所有内部 API。此外,还建立了一个大异常,其具体设计是由兼容性考虑驱动的。然而,随着时间的推移,它将消失,使我们回到更加明确的行性行为。

当允许类路径代码访问 JDK 内部 API 时,会区分静态依赖于它们的代码和通过反射访问它们的代码:

  • 反射访问会产生警告。由于静态分析无法精确识别所有此类调用,因此执行是唯一可靠报告它们的时间。

  • 静态访问不会产生警告。它可以在编译期间或使用 JDeps 时轻松发现。由于静态访问的普遍存在,它也是一个性能敏感的区域,检查和偶尔发出日志消息是有问题的。

可以使用命令行选项配置确切的行为。java 选项 --illegal-access=${value} 管理如何处理对 JDK 内部 API 的非法访问,其中 ${value} 是以下之一:

  • permit — 允许对类路径上的代码访问所有 JDK 内部 API。对于反射访问,每个包的第一次访问都会发出一个警告。

  • warn — 与 permit 的行为类似,但每次反射访问都会发出警告。

  • debug — 与 warn 的行为类似,但每个警告都包含堆栈跟踪。

  • deny — 对于那些相信强封装的人来说:默认情况下禁止所有非法访问。

在 Java 9 到 11 中,permit 是默认值。在某些未来的 Java 版本中,deny 将成为默认值;并且在某些时候,整个选项可能会消失,但我确信这还需要几年时间。

看起来,一旦你通过使用 Java 8 版本或向 Java 9+版本添加所需选项,将麻烦代码通过编译器,Java 9+运行时会不情愿地执行它。要看到--illegal-access的实际效果,现在是时候最终查看那个玩弄内部 Nimbus 外观和感觉的类了:

import com.sun.java.swing.plaf.nimbus.NimbusLookAndFeel; public class Nimbus { public static void main(String[] args) throws Exception { NimbusLookAndFeel nimbus = new NimbusLookAndFeel(); System.out.println("Static access to " + nimbus); Object nimbusByReflection = Class .forName("com.sun.java.swing.plaf.nimbus.NimbusLookAndFeel") .getConstructor() .newInstance(); System.out.println("Reflective access to " + nimbusByReflection); } }

它并没有做任何特别有用的事情,但它显然试图以静态和反射的方式访问NimbusLookAndFeel。要编译它,你需要使用前一个章节中描述的--add-exports。运行它则更简单:

$ java --class-path ${class} j9ms.internal.Nimbus > 静态访问到 "Nimbus Look and Feel" > 警告:发生了非法的反射访问操作 > 警告:j9ms.internal.Nimbus 的非法反射访问 > (文件:...) 到构造函数 NimbusLookAndFeel() > 警告:请考虑向 j9ms.internal.Nimbus 的维护者报告此问题 > 警告:使用--illegal-access=warn 启用进一步非法反射访问操作的警告 > 警告:在未来的版本中,所有非法访问操作都将被拒绝 > 反射访问到 "Nimbus Look and Feel"

你可以观察到默认选项--illegal-access=permit定义的行为:静态访问成功,无需注释,但反射访问会导致一个冗长的警告。将选项设置为warn不会改变任何东西,因为只有一个访问,而debug会为麻烦的调用添加堆栈跟踪。使用deny,你会得到与 3.3.3 节中测试可访问性要求时看到相同的消息:

$ java --class-path ${class} --illegal-access=deny j9ms.internal.Nimbus > 线程"main"中的异常 java.lang.IllegalAccessError: > 类 j9ms.internal.Nimbus(在未命名的模块@0x6bc168e5 中)无法访问类 com.sun.java.swing.plaf.nimbus.NimbusLookAndFeel(在模块 java.desktop 中),因为模块 java.desktop 没有将 com.sun.java.swing.plaf.nimbus 导出到未命名的模块@0x6bc168e5

需要讨论的一个细节是:在 Java 9 中引入的非法访问 JDK 内部结构会发生什么?因为--illegal-access选项是为了简化迁移而引入的,如果它让你有几年时间开始依赖新的内部 API,反而使得最终过渡变得更加困难,那就太遗憾了!这确实是一个风险!

重要信息 为了最小化依赖于新的 JDK 内部 API 的风险,--illegal-access不适用于 Java 9 中引入的包。这缩小了项目可能意外依赖的新 API 集,这些 API 被添加到 Java 9 之前存在的包中。

为了兼容性所做的这些事情——我告诉你这会变得更复杂。而且我还没有说完,因为我们还可以更具体地管理非法访问(参见下一节)。7.1.5 节中的表 7.1 比较了不同的变体。

管理对选定 API 的具体非法访问

illegal-access选项有三个核心特性:

  • 它以整体方式管理非法访问。

  • 这是一个过渡选项,最终将消失。

  • 它通过警告困扰你。

当它消失时会发生什么?强封装是否无法克服?答案是:不会。总会有一些边缘情况需要访问平台和应用模块的内部 API,因此应该存在某种机制(可能不是特别舒适的一种)来实现这一点。再次强调,我们转向命令行选项。

重要信息 如我在 7.1.3 节中讨论编译期间内部 API 时提到的,--add-exports对于java命令也是可用的。它的工作方式完全相同,并使指定的包对指定的模块或所有运行代码可访问。这意味着此类代码可以使用这些包中公共类型的公共成员,这涵盖了所有静态访问。

NimbusLookAndFeel类是公共的,所以你只需导出包含它的包即可正确访问它。为了确保你观察到--add-exports的效果,使用--illegal-access=deny取消默认的非法访问权限:

$ java --class-path ${class} --illegal-access=deny --add-exports java.desktop/com.sun.java.swing.plaf.nimbus=ALL-UNNAMED j9ms.internal.Nimbus > 静态访问 ${Nimbus Look and Feel} > 反射访问 ${Nimbus Look and Feel}

反射访问得以通过。同时请注意,你不会收到警告——关于这一点,我们稍后再谈。

这涵盖了访问公共类型中的公共成员,但反射可以做得更多:通过大量使用setAccessible(true),它允许与非公共类以及非公共字段、构造函数和方法进行交互。即使在导出包中,这些成员也是封装的,但为了成功反射它们,你需要其他东西。

选项--add-opens使用与--add-exports相同的语法,并使包对深度反射开放,这意味着无论其可见性修饰符如何,其所有类型及其成员都是可访问的。由于其与反射的主要关系,该选项在 12.2.2 节中更正式地介绍。

尽管如此,它的用途是访问内部 API,所以在这里查看一个例子是有意义的。一个相当常见的是由生成其他表示形式的类实例的工具提供的,例如 JAXB 从 XML 文件创建一个 Customer 实例。许多这样的库依赖于类加载机制的内部,它们通过反射访问了 JDK 类 ClassLoader 的非公共成员。请注意,有计划在 Java 的下一个版本中删除 –illegal-access 选项,但 Oracle 尚未决定是哪个版本。

如果你使用 --illegal-access=deny 运行这样的代码,你会得到一个错误:

> Caused by: java.lang.reflect.InaccessibleObjectException: > Unable to make ClassLoader.defineClass accessible: > module java.base does not "opens java.lang" to unnamed module

消息非常明确——解决方案是在启动应用程序时使用 --add-opens

$ java --class-path ${jars} --illegal-access=deny --add-opens java.base/java.lang=ALL-UNNAMED ${main-class}

--illegal-access 及其当前默认值 permit 不同,选项 --add-exports--add-opens 可以被视为“正确的方式”(或者更确切地说,“最不神秘的方式”)来访问内部 API。开发者会根据项目需求故意制定它们,并且 JDK 长期支持它们。因此,模块系统不会对这些选项允许的访问发出警告。

更重要的是,它们阻止 illegal-access 为它们使可访问的包发出警告。如果这些警告让你烦恼,但你无法解决根本问题,通过这种方式导出和打开包可以让警告消失。如果即使这样对你也不起作用(也许你没有访问命令行的权限),请查看 Stack Overflow 上的这个链接:mng.bz/Bx6s。但不要告诉任何人你从哪里得到这个链接。

注意:正如我在 7.1.2 节中解释的,JDeps 是一个很好的工具,用于查找对 JDK-内部 API 的静态访问。但关于反射访问呢?没有万无一失的方法来查找通过反射调用的 API 的使用,但通过 java.lang.reflect.AccessibleObject::setAccessible 的调用层次结构或对 setAccessible 的全文搜索将在你的代码中揭示大部分。为了验证整个项目,请使用 --illegal-access=debugdeny 运行测试套件或整个应用程序,以找出所有通过反射进行的非法访问。

7.1.5 访问内部 API 的编译器和 JVM 选项

在完成本节内容后,你应得到掌声。表面上看,内部 API 的整个问题可能很简单,但一旦考虑到生态系统的遗留和兼容性问题,它就会变得稍微复杂一些。表 7.1 概述了选项及其行为方式。

表 7.1 允许在运行时访问内部 API 的不同机制的比较;静态访问(针对此类或成员编译的代码)与反射访问(使用反射 API)之间的拆分

静态访问
类或成员
强封装
由于 Java 9 中的 --illegal-access=permit 而默认
--illegal-access=warn
--illegal-access=debug
--illegal-access=deny
--add-exports
--add-opens
反射访问
类或成员
强封装
由于 Java 9 中的 --illegal-access=permit 而默认
--illegal-access=warn
--illegal-access=debug
--illegal-access=deny
--add-exports
--add-opens

在技术细节之外,重要的是要考虑可能的策略,这些策略将这些选项和其他选项结合起来,以实现 Java 9 兼容。这正是第 9.1 节所做的事情。如果你不希望指定命令行选项(例如,因为你正在构建可执行的 JAR),请特别仔细地查看第 9.1.4 节——它展示了三种替代方法。

7.2 修复拆分包

对于非法访问内部 API、未解决的 JEE 模块或迄今为止讨论的大多数其他更改,尽管它们可能很烦人,但它们有一些优点:基本概念相对容易理解;多亏了精确的错误消息,问题容易识别。对于拆分包则不能这么说。在最坏的情况下,你唯一会看到的症状是编译器或 JVM 抛出错误,因为一个显然位于类路径上的 JAR 中的类找不到。

例如,让我们以类 MonitorServer 为例,它除了其他注解外,还使用了 JSR 305 的 @Nonnull。 (如果你从未见过它,不要担心——我马上会解释。)以下是我尝试编译它时发生的情况:

> 错误:找不到符号 > 符号:类 javax.annotation.Nonnull > 位置:类 monitor.MonitorServer

尽管有 jsr305-3.0.2.jar 在类路径上。

发生了什么?为什么即使类路径中包含它们,某些类型仍然没有被加载?关键观察结果是这些类型位于一个也包含在模块中的包中。现在让我们看看这为什么会造成差异,并导致类无法加载。

当不同的工件包含同一包中的类(无论是否导出)时,它们被称为分割包。如果至少有一个模块化的 JAR 没有导出该包,这也被称为隐藏包冲突。这些工件可能包含具有相同完全限定名的类,在这种情况下,分割重叠;或者类可能具有不同的名称,但只共享包名前缀。无论分割包是否隐藏以及它们是否重叠,本节讨论的效果是相同的。图 7.2 展示了一个分割和隐藏包。

图片

图 7.2 当两个模块包含同一包中的类型时,它们分割了该包。

分包和单元测试

分包问题是有两个原因之一,单元测试通常放置在不同的源树中,但与生产代码在同一包中,它们不构成自己的模块。(另一个原因是强封装,因为单元测试通常测试非公共或不在导出包中的类和方法。)

分包示例的丰富来源是应用程序服务器,它们通常运行各种 JDK 技术。以 JBoss 应用程序服务器和工件 jboss-jaxb-api_2.2_spec 为例。它包含像 javax.xml.bind.Marshallerjavax.xml.bind.JAXBjavax.xml.bind.JAXBException 这样的类。这显然与并因此分割了包含在 java.xml.bind 模块中的 javax.xml.bind 包。(顺便说一下,JBoss 没有做错什么——JAXB 是一个独立的 JEE 技术,如第 6.1.1 节所述,该工件包含它的完整实现。)

一个非重叠且通常更具疑问性的分包示例来自 JSR 305。Java 规范请求(JSR)305 希望将“用于软件缺陷检测的注解”引入 JDK。它决定添加一些注解,如 @Nonnull@Nullable,到 javax.annotation 包中,创建了一个参考实现,根据 Java 社区过程(JCP)成功审查,然后——沉默了。那是 2006 年。

另一方面,社区喜欢这些注解,因此静态分析工具如 FindBugs 支持它们,许多项目也采用了它们。尽管这不是标准做法,但它们在 Java 生态系统中被广泛使用。即使在 Java 9 中,它们也不是 JDK 的一部分,而且不幸的是,参考实现将大多数注解放在了 javax.annotation 包中。这创建了一个与 java.xml.ws.annotation 模块非重叠的分割。

7.2.1 分包的问题是什么?

分包有什么问题?为什么即使它们显然存在,它们也会导致找不到类?答案并不直接。

拆分包的一个严格技术方面是,Java 的整个类加载机制都是基于这样一个假设:任何完全限定的类名都是唯一的——至少,在同一类加载器中,但由于默认情况下整个应用程序代码只有一个类加载器,这并不是放松这一要求的有意义的方式。除非 Java 的类加载被重新设计和从头开始重新实现,否则这禁止了重叠的包拆分。(第 13.3 节展示了如何通过创建多个类加载器来解决这个问题。)

另一个技术方面是,JDK 团队希望利用模块系统来提高类加载性能。第 6.2.1 节描述了细节,但关键是它依赖于知道每个包属于哪个模块。如果每个包只属于一个模块,这将更简单、性能更高。

然后,拆分包与模块系统的一个重要目标相冲突:模块边界之间的强封装。当不同的模块拆分一个包时会发生什么?它们是否应该能够访问彼此的包可见类和成员?允许这样做将严重破坏封装——但是禁止这样做将直接与您对可见性修饰符的理解相冲突。这不是我想做出的设计决策。

也许最重要的方面是概念性的。一个包应该包含一个具有单一目的的连贯的类集,而一个模块应该包含一个具有单一、尽管稍微大一些的目的的连贯的包集。从这个意义上讲,包含相同包的两个模块具有重叠的目的。也许它们应该是一个模块,然后……?

虽然没有单一的杀手论点反对拆分包,但它们有很多不希望的特性,会促进不一致性和歧义。因此,模块系统对它们持怀疑态度,并希望防止它们。

7.2.2 拆分包的影响

由于拆分包可能导致的不一致性和歧义,模块系统实际上禁止了它们:

  • 一个模块不允许从两个不同的模块中读取相同的包。

  • 同一层的两个模块不允许包含相同的包(无论是导出还是未导出)。

什么是层?正如第 12.4 节所解释的,它是一个包含类加载器及其整个模块图的容器。到目前为止,您一直隐式地处于单层情况,其中第二个要点完全包含第一个要点。因此,除非涉及不同的层,否则禁止拆分包。

正如您接下来将看到的,模块系统的行为会根据拆分发生的位置而有所不同。在我们覆盖了这一点之后,我们最终可以转向修复拆分。

模块之间的拆分

当两个模块(如平台模块和应用模块)拆分一个包时,模块系统将检测到这一点并抛出错误。这可以在编译时或运行时发生。

以 ServiceMonitor 应用程序为例。如您所回忆的,monitor.statistics 模块包含一个名为monitor.statistics的包。让我们在 monitor 中创建一个具有相同名称(以及SimpleStatistician类)的包。当编译该模块时,我遇到了以下错误:

> monitor/src/main/java/monitor/statistics/SimpleStatistician.java:1: > error: package exists in another module: monitor.statistics > package monitor.statistics; > ^ > 1 error

图 7.3 类路径内容不会暴露给模块检查,其包也不会被索引。如果它与模块分割了一个包,类加载器只会知道该模块,并在此处查找类。在这里,它查找org.company并检查相应的模块,忽略包的类路径部分。

当尝试编译一个包含从所需模块导出的包的模块时,编译器会注意到错误。但是,当包没有被导出时,即你有一个隐藏的包冲突,会发生什么?

为了找出原因,我在 monitor.statistics 中添加了一个名为monitor.Utils的类来监控统计信息,这意味着我将monitor包分成了monitormonitor.statistics两部分。这种分割是隐藏的,因为 monitor.statistics 没有导出monitor

在那种情况下——这让我有些惊讶——编译 monitor 是可行的。错误报告的责任落在运行时,它会尽职尽责地立即在启动应用程序时报告错误:

> 初始化引导层时发生错误 > java.lang.reflect.LayerInstantiationException: > 包 monitor 在模块 monitor.statistics 和模块 monitor 中

如果两个模块(其中任何一个都不需要另一个)包含相同的包:不是编译器而是运行时会发现错误。

模块与类路径之间的分割

本章主要关注在 Java 9 或更高版本上编译和运行类路径应用程序,因此让我们回到这个用例。有趣的是,模块系统的行为是不同的。所有来自类路径的代码最终都会进入未命名的模块(更多内容请参阅第 8.2 节);为了最大化兼容性,通常不会对其进行审查,也不会对其应用任何模块相关的检查。因此,模块系统不会发现分割的包,并允许您编译和启动应用程序。

起初这听起来可能很好:少了一件需要担心的事情。然而,问题仍然存在,只是不那么明显了。而且可能更糟。

模块系统知道每个命名模块(与未命名的模块相对)包含哪些包,以及每个包只属于一个模块。正如我在第 6.2.1 节中解释的,新的类加载策略得益于这种知识;每次它加载一个类时,它会查找包含该包的模块,并尝试从那里加载。如果它包含该类,那就太好了;如果不包含,结果就是NoClassDefFoundError

如果一个包在模块和类路径之间分割,类加载器在加载该包的类时将始终且仅检查模块(参见图 7.3)。包的类路径部分的类实际上是不可见的!这对于平台模块和类路径之间的分割同样适用,对于应用程序模块(即从模块路径加载的 JAR)和类路径也是如此。

是的,你说对了。如果某些代码包含来自,比如说,javax.annotation 包的类,那么类加载器将检查唯一包含该包的模块:java.xml.ws.annotation。如果在那里找不到该类,即使该类存在于类路径上,你也会得到一个 NoClassDefFoundError 错误!

如你所想,任意缺失的类可能会导致一些令人困惑的情况。这正是 JEE 模块(它们促进包分割)默认不解析的精确原因,如第 6.1 节所述。尽管如此,这些模块可能会造成最奇怪的分割包情况。

考虑一个使用注解 @Generated@Nonnull 的项目。第一个存在于 Java 8 中,第二个来自项目类路径上的 JSR 305 实现。两者都在 javax.annotation 包中。当你使用 Java 9 或更高版本编译时会发生什么?

> 错误:找不到符号 > 符号:类 Generated > 位置:包 javax.annotation

所以 Java 类缺失了?是的,因为它来自 JEE 模块 java.xml.ws.annotation,而这个模块默认不解析。但这里的错误信息不同:它没有指向解决方案。幸运的是,你之前已经注意到了,知道可以通过添加包含的模块 --add-modules java.xml.ws.annotation 来解决这个问题。然后你得到以下结果:

> 错误:找不到符号 > 符号:类 Nonnull > 位置:类 MonitorServer

编译器一分钟前找到了那个类——为什么现在找不到?因为现在有一个包含 javax.annotation 包的模块,所以类路径部分变得不可见。

为了重复(你也可以在图 7.4 中看到):

  • 第一个错误是由 JEE 模块默认不解析引起的。

  • 第二个错误是由模块系统忽略分割包的类路径部分引起的。

这完全说得通(对吧?)。现在你已经彻底理解了发生了什么,让我们转向解决问题的方法。

图片

图 7.4 从同一包加载可能会因为不同的原因而失败。在左侧,没有添加 JEE 模块 java.xml.ws.annotation,因此加载 @Generated 失败,因为类路径上的 JSR 305 艺术品不包含它。在右侧,添加了模块,因此类加载尝试从那里加载所有 javax.annotation 类——甚至 @Nonnull,它只由 JSR 305 包含。最终,两种方法都未能加载所有必需的注解。

7.2.3 处理分割包的多种方法

有很多方法可以使拆分包工作。以下是一般推荐考虑的顺序:

  • 重命名其中一个包。

  • 将拆分包的所有部分移动到同一个工件中。

  • 合并工件。

  • 将两个工件都留在类路径上。

  • 升级带有工件的 JDK 模块。

  • 使用工件的内容修补模块。

注意:只有最后两个适用于迁移期间典型的拆分包场景,其中包在平台模块和类路径上的工件之间拆分。

第一种方法在包名冲突是意外的情况下有效——它应该是最明显的选择,并且尽可能使用。当拆分是故意进行的时候,这不太可能有效。在这种情况下,你可以尝试通过移动一些类或合并工件来修复拆分。这三种选项是解决问题的适当、长期解决方案,但显然它们只在你控制拆分工件的情况下有效。

如果拆分代码不属于你,或者解决方案不适用,你需要其他选项来确保模块系统能够正常工作,即使包仍然是拆分的。一个直接的修复方法是让这两个工件都留在类路径上,它们将被捆绑到同一个未命名的模块中,并像 Java 9 之前那样表现。这是一个有效的中间策略,在你等待项目解决冲突并修复它的时候。

不幸的是,到目前为止讨论的任何解决方案都不适用于拆分的部分属于 JDK 模块的情况,因为你无法直接控制它——为了克服这种拆分,你需要更大的火力。如果你很幸运,拆分的工件由不仅仅是几个随机放入 JDK 包的类和一些整个可升级的 JDK 模块的替代品组成。在这种情况下,请参阅第 6.1.3 节,该节解释了如何使用--upgrade-module-path

如果上述方法都没有帮助,你将陷入最后的也是最复杂的解决方案:修补模块。

7.2.4 修补模块:处理拆分包的最后手段

一种技术可以修复几乎所有的拆分包,但应该始终作为最后的手段:让模块系统假装类路径上的麻烦类属于拆分包的模块。编译器和运行时选项--patch-module ${module}=${artifact}将所有来自${artifact}的类合并到${module}中。有一些需要注意的事项,但让我们在讨论它们之前先看一个例子。

之前,我们查看了一个使用注解@Generated(来自 java.xml.ws.annotation 模块)和@Nonnull(来自 JSR 305 实现)的项目示例。我们发现了三件事:

  • 这两个注解都在javax.annotation包中,从而创建了拆分。

  • 你需要手动添加模块,因为它是一个 JEE 模块。

  • 这样做使得拆分包中的 JSR 305 部分变得不可见。

现在你知道了你可以使用--patch-module来修复拆分:

javac --add-modules java.xml.ws.annotation --patch-module java.xml.ws.annotation=jsr305-3.0.2.jar --class-path 'libs/*' -d classes/monitor.rest ${source-files}

这样,jsr305-3.0.2.jar 中的所有类都成为 java.xml.ws.annotation 模块的一部分,并且可以在成功编译(或在 java 上执行)时被加载。太好了!

有几点需要注意。首先,修补一个模块并不会自动将其添加到模块图中。如果它没有被显式地要求,可能还需要使用 --add-modules(参见第 3.4.3 节)来添加它。

接下来,使用 --patch-module 添加到模块中的类将遵循正常的可访问性规则(参见第 3.3 节和图 7.5):

图 7.5 如果一个模块的类被修补到另一个模块中(这里 B 修补到 A),修补模块的输入和输出依赖以及包导出必须手动编辑,以便包含的类能够正常工作。

  • 依赖于此类代码的需要读取修补的模块,该模块必须导出必要的包。

  • 同样,这些类的依赖也需要在修补模块读取的模块中的导出包中。

这可能需要使用命令行选项如 --add-reads(参见第 3.4.4 节)和 --add-exports(参见第 11.3.4 节)来操作模块图。因为命名模块不能从类路径中访问代码,可能还需要创建一些自动模块(参见第 8.3 节)。

7.2.5 使用 JDEPS 查找拆分包

通过试错来查找拆分包令人不安。幸运的是,JDeps 会报告它们。附录 D 对该工具进行了一般介绍;你不需要知道更多,因为拆分包几乎包含在任何输出中。

让我们看看 JDeps 对使用 javax.annotation.Generated 从 java.xml.ws.annotation 和 javax.annotation.Nonnull 从 JSR 305 的应用程序的报告。在将所有依赖复制到 lib 文件夹后,你可以按照以下方式执行 JDeps:

$ jdeps -summary -recursive --class-path 'libs/*' project.jar > 拆分包: javax.annotation > [jrt:/java.xml.ws.annotation, libs/jsr305-3.0.2.jar] > # 大量项目依赖被截断

这很明确,对吧?如果你好奇哪些依赖于拆分包,可以使用 --package-verbose:class

$ jdeps -verbose:class --package javax.annotation -recursive --class-path 'libs/*' project.jar # 拆分包被截断 # 从 javax.annotation 截断的依赖 > rest-1.0-SNAPSHOT.jar -> libs/jsr305-3.0.2.jar > monitor.rest.MonitorServer -> Nonnull jsr305-3.0.2.jar

7.2.6 关于依赖版本冲突的注意事项

您在第 1.3.3 节中看到,Java 8 没有内置支持来运行同一 JAR 的多个版本——例如,如果应用程序间接依赖于 Guava 19 和 20。就在几页之后,在第 1.5.6 节中,您了解到不幸的是,模块系统不会改变这一点。根据我们刚才讨论的拆分包,应该很清楚为什么会出现这种情况。

Java 模块系统改变了类加载策略(查找特定模块而不是扫描类路径),但没有改变底层假设和机制。对于每个类加载器,仍然只能有一个具有相同完全限定名称的类,这使得同一艺术品的多个版本成为不可能。有关模块系统对版本支持的更多详细信息,请参阅第十三章。

TIP 您已经了解了所有常见的以及一些不常见的迁移挑战。如果您渴望将您的知识付诸实践并将项目升级到 Java 9+,请跳转到第九章——它讨论了如何最佳地处理这个问题。一旦您的应用程序在 Java 9+上运行,您就可以使用jlink来创建仅包含所需模块的运行时镜像——参见第 14.1 节。如果您对下一步感兴趣,即将现有代码库转换为模块,请继续阅读第八章。

摘要

  • 要了解您的项目可能依赖的类在模块系统下如何访问,了解它们在模块系统时代如何分类是很重要的:

  • java.*javax.*包中的所有公共类都是标准化的。这些包由 java.*模块导出,并且可以安全地依赖,因此不需要进行任何更改。

  • Oracle 支持某些com.sun.*包中的公共类。这些包由 jdk.*模块导出,依赖它们将代码库限制在特定的 JDK 供应商。

  • sun.*包中的一些选定类在 Oracle 的支持下暂时可用,直到未来 Java 版本中引入替代品。它们由 jdk-unsupported 导出。

  • 所有其他类均不受支持且无法访问。虽然可以使用命令行标志使用它们,但这样做可能会在不同的小版本或不同供应商的 JVM 上中断,因此通常不建议这样做。

  • 一些内部 API 已被删除,因此即使使用命令行选项也无法继续使用它们。

  • 尽管强封装通常禁止访问内部 API,但对于类路径上访问 JDK 内部 API 的代码,有一个例外。这将大大简化迁移,但也会使模块系统的行为复杂化:

  • 在编译期间,强封装完全激活并阻止访问 JDK 内部 API。如果仍然需要某些 API,则可以使用--add-exports授予访问权限。

  • 在运行时,Java 9 到 11 默认允许对非导出 JDK 包中的公共类进行静态访问。这使得现有应用程序更有可能直接运行,但随着未来版本的发布,这一情况将会改变。

  • 默认情况下,允许反射访问所有 JDK 内部 API,但首次访问包时(默认)或每次访问时(使用 --illegal-access=warn)都会产生警告。分析这一问题的最佳方法是使用 --illegal-access=debug,它会在每个警告中包含堆栈跟踪。

  • 使用 --illegal-access=deny 可以实现静态和反射访问的更严格行为,在必要时使用 --add-exports--add-opens 访问关键所需的包。尽早朝着这个目标努力会使迁移到未来的 Java 更新变得更容易。

  • 模块系统禁止同一层级的两个模块包含相同的包——无论是否导出。然而,对于类路径上的代码,这一规则并不适用,因此,一个未发现的包可能被分割在平台模块和类路径代码之间是可能的。

  • 如果一个包被分割在模块和类路径之间,类路径部分基本上是看不见的,这会导致令人惊讶的编译时和运行时错误。最好的解决办法是移除分割,但如果这不可能,相关的平台模块可以用 --upgrade-module-path(如果它是可升级的模块)替换分割的工件,或者用 --patch-module 用其内容修复。

8

现有项目的增量模块化

本章涵盖

  • 与未命名的工件一起工作

  • 使用自动模块帮助模块化

  • 逐步模块化代码库

  • 混合类路径和模块路径

根据你的 Java 9+ 迁移过程是否顺利(见第六章和第七章),你可能已经遇到了将模块系统引入足够成熟到可以自己点啤酒的生态系统的一些不那么愉快的影响。好消息是,这是值得的!正如我在第 1.7.1 节中简要展示的,Java 9+ 之外还有很多东西可以提供。如果你有机会将你的项目 Java 要求提升到 9,你就可以立即开始使用它们。

你也可以最终开始模块化你的项目。通过将工件转换为模块化 JAR,你和你的用户可以受益于可靠的配置(见第 3.2.1 节)、强大的封装(第 3.3.1 节)、通过服务解耦(见第十章)、包含整个应用程序的运行时镜像(见 14.2 节)以及更多与模块相关的优点。正如第 9.3.4 节所示,你甚至可以模块化运行在 Java 8 及之前的项目。

使 JARs 模块化的有两种方式:

  • 等到所有依赖项都模块化后,一次性为所有工件创建模块描述符。

  • 早期开始模块化工件,可能一次只模块化几个。

考虑到第三章、第四章和第五章中讨论的所有内容,实现第一个选项应该是直截了当的。你可能需要第十章和第十一章中介绍的更高级的模块系统功能,但除此之外,你就可以开始了:为你要构建的每个工件创建一个模块声明,并像你之前学的那样建模它们之间的关系。

然而,也许你的项目位于一个深层次的依赖树之上,你不愿意等到所有依赖都完成模块化后再进行。或者,也许你的项目太大,无法一次性将所有工件转换为模块。在这些情况下,你可能对第二种选择感兴趣,它允许你无论依赖是否模块化,都可以逐步模块化工件。

能够同时使用模块化和单模块工件不仅对单个项目很重要,而且意味着整个生态系统可以独立地接受模块。如果没有这一点,生态系统的模块化可能需要几十年的时间——这样,每个人都应该能在十年内完成。

本章致力于介绍能够逐步模块化现有项目的特性:我们首先讨论类路径和模块路径的组合,然后检查未命名的模块,最后通过查看自动模块来结束讨论。当你完成这些步骤后,即使可能存在未模块化的依赖,你的项目或其部分也将从模块化系统中受益。你也将为第九章中讨论的应用程序模块化策略做好充分准备。

8.1 为什么逐步模块化是一个选择

在我们讨论如何逐步模块化一个项目之前,我想思考一下为什么这是一个选择。模块系统通常要求所有内容都必须是模块。但如果它们来得太晚(比如 JPMS)或者只被其生态系统的一小部分使用(比如 OSGi 或 JBoss Modules),它们几乎不能期望这种情况发生。它们必须找到一种方式与单模块工件交互。

在本节中,我们首先思考如果每个 JAR 都必须在 Java 9+上模块化才能运行会发生什么,得出结论认为必须能够混合使用普通 JAR 和模块(第 8.1.2 节)。然后,我展示了如何使用类路径和模块路径并行使用这种混合匹配方法(第 8.1.3 节)。

8.1.1 如果每个 JAR 都必须是模块化的……

如果 JPMS 非常严格,要求所有内容都必须是模块,那么只有当所有 JAR 都包含模块描述符时才能使用它。由于模块系统是 Java 9+的一个组成部分,因此,即使没有对代码和依赖进行模块化,也无法升级到它。想象一下如果这种情况发生会有什么后果。

一些项目可能会提前更新到 Java 9+,迫使所有用户模块化他们的代码库或停止使用该项目。其他人可能不想强迫做出这个决定,或者有其他原因不做出跳跃,从而阻碍他们的用户。我不希望我的项目有导致对立决定的依赖项。我能做什么呢?

另一方面,一些项目会提供带有和不带有模块描述符的独立变体,为此他们必须使用两组完全不同的依赖项(一个带有,一个不带模块描述符)。此外,除非他们是在旧的主版本和次版本之间回溯,否则用户将被迫一次性进行大量(可能是耗时)的更新,才能跳转到 Java 9+。而且这还不考虑那些不再维护的项目,即使它们自己没有任何依赖项,在 Java 9+ 上也会迅速变得无法使用。

避免浪费努力和深度分裂的唯一方法是在整个生态系统有一个日子,所有项目都更新到 Java 9+ 并开始发布模块化 JAR 文件。但这是不可能实现的。无论我们如何分割,执行 JAR 文件的人都必须知道它是为哪个 Java 版本创建的,因为它在 8 和 9 上无法工作。总的来说:我们会陷入大麻烦!

8.1.2 将普通 JAR 文件与模块混合使用

为了绕过这个麻烦,模块系统必须提供一种方法,在模块化的 JVM 上运行单模块化代码。在第六章的引言中,我解释了这确实如此,并且类路径上的普通 JAR 文件与 Java 9+ 之前的工作方式一样。(正如第六章和第七章所解释的,它们包含的代码可能无法运行,但这又是另一个问题。)第 8.2 节解释了类路径模式是如何工作的。

仅仅它能工作就已经是一个重要的启示:模块系统可以处理单模块化工件,并且知道如何在这些工件和显式模块之间导航边界。这是个好消息——而且还有更多:这个边界不是一成不变的。它不必将应用程序 JAR 文件与 JVM 模块分开。正如 图 8.1 所示,以及本章的其余部分所探讨的,模块系统允许你移动这个边界,并根据项目需求混合和匹配模块化和单模块化应用程序 JAR 文件与平台模块。

图片

图 8.1 模块系统允许非模块化代码在模块化 JDK 上运行(左)。更重要的是,它为你提供了移动这个边界的工具(右)。

8.1.3 增量模块化的技术基础

使增量模块化成为可能的基本原则是类路径和模块路径可以并行使用。没有必要一次性将所有应用程序 JAR 从类路径移动到模块路径。相反,鼓励现有项目从类路径开始,然后随着模块化工作的进展,逐渐将它们的工件移动到模块路径。

同时使用类路径以及平凡和模块化 JAR 的路径需要对这些概念之间关系的清晰理解。你可能认为没有模块描述符的 JAR 会进入类路径,而模块化 JAR 会进入模块路径。尽管我从未那样说过,但你可能会从字里行间读出这样的意思。然而,那个理论是错误的,现在是时候放下它了。

两种机制使那个理论无效,并使增量模块化成为可能:

  • 未命名模块是由模块系统隐式创建的,其中包含从类路径加载的所有内容。在这里,类路径的混乱得以延续。(第 8.2 节详细解释。)

  • 模块系统为它在模块路径上找到的每个平凡 JAR 创建一个自动模块。(第 8.3 节专门介绍这个概念。)

类路径对平凡和模块化 JAR 没有区别:如果它在类路径上,它最终会进入未命名模块。同样,模块路径对平凡和模块化 JAR 的区别也很小:如果它在模块路径上,它最终会变成它自己的命名模块。(对于平凡 JAR,模块系统创建一个自动模块;对于模块化 JAR,它根据描述创建一个显式模块。)

要理解本章的其余部分以及执行模块化,重要的是要完全理解这种行为。表 8.1 展示了一个二维重构。不是 JAR 的类型(平凡或模块化),而是它放置的路径(类路径或模块路径)决定了它是否成为未命名模块的一部分或命名模块。

表 8.1 并不是 JAR 的类型(平凡或模块化)决定了类最终成为命名模块还是未命名模块,而是它放置的路径(类路径或模块路径)。

类路径 模块路径
平凡 JAR 未命名模块(第 8.2 节) 自动模块(第 8.3 节)
模块化 JAR 显式模块(第 3.1.4 节)

在决定将 JAR 放置在类路径或模块路径上时,并不是关于代码来自哪里(JAR 是否模块化?);而是关于你需要代码在何处(在未命名模块或命名模块中)。类路径是用于你想进入泥球代码的代码,而模块路径是用于你想成为模块的代码。

但你如何决定代码应该放在哪里?作为一个一般准则,未命名模块关乎兼容性,使得使用类路径的项目能够在 Java 9+ 上工作;而自动模块关乎模块化,即使依赖项尚未模块化,也允许项目使用模块系统。

为了得到更详细的答案,现在是时候更仔细地研究未命名的和自动模块了。第九章随后定义了更大的模块化策略。如果您在考虑是否值得麻烦对现有项目进行模块化,请参阅第 15.2.1 节。

注意:您的构建工具可能会为您做出很多这些决定。尽管如此,您仍然可能会遇到某些事情出错的情况,在这种情况下,您可以应用本章中探讨的内容来正确配置您的构建。

8.2 未命名的模块,即类路径

我还没有详细解释的一个方面是:模块系统和类路径是如何一起工作的?本书的第一部分清楚地说明了模块化应用程序如何将一切放置在模块路径上,并在模块化的 JDK 上运行。然后是第六章和第七章,它们主要涉及编译非模块化代码和从类路径运行应用程序。但是类路径内容是如何与模块系统交互的?哪些模块被解析,以及如何解析?为什么类路径内容可以访问所有平台模块?未命名的模块回答了这些问题。

探索它们不仅仅具有学术价值。除非应用程序相当小,否则它可能无法一次性全部模块化;但是增量模块化涉及到混合 JAR 和模块、类路径和模块路径。这使得理解模块系统类路径模式的工作底层细节变得很重要。

注意:围绕未命名的模块的机制通常在编译时和运行时适用,但总是提及两者是不必要的,会使文本膨胀。相反,我描述运行时行为,只有在行为确实不同时才提及编译时。

未命名的模块包含所有单模块类,这些类

  • 在编译时,正在编译的类,如果它们没有包含模块描述符

  • 在编译时和运行时,从类路径加载的所有类

如第 3.1.3 节所述,所有模块都有三个核心属性,这同样适用于未命名的模块:

  • 名称——未命名的模块没有名称(这是合理的,对吧?),这意味着没有其他模块可以在它们的声明中提及它(例如,为了要求它)。

  • 依赖关系——未命名的模块读取所有进入图的模块。

  • 导出——未命名的模块导出其所有包,并且也允许它们进行反射(有关开放包和模块的详细信息,请参阅第 12.2 节)。

与未命名的模块相反,所有其他模块都被认为是命名的。META-INF/services中提供的服务对ServiceLoader可用。有关服务的介绍,请参阅第十章,特别是第 10.2.6 节,以了解它们与未命名的模块的交互。

虽然这并不完全直接,但未命名模块的概念是有意义的。这里你有有序的模块图,而那边,稍微偏一点,你有类路径的混乱,它被组合成自己的自由模块,具有一些特殊属性(见图 8.2)。(为了不让事情比必要的更复杂,我没有当时告诉你,但未命名模块是第六章和第七章的基础,在那里你可以用未命名模块替换类路径内容的每个出现。)

让我们回到 ServiceMonitor 应用程序,并假设它是在 Java 9 之前编写的。代码及其组织与我们之前章节中讨论的相同,但它缺少模块声明,因此你创建的是普通 JAR 文件而不是模块 JAR 文件。

假设jars文件夹包含所有应用程序 JAR 文件,而libs包含所有依赖项,你可以按以下方式启动应用程序:

$ java --class-path 'jars/*':'libs/*' monitor.Main

这在 Java 9 及更高版本中有效,除了--class-path选项的替代形式外,在 Java 8 和更早版本中也做同样的事情。图 8.2 显示了模块系统为这种启动配置创建的模块图。

图 8.2 当所有应用程序 JAR 文件都在类路径上启动时,模块系统从平台模块(左侧)构建一个模块图,并将类路径上的所有类分配给未命名模块(右侧),该模块可以读取所有其他模块

带着这种理解,你已准备好从类路径运行简单的单模块应用程序。超出这个基本用例,尤其是在逐步模块化应用程序时,未命名模块的细微之处变得相关,因此我们接下来看看它们。

8.2.1 未命名模块捕捉到的类路径混乱

未命名模块的主要目标是捕获类路径内容,使其在模块系统中工作。由于类路径上的 JAR 文件之间从未有过边界,现在建立它们是没有意义的;因此,为整个类路径保留一个未命名模块是一个合理的决定。在其内部,就像在类路径上一样,所有公共类都是可访问的,并且不存在分割包的概念。

未命名模块的独特角色及其对向后兼容性的关注赋予它一些特殊属性。你在 7.1 节中看到,在运行时,平台模块的强封装对于未命名模块中的代码大部分是禁用的(至少在 Java 9、10 和 11 中)。当我们讨论 7.2 节中的分割包时,你发现未命名模块没有被扫描,因此它与其他模块之间的包分割没有被发现,类路径部分也不可用。

有一个细节稍微有些反直觉且容易出错,那就是未命名模块的构成。看起来很明显,模块化 JAR 文件变成了模块,因此普通的 JAR 文件进入未命名模块,对吧?如第 8.1.3 节所述,这是错误的:未命名模块负责类路径上的所有 JAR 文件,无论是模块化还是非模块化。

因此,模块化 JAR 文件并不一定必须作为模块加载!如果一个库开始提供模块化 JAR 文件,其用户并不一定被迫使用它们作为模块。用户可以选择将它们留在类路径上,这样它们的代码就被捆绑到未命名模块中。如第 9.2 节更详细地解释,这允许生态系统几乎独立地进行模块化。

例如,让我们启动 ServiceMonitor 的完全模块化版本,一次从类路径启动,一次从模块路径启动:

$ java --class-path 'mods/*':'libs/*' -jar monitor $ java --module-path mods:libs --module monitor

这两种方法都运行良好,且没有明显的差异。

要了解模块系统如何处理这两种情况,可以使用我们在第 12.3.3 节中更详细探讨的 API。您可以在一个类上调用 getModule 来获取它所属的模块,然后在该模块上调用 getName 来查看它的名称。对于未命名模块,getName 返回 null

让我们在 Main 中包含以下代码行:

String moduleName = Main.class.getModule().getName(); System.out.println("Module name: " + moduleName);

从类路径启动时,输出为 Module name: null,表明 Main 类最终进入了未命名模块。从模块路径启动时,您会得到预期的 Module name: monitor

第 5.2.3 节讨论了模块系统如何将资源封装在包中。这仅部分适用于未命名模块:在模块内部,没有访问限制(因此类路径上的所有 JAR 文件都可以相互访问资源),未命名模块向所有包开放反射(因此所有模块都可以访问类路径上的 JAR 文件中的资源)。然而,从未命名模块到命名模块的访问确实应用了强封装。

8.2.2 未命名模块的模块解析

未命名模块与模块图其他部分关系的一个重要方面是它可以读取哪些其他模块。如前所述,它可以读取所有进入图中的模块。但那些模块是哪些呢?

记住第 3.4.1 节的内容,模块解析通过从根模块(特别是初始模块)开始构建模块图,然后迭代地添加所有它们的直接和传递依赖项。如果编译下的代码或应用程序的 main 方法在未命名模块中,就像从类路径启动应用程序时那样,这将如何工作?毕竟,普通 JAR 文件不表达任何依赖项。

如果初始模块是没有命名的模块,模块解析将从预定义的根模块集合开始。一般来说,这些是不包含 JEE API 的系统模块(参见第 3.1.4 节),但实际规则要详细一些:

  • 成为根模块的 java.*模块的精确集合取决于 java.se 模块的存在(代表整个 Java SE API 的模块——它在完整的 Java 镜像中存在,但可能不在使用jlink创建的自定义运行时镜像中):

  • 如果 java.se 是可观察的,它就变成根模块。

  • 如果不是这样,每个 java.系统模块和来自升级模块路径的 java.模块(如果至少导出一个未加限定的包,意味着不受限于谁可以访问该包——参见第 11.3 节)都成为根模块。

  • 除了 java.模块之外,所有其他系统模块以及升级模块路径上的模块(如果不是孵化模块并且至少导出一个未加限定的包)都成为根模块。这对于 jdk.和 javafx.*模块尤其相关。

  • 使用--add-modules定义的模块(参见第 3.4.3 节)始终是根模块。

图片

图 8.3 模块解析的根模块(参见第 3.4.1 节)取决于初始模块是否使用--module定义(如果没有,则无命名的模块是初始模块)以及 java.se 是否可观察。在任何情况下,使用--add-modules定义的模块始终是根模块。

这有点复杂(参见图 8.3 以获取可视化),但在边缘情况下可能会变得很重要。所有系统模块(除了 JEE 和孵化模块)都应解决的规则应该至少覆盖 90%的情况。

例如,你可以运行java --show-module-resolution并观察输出的前几行:

> 根 java.se jrt:/java.se > 根 jdk.xml.dom jrt:/jdk.xml.dom > 根 javafx.web jrt:/javafx.web > 根 jdk.httpserver jrt:/jdk.httpserver > 根 javafx.base jrt:/javafx.base > 根 jdk.net jrt:/jdk.net > 根 javafx.controls jrt:/javafx.controls > 根 jdk.compiler jrt:/jdk.compiler > 根 oracle.desktop jrt:/oracle.desktop > 根 jdk.unsupported jrt:/jdk.unsupported

这不是完整的输出,并且在不同系统上的顺序可能不同。但从顶部开始,你可以看到 java.se 是唯一的 java.模块。然后有一系列 jdk.和 javafx.模块(注意第 7.1.1 节中的 jdk.unsupported)以及一个 oracle.模块(不知道这个模块做什么)。

重要信息注意,如果没有命名的模块作为初始模块,根模块集合始终是运行时图像中包含的系统模块的子集。除非显式添加--add-modules,否则模块路径上存在的模块永远不会被解析。如果你手工制作模块路径以包含你需要的所有模块,你可能想使用--add-modules ALL-MODULE-PATH将它们全部添加,如第 3.4.3 节所述。

您可以通过从模块路径启动 ServiceMonitor 而不定义初始模块来轻松观察到这种行为:

$ java --module-path mods:libs monitor.Main > 错误:找不到或加载主类 monitor.Main > 原因:java.lang.ClassNotFoundException: monitor.Main

使用 --show-module-resolution 运行相同的命令确认没有解析出 monitor.* 模块。要修复这个问题,您可以使用 --add-modules monitor,在这种情况下,monitor 被添加到根模块列表中,或者使用 --module monitor/monitor.Main,在这种情况下,monitor 成为唯一的根模块(初始模块)。

8.2.3 依赖于未命名的模块

模块系统的主要目标之一是可靠的配置:一个模块必须表达其依赖关系,并且模块系统必须能够保证它们的可用性。我们在第 3.2 节中为具有模块描述符的显式模块解决了这个问题。如果您尝试将可靠的配置扩展到类路径,会发生什么?

让我们做一个思想实验。想象模块可以依赖于类路径内容,也许在它们的描述符中使用 requires class-path。模块系统可以为这样的依赖提供哪些保证?实际上,几乎没有。只要类路径上至少有一个类,模块系统就必须假设依赖已经满足。这不会很有帮助(参见图 8.4)。

图 8.4 如果 com.framework 依赖于一些具有假设的 requires class-path 的类路径内容,模块系统无法确定该要求是否满足(左)。如果您在这个框架上构建应用程序,您就不知道如何满足这个依赖(右)。

更糟糕的是,它将严重破坏可靠的配置,因为您可能最终依赖于一个 requires class-path 的模块。嗯,这几乎不包含任何信息——需要将什么放在类路径上(再次参见图 8.4)?

将这个假设进一步扩展,想象有两个模块,com.framework 和 org.library,它们依赖于同一个第三方模块,比如 SLF4J。一个在 SLF4J 模块化之前声明了依赖,因此 requires class-path;另一个在模块化的 SLF4J 上声明了依赖,因此 requires org.slf4j(假设这是模块名)。现在,任何依赖于 com.framework 和 org.library 的人会在哪个路径上放置 SLF4J JAR?无论他们选择哪个:模块系统都必须确定这两个传递依赖中的一个是未满足的。图 8.5 展示了这种情况。

图 8.5 如果 com.framework 依赖于 SLF4J,假设使用requires class-path,而 org.library 以requires org.slf4j作为模块依赖它,那么将无法满足这两个要求。无论 SLF4J 是放在类路径上(左)还是模块路径上(右),这两个依赖项中的一个将被视为未满足。

深思熟虑后得出结论,如果你想要可靠的模块,依赖于任意的类路径内容并不是一个好主意。因此,没有requires class-path

我们如何最好地表达最终持有类路径内容的模块无法被依赖?在一个使用名称来引用其他模块的模块系统中?不给该模块命名——换句话说,使其成为未命名的模块——听起来是合理的。

由此可知:未命名的模块没有名称,因为没有模块应该在任何requires指令或其他指令中引用它。没有requires,就没有可读性的优势,而没有这种优势,未命名的模块中的代码对其他模块不可访问。

总结来说,为了使显式模块依赖于一个工件,该工件必须位于模块路径上。如第 8.1.3 节所述,这可能意味着你将纯 JAR 放在模块路径上,这样它们就变成了自动模块——这是我们接下来要探讨的概念。

8.3 自动模块:模块路径上的纯 JAR

任何模块化工作的长期目标都是将纯 JAR 升级为模块化 JAR,并将它们从类路径移动到模块路径。达到这一目标的一种方法是在所有依赖项都以模块的形式到达你这里之后,然后模块化你的项目——这是一种自下而上的方法。但这可能需要很长时间,因此模块系统也允许自顶向下的模块化。

第 9.2 节详细解释了两种方法,但为了自顶向下的方法能够工作,你首先需要一个新成分。想想看:如果你的依赖项以纯 JAR 的形式出现,你如何声明一个模块?正如你在第 8.2.3 节中看到的,如果你将它们放在类路径上,它们最终会进入未命名的模块,而你的模块无法访问它。但在第 8.1.3 节中你注意到了,所以你知道纯 JAR 也可以放在模块路径上,模块系统会自动为它们创建模块。

注意:自动模块周围的机制通常在编译时间和运行时适用。正如我之前所说的,总是提及两者添加的信息很少,并且使文本更难阅读。

对于模块路径上每个没有模块描述符的 JAR,模块系统都会创建一个自动模块。像任何其他命名模块一样,它有三个核心属性(见第 3.1.3 节):

  • 名称——自动模块的名称可以在 JAR 的清单中使用Automatic-Module-Name头定义。如果它缺失,模块系统会从文件名生成一个名称。

  • 依赖关系——自动模块读取所有进入图的模块,包括未命名的模块(正如你很快就会看到的,这很重要)。

  • 导出——自动模块导出其所有包,并且也允许它们进行反射(有关开放包和模块的详细信息,请参阅第 12.2 节)。

此外,可执行 JARs 导致可执行模块,其主类如第 4.5.3 节所述进行标记。在 META-INF/services 中提供的服务对 ServiceLoader 可用——请参阅第十章介绍服务和第 10.2.6 节介绍它们与自动模块的交互。

假设 ServiceMonitor 尚未模块化,您仍然可以将其工件放置在模块路径上。如果目录 jars-mp 包含 monitor.jarmonitor.observer.jarmonitor.statistics.jar,而 jars-cp 包含所有其他应用程序和依赖 JARs,则可以按以下方式启动 ServiceMonitor:

$ java --module-path jars-mp --class-path 'jars-cp/*' --module monitor/monitor.Main

你可以在图 8.6 中看到生成的模块图。一些细节可能不清楚(比如,为什么尽管命令行上只引用了 monitor,但所有三个自动模块都进入了图?)。别担心;我将在下一节中解释。

图片

图 8.6 在模块路径上有普通 JARs monitor.jarmonitor.observer.jarmonitor.statistics.jar 时,JPMS 为它们创建了三个自动模块。类路径的内容最终作为未命名的模块存在,就像以前一样。注意自动模块如何相互读取以及读取未命名的模块,在图中创建了许多循环。

自动模块是完整的命名模块,这意味着

  • 它们可以在其他模块的声明中通过名称引用:例如,要求它们。

  • 强封装使它们无法使用平台模块内部(与未命名的模块不同)。

  • 它们会受到拆分包检查的影响。

另一方面,它们确实有一些特殊性。在您开始认真使用自动模块之前,我想在 9.2 节中讨论这些特殊性。

8.3.1 自动模块名称:小细节,大影响

将普通 JARs 转换为模块的主要目的是能够在模块声明中要求它们。为此,它们需要一个名称,但缺少模块描述符,这个名称从哪里来呢?

首先是清单条目,然后是文件名

确定普通 JAR 的模块名称的一种方法依赖于其清单,这是一个位于 JAR 的 META-INF 文件夹中的文件 MANIFEST.MF。清单包含各种信息,形式为标题-值对。最突出的标题之一是 Main-Class,它通过命名包含 main 方法的类来定义单模块应用程序的入口点——这使得可以使用 java -jar app.jar 启动应用程序。

如果模块路径上的 JAR 文件没有描述符,模块系统将遵循两步过程来确定自动模块的名称:

  1. 它会在清单文件中查找 Automatic-Module-Name 标头。如果找到,它将使用相应的值作为模块的名称。

  2. 如果清单中没有该标头,模块系统将从文件名中推断模块名称。

从清单中推断模块名称比猜测更可靠,因为它要稳定得多——有关详细信息,请参阅第 8.3.4 节。

从文件名推断模块名称的精确规则有点复杂,但细节并不十分重要。以下是关键点:

  • JAR 文件名通常以版本字符串结尾(例如 -2.0.5)。这些会被识别并忽略。

  • 除了字母和数字之外的所有字符都被转换成了点号。

这个过程可能会导致不幸的结果,其中生成的模块名称无效。一个例子是字节码操作工具 Byte Buddy:它在 Maven Central 上发布为 byte-buddy-${version}.jar,这导致自动模块名称为 byte.buddy。不幸的是,这是非法的,因为 byte 是 Java 关键字。(第 9.3.3 节提供了如何修复此类问题的建议。)

为了不让你猜测模块系统为给定的 JAR 文件选择的名称,您可以使用 jar 工具来查询:

$ jar --describe-module --file=${jarfile}

如果 JAR 文件缺少模块描述符,输出将如下所示:

> 未找到模块描述符。派生自动模块。> > ${module-name}@${module-version} 自动 > requires java.base mandated

${module-name} 是实际名称的占位符——这是您要查找的内容。不幸的是,这并不能告诉您名称是从清单条目还是文件名中选择的。要找出这一点,您有几个选择:

  • 使用 jar --file ${jarfile} --extract META-INF/MANIFEST.MF 提取清单,并手动查看。

  • 在 Linux 系统上,unzip -p ${jarfile} META-INF/MANIFEST.MF 将清单文件打印到终端,从而避免了打开文件的需要。

  • 重命名文件,然后再次运行 jar --describe-module 命令。

以下以 Guava 20.0 版本为例:

$ jar --describe-module --file guava-20.0.jar > 未找到模块描述符。派生自动模块。> > guava@20.0 自动 > requires java.base mandated # 省略包含的包

作为自动模块使用时,Guava 20.0 被称为 guava。但这是否是通用的,还是由于模块名称的原因?使用 unzip 命令,我查看了清单文件:

Manifest-Version: 1.0 Build-Jdk: 1.7.0-google-v5 Built-By: cgdecker Created-By: Apache Maven Bundle Plugin [... 省略 OSGi 相关条目 ...]

如您所见,Automatic-Module-Name 没有设置。将文件重命名为 com.google.guava-20.0.jar 后,模块名称变为 com.google.guava。

如果您使用的是较旧的 Guava 版本——例如 23.6,您将得到以下输出:

$ jar --describe-module --file guava-23.6-jre.jar > 未找到模块描述符。派生的自动模块。> > com.google.common@23.6-jre automatic > requires java.base mandated # truncated contained packages

如你所见,所选的名称和文件名并不相同,谷歌选择了 com.google.common 作为 Guava 的模块名称。让我们用 unzip 命令来检查:

Manifest-Version: 1.0 Automatic-Module-Name: com.google.common Build-Jdk: 1.8.0_112-google-v7

就这样:Automatic-Module-Name 已经设置。

设置 AUTOMATIC-MODULE-NAME 的时间

如果你维护的是一个公开发布的项目,这意味着其工件可以通过 Maven Central 或其他公共仓库获取,你应该仔细考虑在清单中何时设置 Automatic-Module-Name。正如我将在第 8.3.4 节中解释的那样,这使将项目作为自动模块的使用更加可靠,但它也带来了未来明确模块将是当前 JAR 文件的直接替代品的承诺。你本质上是在说:“这就是模块的样子;我只是还没有发布它们。”

定义自动模块名称的事实鼓励用户开始依赖你的项目工件作为模块,这有几个重要的含义:

  • 未来模块的名称必须与你现在声明的完全一致。(否则,可靠的配置将让用户感到痛苦,因为缺少模块。)

  • 文件结构必须保持不变,因此你不能将受支持的类或包从一个 JAR 文件移动到另一个 JAR 文件中。(即使没有模块,这种做法也不推荐。但有了类路径,哪个 JAR 文件包含一个类并不重要,所以你可以侥幸逃脱。另一方面,在模块系统的作用下,一个类的来源是相关的,因为可访问性迫使用户要求正确的模块。)

  • 项目在 Java 9+ 上运行得相当好。如果它需要命令行选项或其他解决方案,这些都有很好的文档记录。(否则,你无法确定代码中是否隐藏着使其他承诺失效的问题。)

软件开发当然……让我们说,"并不完全可预测",因此这些不能作为保证。但你应该有充分的理由相信你可以坚持这些承诺。如果你没有带宽在 Java 9+ 上进行测试,或者你发现了使模块化不可预测的问题,请诚实地说明,并且不要设置 Automatic-Module-Name。如果你设置了它并且无论如何都需要进行此类更改,那么需要进行主要版本号的升级。图 8.7 展示了设置 Automatic-Module-Name 的一个示例。

图片

图 8.7 如果你计划在模块化你的项目之前在包之间或 JAR 之间移动类,请等待设置Automatic-Module-Name直到你完成。在这里,项目的 JAR(左侧)在发布带有自动模块名称之前被重构(中间),因此当它们被模块化(右侧)时,结构不会改变。

即使你的项目不需要针对 Java 9+,你也能设置Automatic-Module-Name。JAR 可能包含为旧 JVM 版本编译的字节码,但定义模块名称仍然有助于使用模块系统的用户。正如第 9.3.4 节所解释的,这也适用于模块描述符。

8.3.2 自动模块的模块解决

理解和预测模块系统行为的一个关键因素是了解它在模块解决过程中如何构建模块图。对于显式模块,这是直接的(它遵循要求指令;参见第 3.4.1 节);但对于未命名的模块,则更复杂(参见第 7.2.2 节),因为普通 JAR 不能表达依赖关系。

自动模块也是从普通 JAR 创建的,因此它们也没有显式的依赖关系,这引出了一个问题:在解决过程中它们是如何表现的。我们将稍后回答这个问题,但正如你将看到的,这又引出了一个新的问题:你应该将自动模块的依赖项放在类路径还是模块路径上?当你完成本节后,你就会知道了。

解决自动模块依赖项

需要回答的第一个问题是,当 JPMS 遇到自动模块时,模块解决过程中会发生什么。自动模块是为了面对单模块依赖而创建的模块化,因此它们在开发者积极工作于项目模块表示的情况下被使用。在这种情况下,如果自动模块几乎拉入了所有平台模块(就像未命名的模块所做的那样),这将是有害的,因此它们不会这样做。(为了明确,它们也不会拉入任何显式的应用程序模块。)

然而,JAR 有依赖彼此的倾向;如果模块系统只解决显式要求的自动模块,所有其他自动模块都必须通过--add-modules添加到图中。想象一下,对于一个有数百个依赖项的大型项目,你决定将它们放置在模块路径上,这样做会是什么样子。为了防止这种过度且脆弱的手动模块添加,JPMS 一旦遇到第一个自动模块,就会拉入所有自动模块。

一旦一个自动模块被解决,所有其他模块也会随之解决。你将获得所有作为自动模块的普通 JAR(如果至少需要一个或添加了一个)或者一个也没有(否则)。这就解释了为什么图 8.6 显示了三个监控器模块,尽管只有监控器模块(它不能表达依赖关系)被明确地通过将其作为根模块来解决。

注意,自动模块意味着对其他自动模块的可读性(见第 9.1 节),这意味着任何读取一个模块的模块都会读取所有这些模块。在确定自动模块的依赖关系时,请记住这一点——通过试错可能会导致比所需的 requires 指令更少。

在 ServiceMonitor 应用程序中,monitor.rest 模块依赖于 Spark 网络框架,以及为了这个示例,依赖于 Guava。这两个依赖都是普通 JAR,所以 monitor.rest 需要将它们作为自动模块来要求:

module monitor.rest { requires spark.core; requires com.google.common; requires monitor.statistics; exports monitor.rest; }

问题是,spark.core 或 com.google.common 上的一个 requires 指令可能缺失,但一切仍然可以正常工作。一旦模块系统解析了第一个自动模块,它就会解析所有其他模块,任何读取其中任何一个的模块都会读取所有这些模块。

即使没有 requires com.google.commonguava.jar 也会作为一个自动模块与 spark.core.jar 一起被选中;由于 monitor.rest 读取 spark.core,它也会读取 guava。务必正确确定依赖关系(例如,使用 JDeps—见附录 D)!

模块图中的循环

“自动模块读取所有其他模块”这个细节中隐藏着一个值得注意的细节:这种方法会在模块图中创建循环。显然,至少有一个模块依赖于自动模块(否则它为什么会存在呢?),因此会读取它,同样地,自动模块也会读取它。

虽然这没有实际影响,但我提出来是为了澄清,这并不违反第 3.2.1 节中提到的规则,即不能有静态依赖循环。由于自动模块产生的循环不是静态声明的,而是由模块系统动态引入的。

如果自动模块只能读取其他命名模块,那么你就完成了。一旦你将一个普通 JAR 放在模块路径上,它的所有直接依赖都必须进入模块路径,然后是它们的依赖,以此类推,直到所有传递依赖都被视为模块,无论是显式的还是自动的。

将所有普通 JAR 转换为自动模块有缺点(更多内容见第 8.3.3 节),所以能够将它们留在类路径上,并让它们被无名称模块加载会很好。模块系统正是通过允许自动模块读取无名称模块来实现这一点的,这意味着它们的依赖关系可以是类路径或模块路径。

选择传递依赖关系的路径

对于自动模块的依赖关系,你通常有两个选择(记住,你也可以使用 JDeps 来列出它们):类路径或模块路径。不幸的是,并非所有情况都允许你自由选择,在某些情况下,你需要做的不仅仅是决定路径。

表 8.2 展示了根据它们是否被另一个模块需要以及它们是否是平台模块、平凡 JAR 或模块化 JAR,将这些依赖项引入模块图的选择。以下图例将重点放在特定情况上:

  • 图 8.8 展示了仅由自动模块需要的平台模块默认不会被解决。

图 8.8 如果一个项目(本例中为 your.app)使用自动模块(org.jooq),你不能确定模块图会自动工作。自动模块不表达依赖关系,因此它们需要的平台模块可能不会出现在图中(这里,java.sql 就发生了这种情况),并且必须使用 --add-modules 手动添加。

  • 图 8.9 覆盖了自动模块需要的平凡 JAR 的不同情况。

  • 图 8.10 展示了如果将传递依赖从普通 JAR 转换为模块化 JAR,模块图将如何演变。

表 8.2 如何将自动模块的依赖项添加到模块图中

由另一个显式模块需要的依赖项
平台模块
平凡 JAR
模块化 JAR
由显式模块不需要的依赖项
平台模块
平凡 JAR
模块化 JAR

专注于平台模块一段时间,我们会看到自动模块不能表达对它们的依赖关系。因此,模块图可能包含也可能不包含它们;如果不包含,自动模块在运行时可能会因为缺少类而抛出异常。

解决这个问题的唯一方法是由项目的维护者公开记录他们需要的模块,这样他们的用户就可以确保所需的模块存在。用户可以通过明确要求它们,例如在依赖于自动模块的模块中,或者使用 --add-modules 来实现。

图 8.9 从 monitor.rest(一个模块化 JAR)对 spark.core(一个平凡 JAR)的依赖关系开始,后者需要放置在模块路径上。但它的依赖项 slf4j(另一个平凡 JAR)怎么办?在这里,你可以看到根据 slf4j 是否被另一个模块化 JAR 所需要(顶部与底部行)或放置在哪个路径上(中间与右侧列)而产生的模块图。看起来模块路径是一个明显的胜利,但看看 图 8.10。

图 8.10 在与 图 8.9 右下角相同的情况下,如果一个自动模块的传递依赖项(slf4j)被放置在模块路径上并进行了模块化,会发生什么?它将不再默认解析,需要手动使用 --add-modules 添加。

在检查了平台模块的依赖项之后,让我们看看应用程序模块。如果一个自动模块的依赖项被一个显式模块所需要,它们必须放置在模块路径上,然后由模块系统解析——不需要做其他任何事情。如果没有显式模块需要它们,JAR 包可以被放置在类路径上,在那里它们被合并到未命名的模块中,因此始终可访问,或者被放置在模块路径上,在那里需要某种其他机制将它们拉入图中:

  • 纯粹的 JAR 包是通过自动模块加载的全有或全无方法被拉入的。

  • 平台和显式应用程序模块默认不解析。您必须从其他模块中要求它们,或者使用 --add-modules 手动添加它们(参见第 3.4.3 节)。

结合这样一个事实,即大多数或甚至所有依赖项最终都会从纯 JAR 包转换为模块化 JAR 包,这两个观察结果引起了人们的注意:它们意味着只要它们是纯 JAR 包,模块路径上的传递依赖项就可以正常工作,但一旦它们被模块化,就会从模块图中消失。

让我们专注于第二个要点,并考虑单模块依赖项需要访问的模块。如果您和其他模块都不需要它们,它们将无法进入模块图,依赖项将无法访问它们。在这种情况下,您可以在模块描述符中要求它们(不要忘记添加注释说明为什么这样做)或在使用命令行标志进行编译和启动时添加它们。第 9.2.2 节和第 9.2.3 节简要讨论了该决策中涉及到的权衡,具体取决于特定场景。

道路上的另一个障碍可能是自动模块在其公共 API 中公开的类型。假设一个项目(一个模块化 JAR 包)依赖于一个库(一个纯 JAR 包),该库有一个从 Guava 返回 ImmutableList 的方法(Guava 也是一个纯 JAR 包):

public ImmutableList<String> getAllTheStrings() { // ... }

如果您将项目和库放置在模块路径上,将 Guava 放置在类路径上,您将得到 图 8.11 中所示的模块图:项目(显式模块)读取库(自动模块),该库读取未命名的模块(包含 Guava)。如果代码现在调用返回 ImmutableList 的方法,对该类型的可访问性检查不会对您有利,因为您的模块没有读取未命名的模块。

图片

图 8.11 如果一个自动模块(本例中为 org.lib)中的方法返回未命名的模块(ImmutableList)的类型,那么命名模块(your.app)无法访问它,因为它们没有读取未命名的模块。如果该方法声明返回不可访问的类型(ImmutableList),这将导致应用程序崩溃。声明一个超类型(这里可能是 List)将有效。

这并不是全新的。如果 ImmutableList 是库的非公开类型,你也将无法调用该方法,因为缺乏可见性。就像在那个情况下一样,这也取决于声明的返回类型。如果方法声明返回 List,然后选择 ImmutableList 作为返回的具体类型,那么一切都会正常。这是关于 API 声明哪种类型,而不是它返回哪种类型。

因此,如果一个自动模块暴露了另一个 JAR 的类型,那么该 JAR 也需要添加到模块路径上。否则,其类型最终会出现在未命名的模块中,那里它们对显式模块不可访问。这会导致由于缺少读取边而出现 IllegalAccessError,如第 3.3.3 节所述。

即使你尽了最大努力,如果最终需要命名的模块访问未命名的模块,你只剩下一种选择——字面上的。在第 3.4.4 节中引入的命令行选项 --add-reads 可以通过使用 ALL-UNNAMED 作为目标值,将命名模块到未命名的模块的读取边添加进来。然而,这会将你的模块化代码与不可预测的类路径内容耦合,因此它应该是最后的手段。

通过使用 --add-reads,在类路径上使用 Guava 并返回 ImmutableList 的自动模块最终可以工作。如果获取 ImmutableList 实例(并随后失败访问性检查)的显式模块被命名为 app,那么将 --add-reads app=ALL-UNNAMED 添加到编译器和运行时将使应用程序工作。

所有这些话,你何时选择哪种路径?你应该完全依赖自动模块,还是更倾向于将尽可能多的依赖项留在类路径上?继续阅读以了解详情。

8.3.3 全力投入自动模块?

有能力将普通 JAR 放置在模块路径上以将其转换为自动模块,你还需要类路径吗?难道你不能将每个 JAR 放置在模块路径上,将它们全部转换为显式或自动模块(取决于它们是否包含描述符)?对这个问题的技术答案是,你可以这样做。尽管如此,我不推荐这样做——让我解释原因。

PLAIN JARS DON’T MAKE GOOD MODULES

一般而言,普通 JAR 不适合作为模块:

  • 他们可能访问 JDK 内部 API(参见第 7.1 节)。

  • 他们可能会在它们自己和 JEE 模块之间分割包(参见第 7.2 节)。

  • 他们没有表达他们的依赖关系。

如果它们被转换为自动模块,模块系统将对其施加规则,你可能需要花一些时间来解决由此产生的问题。除此之外,一旦普通 JAR 升级为模块化 JAR,它将默认不再被解析(参见 表 8.2 和 图 8.10),因此对于你项目依赖树中的每个此类升级,你都必须手动添加它。自动模块的唯一优点是它们可以被显式模块要求,但如果你不需要这个,那么你为使一切自动化所付出的努力几乎得不到任何回报。

另一方面,如果留在类路径上,JAR 将被合并到未命名的模块中,

  • 默认情况下,至少允许对 Java 的一个更多版本进行非法访问。

  • JAR 之间的分割并不重要,尽管它们在 JAR 和平台模块之间仍然重要。

  • 如果它们包含应用程序入口点,它们可以读取所有 Java SE 平台模块。

  • 当一个普通 JAR 升级为模块化 JAR 时,无需进行任何操作。

这使得生活变得更加容易。

重要信息 尽管将所有内容都作为模块使用令人兴奋,但我建议你只将使项目工作的最小数量的普通 JAR 放在模块路径上,其余的放在类路径上。

另一方面,自动模块的模块化依赖项通常应该放在模块路径上。因为它们是以模块化 JAR 的形式出现的,所以它们不需要模块系统像对待未命名的模块那样宽容;如果作为模块加载,它们将受益于可靠的配置和强大的封装。

自动模块作为通往类路径的桥梁

在使用更少的自动模块工作时,有一个哲学观点需要提出:这使它们成为模块世界和混乱的类路径之间的桥梁(图 8.12)。模块可以坐在一边,并以自动模块的形式要求它们的直接依赖项,而间接依赖项可以留在另一边。每次你的依赖项变成显式模块时,它就会离开模块一侧的桥梁,并将它的直接依赖项作为自动模块拉到桥梁上。这就是我之前提到的自上而下的方法;我们将在第 9.2 节讨论模块化策略时更详细地探讨它。

图片

图 8.12 河内长贝桥,1939 年。照片由 manhhai 提供。在 Creative Commons CC BY 2.0 许可下使用。

8.3.4 依赖自动模块

自动模块的唯一目的是依赖于普通的 JAR 文件,因此可以在不等待所有依赖项模块化之前创建显式模块。然而,有一个重要的注意事项:如果 JAR 的清单中没有包含 Automatic-Module-Name 条目,依赖项本质上是脆弱的。

如第 8.3.1 节所述,如果没有该条目,自动模块名称将根据文件名推断。但根据它们的配置,不同的项目可能为相同的 JAR 文件使用不同的名称。此外,大多数项目使用由 Maven 支持的本地仓库,其中 JAR 文件命名为${artifactID}-${version},模块系统可能会推断${artifactID}作为自动模块的名称。这是问题所在,因为工件 ID 通常不遵循第 3.1.3 节中定义的反域名命名方案:一旦项目模块化,模块名称很可能会改变。

由于其广泛使用,Google 的 Guava 继续是一个很好的例子。正如你之前看到的,对于guava-20.0.jar,模块系统推导出自动模块名称 guava。这是文件在 Maven 本地仓库中的名称,但其他项目可能有不同的配置。

假设我们将 JAR 命名为${groupID}-${artifactID}-${version},那么文件将被称为com.google.guava-guava-20.0.jar,自动模块名称将是 com.google.guava.guava。另一方面,模块化的 Guava 将被称为 com.google.common,因此没有任何自动模块名称是正确的。

总结来说,相同的 JAR 在不同的项目(取决于它们的配置)和不同时间(在模块化之前和之后)可能会有不同的模块名称。这有可能在下层造成混乱。

想想你最喜欢的项目,并想象其中一个依赖项将其依赖项作为具有不匹配项目设置的自动模块名称引用(参见图 8.13)。也许依赖项命名文件${groupID}-${artifactID}-${version},而你使用 Maven 并将它们命名为${artifactID}-${version}。现在,依赖项需要自动模块${groupID}.${artifactID},但模块系统将在你的项目中推断${artifactID}。这将破坏构建——尽管有方法可以修复它(参见第 9.3.3 节),但没有一个是令人愉快的。

图片

图 8.13 依赖项 org.lib 通过在构建中获得的自动模块名称 com.google.guava.guava 来要求 Guava。不幸的是,在系统上,工件被称为guava.jar,因此推导出模块名称 guava。如果没有进一步的工作,模块系统将抱怨缺少依赖项。

而且情况还在恶化!继续使用同一个项目,并在心理上添加另一个需要相同自动模块但名称不同的依赖项(参见图 8.14)。这就是第 3.2.2 节中描述的“死亡模块钻石”:单个 JAR 文件无法满足具有不同名称的模块的要求,并且由于拆分包的规则,具有相同内容的多个 JAR 文件也无法工作。这种情况必须不惜一切代价避免!

图片

图 8.14 与图 8.12 相比,情况变得更糟。另一个依赖项 com.framework 也依赖于 Guava,但它使用不同的名称(guava)。现在同一个 JAR 需要以两个不同名称的模块出现——这是行不通的。

在这两种情况下,可能看起来关键错误是要求模块通过基于其文件名的模块名来使用一个普通的 JAR。但事实并非如此——使用这种方法对于应用程序和其他开发者完全控制模块描述符要求此类自动模块的场景是可行的。

打破骆驼背的最后一根稻草是将具有此类依赖关系的模块发布到公共存储库。只有在这种情况下,用户才可能处于一个模块隐式依赖于他们无法控制的细节的情况,这可能导致额外的工作甚至无法解决的分歧。

结论是,你不应该发布(到一个公开可访问的存储库)需要普通 JAR 而没有在它的清单中包含Automatic-Module-Name条目的模块。只有有了这个条目,自动模块名称才足够稳定,可以依赖。

是的,这可能意味着你目前还不能发布你库或框架的模块化版本,必须等待你的依赖项添加该条目。这是不幸的,但无论如何这样做都会对你的用户造成极大的不便。

小贴士:迁移和模块化——我们已经涵盖了适用于现有代码库的所有挑战和机制。继续阅读第九章,了解如何最佳应用它们。之后,第三部分将教授模块系统的更高级功能。

摘要

  • 增量模块化通常会使用类路径和模块路径。重要的是要理解,类路径上的任何 JAR(无论是普通还是模块化的)最终都会进入未命名的模块,而模块路径上的任何 JAR 最终都会成为命名模块——无论是自动模块(对于普通 JAR)还是显式模块(对于模块化 JAR)。这使用户(而不是创建者)能够确定它是否成为命名模块。

  • 未命名的模块是一个兼容性特性,使得模块系统可以与类路径一起工作:

  • 它捕获类路径内容,没有名称,读取每个其他模块,并导出和打开所有包。

  • 因为它没有名称,显式模块无法在其模块声明中引用它。一个后果是它们无法读取未命名的模块,因此永远无法使用在类路径上定义的类型。

  • 如果未命名的模块是初始模块,则使用一组特定的规则来确保正确解决模块集。总的来说,这些是非 JEE 模块及其依赖项。这使得类路径上的代码可以读取所有 Java SE API 而无需进一步配置,从而最大化兼容性。

  • 自动模块是一个迁移特性,允许模块依赖于普通的 JAR:

  • 对于模块路径上的每个 JAR 文件,都会创建一个自动模块。其名称由 JAR 文件清单中的Automatic-Module-Name头定义(如果存在)或从其文件名推导出来。它读取每个其他模块,包括未命名的模块,并导出和打开所有包。

  • 它是一个常规命名的模块,因此可以在模块声明中引用它,例如要求它。这允许正在模块化的项目依赖于尚未模块化的其他项目。

  • 自动模块的依赖项可以放置在类路径或模块路径上。使用哪个路径取决于具体情况,但将模块化依赖项放置在模块路径上,将普通依赖项放置在类路径上是一个合理的默认设置。

  • 一旦第一个自动模块被解析,其他所有模块也会随之解析。此外,任何读取一个自动模块的模块都会由于隐含的可读性而读取所有模块。在测试自动模块的依赖关系时,请考虑这一点。

9

迁移和模块化策略

本章涵盖了

  • 准备迁移到 Java 9 及更高版本

  • 持续集成更改

  • 逐步模块化项目

  • 使用 JDeps 生成模块声明

  • 使用jar工具修改第三方 JAR 文件

  • 发布适用于 Java 8 及更早版本的模块化 JAR 文件

第六章、第七章和第八章讨论了迁移到 Java 9+以及将现有代码库转变为模块化代码库的技术细节。本章从更广阔的角度来看,探讨如何将这些细节最佳地组合成成功的迁移和模块化努力。我们首先讨论如何进行逐步迁移,这种迁移与开发过程(尤其是构建工具和持续集成)配合得很好。接下来,我们将探讨如何使用未命名的模块和自动模块作为特定模块化策略的构建块。最后,我们将介绍使 JAR 文件模块化的选项——无论是你的还是你的依赖项的。当你完成本章后,你不仅会了解迁移挑战和模块化功能的机制,还会知道如何最好地在你自己的努力中运用它们。

9.1 迁移策略

在第六章和第七章中收集的所有知识,使你准备好应对 Java 9+可能对你提出的每一个挑战。现在,是时候拓宽你的视野并制定一个更广泛的策略了。你该如何安排这些零散的部分,使迁移尽可能全面和可预测?本节提供了关于准备迁移、估算迁移工作量、在 Java 9+上设置持续构建以及命令行选项的缺点等方面的建议。

注意:本节中的许多主题都与构建工具相关,但它们保持足够的通用性,不需要你了解任何特定工具。同时,我想分享我在 Maven(到目前为止我在 Java 9+上使用的唯一构建工具)上的经验,所以我偶尔会指出我用来满足特定要求的 Maven 功能。不过,我不会深入细节,所以你需要自己弄清楚这些功能是如何帮助你的。

9.1.1 准备更新

首先,如果你还没有在 Java 8 上,你应该进行这次更新!做件好事,一次不要跳过两个或更多 Java 版本。进行更新,确保所有工具和流程正常工作,在生产环境中运行一段时间,然后再处理下一个更新。如果你想要从 Java 8 更新到 11,也是一样,一步一步来。如果你遇到任何问题,你真的会想知道是哪个 Java 版本或依赖项更新导致了这些问题。

谈到依赖项,你还可以在不查看 Java 9+的情况下开始更新它们以及你的工具。除了保持更新的普遍好处外,你可能会无意中从与 Java 9+有问题的版本更新到一个与之兼容的版本。你甚至都不会注意到你遇到了问题。如果还没有与 Java 9+兼容的版本,那么使用你依赖项或工具的最新版本仍然会在兼容版本发布后更容易更新。

采用 AdoptOpenJDK 质量推广

AdoptOpenJDK,“一个由 Java 用户群成员、Java 开发人员和倡导 OpenJDK 的供应商组成的社区”,有一个各种开源项目的列表以及它们在最新和下一个 Java 版本上的表现情况:mng.bz/90HA

9.1.2 估计工作量

有几件事情你可以做来了解接下来会发生什么,我们首先看看这些。下一步是评估和分类你发现的问题。我在本节结束时简要说明一下估计具体数字的方法。

寻找问题

这些是最明显的选择来收集问题列表:

  • 配置你的构建过程以在 Java 9+上编译和测试(Maven: toolchain),理想情况下以能够收集所有错误而不是在第一个错误停止的方式(Maven: --fail-never)。

  • 在 Java 9+上运行你的整个构建过程(Maven: ~/.mavenrc),再次收集所有错误。

  • 如果你正在开发应用程序,按照通常的方式构建它(意味着还不是在 Java 9+上),然后在 Java 9+上运行它。使用--illegal-access=debugdeny来获取有关非法访问的更多信息。

仔细分析输出结果,注意新的警告和错误,并尝试将它们与前面章节讨论的内容联系起来。留意第 6.5.3 节中描述的已删除的命令行选项。

应用一些快速修复措施,如添加导出或 JEE 模块是个好主意。这让你能够看到可能隐藏在良性问题背后的更困难的问题。在这个阶段,没有修复是太快或太脏的——任何能让构建抛出新错误的都是胜利。如果你有太多的编译错误,你可以用 Java 8 编译,然后在 Java 9+上运行测试(Maven: mvn surefire:test)。

然后,在你的项目和依赖项上运行 JDeps。分析对 JDK 内部 API 的依赖(第 7.1.2 节),并注意任何 JEE 模块(第 6.1 节)。还要寻找平台模块和应用 JAR 之间的分割包(第 7.2.5 节)。

最后,搜索你的代码库中调用AccessibleObject::setAccessible(第 7.1.4 节)、转换到URLClassLoader(第 6.2 节)、解析java.version系统属性(第 6.5.1 节)或手工制作资源 URL(第 6.3 节)的地方。把所有找到的东西列在一个大列表上——现在是你分析它的时候了。

这有多糟糕?

你发现的问题应该分为两类:“我在这本书里见过”和“到底发生了什么?”对于前者,进一步将问题分为“至少有一个临时修复”和“是难题。”特别困难的问题包括移除的 API 和平台模块与不实现推荐标准或独立技术的 JAR 之间的包分割。

重要的是不要混淆普遍性与重要性!你可能因为一个 JEE 模块缺失而有大约一千个错误,但修复它是微不足道的。另一方面,如果你的核心功能依赖于应用程序类加载器到URLClassLoader的一次转换,那你就麻烦了。或者你可能有一个对移除的 API 的临界依赖,但由于你很好地设计了系统,它只是导致一个子项目中出现几个编译错误。

一个好的方法是,对于每个你不知道解决方案的具体问题,问自己,“如果我删除了麻烦的代码及其所有依赖,会发生什么?”这会对你的项目造成多大的伤害?按照这个思路,是否有可能暂时禁用麻烦的代码?测试可以忽略,特性可以通过标志来切换。感受一下延迟修复并运行构建和应用程序的可行性。

当你完成时,你应该有三个类别的问题列表:

  • 已知问题但有简单修复

  • 已知的难题

  • 需要调查的未知问题

对于最后两个类别中的问题,你应该知道它们对你的项目有多危险,以及你有多容易在不修复它们的情况下通过。

关于估计数字

很可能有人希望你做出一个涉及一些硬数字的估计——可能是小时,也可能是货币。这通常很难,但在这里尤其有问题。

Java 9+ 迁移让您面对早已过去的决策。您的项目可能紧密耦合到一个您已经多年想要更新的旧版 Web 框架,或者它可能围绕一个未维护的库积累了大量技术债务。不幸的是,这两者都无法在 Java 9+ 上工作。您现在必须偿还一些技术债务——众所周知,费用和利息可能很难估计。最后,就像一场好的老板战斗一样,关键问题——修复成本最高的那个问题——可能隐藏在几个其他麻烦制造者后面,所以您只能在陷入太深时才能看到它。我并不是说这些场景很可能会发生,只是说它们是可能的,所以请小心猜测您迁移到 Java 9 可能需要多长时间。

9.1.3 在 Java 9+ 上持续构建

假设您正在持续构建您的项目,下一步是设置一个成功的 Java 9+ 构建。有许多决策需要做出:

  • 您应该构建哪个分支?

  • 是否应该有一个单独的版本?

  • 如果从第一天开始就无法在 Java 9+ 上完全运行构建,你应该如何切割构建?

  • 您如何保持 Java 8 和 Java 9+ 构建并行运行?

最后,找到适合您项目和持续集成(CI)设置的答案取决于您。让我分享一些在我的迁移中效果很好的想法,您可以根据自己的喜好进行组合。

应该构建哪个分支?

你可能会想为迁移工作设置自己的分支,并让 CI 服务器使用 Java 9+ 构建该分支,而其他分支则像以前一样使用 Java 8。但是,迁移可能需要花费时间,因此很可能会导致长期存在的分支——出于各种原因,我通常尽量避免这种情况:

  • 您将独自一人,您的更改不会持续受到基于这些更改工作的团队的审查。

  • 两个分支都可能积累很多更改,这增加了在更新或合并 Java 9+ 分支时发生冲突的机会。

  • 如果主开发分支上的更改需要一段时间才能进入 Java 9+ 分支,那么其他团队成员可以自由地添加代码,在 Java 9+ 上创建新的问题,而无需立即获得反馈。

虽然在单独的分支上进行迁移的初步调查是有意义的,但我建议尽早切换到主开发分支并在那里设置 CI。但这确实需要您对构建工具进行一些额外的调整,因为您需要根据 Java 版本(Java 编译器不喜欢未知选项)来分离配置的一些部分(例如,编译器的命令行选项)。

应该构建哪个版本?

JAVA 9+构建是否应该创建你工件的一个单独版本——比如-JAVA-LATEST-SNAPSHOT?如果你决定创建一个单独的 JAVA 9+分支,你很可能被迫创建一个单独的版本。否则,很容易混合来自不同分支的快照工件,这很可能会破坏构建,分支越偏离,这种情况就越有可能发生。如果你决定从主开发分支构建,创建一个单独的版本可能不容易;但我从未尝试过,因为我没有找到做这件事的好理由。

无论你如何处理版本,当尝试在 JAVA 9+上使某些内容工作的时候,你可能会偶尔用 JAVA 8 构建相同的子项目并使用相同的版本。尽管我决定不再这样做,但我还是会反复安装我用 JAVA 9+构建的工件。你知道的,就是那种条件反射式的mvn clean install?这并不是一个好主意:然后你无法在 JAVA 8 构建中使用这些工件,因为 JAVA 8 不支持 JAVA 9+的字节码。

当使用 JAVA 9+在本地构建时,尽量记住不要安装工件!我使用mvn clean verify来做这件事。

使用 JAVA 9+能构建什么?

最终目标是让构建工具在 JAVA 9+上运行,并在所有阶段/任务中构建所有项目。根据你之前创建的列表中的项目数量,你可能只需要更改几个东西就能达到这个目标。在这种情况下,就去做吧——没有必要使过程复杂化。另一方面,如果你的列表更令人畏惧,有几种方法可以切割 JAVA 9 构建:

  • 你可以在 JAVA 8 上运行构建,但只编译和测试 JAVA 9+。我稍后会讨论这一点。

  • 你可以按目标/任务进行迁移,这意味着你首先尝试编译整个项目以 JAVA 9+为目标,然后再开始使测试工作。

  • 你可以按子项目进行迁移,这意味着你首先尝试编译、测试和打包整个子项目,然后再进行下一个。

一般而言,对于大型单体项目,我更喜欢“按目标/任务”的方法,如果项目被拆分成足够小以至于可以一次性解决的部分,则采用“按子项目”的方法。

如果你按子项目进行,但其中一个子项目由于某种原因无法在 JAVA 9+上构建,那么你无法轻松构建依赖于它的子项目。我曾经遇到过这种情况,我们决定分两步设置 JAVA 9 构建:

  1. 使用 JAVA 8 构建所有内容。

  2. 除了有问题的子项目外,用 JAVA 9+构建所有内容(然后这些依赖于它们的子项目是用 JAVA 8 的工件构建的)。

在 JAVA 9+上的构建工具

在你的项目完全迁移到 Java 9+之前,你可能需要经常在用 8 和 9+构建之间切换。看看你如何配置你选择的构建工具的 Java 版本,而无需为你的整个机器设置默认的 Java 版本(Maven: ~/.mavenrc或工具链)。然后考虑自动化切换。我最终编写了一个小脚本,将$JAVA_HOME设置为 JDK 8 或 JDK 9+,这样我就可以快速选择我需要的版本。

然后,这有点元信息,构建工具可能在 Java 9+上无法正常工作。可能需要 JEE 模块,或者可能有一个插件使用了已删除的 API。(我有一个使用 Maven 的 JAXB 插件的例子,它需要 java.xml.bind 并依赖于其内部结构。)

在这种情况下,你可以考虑在 Java 8 上运行构建,但只编译或测试针对 Java 9+,但如果构建在其自己的进程中(Java 8)对创建的字节码(Java 9+)执行某些操作,则这不会起作用。(我遇到了 Java 远程方法调用编译器(rmic)的问题;它迫使我们整个构建都在 Java 9+上运行,尽管我们更愿意不这样做。)

如果你决定在 Java 9+上运行构建,即使它表现不佳,你也必须配置构建过程以使用一些新的命令行选项。这样做以便对团队成员来说更容易(没有人想手动添加选项),同时保持它在 Java 8 上也能工作(Java 8 不知道新选项),可能不是一件简单的事情(Maven: jvm.config)。我发现没有方法可以在不要求文件重命名的情况下使它在两个版本上都能工作,所以我最终将其包含在我的“切换 Java 版本”脚本中。

如何配置 Java 9+的构建

当你必须向编译器、测试运行时或其他构建任务添加特定版本的配置选项时,如何保持 Java 8 构建和 Java 9+构建的运行?你的构建工具应该提供帮助。它可能有一个功能允许你根据各种情况调整整体配置(Maven: 配置文件)。熟悉它,因为你可能会经常使用它。

当与 JVM 的特定版本命令行选项一起工作时,有一个替代方案,即让构建工具来处理它们:使用非标准的 JVM 选项-XX:+IgnoreUnrecognizedVMOptions,你可以指示启动的 VM 忽略未知的命令行选项。(此选项在编译器中不可用。)尽管这允许你为 Java 8 和 Java 9+使用相同的选项,但我建议不要将其作为首选,因为它禁用了可以帮助你找到错误的检查。相反,如果可能的话,我更喜欢按版本分离选项。

在两个路径上测试

如果你正在开发一个库或框架,你无法控制用户放置你的 JAR 文件的路径、类路径或模块路径。根据项目的情况,这可能会产生影响,在这种情况下,测试两种变体就变得必要了。

很遗憾,我这里不能提供任何建议。在撰写本文时,Maven 和 Gradle 都没有很好地支持在每个路径上运行测试,你可能不得不创建第二个构建配置。让我们希望工具支持随着时间的推移而改进。

先修复,再解决

通常,Java 9+问题列表中的大多数项目都可以通过命令行标志轻松修复。例如,导出内部 API 很容易。但这并没有解决根本问题。有时解决方案也很简单,比如将内部的sun.reflect.generics.reflectiveObjects.NotImplementedException替换为UnsupportedOperationException(不是开玩笑:我不得不这样做好几次),但通常并不是这样。

你是追求快速且粗糙的解决方案,还是追求更长时间的正确解决方案?在尝试使完整构建正常工作的阶段,我建议采取快速修复:

  • 在必要时添加命令行标志。

  • 关闭测试,最好是仅针对 Java 9+(在 JUnit 4 中,使用假设很容易做到;在 JUnit 5 中,我推荐使用条件)。

  • 如果子项目使用了已删除的 API,将其切换回编译或测试 Java 8。

  • 如果所有其他方法都失败了,就完全跳过该项目。

一个能够立即向整个团队提供项目 Java 9+兼容性反馈的工作构建非常有价值,包括采取捷径来实现这一点。为了能够以后改进这些临时修复,我建议制定一个有助于识别它们的系统。

我用注释如// [JAVA LATEST, <PROBLEM>]: <explanation>标记临时修复,这样全文搜索JAVA LATEST, GEOTOOLS就会把我带到所有必须禁用的测试,因为 GeoTools 版本与 Java 9 不兼容。

在早期构建错误背后发现新问题是常见的。如果发生这种情况,请确保将它们添加到你的 Java 9+问题列表中。同样,划掉那些你已经解决的问题。

保持绿色

一旦你成功设置了构建,你应该对面临的全部 Java 9+挑战有一个完整的了解。现在是时候逐一解决它们了。

一些问题可能很难解决或耗时,你甚至可能确定它们只能在稍后解决——比如在发布一个重要版本或预算有一些灵活性之后。如果需要一些时间,请不要担心。有了每个团队成员都可以破坏和修复的构建,你永远不会走错方向;即使你面前有很多工作,你最终也会一步步到达那里。

9.1.4 关于命令行选项的想法

在 Java 9+中,你可能需要应用比以往更多的命令行选项——对我来说确实是这样。我想分享一些关于以下方面的见解:

  • 应用命令行选项的四种方法

  • 依赖于脆弱的封装

  • 命令行选项的陷阱

逐个来看。

应用命令行选项的四种方法

应用命令行选项最明显的方法是使用命令行,并在 javajavac 后附加选项。但你是否知道还有三种其他可能性?

如果你的应用程序以可执行 JAR 的形式交付,使用命令行不是一种选择。在这种情况下,你可以使用新的清单条目 Add-ExportsAdd-Opens,它们接受以逗号分隔的 ${module}/${package} 对的列表,并将该包导出或对类路径上的代码打开。JVM 只扫描应用程序的可执行 JAR,即通过运行时的 -jar 选项指定的 JAR,以查找这些清单条目,因此没有必要将它们添加到库 JAR 中。

另一种永久设置命令行选项的方法(至少对于 JVM 来说)是环境变量 JDK_JAVA_OPTIONS。它在 Java 9+ 中引入,因此 Java 8 不会拾取它。因此,你可以自由地包含任何特定于 Java 9+ 的命令行选项,这些选项将在你的机器上每次执行 java 时应用。这几乎不会是一个长期解决方案,但它可能会使一些实验更容易进行。

最后,命令行选项不必直接在命令行中输入。一种替代方法是所谓的参数文件(或 @-文件),这些是纯文本文件,可以在命令行中使用 @${filename} 来引用。编译器和运行时会像文件内容已被添加到命令中一样操作。

7.2.4 节展示了如何编译使用 JEE 和 JSR 305 注解的代码:

$ javac --add-modules java.xml.ws.annotation --patch-module java.xml.ws.annotation=jsr305-3.0.2.jar --class-path 'libs/*' -d classes/monitor.rest ${source-files}

在这里,--add-modules--patch-module 被添加以使编译在 Java 9+ 上工作。你可以将这些两行放入一个名为 java-LATEST-args 的文件中,然后按照以下方式编译:

$ javac @java-LATEST-args --class-path 'libs/*' -d classes/monitor.rest ${source-files}

Java 9+ 的新特性是 JVM 也识别参数文件,因此它们可以在编译和执行之间共享。

Maven 和参数文件

不幸的是,参数文件与 Maven 不兼容。编译器插件已经为所有自己的选项创建了一个文件,并且 Java 不支持嵌套的参数文件。

依赖脆弱封装

如 7.1 节详细解释的那样,Java 9–11(或更高)运行时默认允许非法访问,只需一个警告即可。这对于运行未准备好的应用程序来说很棒,但我建议在正式构建过程中不要依赖它,因为它允许新的非法访问悄悄通过而未被注意到。相反,我会收集我需要的所有 --add-exports--add-opens,然后在运行时通过 --illegal-access=deny 激活强封装。

命令行选项的陷阱

使用命令行选项有几个陷阱:

  • 这些选项具有传染性,如果 JAR 需要它们,所有其依赖项也需要它们。

  • 那些需要特定选项的库和框架的开发者希望记录下他们的客户端需要应用这些选项,但没有人会在为时已晚之前阅读文档。

  • 应用程序开发者必须维护一个选项列表,以合并他们使用的几个库和框架的要求。

  • 以一种允许在不同构建阶段和执行之间共享选项的方式维护选项并不容易。

  • 由于 Java 9 兼容版本更新,很难确定哪些选项可以被移除。

  • 将选项应用于正确的 Java 进程可能会很棘手:例如,对于不与构建工具在同一个进程运行的构建工具插件。

这些陷阱清楚地表明:命令行选项是一种权宜之计,而不是一个恰当的解决方案,并且它们有其自身的长期成本。这不是偶然——它们被设计成使得不希望发生的事情成为可能。尽管如此,这并不容易,否则就没有解决根本问题的动力。

尽量只依赖公共和支持的 API,不要拆分包,并通常避免本章描述的麻烦。并且,重要的是要奖励那些做同样事情的库和框架!但通往地狱的道路是由好意铺就的,所以如果其他所有方法都失败了,就使用你所能使用的每一个命令行标志。

9.2 模块化策略

在第八章中,你学习了所有关于未命名模块、自动模块以及混合普通 JAR、模块化 JAR、类路径和模块路径的内容。但如何将这些知识付诸实践?将代码库逐步模块化的最佳策略是什么?为了回答这些问题,想象整个 Java 生态系统是一个巨大的分层图,由各种工件组成(见图 9.1)。

在底层是 JDK,它曾经是一个单独的节点,但由于模块系统的存在,现在由大约一百个节点组成,其中 java.base 作为基础。在其之上是没有任何 JDK 外部运行时依赖的库(如 SLF4J、Vavr 和 AssertJ),然后是只有少数依赖的库(例如 Guava、JOOQ 和 JUnit 5)。在中间位置是具有更深层次结构的框架(例如 Spring 和 Hibernate),而在最顶层则是应用程序。

图 9.1 对 Java 生态系统全局依赖图的美术诠释:java.base 位于底部,其余的 JDK;然后是无第三方依赖的库;再往上是一些更复杂的库和框架;最顶层是应用程序。(不要关注任何个别依赖。)

除了 JDK 之外,所有这些工件在 Java 9 发布时都是普通的 JAR 文件,而且可能需要几年时间才能大多数包含模块描述符。但这是如何发生的?生态系统如何在不破裂的情况下经历如此巨大的变化?由未命名的模块(第 8.2 节)和自动模块(第 8.3 节)启用的模块化策略是答案。这使得 Java 社区几乎可以独立地对生态系统进行模块化。

对于那些最轻松的开发者来说,维护一个没有依赖 JDK 外部或其依赖项已经模块化的项目——他们可以实施自下而上的策略(第 9.2.1 节)。对于应用程序,自上而下的方法(第 9.2.2 节)提供了一种前进的方式。维护具有未模块化依赖项的库和框架的开发者会稍微困难一些,需要从内到外做事(第 9.2.3 节)。

从整体生态系统来看,你的项目在其中的位置决定了你必须使用哪种策略。图 9.2 将帮助你选择正确的策略。但正如第 9.2.4 节所解释的,这些方法也可以在单个项目中使用,在这种情况下,你可以选择这三种中的任何一种。在我们到达那里之前,如果我们假设你一次模块化所有工件,学习这些策略会更容易。

图片 00103

图 9.2 如何决定哪种模块化策略适合你的项目

通过在你的 JAR 文件中包含模块描述符,你宣布该项目已准备好在 Java 9+上作为模块使用。只有在你已经采取所有可能的步骤确保其顺利工作时才应该这样做——第六章和第七章解释了大多数挑战,但如果你的代码使用了反射,你也应该阅读第十二章。

如果用户必须做任何事情才能使你的模块工作,比如向他们的应用程序添加命令行标志,这应该有很好的文档记录。请注意,你可以创建仍然可以在 Java 8 和更早版本上无缝工作的模块化 JAR 文件——第 9.3.4 节为你提供了覆盖。

正如我经常提到的,模块有三个基本属性:一个名称、一个明确定义的 API 和显式的依赖关系。在创建模块时,显然你必须选择名称。导出可能会有争议,但主要是由需要访问哪些类来预定的。真正的挑战,以及生态系统其他部分发挥作用的地方,是依赖关系。本节重点介绍这一方面。

了解你的依赖关系

你必须对你的依赖关系有相当的了解,无论是直接的还是间接的,才能对项目进行模块化。记住,你可以使用 JDeps 来确定依赖关系(尤其是在平台模块方面;参见附录 D)和jar --describe-module来检查 JAR 的模块化状态(参见 4.5.2 节和 8.3.1 节)。

说了这么多,是时候看看三种模块化策略是如何工作的了。

9.2.1 自下而上模块化:如果所有项目依赖项都是模块化的

这是将项目的 JAR 包转换为模块的最简单情况:假设代码只依赖于显式模块(直接和间接)。这些模块是平台模块还是应用模块无关紧要;你可以直接进行:

  1. 创建需要所有直接依赖项的模块声明。

  2. 将包含你的非 JDK 依赖项的 JAR 包放置在模块路径上。

现在,你已经完全模块化了你的项目——恭喜!如果你在维护一个库或框架,并且用户将你的 JAR 包放置在模块路径上,它们将成为显式模块,用户可以开始从模块系统中受益。参见图 9.3 以了解自下而上的模块化示例。

几乎同样重要但不太明显的是,由于所有类路径上的 JAR 包最终都会进入未命名的模块(参见第 8.2 节),没有人被迫将其用作模块。如果有人继续使用类路径一段时间,你的项目将像模块描述符不存在一样正常工作。如果你想要模块化你的库,但你的依赖项还不是模块,请参阅第 9.2.3 节。

图片

图 9.3 依赖于模块化 JAR 包的工件可以立即进行模块化,从而导致自下而上的迁移

9.2.2 自上而下模块化:如果应用程序不能等待其依赖项

如果你是一名应用程序开发者,并且希望尽快模块化,那么你的所有依赖项很可能还没有提供模块化的 JAR 包。如果它们已经有了,那么你很幸运,可以采用我刚才描述的自下而上的方法。否则,你必须使用自动模块,并开始混合模块路径和类路径,如下所示:

  1. 创建需要所有直接依赖项的模块声明。

  2. 将所有模块化 JAR 包,包括你构建的和你依赖的,放置在模块路径上。

  3. 将所有由模块化 JAR 包直接需要的普通 JAR 包放置在模块路径上,在那里它们被转换为自动模块。

  4. 思考如何处理剩余的普通 JAR 包(参见第 8.3.3 节)。

可能最简单的方法是将所有剩余的 JAR 包放置在你的构建工具或 IDE 的模块路径上,并尝试一下。虽然我不认为这通常是最好的方法,但它可能对你有效。在这种情况下,就去做吧。

如果你遇到包拆分或访问 JDK 内部 API 的问题,你可以尝试将这些 JAR 包放置在类路径上。因为只有自动模块需要它们,并且它们可以读取未命名的模块,所以这可以正常工作。

在未来,一旦一个以前自动的模块被模块化,这种设置可能会失败,因为现在它是一个模块化 JAR 文件,位于模块路径上,因此无法访问类路径上的代码。我认为这是一个好事,因为它可以更好地了解哪些依赖项是模块,哪些不是——这也是检查其模块描述符并了解项目的好机会。要解决这个问题,将那个模块的依赖项移动到模块路径上。参见图 9.4 中一个自顶向下的模块化示例。

图片

图 9.4 由于自动模块的存在,可以模块化依赖于普通 JAR 文件的工件。应用程序可以使用这一点从上向下进行模块化。

注意,你不必担心自动模块名称的来源(参见第 8.3.4 节)。确实,如果它们基于文件名,一旦它们获得显式的模块名称,你可能需要更改一些requires指令;但因为你控制所有模块声明,所以这并不是什么大问题。

那么如何确保非模块化依赖项所需的模块进入图中呢?应用程序可以在模块声明中要求它们,或者使用--add-modules在编译和启动时手动添加它们。后者只有在你可以控制启动命令的情况下才是可选的。构建工具可能能够做出这些决定,但你仍然需要了解这些选项以及如何配置它们,以便在出现问题时可以解决问题。

9.2.3 内部-外部模块化:如果项目位于堆栈中间

大多数库和,尤其是框架,既不在堆栈的底部也不在顶部——它们该怎么办?它们从内部向外进行模块化。这个过程包含一点自下而上的(第 9.2.1 节),因为发布模块化 JAR 文件并不强制用户将它们作为模块使用。除此之外,它的工作方式类似于自顶向下(第 9.2.2 节),但有一个重要区别:你计划发布你构建的模块化 JAR 文件。参见图 9.5 中一个内部-外部模块化示例。

图片

图 9.5 如果自动模块被谨慎使用,堆栈中间的库和框架可以发布模块化 JAR 文件,尽管它们的依赖项和用户仍然是普通的 JAR 文件,从而从内部向外模块化生态系统。

正如我在第 8.3.4 节中详细讨论的那样,你应该只发布依赖于自动模块的模块,如果那些普通的 JAR 文件在它们的清单中定义了Automatic-Module-Name条目。否则,当模块名称更改时,造成未来问题的风险太高。

这可能意味着你目前还不能模块化你的项目。如果你处于这种情况,请抵制住无论如何都要做的诱惑,否则你可能会给用户带来困难的问题。

我想要更进一步:检查你的直接和间接依赖项,确保没有任何依赖项依赖于由 JAR 文件名派生的自动模块。你正在寻找任何不是模块化 JAR 且没有定义Automatic-Module-Name条目的依赖项。我不会发布任何包含此类 JAR 的模块描述符——无论是我的依赖项还是他人的。

当涉及到平台模块时,也存在一个微妙的不同之处,这些平台模块是你非模块化依赖项需要的,但你不需要。虽然应用程序可以轻松使用命令行选项,但库或框架却不能。它们只能为用户提供文档说明需要添加,但一些用户可能会忽略这一点。因此,我建议明确要求所有非模块化依赖项需要的平台模块。

9.2.4 在项目内部应用这些策略

使用哪种策略取决于项目在庞大的、生态系统范围内的依赖图中的位置。但如果项目相当大,你可能无法一次性将其全部模块化,并可能想知道如何分步骤进行。好消息是,你可以在较小范围内应用类似的策略。

将自下而上的策略应用于项目通常更容易,首先模块化只依赖于代码库外代码的子项目。如果依赖项已经模块化,这尤其有效,但并不局限于这种情况。如果没有,你需要将自上而下的逻辑应用于子项目的最低层,使它们使用自动模块来依赖于普通 JAR,然后从那里构建起来。

将自上而下的方法应用于单个项目,与应用于整个生态系统时效果相同。在图的最顶层模块化一个工件,将其放置在模块路径上,并将它的依赖项转换为自动模块。然后逐步向下进行依赖树。

你甚至可以反过来操作。第十章介绍了服务:这是一个使用模块系统解耦项目内部以及不同项目之间依赖项的绝佳方式。它们是开始模块化项目依赖图中间某个位置并从那里向上或向下移动的好理由。

重要的是要注意,无论你内部选择了哪种方法,你仍然不能发布依赖于未由 JAR 文件名定义而是由Automatic-Module-Name清单条目定义的名称的自动模块的显式模块。

尽管所有这些都是可能的,但你不应无谓地使事情复杂化。一旦你确定了一种方法,就尝试快速而有条理地将你的项目模块化。将这个过程分解并创建模块意味着你将难以理解项目的依赖图——而这与模块系统的一个重要目标——可靠的配置——是相反的。

9.3 使 JAR 模块化

将一个普通的 JAR 转换为模块化 JAR,你只需要在源代码中添加一个模块声明。简单,对吧?是的(等着听),但是(看这里!)关于这一步还有更多要说,不仅仅是表面上的:

  • 你可能想要考虑创建开放模块(参见 9.3.1 节以获取快速解释)。

  • 你可能会因为创建数十个甚至数百个模块声明而感到不知所措,并希望有一个工具为你完成这项工作(参见 9.3.2 节)。

  • 你可能想要模块化一个你自己没有构建的 JAR,或者可能是依赖项搞乱了它们的模块描述符,你需要修复它(参见 9.3.3 节)。

  • 你可能会对为 Java 8 或更早版本构建的 JAR 中的模块描述符感到好奇——这是否可能(参见 9.3.4 节)?

本节将探讨这些主题,以确保你得到物有所值的回报。

9.3.1 作为中间步骤的开放模块

在应用程序的增量模块化过程中,一个可能有用的概念是开放模块。第 12.2.4 节将详细介绍,但要点是开放模块放弃了强封装:所有其包都是导出和开放的,以便进行反射,这意味着所有其公共类型在编译期间都是可访问的,所有其他类型和成员都可以通过反射访问。它是通过以 open module 开头开始其模块声明来创建的。

当你对 JAR 的包布局不满意时,开放模块会很有用。也许有很多包,或者也许许多包包含你不想公开访问的公共类型——在这两种情况下,重构可能需要太多时间。或者,也许模块在反射中使用得非常频繁,你不想通过确定所有需要打开的包来处理这些问题。

在这种情况下,打开整个模块是将这些问题推迟到未来的好方法。关于技术债务的注意事项适用——这些模块选择放弃强封装,这使他们无法获得其带来的好处。

重要信息:因为将开放模块转换为常规的、封装的模块是一个不兼容的更改,库和框架永远不应该选择从开放模块开始,目的是稍后关闭它。很难想出一个理由说明这样的项目为什么应该发布开放模块。最好是只将其用于应用程序。

9.3.2 使用 JDEPS 生成模块声明

如果你有一个大项目,你可能需要创建数十个甚至数百个模块声明,这是一项艰巨的任务。幸运的是,你可以使用 JDeps 来完成大部分工作,因为这项工作的很大一部分是机械的:

  • 模块名称通常可以从 JAR 名称中推导出来。

  • 一个项目的依赖关系可以通过跨 JAR 边界扫描字节码来分析。

  • 导出是上述分析的逆过程,意味着所有其他 JAR 依赖的包都需要导出。

除了这些基本属性之外,可能还需要进行一些微调,以确保记录所有依赖关系,并配置服务(参见第十章)或更详细的依赖关系和 API(参见第十一章),但所有这些都可以由 JDeps 生成。

使用 --generate-module-info ${target-dir} ${jar-dir} 启动,JDeps 分析 ${jar-dir} 中的所有 JAR 文件,并在 ${target-dir}/${module-name} 中为每个 JAR 生成 module-info.java 文件:

  • 模块名称是从 JAR 文件名派生出来的,就像自动模块(包括注意 Automatic-Module-Name 标头;参见第 8.3.1 节)一样。

  • 依赖关系基于 JDeps 的依赖关系分析。暴露的依赖关系用 transitive 关键字标记(参见第 11.1 节)。

  • 所有包含在分析中其他 JAR 所用类型的包都被导出。

当 JDeps 生成 module-info.java 文件时,你需要检查和调整它们,并将它们移动到正确的源文件夹中,以便你的下一次构建可以编译和打包它们。

再次假设 ServiceMonitor 尚未模块化,你可以使用 JDeps 生成模块声明。为此,你构建 ServiceMonitor,并将它的 JAR 文件及其依赖项一起放在一个名为 jars 的目录中。然后你调用 jdeps --generate-module-info declarations jars,JDeps 生成模块声明,并将其写入如图 9.6 所示的目录结构中。

图片

图 9.6 在你调用 jdeps --generate-module-info declarations jars 之后,JDeps 分析 jars 目录中所有 JAR 之间的依赖关系(未显示),并在 declarations 目录中为它们创建模块声明(非 ServiceMonitor 项目未显示)。

JDeps 为每个模块创建一个文件夹,并将类似你之前手动编写的模块声明放入其中。(为了唤起你的记忆,你可以在 列表 2.2 中找到它们,但细节在这里并不重要。)

JDeps 还可以使用 --generate-open-module 为开放模块生成模块声明(参见第 12.2.4 节)。模块名称和 requires 指令与之前一样确定;但由于开放模块不能封装任何内容,不需要导出,因此没有生成。

检查生成的声明

JDeps 在生成模块声明方面做得很好,但你仍然需要手动检查它们。模块名称是否符合你的喜好?(可能不是,因为 JAR 名称很少遵循反向域名命名方案;参见第 3.1.3 节。)依赖关系是否被正确建模?(有关更多选项,请参阅第 11.1 和 11.2 节。)这些是否是你希望你的公共 API 包含的包?你可能需要添加一些服务。(参见第十章。)

如果你开发的应用程序有太多的 JAR 需要手动检查所有声明,并且你能够容忍一些小问题,那么有一个更宽容的选项:你可以信任你的测试、CI 管道以及你的同事和测试人员来发现这些小问题。在这种情况下,确保在下一个版本发布之前留出一些时间,这样你可以确信你已经修复了一切。

如果你正在发布工件,那么你必须非常仔细地检查声明!这些是你 API 最公开的部分,更改它们通常是不兼容的——努力防止在没有充分理由的情况下发生这种情况。

小心缺少的依赖项

为了 JDeps 能够为一系列 JAR 生成正确的 requires 指令,所有这些 JAR 以及它们的所有直接依赖都必须存在于扫描的目录中。如果依赖项缺失,JDeps 将会如下报告:

> 缺少依赖:> .../module-info.java 未生成 > 错误:缺少依赖 > depending.type -> missing.type 未找到 > ...

为了避免错误的模块声明,如果模块中不是所有依赖项都存在,则不会为这些模块生成任何声明。

在为 ServiceMonitor 生成模块声明时,我忽略了这些信息。一些间接依赖项缺失,可能是由于 Maven 将它们视为可选的,但这并没有阻碍 ServiceMonitor 声明的正确创建:

> 缺少依赖:> declarations/jetty.servlet/module-info.java 未生成 # 省略更多日志信息 > 缺少依赖:> declarations/utils/module-info.java 未生成 # 省略更多日志信息 > 缺少依赖:> declarations/jetty.server/module-info.java 未生成 # 省略更多日志信息 > 缺少依赖:> declarations/slf4j.api/module-info.java 未生成 # 省略更多日志信息 > 错误:缺少依赖 > org.eclipse.jetty.servlet.jmx.FilterMappingMBean > -> org.eclipse.jetty.jmx.ObjectMBean 未找到 > org.eclipse.jetty.servlet.jmx.HolderMBean > -> org.eclipse.jetty.jmx.ObjectMBean 未找到 > org.eclipse.jetty.servlet.jmx.ServletMappingMBean > -> org.eclipse.jetty.jmx.ObjectMBean 未找到 > org.eclipse.jetty.server.handler.jmx.AbstractHandlerMBean > -> org.eclipse.jetty.jmx.ObjectMBean 未找到 > org.eclipse.jetty.server.jmx.AbstractConnectorMBean > -> org.eclipse.jetty.jmx.ObjectMBean 未找到 > org.eclipse.jetty.server.jmx.ServerMBean > -> org.eclipse.jetty.jmx.ObjectMBean 未找到 > org.slf4j.LoggerFactory > -> org.slf4j.impl.StaticLoggerBinder 未找到 > org.slf4j.MDC > -> org.slf4j.impl.StaticMDCBinder 未找到 > org.slf4j.MarkerFactory > -> org.slf4j.impl.StaticMarkerBinder 未找到

仔细分析导出项

导出指令仅基于分析其他 JAR 需要哪些类型的依赖。这几乎保证了库 JAR 将看到非常少的导出项。在检查 JDeps 输出时请记住这一点。

作为库或框架开发者,你可能不希望发布导出你认为是项目内部包的工件,仅仅因为你的几个模块需要它们。请查看第 11.3 节中的合格导出,以解决这个问题。

9.3.3 破解第三方 JAR

有时可能需要更新第三方 JAR。可能你需要的是显式模块或至少是具有特定名称的自动模块。也许它已经是一个模块,但模块描述符有误或与你不希望引入的依赖项造成问题。在这种情况下,是时候拿出锋利的工具开始工作了。(小心不要割伤自己。)

一个很好的例子是,在像 Java 这样庞大的生态系统中,必然存在的奇怪边缘情况之一是字节码操作工具 Byte Buddy。它以bytebuddy-${version}.jar的形式发布在 Maven Central 上,当你尝试将其用作自动模块时,模块系统会给出以下回复:

> byte.buddy: 无效的模块名称:'byte' 不是一个 Java 标识符

哎呀:byte不是一个有效的 Java 标识符,因为它与同名的原始类型冲突。这个特定的情况在 Byte Buddy 1.7.3 及以后的版本中得到了解决(通过Automatic-Module-Name条目),但你可能会遇到类似的边缘情况,需要做好准备。

通常,不建议本地修改已发布的 JAR,因为这很难可靠且以自文档化的方式进行。如果你的开发流程包括一个所有开发者都连接到的本地工件存储库,如 Sonatype 的 Nexus,那么这会变得容易一些。在这种情况下,某人可以创建一个修改过的变体,将版本更改为使修改明显(例如,通过添加-patched),然后将其上传到内部存储库。

在构建过程中执行修改也可能是有可能的,在这种情况下,可以即时使用和编辑标准 JAR,根据需要。修改后将成为构建脚本的一部分。

注意,你永远不应该发布依赖于修改过的 JAR 的工件!用户将无法轻松地重现这些修改,并且会留下一个损坏的依赖项。这很大程度上限制了以下建议仅适用于应用程序。

在排除这些注意事项之后,让我们看看如果第三方 JAR 与你的项目不兼容时,如何操作第三方 JAR。我会展示如何添加或编辑自动模块名称,添加或编辑模块描述符,以及将类添加到模块中。

添加和编辑自动模块名称

除了 JPMS 无法从中推导出名称的场景之外,将自动模块名称添加到 JAR 中的另一个很好的理由是,如果项目在较新版本中已经定义了一个名称,但你由于某种原因还不能更新到它。在这种情况下,编辑 JAR 允许你在模块声明中使用一个未来兼容的名称。

jar 工具有一个选项 --update(备选是 -u),允许修改现有的 Java 归档。结合 --manifest=${manifest-file} 选项,你可以将任何内容添加到现有的清单中——例如,Automatic-Module-Name 条目。

让我们以一个较旧的 Byte Buddy 版本,版本 1.6.5,为例,确保它作为一个自动模块正常工作。首先创建一个纯文本文件,比如 manifest.txt(你可以选择任何你想要的名称),其中包含一行:

Automatic-Module-Name: net.bytebuddy

然后使用 jar 将该行添加到现有的清单中:

$ jar --update --file bytebuddy-1.6.5.jar --manifest=manifest.txt

现在,让我们检查它是否工作:

$ jar --describe-module --file bytebuddy-1.6.5.jar > 未找到模块描述符。派生自动模块。 > > net.bytebuddy@1.6.5 自动 > 需要 java.base 强制指定

很好:没有错误,模块名称符合预期。

同样的方法可以用来编辑现有的自动模块名称。jar 工具会抱怨 Manifest 中的重复名称,但新值仍然会替换旧值。

添加和编辑模块描述符

如果将第三方 JAR 转换为正确命名的自动模块还不够,或者你在显式模块上遇到麻烦,可以使用 jar --update 来添加或覆盖模块描述符。后者的重要用例是解决第 8.3.4 节中描述的模块死亡菱形问题:

$ jar --update --file ${jar} module-info.class

这会将文件 module-info.class 添加到 ${jar}。请注意,--update 不会执行任何检查。这使得意外或故意创建模块描述符和类文件不一致的 JAR 文件变得容易,例如在必需的依赖关系上。请谨慎使用!

更复杂的任务是创建模块描述符。为了编译器创建一个,你需要的不只是模块声明,还有所有依赖(它们的存放在可靠配置的一部分中进行检查)以及 JAR 代码的一些表示(作为源代码或字节码;否则编译器会抱怨不存在包)。

你的构建工具应该能够帮助你处理依赖关系(Maven:copy-dependencies)。对于代码来说,编译器看到整个模块,而不仅仅是声明,这一点非常重要。这最好通过在模块的字节码通过 --patch-module 添加时编译声明来实现。第 7.2.4 节介绍了该选项,以下示例展示了如何使用它:

$ jdeps --generate-module-info . jars ①``# 编辑 ${module-name}/module-info.java ②``$ javac --module-path jars --patch-module ${module-name}=jars/${jar} ${module-name}/module-info.java $ mv ${module-name}/module-info.java . ④``$ jar --update --file jars/${jar} module-info.class ⑤``$ jar --describe-module --file jars/${jar}

为所有 JAR 文件生成模块声明(尽管只有 ${jar} 的那个对我们感兴趣)

按照你的需求编辑声明。

使用 jars 作为模块路径编译声明,并通过 --patch-module 选项将模块的字节码修补到模块中

将 ${jar} 的模块描述符移动到根目录(否则更新 JAR 文件将无法正常工作)

将模块描述符添加到 ${jar}

验证一切是否正常——模块现在应该具有所需的属性

向模块中添加类

如果你已经需要向依赖项的包中添加一些类,你可能已经将它们放在了类路径上。一旦那个依赖项移动到模块路径,反对拆分包的规则就禁止了那种方法。第 7.2.4 节展示了如何使用 --patch-module 选项动态处理这种情况。如果你在寻找一个更持久的解决方案,你还可以再次使用 jar --update,在这种情况下是为了添加类文件。

9.3.4 为 JAVA 8 及更早版本发布模块化 JAR

无论你是维护一个应用程序、库还是框架,你可能需要针对多个 Java 版本。这意味着你必须跳过模块系统吗?幸运的是,不是!有两种方法可以提供在 Java 9 版本之前的版本上运行良好的模块化工件。

无论你选择哪种方式,首先你需要为目标版本构建你的项目。你可以使用对应 JDK 的编译器,或者通过设置 -source-target 使用一个更新的版本。如果你选择了 Java 9+ 编译器,请查看第 4.4 节中的新标志 --release。完成这一步后,就像平常一样创建一个 JAR 文件。请注意,这个 JAR 文件在你的期望的 Java 版本上运行得非常好,但还没有包含模块描述符。

下一步是使用 Java 9+ 编译模块声明。最好的和最可靠的方法是使用 Java 9+ 编译器构建整个项目。现在你有两种方法可以将模块描述符放入你的 JAR 文件中,下面将进行描述。

使用 JAR --UPDATE

你可以使用第 9.3.3 节中描述的 jar --update 命令将模块描述符添加到 JAR 文件中。这是因为版本 9 之前的 JVM 忽略模块描述符。它们只看到其他类文件;而且因为你为正确的版本构建它们,所以一切正常。

虽然这在 JVM 中是正确的,但对于处理字节码的所有工具来说并不一定如此。有些工具会遇到module-info.class并因此对模块 JAR 变得无用。如果你想防止这种情况,你必须创建一个多版本 JAR。

创建多版本 JAR

从 Java 9 开始,jar允许创建多版本 JAR(MR-JAR),其中包含不同 Java 版本的字节码。附录 E 对这个新特性进行了详细介绍;为了充分利用这一部分,你应该阅读它。在这里,我专注于如何使用 MR-JAR,以确保模块描述符不会出现在 JAR 的根目录中。

假设你有一个常规 JAR,并想将其转换为多版本 JAR,其中模块描述符在 Java 9(及以后版本)上加载。以下是使用--update--release进行转换的方法:

$ jar --update --file ${jar} --release 9 module-info.class

你也可以一次创建一个多版本 JAR:

$ jar --create --file mr.jar -C classes . --release 9 classes-9/module-info.class

前三行是从classes中的类文件创建 JAR 的常规方式。然后是--release 9,后面跟着 JVM 版本 9 及以上需要加载的额外源。图 9.7显示了生成的 JAR——正如你所见,根目录不包含module-info.class

图 9.7 通过创建多版本 JAR,你可以将模块描述符放在META-INF/versions/9而不是工件根目录下。

这个功能远不止添加模块描述符。所以,如果你还没有的话,我建议阅读附录 E。

现在我们已经涵盖了绿色字段项目和现有代码库的基本知识,继续阅读以了解模块系统的高级功能在第三部分。

摘要

  • 如果你还没有使用 Java 8,首先进行这个更新。如果初步分析显示你的某些依赖项在 Java 9+上存在问题,接下来更新它们。这确保了你一次只迈出一小步,从而将复杂性保持在最低。

  • 你可以采取一些措施来分析迁移问题:

  • 在 Java 9+上构建,并应用快速修复(--add-modules--add-exports--add-opens--patch-module等)以获取更多信息。

  • 使用 JDeps 来查找分割包和内部 API 的依赖关系。

  • 搜索导致问题的特定模式,如对URLClassLoader的转换和使用已删除的 JVM 机制。

  • 收集这些信息后,正确评估它们非常重要。快速修复的风险是什么?正确解决它们有多难?受影响的代码对你的项目有多重要?

  • 当你开始迁移时,努力持续构建你的更改,理想情况下是从团队其他成员使用的同一分支开始。这确保了 Java 9+的努力和常规开发得到了很好的整合。

  • 命令行选项使你能够快速解决在 Java 9+上使构建工作时的挑战,但要注意不要长时间保留它们。它们使得忽略问题变得容易,直到未来的 Java 版本加剧这些问题。相反,应致力于长期解决方案。

  • 存在三种模块化策略。适用于整个项目的策略取决于其类型和依赖关系:

  • 自下而上的方法适用于仅依赖于模块的项目。创建模块声明,并将所有依赖项放置在模块路径上。

  • 自上而下的方法适用于尚未完全模块化的应用程序。它们可以创建模块声明,并将所有直接依赖项放置在模块路径上——普通 JAR 文件被转换为可以依赖的自动模块。

  • 自内而外适用于尚未完全模块化的库和框架。它的工作方式类似于自上而下,但有一个限制,即只能使用定义了Automatic-Module-Name清单条目的自动模块。否则,自动模块名称在构建设置和时间上可能不稳定,这可能导致用户遇到重大问题。

  • 在项目内部,你可以选择适合其特定结构的任何策略。

  • JDeps 允许使用jdeps --generate-module-info自动生成模块声明。这对于大型项目尤其相关,手动编写模块声明会花费大量时间。

  • 使用jar工具的--update选项,你可以修改现有的 JAR 文件:例如,设置Automatic-Module-Name或添加或覆盖模块描述符。如果依赖项的 JAR 文件存在无法解决的错误,这是解决这些问题的最有效工具。

  • 通过为较旧的 Java 版本编译和打包源代码,然后添加模块描述符(可以在 JARs 根目录中,或者使用jar --version命令添加到 Java 9+特定的子目录中),你可以创建在多种 Java 版本上运行的模块化 JAR 文件,如果放置在 Java 9 模块路径上,还可以作为一个模块使用。

第三部分

高级模块系统功能

而第一部分和第二部分类似于四道菜的晚餐,这本书的这一部分更像是一个自助餐。它涵盖了模块系统的先进功能,你可以自由选择你最喜欢的内容,以你喜欢的顺序进行选择。

第十章介绍了服务,这是一种将用户与 API 的实现解耦的强大机制。如果你对精炼 requiresexports 更感兴趣——例如,用于建模可选依赖项——请查看第十一章。查看第十二章,以准备你的模块供你喜欢的框架进行反射访问,并学习如何更新你自己的反射代码。

模块系统不处理模块版本信息,但在构建模块时可以记录它,并在运行时评估它。第十三章也探讨了这一点,以及为什么没有进一步支持版本的原因,例如运行同一模块的多个版本。

第十四章从开发模块退后一步,而是将它们视为创建自定义运行时图像的输入,这些图像仅包含运行你的项目所需的模块。更进一步,你可以包括你的整个应用程序,创建一个可部署的单个单元,以便发送给你的客户或服务器。

最后,第十五章将所有这些内容整合在一起。它展示了 ServiceMonitor 应用程序的一个变体,该变体使用了大多数高级功能,然后给出了一些设计和维护模块化应用程序的技巧,在勇敢地描绘 Java 的未来:一个模块化生态系统之前。

顺便说一句,这些功能在复杂程度上并不先进,它们只是比基本机制更复杂。它们建立在那些机制之上,因此需要更多关于模块系统的背景知识。如果你已经阅读了第一部分,特别是第三章,你就准备好了。

(我知道,我已经说过几次了,但请记住,我选择的模块名称被缩短了,以便更容易使用。请使用 3.1.3 节中描述的反域名命名方案。)

10

使用服务解耦模块

本章涵盖

  • 使用服务改进项目设计

  • 在 JPMS 中创建服务、消费者和提供者

  • 使用 ServiceLoader 消费服务

  • 开发设计良好的服务

  • 在不同 Java 版本中部署普通和模块化的 JAR 文件

到目前为止,我们使用 requires 指令来表示模块之间的关系,其中依赖模块必须通过名称引用每个特定的依赖项。正如 3.2 节深入解释的那样,这位于可靠配置的核心。但有时你想要一个更高层次的抽象。

本章探讨了模块系统中的服务以及如何通过消除它们之间的直接依赖来使用它们解耦模块。解决服务问题的第一步是掌握基础知识。在此基础上,我们查看细节,特别是如何正确设计服务(第 10.3 节)以及如何使用 JDK 的 API 来消费它们(第 10.4 节)。(要查看服务的实际应用,请查看 ServiceMonitor 存储库中的feature-services分支。)

到本章结束时,你将了解如何设计良好的服务,如何为使用或提供服务的模块编写声明,以及如何在运行时加载服务。你可以使用这些技能来连接 JDK 或第三方依赖项中的服务,以及在你自己的项目中消除直接依赖。

10.1 探索服务需求

如果我们谈论的是类而不是模块,你会对总是依赖于具体类型感到满意吗?或者需要为需要它的每个类实例化每个依赖?如果你喜欢像控制反转和依赖注入这样的设计模式,你现在应该强烈地摇头。比较列表 10.1 和 10.2——第二个看起来不是更好吗?它允许调用者选择成为出色的流,甚至给调用者选择任何InputStream实现的自由。

列表 10.1 依赖于具体类型并建立依赖

public class InputStreamAwesomizer { private final ByteArrayInputStream stream; public AwesomeInputStream(byte[] buffer) { stream = new ByteArrayInputStream(buffer); } // [... 精彩方法 ...] }

依赖于具体类型

直接建立依赖

列表 10.2 依赖于抽象类型;调用者建立依赖

public class InputStreamAwesomizer { private final InputStream stream; public AwesomeInputStream(InputStream stream) { this.stream = stream; } // [... 精彩方法 ...] }

依赖于抽象类型

调用者建立的依赖

依赖于接口或抽象类并让其他人选择具体实例的另一个重要好处是,这样做反转了依赖的方向。不是高级概念(比如说Department)依赖于低级细节(SecretaryClerkManager),两者都可以依赖于一个抽象(Employee)。如图 10.1 所示,这打破了高级和低级概念之间的依赖关系,从而将它们解耦。

图 10.1 如果一个类型建立了自己的依赖(顶部),用户无法影响它们。如果一个类型的依赖在构建过程中传递(底部),用户可以选择最适合其用例的实现。

回到模块,requires指令与列表 10.1 中的代码类似,只是在不同的抽象级别上:

  • 模块依赖于其他具体模块。

  • 用户无法交换依赖项。

  • 无法反转依赖项的方向。

幸运的是,模块系统并没有止步于此。它提供了服务,这是一种模块表达它们依赖于抽象类型或提供满足此类依赖的具体类型的方式,模块系统在其中居中协调。(如果您现在正在考虑服务定位器模式,您完全正确!)正如您将看到的,服务并不完美地解决所有提到的问题,但它们已经走了很长的路。图 10.4 展示了两种类型的依赖。

图片

图 10.2 如果一个模块需要另一个(顶部),则依赖项是固定的;它不能从外部更改。另一方面,如果一个模块使用服务(底部),则具体的实现是在运行时选择的。

10.2 Java 平台模块系统中的服务

当我们在 JPMS 的上下文中谈论一个服务时,它通常指的是我们想要使用的一个特定类型,通常是一个接口,但我们不实例化其实现。相反,模块系统会从其他模块中拉入它们提供的实现,并实例化这些实现。本节详细展示了这一过程,以便您知道应该将什么放入模块描述符中,以及如何在运行时获取实例,以及这对模块解析有何影响)。

10.2.1 使用、提供和消费服务

服务是一个可访问的类型,一个模块想要使用,而另一个模块提供其实例:

  • 消费服务的模块使用其模块描述符中的uses ${service}指令来表达其需求,其中${service}是服务类型的完全限定名。

  • 提供服务的模块使用provides ${service} with ${provider}指令来表达其提供,其中${service}uses指令中的类型相同,而${provider}是另一个类的完全限定名,该类是以下之一:

  • 一个扩展或实现${service}的具体系列,并且有一个公共、无参数的构造函数(称为提供者构造函数)

  • 一个具有公共、静态、无参数方法provide的任意类型,该方法返回一个扩展或实现${service}的类型(称为提供者方法)

在运行时,依赖模块可以使用ServiceLoader类通过调用ServiceLoader.load(${service}.class)来获取服务的所有提供实现。然后,模块系统为模块图中声明的每个提供者返回一个Provider<${service}>。图 10.3 说明了实现Provider的方法。

图片

图 10.3 使用服务的核心是一个特定的类型,这里称为ServiceProvider类实现了它,包含它的模块通过provides — with指令声明了这一点。需要使用服务的模块需要通过uses指令声明。在运行时,它们可以使用ServiceLoader来获取给定服务的所有提供者的实例。

在服务周围有很多细节需要考虑;但一般来说,它们是一个很好的抽象,并且在实践中使用起来很直接,所以让我们从这里开始。请坐好;走流程比输入requiresexports指令要花的时间长。

ServiceMonitor 应用程序提供了一个很好的服务使用示例。来自monitormoduleMonitor类需要一个List<ServiceObserver>来联系它应该监控的服务。到目前为止,Main是这样做的:

private static Optional<ServiceObserver> createObserver(String serviceName) { return AlphaServiceObserver.createIfAlphaService(serviceName) .or(() -> BetaServiceObserver.createIfBetaService(serviceName)); }

代码的具体工作原理并不是特别重要。重要的是它使用了来自monitor.observer.alpha的具体系列类型AlphaServiceObserver和来自monitor.observer.betaBetaServiceObserver。因此,monitor 需要依赖于这些模块,并且它们需要导出相应的包——图 10.4 显示了模块图中匹配的部分。

图 10.4 没有服务的情况下,monitor 模块需要依赖于所有其他涉及的模块:observer、alpha 和 beta,如图中部分模块图所示。

现在,让我们将其转换为服务。首先,创建这些观察者的模块需要声明它打算使用一个服务。首先使用ServiceObserver,所以 monitor 的模块声明看起来像这样:

module monitor { // [...省略的 requires 指令...] // 移除了对 monitor.observer.alpha 和 beta 的依赖 - 好耶!使用 monitor.observer.ServiceObserver; }

第二步是在提供者模块 monitor.observer.alpha 和 monitor.observer.beta 中声明provides指令:

module monitor.observer.alpha { requires monitor.observer; // 移除了 monitor.observer.alpha 的导出 - 好耶!提供 monitor.observer.ServiceObserver,使用 monitor.observer.alpha.AlphaServiceObserver; }

虽然这样不行——编译器抛出了一个错误:

> 服务实现没有 > 公共默认构造函数: > AlphaServiceObserver

提供者构造函数和提供者方法需要是无参数的,但AlphaServiceObserver期望观察的服务 URL。怎么办?你可以在创建后设置 URL,但这会使类可变,并引发如果服务不是 alpha 时应该怎么办的问题。不,创建一个观察者工厂更干净,该工厂仅在 URL 正确时返回一个实例,并将该工厂作为服务。

因此,在 monitor.observer 中创建一个新的接口,ServiceObserverFactory。它有一个单独的方法,createIfMatchingService,该方法期望服务 URL 并返回一个Optional<ServiceObserver>。在 monitor.observer.alpha 和 monitor.observer.beta 中,创建实现,这些实现与AlphaServiceObserverBetaServiceObserver上的静态工厂方法曾经执行的操作相同。图 10.5 显示了模块图的相应部分。

图 10.5 使用服务,监控器只依赖于定义服务的模块:observer。提供模块 alpha 和 beta 不再直接需要。

使用这些类,你可以作为服务提供和消耗ServiceObserverFactory。以下列表显示了 monitor、monitor.observer、monitor.observer.alpha 和 monitor.observer.beta 的模块声明。

列表 10.3 与ServiceObserverFactory一起工作的四个模块

module monitor { requires monitor.observer; // [... truncated other requires directives ...] uses monitor.observer.ServiceObserverFactory; } module monitor.observer { exports monitor.observer; } module monitor.observer.alpha { requires monitor.observer; provides monitor.observer.ServiceObserverFactory with monitor.observer.alpha.AlphaServiceObserverFactory; } module monitor.observer.beta { requires monitor.observer; provides monitor.observer.ServiceObserverFactory with monitor.observer.beta.BetaServiceObserverFactory; }

消耗模块监控器需要 monitor.observer,因为它包含 ServiceObserverFactory。多亏了服务,它不需要 alpha 或 beta。

消耗模块监控器使用服务接口 ServiceObserverFactory。

monitor.observer 没有变化:它不知道它被用作服务。所需的一切只是包含 ServiceObserver 和 ServiceObserverFactory 的包的常规导出。

两个提供模块都需要 monitor.observer,因为它们实现了它包含的接口——服务没有改变什么。

每个提供模块都向服务 ServiceObserverFactory 提供其具体类。

最后一步是在监控器中获取观察器工厂。为此,调用ServiceLoader.load(ServiceObserverFactory.class),对返回的提供者进行流处理,并获取服务实现:

List<ServiceObserverFactory> observerFactories = ServiceLoader .load(ServiceObserverFactory.class).stream() .map(Provider::get) .collect(toList());

Provider::get实例化一个提供者(参见第 10.4.2 节)。

现在你有了服务提供者,消费模块和提供模块都不知道对方。它们之间唯一的联系是它们都依赖于 API 模块。

平台模块也声明并使用了大量服务。其中一个特别有趣的是java.sql.Driver,由 java.sql 声明和使用:

$ java --describe-module java.sql > java.sql # truncated exports # truncated requires > uses java.sql.Driver

这样,java.sql 就可以访问其他模块提供的所有Driver实现。

平台中服务的另一个典型用途是java.lang.System.LoggerFinder。这是 Java 9 中添加的新 API 的一部分,允许用户将 JDK 的日志消息(不是 JVM 的!)导入他们选择的日志框架(例如,Log4J 或 Logback)。而不是写入标准输出,JDK 使用LoggerFinder来创建Logger实例,然后使用它们记录所有消息。

对于 Java 9 及以后的版本,日志框架可以实现使用框架基础设施的日志记录器工厂:

public class ForesterFinder extends LoggerFinder { @Override public Logger getLogger(String name, Module module) { return new Forester(name, module); } }

属于虚构的 Forester 日志框架

但日志框架如何通知 java.base 它们的LoggerFinder实现呢?很简单:它们提供自己的实现来提供LoggerFinder服务:

module org.forester { provides java.lang.System.LoggerFinder with org.forester.ForesterFinder; }

这之所以有效,是因为基础模块使用了LoggerFinder,然后调用ServiceLoader来定位LoggerFinder的实现。它获取一个框架特定的查找器,要求它创建Logger实现,然后使用它们来记录消息。

这应该能给你一个如何创建和使用服务的良好概念。接下来是细节!

10.2.2 服务模块解析

如果你曾经启动了一个简单的模块化应用程序并观察模块系统正在做什么(例如,使用--show-module-resolution,如第 5.3.6 节所述),你可能会对解析的平台模块数量感到惊讶。对于像 ServiceMonitor 这样的简单应用程序,唯一的平台模块应该是 java.base 以及可能的一个或两个更多,那么为什么有这么多其他模块呢?服务就是答案。

基本信息 记住从 3.4.3 节中提到的,只有那些在模块解析过程中进入图的模块在运行时才是可用的。为了确保所有可观察的服务提供者都符合这一条件,解析过程会考虑usesprovides指令。除了 3.4.1 节中描述的解析行为之外,一旦解析了一个消耗服务的模块(例如 monitor 或 java.base),它就会将提供该服务的所有可观察模块添加到图中。这被称为绑定。

使用--show-module-resolution选项启动 ServiceMonitor 会显示许多服务绑定:

$ java --show-module-resolution --module-path mods:libs --module monitor > root monitor > monitor requires monitor.observer # truncated many resolutions > monitor binds monitor.observer.beta > monitor binds monitor.observer.alpha > java.base binds jdk.charsets jrt:/jdk.charsets > java.base binds jdk.localedata jrt:/jdk.localedata # truncated lots of more bindings for java.base # truncated rest of resolution

模块 monitor 绑定了 monitor.observer.alpha 和 monitor.observer.beta 模块,即使它不依赖于它们中的任何一个。类似的情况也发生在 jdk.charsets、jdk.localedata 和其他许多模块上,这是由于 java.base 和其他平台模块导致的。图 10.6 显示了模块图。

图片

图 10.6 服务绑定是模块解析的一部分:一旦一个模块被解析(如 monitor 或 java.base),就会分析其uses指令,并将提供匹配服务的所有模块(包括 alpha 和 beta 以及 charsets 和 localedata)添加到模块图中。

使用--LIMIT-MODULES 排除服务

服务和--limit-modules选项有有趣的交互。如 5.3.5 节所述,--limit-modules将可观察模块的宇宙限制为指定的模块及其传递依赖。这不包括服务!除非提供服务的模块是--limit-modules之后列出的模块的传递依赖,否则它们不可观察,也不会进入模块图。在这种情况下,对ServiceLoader::load的调用通常会返回空。

如果你以检查模块解析的方式启动 ServiceMonitor,但将可观察的宇宙限制为依赖于 monitor 的模块,输出将更加简单:

$ java --show-module-resolution --module-path mods:libs --limit-modules monitor --module monitor root monitor # truncated monitor's transitive dependencies

就这些:没有服务——既没有观察者工厂,也没有许多平台模块通常绑定的服务。图 10.7 显示了这个简化的模块图。

图片

图 10.7 使用--limit-modules monitor,可观察模块的宇宙被限制为 monitor 的传递依赖,这排除了在图 10.6 中解析的服务提供者。

特别强大的是--limit-modules--add-modules的组合:前者可以用来排除所有服务,而后者可以用来添加所需的服务。这允许你在启动时尝试不同的服务配置,而无需操作模块路径。

为什么需要使用指令?

在一个小插曲中,我想回答一些开发者关于uses指令的问题:为什么它是必要的?模块系统在调用ServiceLoader::load之后不能查找提供者吗?

如果模块通过服务正确解耦,那么提供服务的模块很可能不是任何根模块的传递依赖。在不做进一步努力的情况下,服务提供者模块通常会无法进入模块图,因此在模块尝试使用服务时,它们在运行时将不可用。

为了服务能够正确工作,提供者模块必须进入模块图,即使它们不是从任何根模块的传递性需求。但模块系统如何识别哪些模块提供服务呢?这意味着所有具有provides指令的模块吗?那会太多。不,只有所需服务的提供者应该被解析。

这使得识别服务使用变得必要。分析调用ServiceLoader::load的字节码既慢又不可靠,因此需要一个更明确的机制来保证效率和正确性:uses指令。通过要求你声明模块使用哪些服务,模块系统可以可靠且高效地使所有服务提供者模块可用。

10.3 设计良好的服务

正如你在第 10.2 节中看到的,服务是四个演员的表演:

  • 服务——在 JPMS 中,一个类或一个接口。

  • 消费者——任何想要使用服务的代码片段。

  • 提供者——服务的具体实现。

  • 定位器——由消费者的请求触发的总管,定位提供者并返回它们。在 Java 中,这是ServiceLoader

ServiceLoader由 JDK 提供(我们将在第 10.4 节中更详细地探讨它),但在创建服务时,其他三个类是你的责任。你为服务选择哪些类型(见第 10.3.1 节),以及如何最佳地设计它们(第 10.3.2 节)?消费者依赖于丑陋的全局状态(第 10.3.3 节)不是很奇怪吗?包含服务、消费者和提供者的模块之间应该如何相互关联(第 10.3.4 节)?为了设计精心制作的服务,你需要能够回答这些问题。

我们还将探讨使用服务来打破模块之间的循环依赖(第 10.3.5 节)。最后但同样重要的是——这对计划在不同 Java 版本上使用服务的开发者来说尤其有趣——我们讨论了服务如何在平面和模块化 JAR 文件之间工作(第 10.3.6 节)。

10.3.1 可作为服务的类型

一个服务可以是具体的类(甚至是一个最终类),抽象类,或者接口。尽管排除了枚举,但将具体类(尤其是最终类)用作服务是不寻常的——整个模块的目的是依赖于某种抽象的东西。除非有特定的用例要求,否则服务始终应该是抽象类或接口。

关于抽象类

个人来说,我不喜欢深层类层次结构,因此对抽象类有自然的反感。随着 Java 8 在接口中实现方法的能力,抽象类的一个大用途消失了:为具有良好默认行为的接口方法提供基本实现。

现在,我主要将它们用作本地支持(通常是包作用域或内部类)以实现复杂接口,但我总是确保除非绝对必要,否则不要让它们渗入公共 API。在这方面,我从未创建过服务——这必然是模块公共 API 的一部分——它不是一个接口。

10.3.2 使用工厂作为服务

让我们回到第 10.2.1 节中尝试重构服务观察者架构以使用 JPMS 服务的第一次尝试。那并不顺利。将ServiceObserver接口作为服务和其实现AlphaServiceObserverBetaServiceObserver作为提供者存在一些问题:

  • 提供者需要无参数的提供者方法或构造函数,但我们想要使用的类需要用具体的初始状态初始化,而这个状态并不打算被修改。

  • 对于可以处理 alpha 或 beta API 的观察者实例来说,决定它们是否适合特定的网络服务可能会很尴尬。我更喜欢创建处于正确状态的实例。

  • 服务加载器缓存提供者(更多内容请参阅第 10.4 节),因此根据您如何使用 API,每个提供者可能只有一个实例:在这种情况下,一个AlphaServiceObserver和一个BetaServiceObserver

这使得直接创建我们需要的实例变得不切实际,所以我们使用了工厂。结果证明,那并不是一个特殊情况。

不论是连接的 URL 还是记录器的名称,消费者通常都希望配置他们使用的服务。消费者还可能希望创建任何特定服务提供者的多个实例。结合服务加载器对无参数构造的要求以及其缓存实例的自由,这使得将使用的类型ServiceObserverLogger作为服务变得不切实际。

相反,通常为所需类型创建一个工厂,如ServiceObserverFactoryLoggerFinder,并将其作为服务。根据工厂模式,工厂的唯一责任是创建处于正确状态的实例。因此,通常可以设计它们使其没有自己的状态,并且你并不特别关心它们的数量。这使得工厂非常适合ServiceLoader的特殊性。

他们至少有两个额外的优势:

  • 如果实例化所需类型代价高昂,将其作为服务的一部分拥有一个工厂,使得消费者控制实例创建的时间变得最容易。

  • 如果需要检查一个提供者是否能够处理某个特定的输入或配置,工厂可以有一个方法来指示这一点。或者,它的方法可以返回一个类型,表示创建对象是不可能的(例如,一个Optional)。

我想给你展示两个根据其适用性选择服务的例子。第一个来自 ServiceMonitor,其中ServiceObserverFactory没有返回ServiceObservercreate(String)方法,但有一个返回Optional<ServiceObserver>createIfMatchingService(String)方法。这样,你可以将任何 URL 投向任何工厂,返回值会告诉你它是否可以处理。

另一个例子没有使用ServiceLoader,而是使用 JDK 中深层的类似 API,即ServiceRegistry。它是专门为 Java 的 ImageIO API 创建的,它使用它来根据图像的编解码器(例如,JPEG 或 PNG)定位给定的图像的ImageReader

图像输入输出(Image IO)通过从注册表中请求抽象类ImageReaderSpi的实现来定位读取器,该注册表返回类似JPEGImageReaderSpiPNGImageReaderSpi的类实例。然后它对每个ImageReaderSpi实现调用canDecodeInput(Object),如果图像使用文件头指示的正确编解码器,则返回true。只有当某个实现返回true时,Image IO 才会调用createReaderInstance(Object)来创建实际的图像读取器。图 10.8 展示了使用工厂的方法。

ImageReaderSpi充当一个工厂服务,其中canDecodeInput用于选择正确的提供者,createReaderInstance用于创建所需的类型:一个ImageReader。如第 10.4.2 节所示,选择合适提供者的另一种方法是。

总结来说,你应该常规性地考虑不要选择你想要使用的类型作为服务,而是选择另一种类型,即工厂,该工厂返回你想要使用的实例。这个工厂在正确配置下运行时不应有自己的状态。(这也使得在需要的情况下以线程安全的方式实现它变得容易得多。)将工厂视为一种方法,将你想要使用的类型的原始需求与服务基础设施的特定需求分开,而不是将它们混合在一个类型中。

图片

图 10.8 将所需类型作为服务通常与 JDK 的特有性不太相容。相反,考虑设计一个工厂,以正确的配置创建实例,并将其作为服务。

10.3.3 将消费者与全局状态隔离

调用ServiceLoader::load的代码本质上是难以测试的,因为它依赖于全局应用程序状态:启动程序时使用的模块。当使用服务的模块不依赖于提供它的模块(应该是这种情况)时,这很容易成为一个问题,因为构建工具不会将提供模块包含在测试模块路径中。

为了使ServiceLoader在单元测试中返回特定的服务提供者列表,需要做一些繁重的工作。这对于单元测试来说是禁忌的,因为单元测试应该在隔离和小的代码单元上运行。

除了这些,对ServiceLoader::load的调用通常不会解决应用程序用户关心的任何问题。它只是通向这种解决方案的必要且技术性的步骤。这使得它与使用接收到的服务提供者的代码处于不同的抽象级别。遵循单一责任原则的朋友会说这样的代码有两个责任(请求提供者和实现业务需求),这似乎是太多了。

这些属性表明,处理服务加载的代码不应与实现应用程序业务需求的代码混合。幸运的是,将它们分开并不太复杂。在某个地方创建最终使用提供者的实例,通常这是一个调用ServiceLoader并传递提供者的好地方。ServiceMonitor 遵循相同的结构:在Main类中创建运行应用程序所需的所有实例(包括加载ServiceObserver实现),然后将其传递给Monitor,它执行实际的服务监控工作。

列表 10.4 和 10.5 显示了比较。在列表 10.4 中,IntegerStore自己执行了繁重的服务操作,这混淆了职责。这也使得使用IntegerStore的代码难以测试,因为测试必须知道ServiceLoader的调用,并确保它返回所需的整数生成器。

在列表 10.5 中,IntegerStore被重构,现在期望构建它的代码传递一个List<IntegerMaker>。这使得其代码专注于当前的业务问题(生成整数)并移除了对ServiceLoader和全球应用程序状态的任何依赖。测试它变得轻而易举。仍然有人需要处理服务的加载,但一个在应用程序设置期间调用的create...方法是一个更好的地方。

列表 10.4 由于责任过多而难以测试

public class Integers { public static void main(String[] args) { IntegerStore store = new IntegerStore(); List<Integer> ints = store.makeIntegers(args[0]); System.out.println(ints); } } public class IntegerStore { public List<Integer> makeIntegers(String config) { return ServiceLoader .load(IntegerMaker.class).stream() .map(Provider::get) .map(maker -> maker.make(config)) .distinct() .sorted() .collect(toList()); } } public interface IntegerMaker { int make(String config); }

此调用的结果直接依赖于模块路径内容,这使得单元测试变得困难。

解决了加载整数制造者的技术要求

解决了业务问题:生成唯一的整数并对它们进行排序

列表 10.5 重新编写以提高其设计和可测试性

public class Integers { public static void main(String[] args) { IntegerStore store = createIntegerStore(); List<Integer> ints = store.makeIntegers(args[0]); System.out.println(ints); } private static IntegerStore createIntegerStore() { List<IntegerMaker> makers = ServiceLoader .load(IntegerMaker.class).stream() .map(Provider::get) .collect(toList()); return new IntegerStore(makers); } } public class IntegerStore { private final List<IntegerMaker> makers; public IntegerStore(List<IntegerMaker> makers) { this.makers = makers; } public List<Integer> makeIntegers(String config) { return makers.stream() .map(maker -> maker.make(config)) .distinct() .sorted() .collect(toList()); } } public interface IntegerMaker { int make(String config); }

解决了在设置过程中加载整数制造者的技术要求

IntegerStore在构造时获取制造者,并且没有依赖ServiceLoader

makeIntegers方法可以专注于其业务需求。

根据特定的项目和需求,你可能需要向提供者传递多个方法或构造函数调用,将其包装在另一个延迟加载直到最后一刻的对象中,或者配置你的依赖注入框架,但这应该是可行的。而且这值得努力——你的单元测试和同事们会感谢你。

10.3.4 将服务、消费者和提供者组织成模块

当服务的类型、设计和消费确定后,问题随之而来:您如何将服务以及其他两个参与者,消费者和提供者,组织到模块中?显然,服务需要实现,并且为了提供价值,包含服务的模块之外的模块中的代码应该能够实现服务。这意味着服务类型必须是公开的,并且位于导出包中。

消费者不必是公开的或导出的,因此可能位于其模块内部。然而,它必须访问服务类型,因此需要要求包含服务的模块(服务,而不是实现它的类)。消费者和服务最终位于同一模块中并不罕见,例如 java.sql 和Driver以及 java.base 和LoggerFinder

最后,我们来到提供者。因为他们实现了服务,所以他们必须阅读定义它的模块——这一点很明显。有趣的问题是,提供类型是否应该成为模块公共 API 的一部分,而不仅仅是命名在provides指令中。

服务提供者必须是公开的,但对其包的导出没有技术要求——服务加载器可以实例化不可访问的类。因此,导出包含提供者的包无谓地扩大了模块的 API 表面积。它还可能诱使消费者做一些他们不应该做的事情,比如将服务强制转换为其实际类型以访问附加功能(类似于URLClassLoader发生的情况;参见第 6.2.1 节)。因此,我建议您不要使服务提供者可访问。

总结(参见图 10.9)

  • 服务必须是公开的,并且位于导出包中。

  • 消费者可以是内部的。他们需要阅读定义服务的模块,甚至可能是其一部分。

  • 提供者必须是公开的,但不应该位于导出包中,以最大限度地减少误用和 API 表面积。他们需要阅读定义服务的模块。

图片

图 10.9 消费者、服务和提供者的可见性和可访问性要求

注意:如果您在疑惑,一个模块只能提供它拥有的类型的服务。在provides指令中命名的服务实现必须在声明它的同一个模块中。

10.3.5 使用服务来打破循环依赖

当与分割为子项目的代码库一起工作时,总会有一个点,其中一个项目变得太大,我们希望将其分割成更小的项目。这样做需要一些工作,但如果我们有足够的时间解开类,我们通常可以实现目标。有时,尽管如此,代码粘合得如此紧密,以至于我们找不到将其分开的方法。

一个常见的原因是类之间的循环依赖。可能有两个类互相导入,或者是一个涉及多个类的更长的循环,其中每个类都导入下一个类。无论你如何结束这样的循环,如果你希望其中一些构成类在一个项目中,而另一些在另一个项目中,那么这是一个问题。即使没有模块系统,这也是正确的,因为构建工具通常也不喜欢循环依赖;但 JPMS 表达了自己的强烈反对。

注意:由于可访问性规则,存在于不同模块中的类之间的依赖需要这些模块之间的依赖(参见第 3.3 节)。如果类依赖是循环的,那么模块依赖也是循环的,可读性规则不允许这样做(参见第 3.2 节)。

你能做什么?因为你正在阅读关于服务的章节,你可能不会对了解到服务可以帮助你感到惊讶。想法是通过创建一个存在于依赖模块中的服务来反转循环中的依赖之一。以下是逐步操作的方法(也请参阅图 10.10):

  1. 看一下模块依赖的循环,并确定你想要反转的依赖。我将涉及的两个模块称为依赖(具有requires指令的那个)和依赖项。理想情况下,依赖项使用依赖项的单个类型。我将专注于这个特殊情况——如果有更多类型,请为每个类型重复以下步骤。

  2. 在依赖中,创建一个服务类型,并使用uses指令扩展模块声明。

  3. 在依赖中,删除对依赖项的依赖。注意由此产生的编译错误,因为依赖项的类型不再可访问。用服务类型替换所有对其的引用:

  • 更新导入和类名。

  • 方法调用不需要任何更改。

  • 构造函数调用不会立即生效,因为你需要依赖项的实例。这就是ServiceLoader发挥作用的地方:使用它通过加载你刚刚创建的服务类型来替换依赖项类型的构造。

  1. 在独立的情况下,向依赖项中添加一个依赖,以便服务类型变得可访问。提供原始导致问题的类型的服务。

图片

图 10.10 使用服务在四步中打破依赖循环:❶ 选择一个依赖,❷ 在依赖端引入服务,❸ 在依赖端使用该服务,❹ 在依赖项端提供该服务。

成功!你刚刚反转了依赖项和依赖项之间的依赖(现在后者依赖于前者),从而打破了循环。以下是一些需要记住的进一步细节:

  • 依赖项中依赖项使用的类型可能不是作为服务的好候选。如果是这样,考虑创建一个工厂来处理它,如第 10.3.2 节所述,或者寻找可以替换的另一个依赖项。

  • 第 10.3.3 节探讨了在模块中到处撒ServiceLoader调用的问题;这个问题也适用于此处。你可能需要重构依赖于模块的代码以最小化加载次数。

  • 服务类型不必依赖于其他模块。正如第 10.3.4 节解释的那样,它可以存在于任何模块中。或者更确切地说,几乎可以存在于任何模块中——你不想将其放在一个会重新创建循环的模块中,例如在依赖于模块中。

  • 最重要的是,尝试创建一个独立且不仅仅是作为循环断路器的服务。可能涉及的提供者和消费者不止两个模块。

10.3.6 在不同 JAVA 版本间声明服务

服务并不新鲜。它们是在 Java 6 中引入的,当时设计的机制至今仍然有效。因此,研究它们在没有模块的情况下如何操作,尤其是它们如何在普通和模块化 JAR 之间工作,是有意义的。

META-INF/SERVICES中声明服务

在模块系统出现之前,服务的工作方式与现在几乎相同。唯一的区别是没有模块声明来声明 JAR 使用或提供服务。在使用方面,这没问题——所有代码都可以使用它想要的任何服务。然而,在提供方面,JAR 必须声明其意图,并且它们在 JAR 中的专用目录中这样做。

要让一个普通的 JAR 声明一个服务,请遵循以下简单步骤:

  1. 将包含服务完全限定名称的文件作为文件名放在META-INF/services中。

  2. 在文件中列出实现该服务的所有类的完全限定名称。

例如,让我们在新的普通 JAR monitor.observer.zero 中创建第三个ServiceObserverFactory提供者。为此,你首先需要一个具体的类ZeroServiceObserverFactory,它实现了ServiceObserverFactory并具有无参构造函数。这与 alpha 和 beta 变体类似,因此我不需要详细讨论。

普通 JAR 没有模块描述符来声明它提供的服务,但你可以使用META-INF/services目录来做到这一点:在该目录中放置一个简单的文本文件monitor.observer.ServiceObserverFactory(服务类型的完全限定名称),其中包含单行monitor.observer.zero.ZeroServiceObserverFactory(提供者类型的完全限定名称)。图 10.11 显示了它的样子。

图片

图 10.11 要在不进行模块声明的情况下声明服务提供者,文件夹META-INF/services需要包含一个包含服务名称和每个提供者单行的纯文本文件。

我保证这可行,当Main流式传输所有观察者工厂时,ZeroServiceObserverFactory将得到正确解析。但直到我们讨论了普通和模块化 JAR 的服务如何交互之前,你只能相信我的话。接下来就是这一点。

注意META-INF/services中声明服务和在模块声明中声明服务之间有一个小的区别。只有后者可以使用提供者方法——前者需要坚持使用公共、无参数的构造函数。

JAR 和路径之间的兼容性

由于服务加载器 API 在 Java 9 模块系统到来之前就已经存在,因此存在兼容性问题。普通和模块化 JAR 中的消费者能否以相同的方式使用服务?不同类型的 JAR 和路径中的提供者会发生什么?

对于服务消费者来说,情况很简单:显式模块可以使用它们通过uses指令声明的服务;自动模块(见第 8.3 节)和未命名的模块(第 8.2 节)可以使用所有现有服务。总之,在消费者端,这很简单。

对于服务提供者来说,情况稍微复杂一些。有两个轴,每个轴有两个表达式,导致四种组合:

  • JAR 类型:普通(在META-INF/services中声明服务)或模块化(在模块描述符中声明服务)

  • 路径类型:类路径或模块路径

无论普通 JAR 最终位于哪个路径上,服务加载器都会在META-INF/services中识别和绑定服务。如果 JAR 位于类路径上,其内容已经是未命名的模块的一部分。如果它位于模块路径上,服务绑定会导致自动模块的创建。这会触发所有其他自动模块的解析,如第 8.3.2 节所述。

现在你知道为什么你可以尝试使用 monitor.observer.zero,这是一个提供其服务在META-INF/services中的普通 JAR,与模块化的 ServiceMonitor 应用程序一起使用。而且,无论我选择哪个路径;它都可以从两个路径上工作,无需进一步操作。

必要信息 模块路径上的模块化 JAR 是模块系统中服务的最佳选择,因此它们可以无限制地工作。在类路径上,模块化 JAR 可能会引起问题。它们被当作普通 JAR 处理,因此需要在META-INF/services文件夹中包含条目。作为一个依赖于服务并且其模块化工件应在两个路径上工作的开发者,你需要在模块描述符和META-INF/services中声明服务。

从类路径启动 ServiceMonitor 会导致没有有用的输出,因为没有找到观察者工厂——除非你将 monitor.observer.zero 添加到其中。由于它在META-INF/services中有提供者定义,它非常适合从未命名的模块中工作,并且确实如此——与 alpha 和 beta 提供者不同。

10.4 使用 ServiceLoader API 访问服务

尽管 ServiceLoader 自 Java 6 以来就已经存在,但它并没有得到广泛的应用,但我预计随着它显著集成到模块系统中,其使用将会大幅增加。为了确保你熟悉其 API,我们将在本节中对其进行探讨。

如同往常,第一步是了解基础知识,在这种情况下不会花费太多时间。然而,服务加载器确实有一些特性,为了确保它们不会让你感到困惑,我们也会讨论它们。

10.4.1 加载和访问服务

使用ServiceLoader始终是一个两步过程:

  1. 为正确的服务创建一个ServiceLoader实例。

  2. 使用该实例来访问服务提供商。

让我们快速看一下每个步骤,以便你知道选项。同时查看表 10.1 以了解所有ServiceLoader方法的概述。

表 10.1 快速查看ServiceLoader API

返回类型 方法名称 描述
为给定类型创建新的服务加载器的方法
ServiceLoader<S> load(Class<S>) 从当前线程的上下文类加载器开始加载提供商
ServiceLoader<S> load(Class<S>, ClassLoader) 从指定的类加载器开始加载提供商
ServiceLoader<S> load(ModuleLayer, Class<S>) 从给定的模块层中的模块开始加载提供商
ServiceLoader<S> loadInstalled(Class<S>) 从平台类加载器加载提供商
访问服务提供商的方法
Optional<S> findFirst() 加载第一个可用的提供商
Iterator<S> iterator() 返回一个用于延迟加载和实例化可用提供商的迭代器
Stream<Provider<S>> stream() 返回一个用于延迟加载可用提供商的流
void reload() 清除此加载器的提供商缓存,以便所有提供商都将被重新加载

创建 SERVICELOADER 的方法

第一步,创建ServiceLoader实例,由其几个静态load方法处理。最简单的一个只需要你想要加载的服务的Class<S>实例(这被称为类型标记,在这种情况下是类型S):

ServiceLoader<TheService> loader = ServiceLoader.load(TheService.class);

只有在你同时处理多个类加载器或模块层时,你才需要其他load方法(参见第 12.4 节);这不是一个常见的情况,所以我就不深入讨论了。相应重载的 API 文档已经涵盖了这些内容。

另一个获取服务加载器的方法是loadInstalled。在这里它很有趣,因为它有一个特定的行为:它忽略了模块路径和类路径,并且只从平台模块加载服务,这意味着只有 JDK 模块中找到的提供商将被返回。

访问服务提供商

拥有所需服务的ServiceLoader实例后,是时候开始使用这些提供商了。为此有两种半方法:

  • Iterator<S> iterator() 允许你遍历实例化的服务提供商。

  • Optional<S> findFirst() 使用iterator来返回找到的第一个提供商(这是一个便利方法,所以我只将其计为半个方法)。

  • Stream<Provider<S>> stream()允许你流式处理服务提供者,这些提供者被包装在一个Provider实例中。(这是怎么回事?10.4.2 节将解释。)

如果你具有特定的惰性/缓存需求(有关更多信息,请参阅 10.4.2 节),你可能希望保留ServiceLoader实例。但在大多数情况下,这并不是必要的,你可以立即开始迭代或流式处理提供者:

ServiceLoader .load(TheService.class) .iterator() .forEachRemaining(TheService::doTheServiceThing);

如果你想知道iterator列出Sstream列出Provider<S>之间不一致的原因,这有历史原因:尽管iterator自 Java 6 以来就存在,但streamProvider是在 Java 9 中添加的。

一个当你思考时很明显但仍然容易忽视的细节是,可能没有给定服务的提供者。Iteratorstream可能为空,findFirst可能返回一个空的Optional。如果你按能力过滤,如 10.3.2 节和 10.4.2 节所述,最终没有合适的提供者的情况就更加可能了。

确保你的代码要么优雅地处理这种情况并且可以在没有服务的情况下运行,要么快速失败。如果应用程序忽略了一个容易检测到的错误并继续以不期望和不期望的状态运行,那就很烦人。

10.4.2 加载服务的特性

ServiceLoader API 非常简单,但不要被其表面所迷惑。在幕后发生了一些重要的事情,当使用 API 进行超出基本“Hello, services!”示例的操作时,你需要了解它们。这涉及到服务加载器的惰性、其并发能力(或缺乏)以及适当的错误处理。让我们逐一探讨这些问题。

惰性和选择正确的提供者

服务加载器尽可能地惰性。当调用ServiceLoader<S>(其中S是调用ServiceLoader::load时的服务类型)时,其iterator方法返回一个Iterator<S>,只有在调用hasNextnext时才会查找和实例化下一个提供者。

stream方法甚至更加惰性。它返回一个Stream<Provider<S>>,不仅像iterator一样惰性查找提供者,还返回Provider实例,进一步延迟服务实例化直到其get方法被调用。它们的type方法提供了访问特定提供者的Class<? extends S>实例的途径(意味着实现服务的类型,而不是服务本身)。

访问提供者的类型对于在没有实际类实例的情况下扫描注解很有用。类似于我们在 10.3.2 节末尾讨论的内容,这为你提供了一个工具,可以根据给定的配置选择正确的服务提供者,而无需首先实例化它。如果类被注解以提供对提供者适用性的指示,那就更好了。

继续以 ServiceObserver 工厂适用于特定 REST 服务生成为例的 ServiceMonitor 示例,这些工厂可以用 @Alpha@Beta 注解来表示它们创建的生成:

Optional<ServiceObserverFactory> alphaFactory = ServiceLoader .load(ServiceObserverFactory.class).stream() .filter(provider -> provider.type().isAnnotationPresent(Alpha.class)) .map(Provider::get) .findFirst();

在这里,使用 Provider::type 来访问 Class<? extends ServiceObserver>,然后使用 isAnnotationPresent 检查它是否被 @Alpha 注解。只有在调用 Provider::get 时才会实例化工厂。

为了进一步体现懒加载,ServiceLoader 实例会缓存已加载的提供者,并始终返回相同的实例。尽管如此,它确实有一个 reload 方法,该方法会清空缓存,并在下一次调用 iteratestreamfindFirst 时触发新的实例化。

使用并发 ServiceLoader

ServiceLoader 实例不是线程安全的。如果多个线程需要同时操作一组服务提供者,则每个线程都需要进行相同的 ServiceLoader::load 调用,从而获得自己的 ServiceLoader 实例,或者您必须为它们中的每一个都进行一次调用,并将结果存储在线程安全的集合中。

加载服务时处理错误

ServiceLoader 尝试定位或实例化服务提供者时,可能会出现各种问题:

  • 提供者可能无法满足所有要求。也许它没有实现服务类型,或者没有合适的提供者方法或构造函数。

  • 提供者的构造函数或方法可以抛出异常,或者(在方法的情况下)返回 null

  • META-INF/services 目录中的文件可能违反所需格式,或者由于其他原因无法处理。

这些只是显而易见的问题。

由于加载是懒加载的,load 方法不能抛出任何异常。相反,迭代器的 hasNextnext 方法,以及流处理和 Provider 方法都可以抛出错误。这些错误都将属于 ServiceConfigurationError 类型,因此捕获该错误可以让您处理可能发生的所有问题。

摘要

  • 服务架构由四个部分组成:

  • 服务是一个类或一个接口。

  • 提供者是对服务的具体实现。

  • 消费者是指任何想要使用服务的代码片段。

  • ServiceLoader 为消费者创建并返回给定服务的每个提供者的实例。

  • 服务类型的要求数据和建议如下:

  • 任何类或接口都可以是服务,但由于目标是向消费者和提供者提供最大灵活性,建议使用接口(或者至少是抽象类)。

  • 服务类型需要是公开的,并且位于导出包中。这使得它们成为其模块的公共 API 的一部分,并且应该适当地设计和维护。

  • 定义服务的模块的声明中不包含标记类型为服务的入口。一个类型通过消费者和提供者将其用作服务而成为服务。

  • 服务很少是随机出现的,而是专门为特定目的而设计的。始终考虑将使用的类型不是服务本身,而是它的工厂。这使搜索合适的实现更加容易,同时也更容易控制实例的创建时间和状态。

  • 对提供者的要求和推荐如下:

  • 提供服务的模块需要访问服务类型,因此它们必须要求包含它的模块。

  • 创建服务提供者有两种方式:一个实现了服务类型并具有提供者构造函数(一个公共的无参数构造函数)的具体类,或者一个具有提供者方法(一个公共的静态无参数方法,称为provide)的类型,该方法返回实现服务类型的实例。无论哪种方式,类型必须是公共的,但不需要导出包含它的包。相反,建议不要将提供者类型作为模块的公共 API 的一部分。

  • 提供服务的模块会在其描述符中声明通过添加provides ${service} with ${provider}指令。

  • 如果一个模块化的 JAR 文件即使在放置在类路径上也要提供服务,它也需要在META-INF/services目录中添加条目。对于每个provides ${service} with ${provider}指令,创建一个名为\({service}的普通文件,其中每行包含一个`\){provider}`(所有名称都必须是完全限定的)。

  • 对消费者的要求和推荐如下:

  • 消费服务的模块需要访问服务类型,因此它们必须要求包含它的模块。不过,它们不应该要求提供该服务的模块——相反,这会违背最初使用服务的主要原因:解耦消费者和提供者。

  • 服务类型和服务的消费者位于同一模块中并没有什么问题。

  • 任何代码都可以消费服务,无论其自身的可访问性如何,但包含它的模块需要通过uses指令声明它使用的服务。这允许模块系统有效地执行服务绑定,并使模块声明更加明确和可读。

  • 通过调用ServiceLoader::load并随后通过调用iteratestream来迭代或流式传输返回的实例,可以消费模块。可能会找到提供者,消费者必须优雅地处理这种情况。

  • 消费服务的代码的行为取决于全局状态:哪些提供者模块存在于模块图中。这给这样的代码带来了不希望的性质,例如使其难以测试。尝试将服务加载推入创建对象并配置正确的设置代码(例如,你的依赖注入框架),并且始终允许常规提供者代码将服务提供者传递给消费类(例如,在构造期间)。

  • 服务加载器尽可能晚地实例化提供者。它的stream方法甚至返回一个Stream<Provider<S>>,其中Provider::type可以用来访问提供者的Class实例。这允许在尚未实例化提供者的情况下通过检查类级别的注解来搜索合适的提供者。

  • 服务加载器实例不是线程安全的。如果你并发使用它们,你必须提供同步。

  • 所有在加载和实例化提供者期间的问题都会抛出ServiceConfigurationError。由于加载器的惰性,这不会在load期间发生,而是在iteratestream期间,当遇到有问题的提供者时。如果你想要处理错误,请确保将整个与ServiceLoader的交互放入try块中。

  • 这里有一些关于模块解析和更多的要点:

  • 当模块解析过程处理声明使用服务的模块时,所有提供该服务的模块都会被解析,从而包含在应用程序的模块图中。这被称为服务绑定,并且与 JDK 中服务的使用一起,解释了为什么默认情况下即使是小型应用程序也会使用大量的平台模块。

  • 另一方面,命令行选项--limit-modules不进行服务绑定。因此,不是给定此选项的模块的传递依赖关系的提供者不会进入模块图,并且在运行时不可用。此选项可用于排除服务,可选地与--add-modules一起使用,以添加其中的一些。

11

精炼依赖和 API

本章涵盖

  • 处理模块 API 中的一部分依赖

  • 在不破坏客户端的情况下聚合和重构模块

  • 定义可选依赖

  • 在缺少依赖的情况下编写代码

  • 仅将包导出到选定的模块

第三章解释了requiresexports指令是可读性和可访问性的基础。但这些机制是严格的:每个模块都必须被显式地要求,所有必需的模块都必须存在,应用程序才能编译和启动,并且导出的包对所有其他模块都是可访问的。这对于大多数用例来说足够了,但仍然有一大部分用例中这些解决方案过于宽泛。

最明显的用例是可选依赖,模块希望针对它进行编译,但在运行时它不一定存在。例如,Spring 使用 Jackson databind 库就是这样做的。如果你运行一个 Spring 应用程序并想使用 JSON 作为数据传输格式,你可以通过添加 Jackson 工件来获得对该格式的支持。另一方面,如果该工件不存在,Spring 仍然很乐意——它不支持 JSON。Spring 使用 Jackson,但不需要它。

然而,常规的requires指令并不涵盖这个用例,因为模块必须存在才能启动应用程序。在某些情况下,服务可以是解决方案,但将它们用于所有可选依赖会导致许多尴尬和复杂的实现。因此,明确表达在运行时不需要依赖是一个重要的功能;11.2 节展示了 JPMS 如何实现它。

模块系统严格性可能成为障碍的另一个用例是随着时间的推移重构模块。在任何规模适中的项目中,架构都会随着时间的推移而演变,开发者会希望合并或拆分模块。但那么依赖于旧模块的代码会怎样呢?如果它被拆分到一个新的模块中,会不会丢失功能(如果它被拆分到一个新的模块中)或者甚至整个模块(如果它们被合并了)?幸运的是,模块系统提供了一个名为隐式可读性的功能,这在这里可能很有用。

虽然我们迄今为止所知的requiresexports机制提供了一个相对简单的心理模型,但它们为不适合其一刀切方法的用例提供了没有优雅的解决方案。在本章中,我们将探讨这样的特定用例,并探索模块系统提供的解决方案。

在你完成它之后,你将能够使用更精细的机制来访问依赖关系并导出功能。这将允许你做许多事情,例如,表达可选依赖(11.2 节),重构模块(11.1 节),并在定义的模块集之间共享代码,同时将其对其他代码保持私有(11.3 节)。

11.1 隐式可读性:传递依赖

在 3.2 节中,我们深入探讨了requires指令如何建立模块之间的依赖关系,以及模块系统如何使用它们来创建读取边(最终形成模块图,如 3.4.1 和 3.4.2 节所示)。在 3.3 节中,你看到可访问性基于这些边,要访问一个类型,访问模块必须读取包含该类型的模块(类型也必须是公共的,并且包必须导出,但这在这里并不相关)。

在本节中,我们将探讨另一种使模块能够访问其他模块的方法。在介绍新机制并制定最佳使用该机制的指南之前,我们将首先讨论一个激励用例。在结尾处,你将看到它有多么强大,以及它如何能帮助解决比初始示例更多的问题。

查看 ServiceMonitor 的feature-implied-readability分支以获取本节所伴随的代码。

11.1.1 展示模块的依赖

当涉及到requires指令和可访问性之间的交互时,有一个需要注意的细节:requires指令创建读取边,但这些边是可访问性的先决条件。这难道不引发了一个问题:还有哪些其他机制可以建立可读性,从而解锁对类型的访问?这不仅仅是理论上的思考——从实际的角度来看待这个问题,我们最终会到达同一个地方。

让我们回到 ServiceMonitor 应用程序,特别是 monitor.observer 和 monitor.observer.alpha 模块。假设有一个新的模块,让我们称它为 monitor.peek,它想直接使用 monitor.observer.alpha。它不需要 monitor.observer 或你在上一章中创建的服务架构。monitor.peek 是否只需requiremonitor.observer.alpha 并开始使用它?

ServiceObserver observer = new AlphaServiceObserver("some://service/url"); DiagnosticDataPoint data = observer.gatherDataFromService();

它看起来需要ServiceObserverDiagnosticDataPoint类型。这两个类型都在monitor.observer中,那么如果monitor.peekrequire``monitor.observer会发生什么?它将无法访问其类型,导致编译错误。正如我们在 3.3.2 节讨论传递依赖的封装时所见,这是模块系统的一个特性。

然而,这里存在一个障碍。如果没有来自monitor.observer的类型,monitor.observer.alpha实际上是无用的;并且每个想要使用它的模块都必须读取monitor.observer。(这可以在图 11.1 中看到。)使用monitor.observer.alpha的每个模块都必须require``monitor.observer吗?

这不是一个令人舒适的解决方案。如果只有另一种机制可以建立可读性,从而解锁对类型的访问。

图 11.1 模块 peek 使用了 observer.alpha,它在其公共 API 中使用了 observer 的类型。如果 peek 不requireobserver(左侧),它将无法读取其类型,使得 observer.alpha 变得无用。使用常规的requires指令,绕过这种情况的唯一方法是将 peek 也requireobserver(右侧),当涉及更多模块时,这会变得繁琐。

图 11.2 有三个模块涉及到暴露依赖的问题:一个是提供一些类型(暴露;右侧)的无辜者,一个是使用这些类型在其公共 API 中(暴露;中间)的有罪者,以及一个必须访问无辜者类型的受影响者(依赖;左侧)。

在前面的示例中发生的情况是常见的。一个暴露模块依赖于某些暴露的模块,但使用暴露中的类型在其自己的公共 API(如第 3.3 节中定义的)中使用。在这种情况下,暴露被认为向其客户端暴露了对暴露的依赖,因为它们也需要依赖暴露才能使用暴露。

为了使讨论这种情况不那么令人困惑,请确保你理解了 图 11.2 中的这些定义。在描述涉及的模块时,我会坚持使用这些术语:

  • 暴露其依赖的模块称为暴露模块。

  • 作为依赖关系被暴露的模块是暴露模块。

  • 依赖该混乱模块的模块称为依赖模块。

在 JDK 中可以找到许多示例。例如,java.sql 模块包含一个类型 java.sql.SQLXML(由 java.sql.Connection 等使用),它在公共方法中使用来自 java.xml 模块的类型。类型 java.sql.SQLXML 是公共的,并且在一个导出包中,因此它是 java.sql API 的一部分。这意味着为了任何依赖模块能够正确使用暴露的 java.sql,它还必须读取暴露的 java.xml。

11.1.2 传递修饰语:在依赖关系上暗示可读性

从情况来看,很明显,暴露模块的开发者需要解决这个问题。毕竟,他们决定在自己的 API 中使用暴露模块的类型,迫使依赖它们的模块读取暴露模块。

这些情况的解决方案是在暴露模块的声明中使用 requires transitive 指令。如果暴露声明 requires transitive exposed,则任何读取暴露的模块将隐式地读取暴露。这种效果称为隐含的可读性:读取暴露意味着读取暴露。图 11.3 展示了这个指令。

图 11.3 当使用 requires transitive 指令依赖暴露时,读取暴露意味着暴露的可读性。因此,即使依赖项仅要求暴露,依赖模块(左侧)也可以读取暴露。

当查看模块声明或描述符时,隐含的可读性使用是显而易见的。通过你在第 5.3.1 节中学到的技能,你可以查看 java.sql。以下列表显示对 java.xml 的依赖关系被标记为 transitive

列表 11.1 java.sql 模块描述符:暗示 java.xml 和 java.logging 的可读性

$ java --describe-module java.sql > java.sql@9.0.4 > exports java.sql > exports javax.sql > exports javax.transaction.xa > requires java.base mandate > requires java.logging transitive ①``> requires java.xml transitive > uses java.sql.Driver

这些指令表明,读取 java.sql 的模块也可以读取 java.xml 和 java.logging。

同样,对 java.logging 的依赖被标记为 transitive。原因是公共接口 java.sql.Driver 和其方法 getParentLogger()。它从 java.logging 暴露了 java.util.logging.Logger 类型到 java.sql 的公共 API 中,因此 java.sql 隐式传递了 java.logging 的可读性。请注意,尽管 java --describe-moduletransitive 放在最后,但模块声明期望修饰符位于 requires 和模块名称之间(requires transitive ${module})。

回到如何使 monitor.observer.alpha 可用而不需要依赖模块也必须要求 monitor.observer 的激励示例,现在的解决方案很明显——使用 requires transitive 来声明 monitor.observer.alphamonitor.observer 的依赖:

module monitor.observer.alpha { requires transitive monitor.observer; exports monitor.observer.alpha; }

在探索第 3.2.2 节中的可靠配置和缺失依赖时,你发现尽管运行时要求所有依赖(直接和间接)都必须是可观察的,但编译器只强制要求直接依赖。这意味着你可以编译你的模块,即使其依赖项不存在也可以。那么隐式可读性如何适应这种情况?

必要信息:那些对正在编译的模块具有隐式可读性的模块将进入“必须可观察”的类别。这意味着当你编译你的模块针对暴露时,所有暴露通过传递性要求的依赖,就像在早期示例中展示的那样,都必须是可观察的。

这与你是否使用暴露的类型无关,这乍一看可能过于严格。但记住从第 3.4.1 节中,模块在代码编译之前被解析,模块图被构建。模块图是编译的基础,而不是相反,根据遇到类型对其进行修改将违反可靠配置的目标。因此,模块图必须始终包含传递性依赖。

依赖链

你可能会想知道在一个依赖链中会发生什么,其中每个 requires 指令都使用 transitive。可读性会沿着更长的路径隐式传递吗?答案是肯定的。无论是由于显式依赖还是隐式可读性而读取暴露的模块,它都会同样地隐式传递其依赖的可读性。以下图示说明了 transitive 的传递性。

依赖模块需要暴露,这隐式传递了暴露的可读性,进而隐式传递了暴露.alpha 和暴露.beta 的可读性。隐式可读性是传递的,因此依赖可以读取所有其他四个模块,尽管它只依赖于其中一个。

11.1.3 何时使用隐式可读性

正如你所见,隐含的可读性减少了依赖模块中显式requires指令的需求。这可能是个好事,但我想要回到之前只是简单提到的事情。隐含的可读性与模块系统的特性相矛盾:即第 3.2.2 节中讨论的传递依赖的封装。由于有两个对立的要求(严格性与便利性)和两个特性来满足它们(requiresrequires transitive),因此仔细考虑权衡是很重要的。

这种情况与可见性修饰符类似。为了方便起见,很容易让每个类、每个字段和每个方法都是公开的。我们并没有这样做,因为我们知道减少暴露可以减少代码不同部分之间的接触面,使修改、替换和重用更容易。并且,就像使类型或成员公开一样,暴露依赖成为该模块公共 API 的一部分,客户端可能会依赖隐含的可读性。这可能会使模块及其依赖的演变更加困难,因此不应轻率地进行。

11.2 节:重要信息 沿着这个思路,使用transitive应该是例外,并且只有在非常具体的情况下才使用。最突出的是我之前描述的情况:如果一个模块在其自己的公共 API(如第 3.3 节定义)中使用来自第二个模块的类型,它应该通过使用requires transitive指令来隐含第二个模块的可读性。

其他用例包括模块的聚合、分解和合并,我们将在 11.1.5 节中讨论这些内容。在此之前,我想探讨一个可能需要另一种解决方案的类似用例。

到目前为止,一直假设暴露模块没有暴露就无法运行。有趣的是,这并不总是情况。暴露模块可以基于暴露模块实现一些实用函数,只有已经使用暴露模块的代码才会调用这些函数。

假设有一个名为 uber.lib 的库提供了基于 com.google.common 的实用函数。在这种情况下,只有 Guava 的用户才会使用 uber.lib。在这种情况下,可选依赖可能是可行的;请参阅 11.2 节。

11.1.4 何时依赖隐含可读性

你已经看到了隐含的可读性如何允许一个模块“传递”暴露依赖的可读性。我们讨论了决定何时使用该功能的考虑因素。这是从编写暴露模块的开发者的角度来讨论的。

现在,让我们转换视角,从依赖模块的角度来看这个问题:即暴露模块的可读性传递给哪个模块。它应该在多大程度上依赖隐含的可读性?在什么情况下它应该要求暴露模块?

当我们最初探索隐含可读性时,你看到 java.sql 暴露了对 java.logging 的依赖。这引发了一个问题,使用 java.sql 的模块是否也应该要求 java.logging?技术上,这样的声明是不必要的,可能看起来是多余的。

这也适用于 motivating example of monitor.peek、monitor.observer 和 monitor.observer.alpha 的激励示例:在最终解决方案中,monitor.peek 使用来自其他模块的类型,但只要求 monitor.observer.alpha,这暗示了 monitor.observer 的可读性。它是否也应该显式要求 monitor.observer?如果不是,只是在那个特定示例中,或者永远不是?

要决定何时依赖一个模块的隐含可读性依赖,何时直接要求该模块,回到模块系统的核心承诺之一:可靠的配置(见第 3.2.1 节)是有意义的。使用requires指令通过使依赖关系明确,使代码更可靠,你可以应用这个原则,通过提出不同的问题来做出决定。

重要信息:依赖模块是否无论暴露模块如何都依赖于暴露模块?换句话说,如果依赖模块被修改为不再使用暴露模块,它是否仍然需要暴露模块?

  • 如果答案是负面的,移除使用暴露模块的代码也会移除对暴露模块的依赖。我们可以这样说,暴露模块仅在依赖和暴露模块之间的边界处使用。在这种情况下,没有必要显式地要求它,依赖隐含可读性是可行的。

  • 另一方面,如果答案是肯定的,那么暴露模块不仅用于暴露模块的边界。相应地,它应该通过requires指令显式依赖。

图 11.4 展示了这两个选项的可视化。

图片

图 11.4 两个涉及依赖、暴露和暴露模块的隐含可读性案例。当两个方框接触时,依赖模块使用暴露模块,它明确依赖于该模块。两者都使用暴露模块(条纹区域)。但使用的程度可能不同:依赖模块可能仅在暴露的边界处使用它(顶部),或者它可能使用内部类型来实现自己的功能(底部)。

回顾 java.sql 的例子,你可以根据依赖模块如何使用 java.logging 来回答这个问题,比如说它是 monitor.persistence:

  • 它可能只需要读取 java.logging,因此能够调用java.sql.Driver.getParentLogger(),更改记录器的日志级别,然后完成。在这种情况下,它与 java.logging 的交互仅限于 monitor.persistence 和 java.sql 之间的边界,你处于隐含可读性的甜蜜点。

  • 或者,monitor.persistence 可能在它的代码中到处使用日志记录。然后,来自 java.logging 的类型出现在许多地方,独立于Driver,并且不能再被认为是局限于边界的。在这种情况下,monitor.persistence 应明确要求 java.logging。

可以对 ServiceMonitor 应用程序的例子进行类似的对比。monitor.peek,它需要 monitor.observer.alpha,是否只使用 monitor.observer 中的类型来创建ServiceObserver?或者它是否独立于与 monitor.observer.alpha 的交互而使用 monitor.observer 模块中的类型?

11.1.5 使用隐式可读性重构模块

初看之下,隐式可读性似乎是一个解决特定用例的小功能。然而,有趣的是,它并不仅限于那个用例!相反,它解锁了一些有用的技术,有助于模块重构。

使用这些技术的动机通常是防止重构模块时模块依赖项发生变化。如果你完全控制模块的所有客户端,并且一次性编译和部署它们,那么你可以更改它们的模块声明,而不是做更复杂的事情。但通常你无法做到——例如,当开发库时——因此你需要一种在不破坏向后兼容性的情况下重构模块的方法。

使用聚合模块表示模块家族

假设你的应用程序有几个核心模块,几乎任何其他模块都必须依赖它们。你当然可以将必要的requires指令复制粘贴到每个模块声明中,但这相当繁琐。相反,你可以使用隐式可读性来创建所谓的聚合模块。

聚合模块不包含代码,并通过在其所有依赖项上使用requires transitive来体现可读性。它用于创建一组连贯的模块,其他模块可以通过仅要求聚合模块来轻松依赖。

ServiceMonitor 应用程序的规模有点小,不足以证明创建聚合模块的必要性;但为了举例,让我们决定 monitor.observer 和 monitor.statistics 是其核心 API。在这种情况下,你可以按以下方式创建 monitor.core:

module monitor.core { requires transitive monitor.observer; requires transitive monitor.statistics; }

现在,所有其他模块都可以依赖 monitor.core,并免费获得 monitor.observer 和 monitor.statistics 的可读性。图 11.5 展示了这个例子。

图片

图 11.5 聚合模块 core(左)不包含代码,并使用requires transitive指令引用聚合的模块 observer 和 statistics(右),其中包含功能。多亏了隐式可读性,聚合模块的客户端可以使用聚合模块的 API。

当然,没有理由将聚合限制在核心功能上。每个合作实现功能的模块家族都是获得代表它的聚合模块的候选者。

但是等等:聚合模块不会让客户端陷入一个内部使用它们明确不依赖的模块的 API 的情况吗?这似乎与我在讨论何时依赖隐含可读性时所说的相矛盾:它应该在模块边界处使用。但我认为这里的情况微妙地不同。

聚合模块有一个特定的责任:将相关模块的功能捆绑成一个单一单元。修改捆绑内容是一个关键的概念性变化。“常规”隐含的可读性,另一方面,通常表现在不直接相关的模块之间(如 java.sql 和 java.logging),其中隐含的模块更偶然地被使用(尽管改变它仍然是破坏 API 的;参见第 15.2.4 节)。

如果你熟悉面向对象的编程术语,你可以将其与关联、聚合和组合(比较远非完美,术语也不整齐对齐,但如果你了解术语,它应该给你一些直观的感受):

  • 定义的 requires 指令在两个相关模块之间创建了一个简单的关联。

  • 使用 requires transitive 将其转变为一种聚合,其中一个模块使其它模块成为其 API 的一部分。

  • 聚合模块在某种程度上类似于组合,因为涉及到的模块的生命周期是耦合的——聚合模块本身没有存在的理由。然而,这并不完全准确,因为在真正的聚合中,被引用的模块本身没有目的——而另一方面,聚合模块通常是有目的的。

考虑到这些类别,我认为要求聚合公开的依赖关系受第 11.1.4 节中引入的指南的约束,而依赖于组合公开的依赖关系始终是可行的。为了不让事情比必要的更复杂,我将在本书的其余部分不使用聚合和组合这两个术语;我将坚持使用隐含的可读性和聚合模块。

重要信息 最后,一个警告:聚合模块是一个有缺陷的抽象!在这种情况下,它们泄露了服务和有资格的导出和公开。后者在第 11.3 节和 12.2.2 节中介绍,所以我不将详细说明。只需说,它们通过命名特定的模块来工作,因此只有它们可以访问一个包。尽管聚合模块鼓励开发者使用它而不是其组成模块,但向聚合模块导出或公开一个包是没有意义的,因为它不包含自己的代码,组成模块仍然会看到一个强封装的包。

如第 10.1.2 节所述的服务绑定,也破坏了聚合器模块是完美占位符的幻想。在这里,问题是如果组成模块提供了一个服务,绑定将把它拉入模块图中,但当然不是聚合器模块(因为它没有声明提供该服务),因此也不是其他组成模块。在创建聚合器模块之前,仔细考虑这些情况。

通过拆分模块进行重构

我确信你曾经遇到过这样的情况,你意识到你曾经认为的一个简单功能已经发展成为一个更复杂的子系统。你一次又一次地改进和扩展它,它变得有些混乱;因此,为了清理代码库,你将其重构为更小的部分,这些部分以更好的定义方式交互,同时保持其公共 API 稳定。

重要信息 以 ServiceMonitor 应用程序为例,其统计操作可能收集了如此多的代码,以至于将其拆分为几个较小的子项目是有意义的,例如平均值、中位数和百分位数。到目前为止,一切顺利;现在,让我们考虑这如何与模块交互。

假设简单功能最初就有自己的模块,而新的解决方案将使用几个模块。如果原始模块消失了,模块系统将抱怨缺少依赖项。

根据我们刚才讨论的内容,为什么不保留原始模块并将其转换为聚合器呢?只要原始模块的所有导出包现在都由新的模块导出,这是可能的。(否则,依赖于新的聚合器模块并不授予对其以前 API 所有类型的访问权限。)

重要信息 为了保持对 monitor.statistics 的依赖关系不变,它可以被转换为一个聚合器模块。将所有代码移动到新的模块中,并编辑 monitor.statistics 模块的声明,使用 transitive 关键字要求新的模块:

module monitor.statistics { requires transitive monitor.statistics.averages; requires transitive monitor.statistics.medians; requires transitive monitor.statistics.percentiles; }

查看 图 11.6 来了解这种分解。这是一个重申隐含可读性传递性质的好机会:所有依赖于上一个示例中创建的假设 monitor.core 模块的模块都将读取新的统计模块,因为 monitor.core requires transitive monitor.statistics,而 monitor.statistics requires transitive 新的模块。

图片

图 11.6 在重构之前,统计模块包含了很多功能(左)。然后它被分解成三个更小的模块,包含所有代码(右)。为了不强制更改依赖于统计模块的模块,它没有被移除,而是转换成了一个聚合器模块,它暗示了被拆分的模块的可读性。

如果你希望客户端用更具体的 requires 指令替换对旧模块的依赖,考虑弃用聚合器:

@Deprecated module my.shiny.aggregator { // ... }

重要信息 关于聚合器模块是泄漏抽象的早期警告完全适用。如果用户在聚合器模块上使用有资格的导出或公开,新模块将不会从中受益。

11.1.6 通过合并模块重构模块

虽然可能不如拆分已经超出其根基的模块常见,但你偶尔可能想要将几个模块合并成一个。和之前一样,移除现在技术上无用的模块可能会破坏客户端;和之前一样,你可以使用隐含的可读性来解决这个问题:保留空的旧模块,并确保旧模块声明中只有一行是 requires transitive 指令针对新模块。

重要信息 在 ServiceMonitor 应用程序上工作,你可能会意识到每个观察器实现都有一个模块是过度的,你希望将所有模块合并到 monitor.observer 中。将代码从 monitor.observer.alpha 和 monitor.observer.beta 移动到 monitor.observer 是简单的。为了保持直接需要实现模块的应用程序部分在无需更改的情况下工作,你使它们对更大的模块具有隐含的可读性:

@Deprecated module monitor.observer.alpha { requires transitive monitor.observer; } @Deprecated module monitor.observer.beta { requires transitive monitor.observer; }

你可以在图 11.7 中看到这些模块。你还可以弃用它们,以推动用户更新他们的依赖项。

图 11.7 在重构之前,观察代码在三个模块 alpha、beta 和 observer(左侧)之间共享。之后,所有功能都集中在 observer 模块中,而空心的模块 alpha 和 beta 则暗示了对其的可读性,以避免要求其客户端进行更改(右侧)。

重要信息 虽然如此,仔细考虑这种方法。它使较小模块的客户端突然依赖于比他们最初期望的更大的东西。此外,记住之前关于聚合器模块是泄漏抽象的警告。

11.2 可选依赖

在 3.2 节中,你看到模块系统使用 requires 指令通过确保依赖项在编译和运行时存在来实现可靠的配置。但正如我们在 2.3 节末讨论的那样,在第一次查看 ServiceMonitor 应用程序后,这种方法可能过于僵化。

有时候代码最终会使用那些在运行时不必存在的类型——它们可能存在,但不必存在。目前,模块系统要么要求它们在启动时存在(当你使用 requires 指令时),要么完全不允许访问(当你不使用它时)。

在本节中,我将向您展示几个例子,说明这种严格性会导致问题。然后我将介绍模块系统的解决方案:可选依赖项。尽管针对它们进行编码并不简单,但我们将仔细研究这一点。在本节结束时,您将能够针对不需要在运行时存在的模块进行编码。ServiceMonitor 仓库中的 feature-optional-dependencies 分支展示了如何使用可选依赖项。

11.2.1 可靠配置的难题

假设有一个包含 stats.fancy 模块的先进统计库,这个模块不能在 ServiceMonitor 应用程序的每个部署中都存在于模块路径上。(原因无关紧要,但让我们假设它是一个许可问题。)

您想在 monitor.statistics 中编写使用 fancy 模块类型的代码,但要使其工作,您需要使用 requires 指令来依赖它。但如果你这样做,如果 stats.fancy 不存在,模块系统就不会让应用程序启动。图 11.8 展示了这个死锁。(如果这个情况看起来很熟悉,那是因为我们之前从另一个角度看过它。几分钟后,我会告诉你具体位置。)

图 11.8 可靠配置的难题:要么模块系统没有授予 stats.fancy 统计访问权限,因为统计不需要访问权限(左),要么统计需要访问权限,这意味着 stats.fancy 必须始终存在才能启动应用程序(右)。

另一个例子是一个实用库——让我们称它为 uber.lib——它集成了几个其他库。它的 API 提供了基于它们的功能,因此暴露了它们的类型。到目前为止,这可能会让人认为这是一个显而易见的隐含可读性案例,如第 11.1 节所述,但事情可以从另一个角度来看。

让我们以 uber.lib 集成的 com.google.common 为例来演示这个过程。uber.lib 的维护者可能认为,任何没有使用 Guava 的人永远不会调用他们库中的 Guava 部分。在某些情况下,这是有道理的。如果你没有这样的图,你为什么要调用 uber.lib 中创建一个漂亮的报告的 com.google.common.graph.Graph 实例的方法呢?

对于 uber.lib,这意味着它可以在不使用 com.google.common 的情况下完美运行。如果 Guava 进入模块图,客户端可能会调用 uber.lib API 的那一部分。如果没有,它们就不会调用,库也会正常运行。你可以这样说,uber.lib 从不需要依赖它自己。

运用我们迄今为止探索的功能,这样的可选关系无法实现。根据第三章的可读性和可访问性规则,uber.lib 必须要求 com.google.common 来编译其类型,从而强制所有客户端在启动应用程序时始终在模块路径上拥有 Guava。

如果 uber.lib 集成了一小部分库,它将使客户端依赖所有这些库,即使他们可能永远不会使用超过一个。这不是 uber.lib 的好做法,因此其维护者将寻找一种方法来标记他们的依赖项在运行时为可选。正如下一节所示,模块系统已经为他们提供了解决方案。

注意:构建工具也了解这样的可选依赖。在 Maven 中,你将依赖项的 <optional> 标签设置为 true;在 Gradle 中,你将它们列在 compileOnly 下。

11.2.2 静态修饰符:将依赖项标记为可选

当一个模块需要针对另一个模块的类型进行编译,但又不希望在运行时依赖它时,可以使用 requires static 指令来建立这种可选依赖。对于两个依赖和可选的模块,其中依赖的声明包含 requires static optional 这一行,模块系统在编译和启动时表现不同:

  • 在编译时,可选必须存在,否则将出现错误。在编译期间,可选对依赖项是可读的。

  • 在启动时,可选可能不存在,这既不会导致错误也不会导致警告。如果存在,它对依赖项是可读的。

表 11.1 比较了这种行为与常规 requires 指令。请注意,尽管模块系统不会发出错误,但运行时仍然可能会。可选依赖使得运行时错误,如 NoClassDefFoundError,更加可能,因为模块编译时可能缺少类。在第 11.2.4 节中,您将看到为这种情况做准备的代码。

表 11.1 比较 requiresrequires static 在编译和启动时对现有和缺失依赖项的行为。唯一的区别在于它们在启动时如何处理缺失依赖项(最右侧列)。

依赖项存在 依赖项缺失
编译时 启动时
requires 读取 读取
requires static 读取 读取

以一个例子,让我们创建一个从 monitor.statisticsstats.fancy 的可选依赖。为此,你使用 requires static 指令:

module monitor.statistics { requires monitor.observer; requires static stats.fancy; exports monitor.statistics; }

如果在编译时缺少 stats.fancy,则在模块声明编译时会出现错误:

> monitor.statistics/src/main/java/module-info.java:3: > error: module not found: stats.fancy > requires static stats.fancy; > ^ > 1 error

另一方面,在启动时,模块系统并不关心 stats.fancy 是否存在。

uber.lib 的模块描述符将所有依赖项声明为可选:

module uber.lib { requires static com.google.common; requires static org.apache.commons.lang; requires static org.apache.commons.io; requires static io.vavr; requires static com.aol.cyclops; }

现在你已经知道了如何声明可选依赖,但还有两个问题需要回答:

  • 在什么情况下依赖项会存在?

  • 如何针对可选依赖进行编码?

我们将在下文中回答这两个问题,完成之后,你们都将准备好使用这个方便的功能。

11.2.3 可选依赖项的模块解析

如第 3.4.1 节所述,模块解析是一个过程,给定一个初始模块和一组可观察的模块,通过解析requires指令来构建模块图。当一个模块正在解析时,它所需要的所有模块都必须是可观察的。如果它们是,它们将被添加到模块图中;否则,将发生错误。稍后我这样描述了这个图:

重要的是要注意,在解析期间没有进入模块图的模块在编译或执行期间也不可用。

ESSENTIAL INFO 在编译时,模块解析像常规依赖项一样处理可选依赖。另一方面,在启动时,requires static指令大多被忽略。当模块系统遇到一个时,它不会尝试满足它,这意味着它甚至不会检查是否有名为该模块的可观察模块。

因此,即使一个模块存在于模块路径上(或者对于 JDK 来说也是如此),由于可选依赖的存在,它也不会被添加到模块图中。只有当它也是正在解析的其他模块的常规依赖项,或者因为它被明确地通过命令行选项--add-modules添加,正如第 3.4.3 节所述时,它才会进入图。图 11.9Figure 11.9 展示了这两种行为,使用该选项确保可选依赖的存在。

这就是我们的循环。我第一次提到这个花哨的统计库是在解释为什么有时有必要明确地将模块添加到模块图中时。我没有特别提到可选依赖(这并不是该选项的唯一用例),但总体想法与现在相同:花哨的统计模块不是严格必需的,因此不会自动添加到模块图中。如果你想让它在那里,你必须使用--add-modules选项——要么命名特定的模块,要么使用ALL-MODULE-PATH

图 11.9 两边显示了类似的情况。这两种情况都涉及三个模块 A、B 和 C,其中 A 严格依赖于 B,并且可选地依赖于 C。在左边,A 是初始模块,由于可选依赖没有被解析,所以没有 C 的模块图。在右边,使用命令行选项--add-modules将 C 强制加入到图中,使其成为第二个根模块。因此,它被解析并且可以被 A 读取。

可能你注意到了这样一个短语:在模块解析过程中,可选依赖“通常被忽略”。为什么是“通常”?好吧,如果一个可选依赖被加入到图中,模块系统会添加一个读取边。所以如果花哨的统计模块在图中(可能是因为常规的requires,也可能是因为使用了--add-modules),任何可选依赖它的模块都可以读取它。这确保了它的类型可以立即访问。

11.2.4 针对可选依赖的编码

当你编写针对这些可选依赖的代码时,需要稍微多想一点,因为这是当monitor.statistics使用stats.fancy中的类型但模块在运行时不存在时发生的情况:

Exception in thread "main" java.lang.NoClassDefFoundError: stats/fancy/FancyStats at monitor.statistics/monitor.statistics.Statistician.<init>(Statistician.java:15) at monitor/monitor.Main.createMonitor(Main.java:42) at monitor/monitor.Main.main(Main.java:22) Caused by: java.lang.ClassNotFoundException: stats.fancy.FancyStats ... 更多

哎呀。你通常不希望你的代码这样做。

一般而言,当当前正在执行的代码引用一个类型时,JVM 会检查它是否已经被加载。如果没有,它会告诉类加载器去加载;如果失败了,结果就是NoClassDefFoundError,这通常会导致应用程序崩溃或者至少导致正在执行的逻辑块失败。

这正是 JAR 地狱臭名昭著的原因(参见 1.3.1 节)。模块系统希望通过在启动应用程序时检查声明的依赖来克服这个问题。但是,使用requires static,你选择退出这个检查,这意味着最终你可能会遇到NoClassDefFoundError。对此你能做什么呢?

在探讨解决方案之前,你需要确认你是否真的遇到了问题。在uber.lib的情况下,你只期望在调用库的代码已经使用这些类型的情况下使用可选依赖的类型,这意味着类加载已经成功。换句话说,当调用uber.lib时,所有必需的依赖都必须存在,否则调用不可能发生。所以你实际上没有问题,不需要做任何事情。图 11.10 说明了这种情况。

图 11.10 假设,只有在客户端已经使用可选依赖项中的类型时调用uber.lib才有意义。因此,所有依赖于可选依赖项对uber.lib可用的执行路径(顶部两个)都已经通过了依赖于该依赖项的客户端代码(条纹区域)。如果那没有失败,uber.lib也不会失败。

然而,一般情况是不同的,如图 11.11 所示。可能正是具有可选依赖项的模块首先尝试从可能不存在的依赖项中加载类,因此NoClassDefFoundError的风险是非常真实的。

图 11.11 在一般情况下,不能保证调用像统计这样的模块的客户端代码已经建立了可选依赖项。在这种情况下,执行路径(波浪线)可能首先在统计模块(条纹区域)中遇到依赖项,如果可选依赖项不存在,则将失败。

重要信息 一个解决方案是确保所有可能调用具有可选依赖项的模块的调用都必须在访问依赖项之前通过检查点。如图 11.12 所示,该检查点必须评估依赖项是否存在,如果不存在,则将到达它的所有代码发送到不同的执行路径。

图 11.12 为了确保像统计这样的模块,它有一个可选的依赖项,无论该依赖项是否存在都能保持稳定,需要检查点。根据依赖项是否存在,代码会根据执行路径(波浪线)分支到使用该依赖项的代码(条纹区域)或到其他不使用该依赖项的代码。

模块系统提供了一个 API 来检查模块是否存在。我目前不会深入讲解它是如何工作的,因为您缺少理解代码所需的一些先决条件。所以您需要等待(或跳到)第 12.4.2 节,亲自查看以下类似实用方法是如何实现的:

public static boolean isModulePresent(String moduleName) { // ... }

使用类似"stats.fancy"这样的参数调用此方法将返回该模块是否存在。如果使用常规依赖项的名称(简单的requires指令)调用,结果始终为true,因为否则模块系统不会允许应用程序启动。

如果使用可选依赖项的名称(requires static指令),结果将是truefalse。如果存在可选依赖项,模块系统建立了可读性,因此可以安全地走一个使用模块中类型的执行路径。如果不存在可选依赖项,选择这样的路径将导致NoClassDefFoundError,因此必须找到另一个路径。

11.3 合格导出:限制对特定模块的访问

而前两个部分展示了如何细化依赖项,这一部分介绍了一种允许更精细 API 设计的机制。如第 3.3 节所述,模块的公共 API 是通过使用exports指令导出包来定义的,在这种情况下,每个读取导出模块的模块都可以在编译和运行时访问这些包中的所有公共类型。这是强封装的核心,第 3.3.1 节对此进行了深入解释。

到目前为止,我们讨论的内容要求你在强封装一个包或始终使其对所有人都可访问之间做出选择。为了处理那些不容易适应这种二分法的用例,模块系统提供了两种不那么坦率的导出包的方式:合格导出,我们现在将探讨;以及开放包,第 12.2 节将介绍,因为它们与反射有关。和以前一样,我将先通过例子介绍机制。在本节结束时,你将能够比使用常规exports指令更精确地暴露 API。查看 ServiceMonitor 存储库中的feature-qualified-exports分支,以了解合格导出的实际应用。

11.3.1 展示内部 API

最好的例子表明exports指令可能过于通用,来自 JDK。如你在第 7.1 节所见,只有一个平台模块导出sun.*包,而很少有模块导出com.sun.*包。但这是否意味着所有其他包都只在其声明的模块中使用?

远非如此!许多包在模块间共享。以下是一些例子:

  • 基础模块 java.base 的内部结构被广泛使用。例如,java.sql(提供 Java 数据库连接 API [JDBC])使用了jdk.internal.miscjdk.internal.reflectsun.reflect.misc。与安全相关的包,如sun.security.providersun.security.action,被 java.rmi(远程方法调用 API [RMI])或 java.desktop(AWT 和 Swing 用户界面工具包,以及可访问性、多媒体和 JavaBeans API)使用。

  • java.xml 模块定义了 Java XML 处理 API(JAXP),它包括 XML 流式 API(StAX)、简单 XML API(SAX)和 W3C 文档对象模型(DOM)API。其六个内部包(大多数以com.sun.org.apache.xmlcom.sun.org.apache.xpath为前缀)被 java.xml.crypto(XML 加密 API)使用。

  • 许多 JavaFX 模块访问 javafx.graphics 的内部包(主要是com.sun.javafx.*),而 javafx.graphics 反过来又使用 javafx.swing 中的com.sun.javafx.embed.swing(集成 JavaFX 和 Swing),而 javafx.swing 反过来又使用 java.desktop 的七个内部包(如sun.awtsun.swing),以此类推...

我可以继续说,但我相信你已经明白了我的意思。然而,这提出了一个问题:JDK 是如何在没有将其导出到其他所有人的情况下,在其模块之间共享这些包的?

虽然 JDK 确实有更针对性的导出机制的最强用例,但它并不是唯一的。每当一组模块想要在不暴露的情况下相互共享功能时,这种情况就会发生。这可能是一个库、一个框架,甚至是一个大型应用程序模块集的子集。

这与在模块系统引入之前隐藏实用类的问题是对称的。一旦实用类需要在包之间可用,它就必须是公开的;但在 Java 9 之前,这意味着所有在同一个 JVM 中运行的代码都可以访问它。现在你面临的情况是你想隐藏一个实用包,但一旦它需要在模块之间可用,它就必须被导出,因此可以被同一 JVM 中运行的所有模块访问——至少到目前为止你所使用的机制是这样的。图 11.13 说明了这种对称性。

图片

图 11.13(左)Java 9 之前的状况,一旦类型是公开的(如util包中的FancyUtil),它就可以被所有其他代码访问。(右)模块的类似情况,但处于更高的层面,一旦包被导出(如utils.fancy中的util),它就可以被所有其他模块访问。

11.3.2 将包导出到模块

exports指令可以通过跟在它后面加上to ${modules}来限定,其中${modules}是一个以逗号分隔的模块名称列表(不允许使用占位符)。对于在exports to指令中命名的模块,包的访问权限将与常规的exports指令完全相同。对于所有其他模块,包的封装性将像没有exports一样强。这种情况在图 11.14 中显示。

图片

图 11.14 模块所有者使用限定导出来使包pack仅对特权模块可用。对于特权模块,它的访问权限与使用常规导出时一样;但其他模块,如常规模块,无法访问它。

作为一个假设的例子,假设 ServiceMonitor 应用程序中的所有观察者实现都需要共享一些实用代码。第一个问题是把这些类型放在哪里。所有观察者已经依赖于 monitor.observer,因为它包含了它们实现的 ServiceObserver 接口,所以为什么不把它放在那里呢?好吧,它们最终放在了包monitor.observer.utils中。

接下来是有趣的部分。这是仅将新包导出到实现模块的 monitor.observer 模块声明:

module monitor.observer { exports monitor.observer; exports monitor.observer.utils to monitor.observer.alpha, monitor.observer.beta; }

虽然monitor.observer对所有模块都进行了导出,但monitor.observer.utils将只能由 monitor.observer.alpha 和 monitor.observer.beta 模块访问。

这个例子展示了两个有趣的细节:

  • 被导出的包可以依赖于导出模块,从而形成一个循环。思考这个问题,除非使用了隐含的可读性,否则这必须是这种情况:否则,被导出包的模块如何读取导出模块?

  • 每当一个新的实现想要使用这些工具时,API 模块需要被更改,以便它能够访问这个新模块。尽管让导出模块控制哪些模块可以访问包是合格导出的主要目的,但这仍然可能有些繁琐。

作为现实世界的例子,我想向您展示 java.base 声明的合格导出,但一共有 65 个,这可能会有些令人眼花缭乱。相反,让我们通过java --describe-module java.xml(如第 5.3.1 节所述)来查看 java.xml 的模块描述符:

> 模块 java.xml@9.0.4 # 除了合格导出之外的所有内容都被截断 > 合格导出 com.sun.org.apache.xml.internal.utils 到 java.xml.crypto > 合格导出 com.sun.org.apache.xpath.internal.compiler 到 java.xml.crypto > 合格导出 com.sun.xml.internal.stream.writers 到 java.xml.ws > 合格导出 com.sun.org.apache.xpath.internal 到 java.xml.crypto > 合格导出 com.sun.org.apache.xpath.internal.res 到 java.xml.crypto > 合格导出 com.sun.org.apache.xml.internal.dtm 到 java.xml.crypto > 合格导出 com.sun.org.apache.xpath.internal.functions 到 java.xml.crypto > 合格导出 com.sun.org.apache.xpath.internal.objects 到 java.xml.crypto

这表明 java.xml 允许 java.xml.crypto 和 java.xml.ws 使用其一些内部 API。

现在您已经了解了合格导出,我可以澄清我们在分析模块系统日志时留下的一个小谜团。在那里,您看到了如下信息:

> 将从模块 java.xml 读取到模块 java.base > 模块 java.xml 中的包 com/sun/org/apache/xpath/internal/functions 导出到模块 java.xml.crypto > 模块 java.xml 中的包 javax/xml/datatype 导出到所有未命名的模块

我没有解释为什么日志谈论的是导出到模块,但根据我们刚才讨论的,现在应该很清楚。正如你在最近的例子中看到的,java.xml 导出com.sun.org.apache.xpath.internal.functions到 java.xml.crypto,这正是第二条消息所说的。第三条消息将javax.xml.datatype导出到“所有未命名的模块”,这看起来有点奇怪,但这是模块系统表示该包未进一步限定导出,因此对读取 java.xml 的每个模块都可用,包括未命名的模块。

基本信息 最后,关于编译的两个小贴士:

  • 如果一个声明了合格导出的模块被编译,而目标模块不在可观察模块的宇宙中,编译器将发出警告。这不是一个错误,因为目标模块被提及了,但不是必需的。

  • exportsexports to指令中不允许使用一个包。如果两个指令都存在,后者将实际上是无用的,因此这种情况被解释为实现错误,因此导致编译错误。

11.3.3 何时使用合格导出

合格导出允许模块之间共享一个包,而不会使其对所有同一 JVM 中的其他模块可用。这使得合格导出对于由多个模块组成且希望在不让客户端使用的情况下共享代码的库和框架非常有用。它们对于希望限制对特定 API 依赖的大型应用程序也非常有用。

合格导出可以看作是从保护工件中的类型到保护模块集合中的包的强封装的提升。这由图 11.15 所示。

图片

图 11.15(左)一个非导出包中的公共类型如何被同一模块中的其他类型访问,但不能被其他模块的类型访问。(右)一个类似的情况,但处于更高的层面,其中合格导出被用来使一个模块中的包对一组定义的模块可用,同时使其对无权限的模块不可访问。

假设你正在设计一个模块。你何时应该优先选择合格导出而不是无合格导出?为了回答这个问题,我们必须关注合格导出的核心好处:控制谁使用 API。一般来说,当问题包与其客户端的距离越远时,这变得越重要。

假设你有一个由少量模块(不计依赖项)组成的小到中等规模的应用程序,由一个小团队维护,并且一次性编译和部署。在这种情况下,控制哪个模块使用哪个 API 相对容易;如果出现问题,由于一切都在你的控制之下,所以很容易修复。在这种情况下,合格导出的好处影响很小。

在光谱的另一端是 JDK,它被世界上几乎每个 Java 项目使用,并且极端关注向后兼容性。代码“在外部”依赖于内部 API 可能会出现问题,并且难以修复,因此控制谁可以访问什么的需求很大。

区分这两种极端的最明显的界限是您是否可以自由更改包的客户端。如果您可以,因为您正在开发该模块及其所有客户端模块,常规导出是一个不错的选择。如果您不能,因为您维护一个库或框架,那么只有您希望客户端使用并且愿意维护的 API 应该无条件地导出。所有其他内容,尤其是内部实用程序,应该仅导出到您的模块中。

在大型项目中,这条线变得模糊。如果一个大型代码库由一个大型团队在多年内维护,那么在 API 更改变得必要时,您可能技术上能够更改所有客户端,但这可能很痛苦。在这种情况下,使用有条件的导出不仅防止了意外依赖于内部包,而且还记录了 API 是为哪些客户端设计的。

11.3.4 命令行上的导出包

如果在编写时没有预见(或者更可能的是,没有打算)使用内部 API,那会怎样?如果代码绝对必须访问包含模块未导出的类型,无论是合格的还是不合格的,那会怎样?如果模块系统坚决执行这些规则,许多应用程序在 Java 9+上可能无法编译或启动;但如果它是一个绕过强封装的简单方法,那么它几乎不会是“强”的,从而失去了其优势。通过定义可以作为逃生口但又不至于成为普遍解决方案的命令行选项,找到了中间地带。

除了exports to指令外,还有一个具有相同效果的命令行选项可以应用于编译器和运行时命令:使用--add-exports ${module}/${package}=${accessing-modules},模块系统将\(module 的`\){package}导出到以逗号分隔的列表${accessing-modules}中命名的所有模块。如果其中包含ALL-UNNAMED`,未命名的模块中的代码也可以读取该包。

正常的可访问性规则,如第 3.3 节所述,适用——对于由于--add-exports选项而需要访问类型的模块,必须满足以下条件:

  • 类型必须是公开的。

  • 类型必须在${package}中。

  • ${accessing-modules}中指定的模块必须读取${module}。

对于--add-exports的示例,请翻回到 7.1.3 和 7.1.4 节,在那里您使用它来在编译和运行时访问平台模块的内部 API。与其他命令行选项一样,要求--add-exports在实验之外存在是一个可维护性问题;请参阅 9.1 节以获取详细信息。

摘要

  • 隐含的可读性:

  • 使用requires transitive指令,一个模块使其客户端读取因此所需的模块,即使该模块没有明确依赖它。这允许模块在其 API 中使用依赖项的类型,而无需将手动要求这些依赖项的负担放在客户端模块上。因此,该模块可以立即使用。

  • 如果一个模块仅在边界上使用传递依赖项,那么它应该仅依赖于传递依赖项的隐含可读性。一旦模块开始使用传递依赖项来实现其自己的功能,它应该将其作为直接依赖项。这确保了模块声明反映了真实的依赖项集合,并使模块在可能删除传递依赖项的重构中更加健壮。

  • 当在模块之间移动代码时,可以通过使曾经包含代码的模块对现在包含代码的模块暗示可读性来使用隐含的可读性。这允许客户端访问他们依赖的代码,而无需要求他们更改他们的模块描述符,因为他们最终仍然会读取包含代码的模块。保持这种兼容性对库和框架尤其有趣。

  • 可选依赖项:

  • 使用requires static指令,一个模块标记了一个模块系统将确保在编译时存在但在运行时可以不存在的依赖项。这允许在不强制客户端始终在其应用程序中包含这些模块的情况下针对模块进行编码。

  • 在启动时,仅由requires static指令要求的模块即使可观察也不会添加到模块图中。相反,您必须使用--add-modules手动添加它们。

  • 针对可选依赖项的编码应确保没有执行路径会因为依赖项缺失而失败,因为这会严重损害模块的可用性。

  • 有资格的导出:

  • 使用exports to指令,一个模块使一个包仅对命名的模块可访问。这是在封装包和使其对每个人可访问之间的一种第三种更具体的选择。

  • 将代码导出到特定模块允许在一系列特权模块内共享代码,而不将其公开为公共 API。这减少了库或框架的 API 表面积,从而提高了可维护性。

  • 使用--add-exports命令行选项,您可以在编译和运行时导出模块的开发者打算作为内部 API 的包。一方面,这保留了依赖于这些内部代码的代码运行;另一方面,它引入了自己的可维护性问题。

12

模块世界的反射

本章涵盖了

  • 向反射开放包和模块

  • 模块和反射的组合

  • 反射 API 的替代方案

  • 分析和修改模块属性

如果你正在开发一个 Java 应用程序,那么你很可能依赖于 Spring、Hibernate、JAXP、GSON 等框架。什么是“类似的东西”?这些是使用 Java 的反射 API 来检查你的代码、搜索注解、实例化对象或调用方法的框架。多亏了反射,它们可以在不针对你的代码进行编译的情况下完成所有这些操作。

此外,反射 API 允许框架访问非公共类和非公共成员。它具有超出编译代码可能性的超级能力,如果类或成员不是公共的,它会在包边界上反弹。问题是,随着模块的出现,反射不再自动工作。

恰恰相反:反射失去了它的超级能力,并且被绑定到与编译代码相同的精确可访问性规则。它只能访问导出包中公共类的公共成员。另一方面,这些框架使用反射来访问通常不是公共的属性和方法,以及你可能不希望导出的类,因为这些类不是模块 API 的一部分。那么你该怎么办?这正是本章的主题!

为了充分利用本章内容,你应该

  • 对反射的工作原理有一个基本的理解(否则,附录 B 将帮助你跟上进度)。

  • 知道每次你在某个地方放置一个注解,你实际上是在标记这个类供框架进行反射(参见 列表 12.1 以获取一些示例)。

  • 理解可访问性规则(如第 3.3 节所述)。

列表 12.1 基于反射的标准和框架的代码片段

// JPA @Entity @Table(name = "user") public class Book { @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) @Column(name = "id", updatable = false, nullable = false) private Long id; @Column(name = "title", nullable = false) private String title; // [...] } // JAXB @XmlRootElement(name = "book") @XmlAccessorType(XmlAccessType.FIELD) public class Book { @XmlElement private String title; @XmlElement private String author; // [...] } // SPRING @RestController public class BookController { @RequestMapping(value = "*book*{id}", method = RequestMethod.GET) @ResponseBody public Book getBook(@PathVariable("id") long id) { // [...] } // [...] }

在掌握这些知识的基础上,你会了解到,如果你想要允许对模块进行反射访问(第 12.1 节),仅使用 exports 指令是远远不够的,以及你可以采取的其他措施(第 12.2 节)。(请注意,这仅适用于显式模块——如果你的代码从类路径运行,它不是封装的,因此你不必担心这些问题。)

但本章不仅仅关于“仅仅”为反射准备模块:它还涵盖了另一面,讨论了如何更新反射代码以及反射 API 的替代方案和补充(第 12.3 节)。它以如何使用层在运行时动态加载模块结束(第 12.4 节)。(这两个部分是为那些在 Java 9 之前就已经有这些用例的开发者编写的,因此它们比本章的其余部分需要更多对反射和类加载器的熟悉。)

完成之后,你将了解如何在模块世界中为反射准备你的项目,无论它是被反射的、反射之上的还是执行反射。你还将能够使用反射在运行时动态加载代码,例如实现基于插件的程序。

12.1 为什么 exports 指令不适合反射

在我们讨论如何最佳准备你的代码以进行反射之前,讨论到目前为止所讨论的用于公开类、exports指令(见第 3.3 节)的机制为何不适合是有意义的。有三个原因:

  • 是否应该将设计用于与这样的框架一起使用的类包含在模块的公共 API 中,这是一个高度可疑的问题。

  • 将这些类导出到选定的模块中可以将模块耦合到实现而不是标准。

  • 导出不支持对非私有字段和方法的深度反射。

在我们讨论模块系统之前的工作方式之后,我们将依次查看这些内容。

12.1.1 打破非模块代码

假设你已经按照第六章和第七章的描述成功地将你的应用程序迁移到 Java 9+,尽管如此,你还没有对其进行模块化,所以你仍然是从类路径上运行它。在这种情况下,对你的代码的反射仍然像在 Java 8 中一样继续工作。

基于反射的框架将常规地通过访问非公共类型和成员来创建和修改类的实例。尽管无法针对包可见或私有元素进行编译,但反射允许你在使其可访问后使用它们。以下列表显示了一个假设的持久化框架,该框架使用反射来创建一个实体并为私有字段分配一个 ID。

列表 12.2 使用反射

Class<?> type = ... Constructor<?> constructor = entityType.getConstructor(); constructor.setAccessible(true); Object entity = constructor.newInstance(); Field id = entity.getDeclaredField("id"); id.setAccessible(true); id.set(entity, 42);

无论框架需要做什么来获取那个类

使可能私有的构造函数和字段对以下调用可访问

现在想象一下,如果应用程序被模块化,突然在您的代码和那些框架之间出现了一个模块边界。模块系统,特别是exports指令,为您提供了哪些选项来使内部类型可访问?

12.1.2 强制发布内部类型

关键信息:根据第 3.3 节中讨论的可访问性规则,类型必须是公共的,并且位于导出包中才能被访问。这也适用于反射;因此,如果不使用exports指令,您将得到以下异常:

> 异常发生在主线程中 java.lang.IllegalAccessException: > 类 p.X(在模块 A 中)无法访问类 q.Y(在模块 B 中) > 因为模块 B 没有将 q 导出到模块 A > at java.base/....Reflection.newIllegalAccessException > at java.base/....AccessibleObject.checkAccess > at java.base/....Constructor.newInstance

这似乎表明,你必须将 Spring、Hibernate 等类库需要访问的类设置为 public,并导出包含它们的包。然而,这样做会将它们添加到模块的公共 API 中,鉴于我们之前将这些类型视为内部类型,这是一个重大的决定。

如果您正在编写一个包含几千行代码的小型服务,这些代码被分成几个模块,这可能看起来不是问题。毕竟,关于您模块的 API 和关系的重大混淆的可能性很小。但这也不是您需要模块发光的场景。

另一方面,如果您正在处理一个包含六位数或七位数代码行的大型代码库,分成由十几个或更多开发者共同工作的几十个或几百个模块,情况就完全不同了。在这种情况下,导出一个包会给其他开发者一个强烈的信号,表明可以在模块外部使用这些类,并且它们被特别设计为可以跨越模块边界使用——毕竟,这就是 exports 的作用。

但由于这次探索的起点是您出于某种原因更喜欢不将这些类设置为 public,您显然重视它们的封装性。如果模块系统迫使您标记某些您甚至不希望其可访问的内容为受支持,从而削弱了封装性,那就非常讽刺了。

12.1.3 限定导出创建对特定模块的耦合

在这一点上,回顾第 11.3 节,并考虑使用限定导出以确保只有单个模块可以访问这些内部类型。首先,对于您即兴思考的能力表示赞赏——这确实可以解决我刚才描述的问题。

虽然它可以引入一个新的。想想 JPA 及其各种实现,比如 Hibernate 和 EclipseLink。根据你的风格,你可能已经努力防止直接依赖于你选择的实现,所以你不会期待在模块声明中硬编码一个exports … to concrete.jpa.implementation。如果你依赖于限定导出,那就没有其他办法了。

12.1.4 不支持深度反射

将你本想作为实现细节处理的类型暴露给其他代码是件坏事。但这还只是开始。

假设你决定使用一个exports指令(无论是否限定)来允许你选择的框架访问你的类。尽管通常可以使用仅包含公共成员的基于反射的框架,但这并不总是情况,也不是最佳方法。相反,通常依赖于对私有字段或非公共方法的深度反射,以防止将框架相关的细节暴露给代码库的其他部分。(列表 12.1 展示了一些示例,列表 12.2 展示了如何使用setAccessible来实现对内部结构的访问。)

重要的是——一般来说是幸运的——但不幸的是在这种情况下——将类型公开并导出其包并不授予对非公共成员的访问权限。如果框架尝试通过调用setAccessible来使用它们,你会得到这样的错误:

> 线程 "main" 中发生异常 java.lang.reflect.InaccessibleObjectException: > 无法使字段 q.Y.field 可访问:> 模块 B 没有向模块 A "打开" q > at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible > at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible > at java.base/java.lang.reflect.Field.checkCanSetAccessible > at java.base/java.lang.reflect.Field.setAccessible

如果你真的想走这条路,你必须将所有通过反射访问的成员公开,这使得之前“这削弱了封装性”的结论变得更糟。

总结来说,这些是使用exports指令对主要用于反射的代码的缺点:

  • 只允许访问公共成员,这通常需要将实现细节公开。

  • 允许其他模块编译代码时针对那些公开的类和成员。

  • 限定导出可能会使你绑定到一个实现而不是一个规范。

  • 标记包为模块公共 API 的一部分。

取决于你哪一点觉得最糟糕。对我来说,是最后一点。

12.2 开放包和模块:为反射用例设计

既然我们已经确定了exports对于使代码对基于反射的库可访问是多么不合适,模块系统提供了什么替代方案?

答案是 opens 指令,在我们介绍其合格变体(类似于 exports … to;第 12.2.2 节)之前,这是我们首先要关注的内容(第 12.2.1 节)。为了确保你选择正确的工具来完成这项工作,我们还将彻底比较导出和打开模块的效果(第 12.2.3 节)。最后但同样重要的是,提供反射访问权限的锤子:打开模块(第 12.2.4 节)。

12.2.1 打开包以进行运行时访问

定义:opens 指令

可以通过在模块声明中添加指令 opens ${package} 来打开一个包。在编译时,打开的包被强封装:它们是否被打开没有区别。在运行时,打开的包是完全可访问的,包括非公共类、方法和字段。

模块 monitor.persistence 使用 Hibernate,因此它打开一个包含实体的包以允许对其反射:

module monitor.persistence { requires hibernate.jpa; requires monitor.statistics; exports monitor.persistence; opens monitor.persistence.entity; }

这使得 Hibernate 能够与 StatisticsEntity 类(见清单 12.3)等类一起工作。由于该包没有被导出,其他 ServiceMonitor 模块不会意外地编译包含其类型的代码。

清单 12.3 从 StatisticsEntity 中摘录的内容,Hibernate 将对其进行反射

@Entity @Table(name = "stats") public class StatisticsEntity { @Id @GeneratedValue(strategy = GenerationType.AUTO) private int id;@ManyToOne @JoinColumn(name = "quota_id", updatable = false) private LivenessQuotaEntity totalLivenessQuota;private StatisticsEntity() { }// [...] }

Hibernate 将将这些私有字段注入值。

Hibernate 也可以访问私有构造函数。

正如你所看到的,opens 是专门为反射用例设计的,并且与 exports 的行为非常不同:

  • 它允许访问所有成员,因此不会影响你的可见性决策。

  • 它防止了对打开包中代码的编译,并且只允许在运行时访问。

  • 它将包标记为设计用于由基于反射的框架使用。

除了在特定用例中对 exports 的明显技术优势之外,我认为最后一个要点是最重要的:使用 opens 指令,你可以在代码中清楚地传达这个包不是用于通用用途,而只是供特定工具访问。如果你愿意,甚至可以通过只为该模块打开包来包含该工具。继续阅读以了解如何做到这一点。正如第 5.2.3 节所解释的,如果你想访问位于你的包中的资源,如配置或媒体文件,你也需要打开它们。

12.2.2 为特定模块打开包

我们之前讨论的 opens 指令允许所有模块反射打开的包。这与 exports 指令允许所有模块访问导出的包的方式相似。正如 exports 可以限制为特定的模块(见第 11.3 节)一样,opens 也可以。

定义:限定打开

opens 指令可以通过跟在后面 to ${modules} 来限定,其中 ${modules} 是模块名称的逗号分隔列表(不允许使用占位符)。对于 opens to 指令中命名的模块,包的访问权限将与常规 opens 指令完全相同。对于所有其他模块,包的封装性将像没有 opens 一样强。

为了使封装性更强,monitor.persistence 可能只能将其实体包打开给 Hibernate:

module monitor.persistence { requires hibernate.jpa; requires monitor.statistics; exports monitor.persistence; // assuming Hibernate were an explicit module opens monitor.persistence.entity to hibernate.core; }

在规范和实现分离的情况下(例如,JPA 和 Hibernate),您可能会觉得在模块声明中提及实现有些奇怪。第 12.3.5 节解决了这个问题——总结来说,在标准更新以考虑模块系统之前,这是必要的。

我们将在第 12.2.3 节中讨论何时可能需要使用限定 opens,但在我们这样做之前,让我们正式介绍在第 7.1 节中使用的命令行选项。

定义:--add-opens

选项 --add-opens ${module}/${package}=${reflecting-module} 将 ${module} 中的 ${package} 打开给 \({reflecting-module}。因此,\){reflecting-module} 中的代码可以访问 ${package} 中的所有类型和成员,包括公开和非公开的,但其他模块则不能。

当您将 ${reading-module} 设置为 ALL-UNNAMED 时,类路径上的所有代码,或者更确切地说,是从未命名的模块(见第 8.2 节)中,都可以访问该包。当迁移到 Java 9+时,您将始终使用该占位符——只有当您自己的代码在模块中运行时,您才能将打开的包限制为特定的模块。

如果您对示例感兴趣,请查看第 7.1.4 节末尾的示例。

由于 --add-opens 与反射绑定,而反射是一个纯运行时概念,因此它只适用于 java 命令。有趣的是,尽管如此,它也存在于 javac 中,但会导致警告:

> 警告:[options] --add-opens 在编译时没有效果

我认为 javac 并不全面拒绝 --add-opens 的原因可能是,这使得在编译和启动之间共享相同的参数文件成为可能,该参数文件与模块系统相关的命令行标志相关。

备注:什么是参数文件?您可以将编译器和 JVM 参数放入文件中,并通过 javac @file-namejava @file-name 将它们添加到命令中。有关详细信息,请参阅 Java 文档:mng.bz/K1ZK

12.2.3 导出与打开包的比较

exportsopen指令有一些共同点:

  • 它们使包内容在模块边界之外可用。

  • 它们有一个合格变体to ${modules},只允许访问列出的模块。

  • 它们为javacjava提供了命令行选项,可以在需要时绕过强封装。

它们在何时以及提供何种访问权限方面有所不同:

  • 导出包在编译时提供对公共类型和成员的访问权限,因此它们非常适合定义其他模块可以使用的公共 API。

  • 打开的包提供对所有类型和成员(包括非公共成员)的访问权限,但仅限于运行时,这使得它们非常适合为基于反射的框架提供对其他情况下被认为是模块内部代码的访问权限。

表 12.1 总结了这一点。你可能还想翻回到表 7.1 以查看它如何与使用--add-exports--add-opens获取内部 API 的访问权限相关。

表 12.1 对封装、导出和打开的包何时以及提供何种访问权限的比较

访问 编译时 运行时
类或成员 公共 非公共
封装包
导出包
打开的包

你可能会想知道是否以及如何组合exportsopens指令,以及合格和不合格变体。答案是简单的——你喜欢的方式:

  • 你的 Hibernate 实体是公共 API 吗?使用exportsopens

  • 想要只给应用程序的少数模块提供编译时访问你的 Spring 上下文?使用exports … toopens

对于四种可能的组合中的每一种可能都没有明显的用例(我甚至可以争论你应该设计代码,以便不需要任何一种),但请放心,如果你遇到一个,你可以相应地安排这些指令。

当涉及到是否应该将opens限制为特定的模块时,我的观点是,这通常不值得额外的努力。尽管合格导出是防止同事和用户意外依赖内部 API 的重要工具(有关更多信息,请参阅第 11.3.3 节),但合格opens的目标受众是完全独立于你代码的框架。无论你是否只为 Hibernate 打开一个包,Spring 都不会依赖于它启动。如果你的项目在其代码上使用了大量的反射,那么情况可能会有所不同;但否则,我的默认做法是不加限定地打开。

12.2.4 打开模块:反射关闭

最后,如果你有一个包含许多暴露于反射的包的大模块,你可能发现逐个打开它们会很麻烦。尽管没有类似于opens com.company.*的通配符,但存在类似的东西。

定义:打开模块

通过在模块声明中将关键字open放在module之前,创建了一个公开的模块:

open module ${module-name} { requires ${module-name}; exports ${package-name}; // no opens allowed }

一个公开的模块将其包含的所有包都公开,就像每个包都在使用一个opens指令一样。因此,手动进一步打开包是没有意义的,这也是为什么编译器不接受公开模块中的opens指令的原因。

作为使用opens monitor.persistence.entity的替代方案,monitor.persistence 模块可以改为公开:

open module monitor.persistence { requires hibernate.jpa; requires monitor.statistics; exports monitor.persistence; }

如您所见,公开模块实际上只是为了方便您不必手动打开数十个包。理想情况下,您永远不会处于这种情况,因为您的模块并不大。在模块化过程中,当您在拆分之前将一个大 JAR 转换成一个大模块时,出现如此多打开包的情况更为常见。这也是为什么jdeps可以为公开模块生成声明的原因——参见第 9.3.2 节。

12.3 模块上的反射

12.1 和 12.2 节探讨了如何将代码暴露给反射,以便像 Hibernate 和 Spring 这样的框架可以访问它。由于大多数 Java 应用程序都使用这样的框架,您将经常遇到这种情况。

现在我们将转换到另一边,对模块化代码进行反射。了解这一点是好的,这样您就可以更新对反射 API 的理解;但由于编写反射代码对于大多数开发者来说很少见,所以您不太可能经常这样做。因此,本节更多地是关于对模块及其代码进行反射的值得注意方面的讨论,而不是对所有相关主题和 API 的全面介绍。

我们首先将探讨为什么您不需要更改反射代码以与模块化代码(12.3.1)一起工作,以及为什么您可能需要切换到更现代的 API(12.3.2)。然后我们将深入到模块本身,它们在反射 API 中有显著的表示,可以用来查询(12.3.3)甚至修改(12.3.4)它们。最后,我们将更详细地研究如何修改模块以允许其他模块通过反射访问它(12.3.5)。

12.3.1 更新模块的反射代码(或不)

在探索新领域之前,我想通过模块系统引起的变化来更新您的反射知识。虽然了解反射如何处理可读性和可访问性是好的,但您会发现您在代码中需要更改的内容并不多。更重要的是,您需要通知用户在创建模块时他们需要做什么。

读取性方面无需进行任何操作

我反复强调的一点是,反射受到与静态访问相同的可访问性规则的约束(参见第 3.3 节)。首先,这意味着一个模块中的代码要能够访问另一个模块中的代码,第一个必须读取第二个。一般来说,模块图不会以这种方式设置——Hibernate 通常不会读取应用程序模块。

重要的信息 这听起来像是反射模块需要向反射模块添加一个读取边,确实,有一个 API 可以做到这一点(参见第 12.3.4 节)。但由于反射始终需要这个边,总是添加它只会导致不可避免的样板代码,因此反射 API 会内部处理。总之,你不需要担心可读性。

无法实现可访问性

访问代码的下一个障碍是它需要被导出或打开。在第 12.2 节中已经彻底讨论过,这确实是一个问题,尽管作为反射库的作者,你能做的很少。要么模块的所有者通过打开或导出包来准备它,要么他们没有这样做。

重要的信息 模块系统不会限制可见性:像 Class::forName 或通过反射获取构造函数、方法和字段的引用这样的调用是成功的。但可访问性是有限的:如果反射模块没有提供访问权限,那么调用构造函数或方法、访问字段以及调用 AccessibleObject::setAccessible 将会失败并抛出 InaccessibleObjectException

InaccessibleObjectException 扩展了 RuntimeException,使其成为一个非受检异常,因此编译器不会强制你捕获它。但请确保你确实捕获了它,并在该操作上也是如此——这样,你可以为用户提供一个尽可能有帮助的错误信息。参见列表 12.4 中的示例。

定义:AccessibleObject::trySetAccessible

如果你更喜欢在不抛出异常的情况下检查可访问性,Java 9 中新增的 AccessibleObject::trySetAccessible 方法正是为你准备的。本质上,它做的是与 setAccessible(true) 相同的事情:它尝试使底层成员可访问,但使用其返回值来指示是否成功。如果授予了可访问性,它返回 true;否则返回 false。列表 12.4 展示了它的实际应用。

列表 12.4 三种处理不可访问代码的方法

private Object constructWithoutExceptionHandling(Class<?> type) throws ReflectiveOperationException { Constructor<?> constructor = type.getConstructor(); constructor.setAccessible(true); return constructor.newInstance(); } private Object constructWithExceptionHandling(Class<?> type) throws ReflectiveOperationException, FrameworkException { Constructor<?> constructor = type.getConstructor(); try { constructor.setAccessible(true); } catch (InaccessibleObjectException ex) { throw new FrameworkException(createErrorMessage(type), ex); } return constructor.newInstance(); } private Object constructWithoutException(Class<?> type) throws ReflectiveOperationException, FrameworkException { Constructor<?> constructor = type.getConstructor(); boolean isAccessible = constructor.trySetAccessible(); if (!isAccessible) throw new FrameworkException(createErrorMessage(type)); return constructor.newInstance(); } private String createErrorMessage(Class<?> type) { return "当进行框架操作时,访问 " + type + " 的无参构造函数失败,因为模块没有打开包含它的包。 " + "有关详细信息,请参阅 https://framework.org/java-modules"; }

此调用可能会抛出 InaccessibleObjectException,该异常没有明确处理——用户需要自行解决问题。

在这里,异常被转换为一个具有附加错误消息的框架特定异常,该消息解释了异常发生的环境。

通过使用 trySetAccessible,可以防止初始异常,但在此情况下,仍然会抛出一个框架特定的异常。

除了确保正确处理无法授予访问权限的情况外,您别无他法。这使得更新项目以适应模块系统更像是一个沟通挑战,而不是技术挑战:用户需要了解项目可能需要访问哪些包以及如何处理。您的文档是教育他们的明显位置。

专门的 JPMS 页面

稍微偏离主题,我建议在您的文档中为如何为项目准备模块使用的问题创建一个专门的页面。越专注,搜索的用户就越有可能找到它,所以不要把它埋在一个已经非常大的文档中。然后广泛传播这个资源,包括在 Javadoc 和失败访问的异常消息中。

12.3.2 使用变量处理程序而不是反射

Java 9 引入了一个名为变量句柄的新 API(扩展了 Java 7 的方法句柄,而很少有开发者有使用场景)。它围绕 java.lang.invoke.VarHandle 类展开,其实例是变量的强类型引用:例如,字段(尽管它不仅限于这一点)。它解决了来自反射、并发和堆外数据存储等领域的用例。与反射 API 相比,它提供了更多的类型安全和更好的性能。

方法句柄和变量句柄是多功能、复杂的特性,与模块系统关系不大,所以在这里我不会正式介绍它们。如果你偶尔编写使用反射的代码,你绝对应该研究它们——一个简单的例子,请参见下一个列表。不过,有一个特别有趣的问题,我想更深入地讨论:变量句柄如何用于访问模块内部。

列表 12.5 使用 VarHandle 访问字段值

Object object = // ... ①``String fieldName = // ... ①``Class<?> type = object.getClass(); ②``Field field = type.getDeclaredField(fieldName); ②``Lookup lookup = MethodHandles.lookup(); ③``VarHandle handle = lookup.unreflectVarHandle(field); handle.get(object);

给定一个对象和一个字段的名称 …

…这是获取类型和字段的典型反射代码。

查找和变量句柄是方法/变量句柄 API 的一部分,该 API 基于查找。

你已经看到,反射 API 要求用户打开一些包,但反射框架没有方法在代码中表达这一点。用户要么基于对模块系统的理解知道这一点,要么必须通过阅读你的文档来学习它——这两种方式都不是表达要求的最稳健方式。如果框架代码能够使这一点更清晰会怎样?

方法句柄和变量句柄为你提供了这样的工具。再看一下列表 12.5——看到了对 MethodHandles.lookup() 的调用吗?这创建了一个 Lookup 实例,它除了其他特权和信息外,还捕获了调用者的访问权限。

这意味着所有代码,无论它属于哪个模块,只要获取了那个特定的 lookup 实例,就可以对与创建查找的代码相同的类进行深度反射(参见图 12.1)。这样,一个模块可以捕获它对自己内部结构的访问权限,并将它们传递给其他模块。

图片

图 12.1 反射模块创建一个查找并将其传递给反射,然后反射可以使用它来访问反射可以访问的相同类和成员——这些包括反射的内部成员。

你的反射代码可以通过要求用户将其传递给查找对象来利用这一点;例如,在引导你的框架时。当用户必须调用一个接受一个或多个 Lookup 实例的方法时,他们必然会阅读文档来了解他们应该做什么。然后,他们为需要访问的每个模块创建一个实例并将它们传递给你,而你则使用它们来访问它们的模块内部。 列表 12.6 展示了这是如何工作的。

列表 12.6 使用 VarHandle 通过私有查找访问字段值

Lookup lookup = // ... Object object = // ... String fieldName = // ... Class<?> type = object.getClass(); Field field = type.getDeclaredField(fieldName); Lookup privateLookup = MethodHandles.privateLookupIn(type,lookup); VarHandle handle = privateLookup.unreflectVarHandle(field); handle.get(object);`

这个查找是在拥有对象的模块中创建的。

通过从用户提供的查找中创建一个私有查找,你可以从不同的模块访问对象的内部。

关于查找的有趣之处在于,它们可以在模块之间传递。在标准与实现分离的情况下,例如 JPA 及其提供者,用户可以将查找传递给 JPA 的引导方法,然后这些方法可以将它们传递给 Hibernate、EclipseLink 等类似系统。我认为这是一种相当巧妙实现查找的方法:

  • 用户知道他们必须做些什么,因为引导方法需要 Lookup 实例(这与打开包的要求不同,后者不能在代码中表达)。

  • 没有必要更改模块声明(与 opens 指令不同)。

  • 标准可以将查找传递给实现,因此不必强制用户在代码或模块声明中引用实现(正如第 12.3.5 节所解释的,对于开放包也是如此)。

这就结束了使用反射或变量处理程序访问模块中封装的类型的相关讨论。现在,我们将转向模块本身,看看你能从中获取哪些信息。

12.3.3 使用反射分析模块属性

如果你曾经尝试在运行时分析一个 JAR 文件,你会发现这样做并不方便。这回到了 JAR 文件的基本解释:仅仅是容器(参见第 1.2 节)。Java 并不把它们视为像包和类型那样的第一类公民,因此在运行时没有将它们视为除了 Zip 文件之外任何东西的表示。

模块系统的关键变化是将 Java 对 JAR 文件的解释与我们的一致,即具有名称、依赖关系和显式 API 的代码单元。超出本书中我们迄今为止讨论的所有内容,这一点应该一直延伸到反射 API,在那里,模块(与 JAR 文件不同,但与包和类型相同)应该被表示。确实如此。

定义:ModuleModuleDescriptor 类型

Java 9 引入了新的类型 java.lang.Module,它在运行时表示一个模块。Module 实例允许你执行以下操作:

  • 分析模块的名称、注解、导出/公开指令和服务使用

  • 访问模块包含的资源(参见第 5.2 节)

  • 通过导出和公开包或添加读取边和服务使用来修改模块(如果修改代码在同一模块中)

其中一些信息仅在同样新引入的类型 java.lang.module.ModuleDescriptor 上可用,该类型由 Module::getDescriptor 返回。

获取 Module 实例的一种方法是在任何 Class 实例上调用 getModule,这毫不奇怪,返回该类所属的模块。以下列表展示了如何通过查询 ModuleModuleDescriptor 来分析模块;一些示例模块的输出显示在 列表 12.8 中。

列表 12.7 通过查询 ModuleModuleDescriptor 分析模块

public static String describe(Module module) { String annotations = Arrays .stream(module.getDeclaredAnnotations()) .map(Annotation::annotationType) .map(Object::toString) .collect(joining(", ")); ModuleDescriptor md = module.getDescriptor(); if (md == null) return "UNNAMED module { }"; return "" + "@[" + annotations + "]\n" + md.modifiers() + " module " + md.name() + " @ " + toString(md.rawVersion()) + " {\n" + "\trequires " + md.requires() + "\n" + "\texports " + md.exports() + "\n" + "\topens " + md.opens() + "\n" + "\tcontains " + md.packages() + "\n" + "\tmain " + toString(md.mainClass()) + "\n" + "}"; } private static String toString(Optional<?> optional) { return optional.isPresent() ? optional.get().toString() : "[]"; }

列表 12.8 从 列表 12.7 调用 describe(Module) 的输出

> @[] > [] 模块监控器 @ [] { > requires [ > monitor.observer, > monitor.rest > monitor.persistence, > monitor.observer.alpha, > mandated java.base (@9.0.4), > monitor.observer.beta, > monitor.statistics] > exports [] > opens [] > contains [monitor] > main monitor.Main > } > > @[] > [] 模块 monitor.persistence @ [] { > requires [ > hibernate.jpa, > mandated java.base (@9.0.4), > monitor.statistics] > exports [monitor.persistence] > opens [monitor.persistence.entity] > contains [ > monitor.persistence, > monitor.persistence.entity] > main [] > } > > @[] > [] 模块 java.logging @ 9.0.4 { > requires [mandated java.base] > exports [java.util.logging] > opens [] > contains [ > java.util.logging, > sun.util.logging.internal, > sun.net.www.protocol.http.logging, > sun.util.logging.resources] > main [] > } > > @[] > [] 模块 java.base @ 9.0.4 { > requires [] > exports [... lots ...] > opens [] > contains [... lots ...] > main [] > }

一些ModuleDescriptor方法返回与其它模块相关的信息:例如,哪些模块是必需的,或者哪些模块的包被导出和公开。这些只是作为字符串的模块名称,而不是实际的Module实例。同时,许多Module方法需要这样的实例作为输入。因此,您得到的是字符串,但需要将模块放入——如何弥合这个差距?正如第 12.4.1 节所示,答案是层。

12.3.4 使用反射修改模块属性

除了分析模块属性外,您还可以使用Module通过调用这些方法来修改它们:

  • addExports将包导出到模块。

  • addOpens向模块公开一个包。

  • addReads允许模块读取另一个模块。

  • addUses使模块使用一个服务。

在查看这些内容时,您可能会 wonder 为什么可以导出或公开模块的包。这难道不是与强封装相矛盾吗?我们不是在第 12.2 节中讨论了模块所有者必须做什么来为反射做准备,因为反射代码不能破坏吗?

重要的信息:这些方法是调用者敏感的,这意味着它们根据调用它们的代码表现不同。为了成功调用,它必须来自正在修改的模块内部或来自未命名的模块。否则,它将失败并抛出IllegalCallerException

以下代码作为示例:

public boolean openJavaLangTo(Module module) { Module base = Object.class.getModule(); base.addOpens("java.lang", module); return base.isOpen("java.lang", module); }

如果将其复制到从类路径执行的main方法中(因此它在未命名的模块中运行),这将正常工作,并且方法返回true。另一方面,如果它从任何命名模块(以下示例中的 open.up)内部运行,它将失败:

> Exception in thread "main" java.lang.IllegalCallerException: > java.lang is not open to module open.up > at java.base/java.lang.Module.addOpens(Module.java:751) > at open.up/open.up.Main.openJavaLangTo(Main.java:18) > at open.up/open.up.Main.main(Main.java:14)

您可以通过将代码注入它修改的模块(即 java.base)并使用--patch-module(参见第 7.2.4 节)来使其(再次)工作:

$ java --patch-module java.base=open.up.jar --module java.base/open.up.Main > WARNING: module-info.class ignored in patch: open.up.jar > true

这样就完成了:最后的true是从带有任意平台模块的openJavaLangTo调用返回的值。

动态修改您自己的模块属性不是您会经常做的事情,即使您正在开发基于反射的框架。那么,为什么我要告诉您所有这些?因为正如您将在下一节中看到的那样,这里隐藏了一个有趣的细节:在特定情况下,您可以打开其他模块的包。

12.3.5 前向公开包

我说过只有模块可以通过Module:addOpens打开其包中的一个,但这并不完全正确。如果一个模块的包已经对一组其他模块打开,那么所有这些模块也可以打开该包。换句话说,具有对包进行反射访问的模块可以将该包打开给其他模块。这意味着什么呢?

再次思考 JPA。你可能在第 12.2.2 节中退缩了,因为看起来你需要无条件地打开一个包或者打开实际进行反射的模块,因为在 JPA 的情况下,这意味着如下所示:

module monitor.persistence { requires hibernate.jpa; requires monitor.statistics; exports monitor.persistence; // 假设 Hibernate 是一个显式模块,打开 monitor.persistence.entity 到 hibernate.core; }

不打开到 JPA 而不是特定的实现不是更好吗?这正是通过启用具有对包进行反射访问的模块打开包给其他模块的功能所实现的!这样,JPA 的引导代码就可以打开所有包给 Hibernate,即使那些只有反射访问的包。

尽管只有模块可以添加包导出、读取边和服务使用,但打开包的规则已经放宽,所有被打开包的模块都可以将其打开给其他模块。对于基于反射的框架要利用这一点,它们当然必须了解模块系统并更新它们的代码。在 JEE 技术的情况下,这仍然可能需要一段时间,除非 Eclipse 为 Jakarte EE 采用更快的发布周期(从 Java SE 8 到 Java EE 8 花费了超过三年时间)。

现在我们已经解决了如何对单个模块进行反射、分析和修改的问题,我们可以将其提升到下一个层次,或者说在接下来的章节中,我们将看到的工作层,并处理整个模块图。

12.4 使用层动态创建模块图

在第 12.3 节中,我们关注了单个模块:如何对模块代码进行反射以及如何分析和修改单个模块的属性。在本节中,我们扩大了我们的范围,并查看整个模块图。

到目前为止,我们将模块图的创建留给了编译器或 JVM,它们在开始工作之前生成它们。从那时起,图几乎是一个不可变的实体,没有提供添加或删除模块的方法。

虽然这对许多常规应用程序来说很好,但还有一些需要更多灵活性的应用程序。想想应用服务器或基于插件的程序。它们需要一个动态机制,允许它们在运行时加载和卸载类。

例如,假设 ServiceMonitor 应用程序提供了一个端点或图形界面,用户可以通过它指定需要观察的附加服务。这可以通过实例化适当的ServiceObserver实现来完成,但如果该实现来自启动时未知的模块呢?那么(及其依赖项)就必须在运行时动态加载。

在模块系统之前,此类容器应用程序使用裸类加载器进行动态加载和卸载,但它们是否也能像编译器和 JVM 一样,将其提升到更高的抽象层次,并操作模块呢?幸运的是,模块系统通过引入层的概念实现了这一点。首先,你需要了解层,包括那些你一直不知道的层(第 12.4.1 节)。下一步是在运行时动态创建自己的层之前分析层(第 12.4.2 节)。

注意,处理层的代码甚至比使用反射 API 更不常见。这里有一个简单的试金石:如果你从未实例化过类加载器,你不太可能很快使用层。因此,本节为你提供了地形图,让你知道自己的位置,但不会深入细节。尽管如此,你可能会看到一些你不知道可以实现的事情,并最终得到一些新想法。

12.4.1 层是什么?

定义:模块层

模块层包含一个名为模块的完全解析的图以及用于加载模块类的类加载器(们)。每个类加载器都与一个未命名的模块相关联(可以通过ClassLoader::getUnnamedModule访问)。层还引用一个或多个父层——层中的模块可以读取祖先层中的模块,但反之则不行。

我们到目前为止讨论的所有关于模块的解析和关系都发生在单个模块图中。使用层,可以堆叠尽可能多的图,因此,从概念上讲,层为二维模块图的概念添加了第三个维度。父层在创建层时定义,之后不能更改,因此无法创建循环层。图 12.2 显示了带有层的模块图。

图

图 12.2 使用层,模块图可以堆叠,为你的应用程序的心理模型添加第三个维度。因为它们不共享类加载器,所以层之间很好地隔离。(像每个好的计算机科学图一样,这个图可能看起来是颠倒的。父层位于其子层下方,因为这样可以保持包含平台模块的层在底部。)

对于 ServiceMonitor 来说,这意味着为了动态加载新的观察者实现,它需要创建一个新的层。在我们第 12.4.3 节中讨论这一点之前,让我们更仔细地看看现有的层以及如何分析它们。

所有的模块都包含在一个层中吗?几乎是这样。正如你所看到的,从技术上来说,未命名的模块不是。然后还有所谓的动态模块,它们不必属于一个层,但我在这本书中不会涉及它们。除了这些例外,所有模块都是层的组成部分。

引导层

那么,在这本书中通过图形放入的所有应用程序和平台模块呢?它们也应该属于一个层,对吧?

定义:引导层

的确如此。在启动时,JVM 创建一个初始层,即引导层,它包含根据命令行选项解析的应用程序和平台模块。

引导层没有父层,包含三个类加载器:

  • 引导类加载器为它加载的所有类授予所有安全权限,因此 JDK 团队努力最小化它负责的模块;这些是一些核心平台模块,其中最重要的是 java.base。

  • 平台类加载器从所有其他平台模块中加载类;可以通过静态方法 ClassLoader::getPlatformClassLoader 访问它。

  • 系统或应用程序类加载器从模块和类路径中加载所有类,这意味着它负责所有应用程序模块;可以通过静态方法 ClassLoader::getSystemClassLoader 访问它。

只有系统类加载器可以访问类路径,因此在这三个加载器中,只有它的未命名模块可能不为空。因此,当第 8.2 节提到未命名模块时,它总是引用系统类加载器的。

正如你在图 12.3 中可以看到的,类加载器不是孤岛:每个类加载器都有一个父类加载器,包括刚刚提到的三个在内的大多数实现,首先会请求父类加载器加载一个类,然后再尝试自己查找。对于三个引导层类加载器,引导层是没有任何父类的祖先,平台委托给引导层,系统委托给平台。因此,系统类加载器可以访问来自引导层和平台加载器的所有应用程序和 JDK 类。

图片

图 12.3 引导层中三个类加载器之间的委托

12.4.2 分析层

定义:ModuleLayer

在运行时,层由 java.lang.ModuleLayer 实例表示。它们可以查询层由以下三个部分组成:

  • 模块:

  • 方法 modules() 返回层包含的模块,作为一个 Set<Module>

  • 方法 findModule(String) 在自身层以及所有祖先层中搜索具有指定名称的模块。因为它可能找不到,所以它返回一个 Optional<Module>

  • 通过parents()方法,层的父级以List<ModuleLayer>的形式返回。

  • 通过调用findLoader(String)并传递模块的名称,可以确定每个模块的类加载器。

然后是configuration方法,它返回一个Configuration实例——有关更多内容,请参阅第 12.4.3 节。

要获取ModuleLayer实例,您可以要求任何模块提供它所属的层:

Class<?> type = // ... 任何类 ModuleLayer layer = type .getModule() .getLayer();

如果类型来自未命名的模块或不属于层的动态模块,则最后一行返回null。如果您想访问引导层,可以调用静态的ModuleLayer::boot方法。

那么,从ModuleLayer实例中你可以学到什么?毫无疑问,最有趣的方法是modules()findModule(String),因为与Module上的方法(参见第 12.3.3 节)一起,它们允许遍历和分析模块图。

描述模块层

给定列表 12.7 中的describe(Module)方法,这就是如何描述整个层的方法:

private static String describe(ModuleLayer layer) { return layer .modules().stream() .map(ThisClass::describe) .collect(joining("\n\n")); }

在层内和层间查找模块

还可以确定特定模块的存在或不存在,如果对这些模块的依赖是可选的(使用requires static;参见第 11.2 节)。在第 11.2.4 节中,我声称实现一个名为isModulePresent(String)的方法来做这件事是直接的。这使你将迄今为止学到的关于层的内容付诸实践,所以让我们一步一步来做。

起初这似乎很简单:

public boolean isModulePresent(String moduleName) { return ModuleLayer .boot() .findModule(moduleName) .isPresent(); }

但这仅显示模块是否存在于引导层。如果创建了额外的层,并且模块位于另一个层中怎么办?您可以替换引导层为包含isModulePresent的层:

public boolean isModulePresent(String moduleName) { return searchRootModuleLayer() .findModule(moduleName) .isPresent(); } private ModuleLayer searchRootModuleLayer() { return this .getClass() .getModule() .getLayer(); }

这样,isModulePresent会搜索包含自身的层——让我们称它为search——以及所有父层。但即使这样也不够好。调用该方法的模块可能位于不同的层,称为call,该层将search作为祖先。(困惑吗?参见图 12.4。)然后search无法查看call,因此无法搜索所有可能的模块。不,您需要调用者的模块来使用其层作为搜索的根。

图片

图 12.4 请求查找模块的层只扫描它自己和它的父级(在这个图中,是向下)。所以如果search查询它自己的层,它可能会忽略调用层,即启动搜索的模块可以看到的层,从而冒着返回错误结果的风险。这就是为什么查询调用层很重要。

以下列表实现了getCallerClass,它使用 Java 9 引入的堆栈遍历 API 确定调用者的类。

列表 12.9 新的 API 用于遍历调用栈

private Class<?> getCallerClass() { return StackWalker.getInstance(RETAIN_CLASS_REFERENCE).walk(stack -> stack.filter(frame -> frame.getDeclaringClass() != this.getClass()).findFirst().map(StackFrame::getDeclaringClass).orElseThrow(IllegalStateException::new)); }

静态工厂方法用于获取一个 StackWalker 实例,其中每个帧都引用了声明类

StackWalker::walk 期望一个从 Stream到任意对象的函数。它创建了一个对堆栈的懒视图,并立即用它调用函数。函数返回的对象随后由 walk 返回。

你对来自不是这个类的第一个帧感兴趣(这必须是调用者!);你现在有一个 Optional

获取那个类

如果没有这样的帧存在,那就奇怪了……

在你的工具箱中有这个,调用者的模块就在你的指尖:

public boolean isModulePresent(String moduleName) { return searchRootModuleLayer().findModule(moduleName).isPresent(); } private ModuleLayer searchRootModuleLayer() { return getCallerClass().getModule().getLayer(); }

分析层到此结束。现在我们终于可以进入最激动人心的部分:通过创建新层将新代码加载到运行中的应用程序中。

12.4.3 创建模块层

只有少数用 Java 编写的应用程序需要在运行时动态加载代码。同时,这些往往是更重要的。最著名的可能是 Eclipse,它对插件有很强的关注,但像 WildFly 和 GlassFish 这样的应用服务器也必须同时从一个或多个应用程序中加载代码。如 1.7.3 节所述,OSGi 也能够动态加载和卸载包(它对模块的称呼)。

它们对它们使用的加载插件、应用程序、包和其他运行 JVM 的新片段的机制有相同的基本要求:

  • 必须能够在运行时从一组 JAR 文件中启动一个片段。

  • 必须允许与加载的片段交互。

  • 必须允许不同片段之间的隔离。

在模块系统之前,这是通过类加载器完成的。简要来说,为新的 JAR 文件创建了一个新的类加载器。它委托给另一个类加载器,例如系统类加载器,这使它能够访问运行中的 JVM 中的其他类。尽管每个类(通过其完全限定名标识)在每个类加载器中只能存在一次,但它可以很容易地被多个加载器加载。这隔离了片段,并给每个片段提供了在没有与其他片段冲突的情况下提出其自身依赖的可能性。

模块系统在这方面没有改变。保持现有的类加载器层次结构完整是实施模块系统在类加载器之下(见第 1.7.3 节)的主要驱动因素之一。模块系统添加的是围绕类加载器的层概念,这使得与启动时加载的模块集成成为可能。让我们看看如何创建一个。你可以找到创建层的 ServiceMonitor 变体的分支 feature-layers)。

创建一个配置

ModuleLayer 的一个重要组成部分是 Configuration。创建一个会触发模块解析过程(见第 3.4.1 节),创建的实例代表一个成功解析的模块图。创建配置的最基本形式是使用静态工厂方法 resolveresolveAndBind。两者之间的唯一区别是第二个会绑定服务(见第 10.1.2 节),而第一个不会。

resolveresolveAndBind 都接受相同的四个参数:

  • 在检查父配置之前,ModuleFinder before 被要求定位模块。

  • List<Configuration> parents 是父层的配置。

  • ModuleFinder after在检查父配置后会被要求定位模块。

  • Collection<String> roots 是解析过程中的根模块。

为模块路径创建一个 ModuleFinder 与调用 ModuleFinder.of(Path...) 一样简单。通常,会尝试尽可能多地从父层引用模块,因此 before 查找器通常在没有参数的情况下创建,因此无法找到任何模块。

对于想要创建具有单个父级的配置的常见情况,调用实例方法 resolveresolveAndBind 更容易。它们没有 List<Configuration> parents 参数,并使用当前配置作为父级。

假设你想要创建一个以引导层作为父级的配置,该配置模拟启动命令 java --module-path mods --module root 但不进行服务绑定。为此,你可以在引导层的配置(使其成为父级)上调用 resolve(这样就不会绑定服务),并传递一个查看 mods 目录的模块查找器。以下列表显示了这一点:它创建了一个模拟 java --module-path mods --module initial 的配置,但不包括服务绑定。

列表 12.10 模拟 java --module-path mods --module

ModuleFinder emptyBefore = ModuleFinder.of(); ①``ModuleFinder modulePath = ModuleFinder.of(Paths.get("mods")); Configuration bootGraph = ModuleLayer.boot().configuration(); Configuration graph = bootGraph .resolve(emptyBefore, modulePath, List.of("initial"));

在查看父图之前不需要查找模块

查找父图中不存在的模块的查找器查看 mods 目录。

通过调用 resolve 来定义引导层的配置作为父层(resolveAndBind 会绑定服务)

作为第二个例子,让我们回到这样一个场景:你希望在运行时让 ServiceMonitor 开始观察新的服务,为此需要加载新的ServiceObserver实现。第一步是创建一个配置,以当前层为父层,在指定的路径上查找模块。

因为你在你的服务中使用模块系统的服务基础设施,所以调用resolveAndBind。你可以完全依赖该机制来查找所有需要的模块(及其依赖项),因此你甚至不需要指定根模块。以下是实现方式。

列表 12.11 绑定指定路径上所有模块的配置

private static Configuration createConfiguration(Path[] modulePaths) { return getThisLayer() .configuration() .resolveAndBind( ModuleFinder.of(), ModuleFinder.of(modulePaths), Collections.emptyList() ); }

返回包含 createConfiguration 的类的层

被称为服务解析调用

你依赖服务绑定为你完成工作并拉入所需的模块,因此你不需要定义根模块。

创建 MODULELAYER

如第 12.4.1 节所述,一个层由模块图、类加载器和父层引用组成。创建模块的裸骨形式是使用静态方法defineModules(Configuration, List<ModuleLayer>, Function<String, ClassLoader>)

  • 你已经知道如何获取Configuration实例。

  • List<ModuleLayer>是父层。

  • Function<String, ClassLoader>将每个模块名称映射到你想要负责该模块的类加载器。

该方法返回一个Controller,可以用来在调用layer()之前进一步编辑模块图,通过添加读取边或导出/打开包,layer()返回ModuleLayer

有几种替代方法可以调用,这些方法基于defineModules

  • defineModulesWithOneLoader为所有模块使用单个类加载器。方法参数中给出的类加载器成为其父类加载器。

  • defineModulesWithManyLoaders使用为每个模块单独的类加载器。方法参数中给出的类加载器成为每个模块的父类加载器。

  • 每个方法都有一个变体可以在 ModuleLayer 实例上调用,并使用该实例作为父层;它们返回创建的层而不是中间的 Controller

继续你的动态加载 ServiceObserver 实现的探索之旅,下一步是从配置创建实际层。这相当简单,如下面的列表所示。

列表 12.12 从配置创建层

private static ModuleLayer createLayer(Path[] modulePaths) { Configuration configuration = createConfiguration(modulePaths); ①``ClassLoader thisLoader = getThisLoader(); ②``return getThisLayer(); ③``defineModulesWithOneLoader(configuration, thisLoader); ④``}

创建配置,如列表 12.11

getThisLoader 返回加载包含 createLayer 的类的类加载器

与列表 12.11 中的 getThisLayer 相同

你只想为具有此层作为父级的所有模块使用单个加载器,因此你在其上调用 defineModulesWithOneLoader。

最后一步是检查新创建的层是否包含一个可以处理你需要观察的服务 ServiceObserver。为此,你可以使用一个 ServiceLoader::load 的重载版本,它除了查找的服务类型外,还期望一个 ModuleLayer。语义应该是清晰的:在定位提供者时,查看该层(及其祖先)。

列表 12.13 在新层(及其祖先)中查找服务提供者

private static void registerNewService(String serviceName, Path... modulePaths) { ModuleLayer layer = createLayer(modulePaths); ①``Stream<ServiceObserverFactory> observerFactories = ServiceLoader.load(layer, ServiceObserverFactory.class).stream(); ②``map(Provider::get); Optional<ServiceObserver> observer = observerFactories.map(factory -> factory.createIfMatchingService(serviceName)).flatMap(Optional::stream).findFirst(); observer.ifPresent(monitor::addServiceObserver); }

创建层,如列表 12.1.2

使用接受新层的 ServiceLoader::load 变体

其余部分是常规服务业务,以找到 serviceName 的观察者。

如果这些还不够,还有一些我们几乎没提到的事情,你可以用模块层来做:

  • 使用多个父级或多个类加载器创建配置和层

  • 使用层来加载同一模块的多个版本

  • 在将其转换为 ModuleLayer 之前,使用 Controller 修改模块图——例如,导出或打开模块

  • 直接从创建的层中加载特定的类作为片段的入口点,而不是使用 JPMS 服务

你可以从涉及方法的优秀 Javadoc 中了解更多信息,特别是在 ModuleLayerConfiguration 中。或者翻到第 13.3 节,它很好地利用了这些可能性中的几个。

摘要

  • 被代码反射的模块:

  • 在大多数情况下,exports 指令并不适合用于使类可用于反射,因为为与基于反射的框架一起使用而设计的类很少适合成为模块公共 API 的一部分;使用限定导出,你可能会被迫将你的模块耦合到实现而不是标准;并且导出不支持对非私有字段和方法的深度反射。

  • 默认情况下,你不应该使用 exports,而应该使用 opens 指令来打开包以进行反射。

  • opens 指令与 exports 具有相同的语法,但工作方式不同:打开的包在编译时不可访问;并且打开包中的所有类型和成员,包括非公共的,在运行时都是可访问的。这些属性与基于反射的框架的要求紧密一致,这使得 opens 指令在为反射准备模块时的默认选择。

  • 限定变体 opens ... to 仅将包打开给命名的模块。由于通常非常明显哪些框架会反射哪些包,因此质疑限定 open 指令是否增加了多少价值是有疑问的。

  • 如果反射框架分为标准及其实现(如 JPA 和 Hibernate、EclipseLink 等等),从技术上讲,只打开包给标准是可能的,然后它可以使用反射 API 将其打开到特定的实现。尽管如此,这还没有得到广泛实现,因此目前,限定打开需要指定特定的实现模块。

  • 命令行选项 --add-opens--add-exports 具有相同的语法,并且像限定 opens 一样工作。在迁移到 Java 9+ 期间,从命令行打开平台模块以访问其内部结构是常见的,但如果你绝对需要,你也可以用它来突破其他应用程序模块。

  • 通过以 open module(而不是仅 module)开始模块声明,该模块中的所有包都被打开。如果模块包含大量需要打开的包,这是一个很好的解决方案,但应该仔细评估这是否真的必要或是否可以纠正。理想情况下,打开模块主要在模块化之前使用,在将模块重构为更干净的状态并暴露较少内部结构之前。

  • 反射模块的代码:

  • 反射受限于与常规代码相同的可访问性规则。关于必须读取你访问的模块,反射 API 通过隐式添加读取边来简化了操作。至于导出或打开的包,如果模块所有者没有为反射准备他们的模块,那么反射代码的作者对此无能为力。(唯一的解决方案可能是使用--add-opens命令行选项。)

  • 这使得教育用户关于强封装以及你的模块需要访问哪些包变得尤为重要。做好文档记录,并确保源代码易于获取。

  • 确保妥善处理由于强封装而抛出的异常,以便你可以为用户提供一个信息丰富的错误消息,可能还会链接到你的文档。

  • 考虑使用变量句柄而不是反射 API。它们提供了更多的类型安全性,性能更优,并且通过要求Lookup实例来表达你在引导 API 中对访问的需求。

  • Lookup实例为所有使用它的人提供了与创建它的模块相同的可访问性。因此,当你的用户在他们的模块中创建一个Lookup实例并将其传递给你的框架时,你可以访问他们的模块内部。

  • 新的类ModuleModuleDescriptor是反射 API 的一部分,提供了关于模块的所有信息,例如它的名称、依赖项以及导出或打开的包。你可以用它来分析运行时的实际模块图。

  • 使用该 API,模块还可以修改自己的属性,导出或打开包,或向其他模块添加读取边。通常无法修改其他模块,但有一个例外,即任何打开另一个模块包的模块都可以将该包打开给第三个模块。

  • 动态加载模块的代码:

  • 类加载器是将代码动态加载到运行程序中的方式。模块系统不会改变这一点,但它确实提供了带有层的模块包装器。层封装了一个类加载器和模块图,创建后者会将加载的模块暴露给模块系统提供的所有一致性检查和可访问性规则。因此,层可以用来为加载的模块提供可靠的配置和强封装。

  • 启动时,JVM 创建引导层,它由三个类加载器和所有最初解析的平台和应用模块组成。它可以通过静态方法ModuleLayer::boot访问,返回的ModuleLayer实例可以用来分析整个模块图。

13

模块版本:可能性和不可能性

本章涵盖

  • 为什么模块系统不对版本信息采取行动

  • 记录版本信息

  • 运行时分析版本信息

  • 加载多个模块版本

如第 1.5.6 节简要提到的,JPMS 不支持模块版本。那么jar --module-version有什么好处呢?而且第 12.3.3 节不是显示ModuleDescriptor至少可以报告模块的版本吗?本章将澄清这些问题,并从几个不同的角度探讨模块版本。

我们首先将讨论模块系统如何支持版本以及为什么它不这样做(第 13.1 节)。尽管如此,它至少允许你记录和评估版本信息,我们将在下一节中探讨这一点(第 13.2 节)。列表上的最后一项是圣杯:运行同一模块的不同版本(第 13.3 节)。尽管没有原生支持,但有一些方法可以通过一些努力实现这一点。

到本章结束时,你将对模块系统对版本的有限支持有一个清晰的理解。这将帮助你分析你的应用程序,甚至可以用来主动报告可能的问题。也许更重要的是,你还将了解限制的原因,以及你是否可以期待它们发生变化。你还将学习如何运行同一模块的多个版本——但正如你将看到的,这很少值得付出努力。

13.1 JPMS 中版本支持的缺乏

Java 8 及之前版本没有版本的概念。如第 1.3.3 节所述,这可能导致意外的运行时行为,唯一的解决方案可能是选择你不想选择的依赖项的不同版本。这是不幸的,当模块系统最初被构想时,其目标之一就是解决这个问题。

虽然如此,但这种情况并未发生。目前 Java 中运行的模块系统在版本方面仍然相对盲目。它仅限于记录模块或依赖项的版本(参见第 13.2 节)。

但为什么是这样呢?模块系统不能支持同一模块的多个版本吗(第 13.1.1 节)?如果不能,它至少不能接受一系列模块和版本要求作为输入,并为每个模块选择一个版本吗(第 13.1.2 节)?这两个问题的答案都是“不”,我想解释一下原因。

13.1.1 不支持多个版本

解决版本冲突的一个看似简单的解决方案是允许运行同一 JAR 的两个版本。简单直接。那么,为什么模块系统不能这样做呢?要回答这个问题,你必须了解 Java 如何加载类。

如何防止加载多个版本

如同我们在第 1.3.2 节讨论阴影时提到的,JVM——更准确地说,是其类加载器——通过完全限定名识别类,例如java.util.Listmonitor.observer.ServiceObserver。为了从类路径中加载一个类,应用程序类加载器扫描所有 JAR 文件,直到它遇到一个具有特定名称的类,然后加载它。

关键信息 重要的观察结果是,无论类路径上的另一个 JAR 文件是否包含具有完全相同名称的类——它永远不会被加载。换句话说,类加载器在假设每个类(通过其完全限定名识别)恰好存在一次的情况下运行。

回到我们希望运行同一模块的多个版本的需求,障碍显而易见:这样的模块必然包含具有相同完全限定名的类,并且如果没有任何更改,JVM 将始终只看到其中之一。这些更改可能看起来是什么样子?

允许多个版本的类加载器更改

允许多个具有相同名称的类的第一个选项是重写整个类加载机制,以便单个类加载器可以处理这种情况。这将是一个巨大的工程任务,因为每个类加载器最多只有一个给定名称的类的假设贯穿整个 JVM。除了巨大的努力之外,它还会带来很多风险:这将是一个侵入性的更改,因此几乎可以保证与向后不兼容。

第二个选项是允许具有相同名称的多个类执行类似 OSGi 的操作:为每个模块使用一个单独的类加载器(参见图 13.1)。这将相对简单,但也可能引起兼容性问题。

图片

图 13.1 JPMS 为所有应用程序模块使用相同的类加载器(左侧),但可以设想它可以为每个模块使用一个单独的加载器(右侧)。在许多情况下,这会改变应用程序的行为。

一个潜在的问题来源是,一些工具、框架甚至应用程序对确切的类加载器层次结构做出了特定的假设。(默认情况下,有三个类加载器相互引用——在 Java 9 中这一点没有改变。详情请参阅第 12.4.1 节中关于引导层的描述。)将每个模块放入其自己的类加载器中将会显著改变这个层次结构,并且可能会破坏这些项目中的大多数。

在改变层次结构中隐藏的另一个狡猾的细节是,即使你愿意要求项目适应这种变化以从模块路径运行,如果它们从类路径运行会发生什么?类路径上的 JAR 文件也会每个都得到一个单独的类加载器吗?

  • 如果是这样,那些在更改后的类加载器层次结构上遇到麻烦的项目不仅不能作为模块运行,甚至不能在 Java 9+上运行。

  • 如果不是这样,它们需要意识到两个不同的类加载器层次结构,并且根据它们落在哪个路径上,正确地与每个层次结构进行交互。

如果将这些影响兼容性或迁移路径的更改应用于整个生态系统,则这些更改都是不可接受的。

备注:对于 OSGi 来说,这些担忧的权重是不同的。它提供了大多数使用它的应用程序无法没有的功能,因此可以预期它们的开发者会投入更多的工作。另一方面,Java 9+还需要为不关心模块系统的项目工作。OSGi 是可选的,所以如果事情变得棘手,并且它对任何特定项目不起作用,可以忽略它。显然,Java 9+的情况并非如此。

必要信息:一个 JAR 文件对应一个特定的类加载器可能存在问题的另一个原因是与类等价性有关。假设同一个类被两个不同的类加载器加载。它们的Class<?>实例不相等,因为类加载器总是包含在检查中。那么呢?谁会在意,对吧?

好吧,如果你有每个类的实例并且比较这两个,equals比较中首先发生的事情之一是什么?是this.getClass() == other.getClass()或者一个instanceof检查。在这种情况下,这总是错误的,因为两个类不相等。

这意味着,例如,使用两个版本的 Guava,mutimap1.equals(multimap2)将始终为 false,无论两个Multimap实例包含什么元素。你也不能将来自一个类加载器的类的实例转换为来自另一个类加载器的相同类加载的实例,因此(Multimap) multimap2可能会失败:

static boolean equalsImpl( Multimap<?, ?> multimap, ①``@NullableDecl Object object) { if (object == multimap) { return true; } if (object instanceof Multimap) { Multimap<?, ?> that = (Multimap<?, ?>) object; return multimap.asMap().equals(that.asMap()); } return false; }

被调用 equals 方法的 Multimap 实例。该方法在其类加载器的上下文中执行。

传递给 equals 调用的对象。它假定是一个来自不同类加载器的 Multimap 实例。

对象是 Multimap 类型,但它来自不同的类加载器,因此这个 instanceof 检查总是失败。

很想知道有多少项目仅仅因为这个细节而受到影响。没有办法知道,但我的猜测是很多。相比之下,第六章和第七章是直接无害的。

备注:顺便说一下,我们刚才讨论的每一件事也适用于分割包(见第 7.2 节)。如果模块系统不关心两个模块是否包含相同的包并且可以保持它们分开,那不是很好吗?它会,但会遇到我们刚才探讨的相同问题。

我们到目前为止所确定的是,模块系统默认不允许同一模块的多个版本。没有原生支持,但这并不意味着它绝对不可能。请参阅第 13.3 节了解使其工作的方法。

13.1.2 不支持版本选择

如果模块系统不能加载相同模块的多个版本,为什么它至少不能为我们选择正确的版本呢?这当然在理论上也是可能的,但不幸的是并不可行——让我解释一下原因。

如何处理版本

Maven 和 Gradle 这样的构建工具一直与版本化的 JAR 文件打交道。它们知道每个 JAR 文件的版本以及其依赖项的版本。考虑到许多项目都站在巨人的肩膀上,它们自然会有深度依赖树,包含多次相同的 JAR 文件,可能具有不同的版本。

虽然知道有多少不同版本的 JAR 文件是件好事,但这并不能改变它们最好不要都出现在类路径上的事实。如果它们真的都出现了,你可能会遇到像阴影(见第 1.3.2 节)和直接版本冲突(见第 1.3.3 节)等问题,这会威胁到项目的稳定性。

重要信息 当编译、测试或启动项目时,构建工具必须将这个树扁平化为一个只包含每个 JAR 文件一次的列表(见图 13.2)。实际上,它们必须为每个工件选择一个版本。这是一个非平凡的过程,尤其是如果工件可以定义每个依赖项可接受的版本范围。由于这个过程非平凡,它也不太透明。很难预测 Maven 或 Gradle 将选择哪个版本,它们在相同情况下不一定选择相同的版本并不令人惊讶。

图片

图 13.2 一个应用程序的依赖树(左侧)可能包含相同的 JAR 文件多次,例如johnsonmango,可能在不同版本中。为了在类路径上工作,这个树必须缩减为一个只包含每个 JAR 文件一次的集合(右侧)。

为什么模块系统不选择版本

现在让我们放下构建工具,谈谈模块系统。正如你在第 13.2 节中将会看到的,模块可以记录它们自己的版本以及它们依赖的版本。假设模块系统不能运行相同模块的多个实例,它难道不能选择每个模块的单个版本吗?

让我们通过一个假设场景来探讨这个问题。在这个场景中,JPMS 会在模块路径上接受相同模块的多个版本。当构建模块图时,它会为每个模块决定选择哪个版本。

重要信息 这意味着 JPMS 现在会复制构建工具已经做的事情。由于它们并不完全以相同的方式做这件事,模块系统的行为可能会与大多数(可能所有)其他系统有细微的差别。更糟糕的是,因为 Java 基于一个标准,精确的行为可能需要标准化,这使得随着时间的推移进行演变变得困难。

在此之上,还需要努力实现和维护版本选择算法。棺材的最后一根钉子是性能:如果编译器和 JVM 必须在开始实际工作之前运行该算法,这将显著增加编译和启动时间。如您所见,版本选择不是一个便宜的特性,Java 不采用它是合理的。

13.1.3 未来可能带来的变化

总结来说,模块系统对版本信息不敏感,这意味着版本信息不会影响其行为。这是今天的现状。许多开发者希望 Java 在未来支持这些特性之一。如果您是其中之一,我不想浇你冷水,无论今天的未来看起来如何,并不意味着它不会发生。不过,我看不出来。

重要信息:Oracle Java 平台组的首席架构师马克·雷诺尔德(Mark Reinhold)以及模块系统的规范负责人,反复公开表示,他看不到 Java 未来会有版本支持。鉴于这样一个特性所需的巨大投资以及其可疑的回报,我可以理解他做出这一决定的原因。

这意味着我们仍然需要与版本问题作斗争。也许是我的斯德哥尔摩综合症在作祟,但那些斗争并非毫无意义。努力统一项目中的版本范围,并确保有一组独特的 JAR 文件可以支持应用程序,这实际上是有益的。

想象一下,如果您没有任何动力去做这件事。您的项目会拖多少 JAR 文件到类或模块路径上?它会变得多大,调试会变得多么复杂?不,我认为允许冲突版本默认工作是一个糟糕的想法。

话虽如此,事实仍然是,在某些情况下,版本冲突会立即停止重要的工作,或者在没有更新大量其他依赖项的情况下,使关键更新变得不可能。为此,有一个像 java --one-class-loader-per-module 这样的命令行开关,您可以在下雨天尝试使用,那将是件好事。然而,它(目前)还不存在。

13.2 记录版本信息

正如我们刚刚详细讨论的那样,模块系统不处理版本信息。有趣的是,它确实允许我们记录和访问这些信息。一开始这可能看起来有点奇怪,但事实证明,在调试应用程序时这很有帮助。

在讨论您在哪里看到这些信息以及它提供了哪些好处(第 13.2.2 节)之前,让我们首先看看如何在编译和打包过程中记录版本信息(第 13.2.1 节)。在 ServiceMonitor 的 feature-versions 分支中演示了如何记录和评估版本信息。

13.2.1 在构建模块时记录版本

定义:--module-version

javacjar 命令接受命令行选项 --module-version ${version}。它们将给定的版本(可以是任意字符串)嵌入到模块描述符中。

无论是否使用该选项,如果模块针对记录了其版本的依赖项进行编译,编译器也会将此信息添加到模块描述符中。这意味着模块描述符可以包含模块本身的版本以及模块编译时针对的所有依赖项的版本。

如果之前存在模块版本,jar命令将覆盖模块版本。因此,如果jarjavac都使用了--module-version,则只有jar提供的值有效。

列表 2.5 展示了如何编译和打包 monitor 模块,但您不需要翻回。将jar命令更新为记录版本是微不足道的:

$ jar --create --file mods/monitor.jar --module-version 1.0 --main-class monitor.Monitor -C monitor/target/classes .

如您所见,只需简单地将--module-version 1.0添加进去即可。因为脚本编译并立即打包模块,所以没有必要将其也添加到javac中。

要查看您是否成功,您只需执行jar --describe-module(参见第 4.5.2 节):

$ jar --describe-module --file mods/monitor.jar > monitor@1.0 jar:.../monitor.jar/!module-info.class > requires java.base mandated > requires monitor.observer # truncated requires > contains monitor > main-class monitor.Main

版本信息就在第一行:monitor@1.0。为什么依赖项的版本没有显示出来呢?在这个特定的情况下,我没有记录它们,但 java.base 肯定有一个版本,也没有显示出来。实际上,--describe-module并没有打印出这些信息——无论是jar还是java变体。

要访问模块依赖项的版本,您需要采取不同的方法。让我们看看版本信息出现在哪里以及如何访问它。

13.2.2 访问模块版本

编译和打包过程中记录的版本信息出现在各种地方。正如您所看到的,jar --describe-modulejava --describe-module都打印了模块的版本。

栈跟踪中的版本信息

栈跟踪也是重要的位置。如果代码在模块中运行,模块的名称将与包、类和方法名称一起打印在每个栈帧中。好消息是版本信息也包括在内:

> 异常发生在主线程 "main" java.lang.IllegalArgumentException > 在 monitor@1.0/monitor.Main.outputVersions(Main.java:46) > 在 monitor@1.0/monitor.Main.main(Main.java:24)

虽然不是革命性的,但确实是一个很好的补充。如果您的代码因为看似神秘的原因表现不佳,版本问题可能是原因之一,而且将它们放在如此显眼的位置使得更容易注意到它们是否可疑。

ESSENTIAL INFO 我坚信版本信息可以大有裨益。我强烈建议您配置您的构建工具以记录它。

反射 API 中的模块版本信息

争议性地,处理版本信息最有趣的地方是反射 API。(从现在开始,你需要了解 java.lang.ModuleDescriptor。如果你还没有,请查看第 12.3.3 节。)

重要信息 如你在 列表 12.7 和再次在 列表 13.1 中所见,类 ModuleDescriptor 有一个 rawVersion() 方法。它返回一个包含版本字符串的 Optional<String>,该字符串与传递给 --module-version 的完全相同,如果没有使用该选项,则为空。

此外,还有一个 version() 方法,它返回一个 Optional<Version>,其中 VersionModuleDescriptor 的内部类,它将原始版本解析为可比较的表示形式。如果没有原始版本,或者解析失败,则 Optional 为空。

列表 13.1 访问模块的原始和解析版本

ModuleDescriptor descriptor = getClass() .getModule() .getDescriptor(); String raw = descriptor .rawVersion() .orElse("unknown version"); String parsed = descriptor .version() .map(Version::toString) .orElse("unknown or unparsable version");

如果没有使用 --module-version,则返回一个空的 Optional<String>

如果 rawVersion() 为空或原始版本无法解析,则返回一个空的 Optional<Version>

反射 API 中的依赖项版本信息

这样就解决了模块自身的版本问题。尽管如此,你还没有看到如何访问记录的依赖项版本。或者你已经看到了?列表 12.8,它显示了打印 ModuleDescriptor 提供的几乎所有内容的输出,包含以下片段:

[] module monitor.persistence @ [] { requires [ hibernate.jpa, mandated java.base (@9.0.4), monitor.statistics] [...] }

你看到那里的 @9.0.4 吗?这是 Requires::toString 输出的一部分。RequiresModuleDescriptor 的另一个内部类,它表示模块描述符中的 requires 指令。

重要信息 对于给定的模块,你可以通过调用 module.getDescriptor().requires() 获取一个 Set<Requires>Requires 实例包含一些信息,最值得注意的是所需模块的名称(name() 方法)以及针对编译的原始和解析版本(分别使用 rawCompiledVersion()compiledVersion() 方法)。以下列表显示了获取模块描述符并随后流式传输记录的 requires 指令的代码。

列表 13.2 打印依赖项版本信息

module .getDescriptor() .requires().stream() .map(requires -> String.format("\t-> %s @ %s", requires.name(), requires.rawCompiledVersion().orElse("unknown"))) .forEach(System.out::println);

此代码生成如下输出:

> monitor @ 1.0 > -> monitor.persistence @ 1.0 > -> monitor.statistics @ 1.0 > -> java.base @ 9.0.4 # 更多依赖被截断

下面就是它们:监控编译时依赖项的依赖项版本。很整洁。

编写一个使用此信息来比较模块编译时版本与运行时依赖项实际版本的类相当直接。例如,如果实际版本较低,它可以发出警告,或者在出现问题时记录所有这些信息以供后续分析。

13.3 在单独的层中运行模块的多个版本

13.1.1 节指出,模块系统没有对运行相同模块的多个版本提供原生支持。但正如我已经暗示的,这并不意味着不可能。以下是 JPMS 出现之前人们是如何做到这一点的:

  • 构建工具可以将依赖项阴影到 JAR 文件中,这意味着所有来自依赖项的类文件都被复制到目标 JAR 文件中,但使用新的包名。对这些类的引用也被更新为使用新的类名。这样,带有 com.google.collect 包的独立 Guava JAR 文件就不再需要了,因为其代码已被移动到 org.library.com.google.collection。如果每个项目都这样做,Guava 的不同版本就不会发生冲突。

  • 一些项目使用 OSGi 或其他支持开箱即用多版本的模块系统。

  • 其他项目创建自己的类加载器层次结构,以防止不同实例之间发生冲突。(这也是 OSGi 所做的。)

这些方法各自都有其缺点,这里不再赘述。如果你绝对需要运行相同 JAR 的多个版本,你需要找到一个解决方案,使你的项目值得付出努力。

重要信息 话虽如此,模块系统重新打包了一个现有解决方案,这就是本节的重点。但尽管你可以像这样并行运行多个版本,你也会发现这相当复杂,所以你可能不希望这样做。这与其说是一个食谱,不如说是一个演示案例。

13.3.1 为什么你需要一个启动器来启动额外的层

如 12.4 节所述,模块系统引入了层的概念,这本质上是将模块图与类加载器配对。始终至少有一个层在起作用:启动层,模块系统在启动时根据模块路径内容创建它。

此外,可以在运行时创建层,并需要一个模块集作为输入:例如,从一个文件系统目录中,然后根据可读性规则对其进行评估,以确保可靠的配置。由于包含相同模块多个版本的层无法创建,因此唯一使其工作的方式是将它们安排在不同的层中。

重要信息 这意味着你不需要启动应用程序,而是需要启动一个启动器,该启动器期望以下输入:

  • 所有应用程序模块的路径

  • 模块的关联关系,必须考虑它们的不同版本

然后需要创建一个待创建层的图,这些层被安排得使得每个层只包含每个模块一次,尽管不同的层可以包含相同模块的多个版本。最后一步是填写实际的层,然后调用 main 方法。

将这样的启动器作为通用解决方案开发是一个相当大的工程任务,实际上意味着重新实现现有的第三方模块系统。虽然创建一个仅解决你特定问题的启动器更容易,但我们将专注于这一点。到本节结束时,你将知道如何创建一个简单的层结构,允许你运行相同模块的两个版本。

13.3.2 为您的应用程序、Apache Twill 和 Cassandra Java Driver 启动层

假设你依赖于两个项目,Apache Twill 和 Cassandra Java Driver。它们对 Guava 的版本要求存在冲突:Apache Twill 在 13 版本之后的任何版本都会崩溃,而 Cassandra Java Driver 在 16 版本之前的任何版本都会崩溃。你已经尝试了所有能想到的方法来解决这个问题,但都没有成功,现在你希望通过使用层来解决这个问题。

这意味着基本层只包含你的应用程序启动器。启动器需要创建一个包含 Guava 13 的层,另一个包含 Guava 16 的层——它们需要引用基本层以访问平台模块。然后是一个包含应用程序其余部分及其依赖关系的第四层——它引用了启动器创建的两个其他层,因此可以在其中查找依赖项。

然而,它不会完全像那样工作。一旦 Apache Twill 的依赖项被解决,模块系统将看到 Guava 两次:一次在顶层引用的每个层中。但是,模块不允许读取另一个模块超过一次,因为这会导致不清楚应该从哪个版本加载类。

因此,你将这些两个模块及其所有依赖项拉入各自的 Guava 层,然后就可以出发了。几乎是这样。这两个模块都公开了它们对 Guava 的依赖,所以你的代码也需要看到 Guava;如果这段代码在顶层,你最终会陷入与之前相同的情况,模块系统会抱怨代码看到了两个版本的 Guava。

如果你将 Twill 和 Cassandra 特定的代码也拉入相应的层,你将得到 图 13.3 中所示的层图。现在让我们创建这些层。为此,假设你已经将应用程序模块组织到了三个目录中:

  • mods/twill 包含 Apache Twill 及其所有依赖项以及直接与之交互的你的模块(在这个例子中,是 app.twill)。

  • mods/cassandra 包含 Cassandra Java Driver 及其所有依赖项以及直接与之交互的你的模块(在这个例子中,是 app.cassandra)。

  • mods/app 包含应用程序的其余部分及其依赖项(在这个例子中,主模块是 app)。

图片

图 13.3 Apache Twill 和 Cassandra Java Driver 在 Guava 上存在冲突的依赖关系。要使用这两个库启动应用程序,每个库及其各自的依赖项都必须放在自己的层中。在其上方是包含应用程序其余部分的层,在其下方是基本层。

您的启动器可以按照列表 13.3 所示进行操作:

  1. mods/cassandra目录下创建一个包含模块的层。请小心选择正确的模块作为根模块以进行解析过程。选择引导层作为父层。

  2. mods/twill中的模块执行相同的操作。

  3. mods/app目录下创建一个包含模块的层,并将您的主模块作为根模块。使用其他两个层作为父层;这样,您的应用程序对mods/cassandramods/twill中模块的依赖关系就可以得到解决。

  4. 当所有这些都完成时,获取上层主要模块的类加载器,并调用其main方法。

列表 13.3 创建 Cassandra、Apache Twill 和应用程序层的启动器

public static void main(String[] args) throws ReflectiveOperationException { createApplicationLayers() .findLoader("app") .loadClass("app.Main") .getMethod("main", String[].class) .invoke(null, (Object) new String[0]); } private static ModuleLayer createApplicationLayers() { Path mods = Paths.get("mods"); ModuleLayer cassandra = createLayer( List.of(ModuleLayer.boot()), mods.resolve("cassandra"), "app.cassandra"); ModuleLayer twill = createLayer( List.of(ModuleLayer.boot()), mods.resolve("twill"), "app.twill"); return createLayer( List.of(cassandra, twill), mods.resolve("app"), "app"); } private static ModuleLayer createLayer( List<ModuleLayer> parentLayers, Path modulePath, String rootModule) { Configuration configuration = createConfiguration( parentLayers, modulePath, rootModule); return ModuleLayer .defineModulesWithOneLoader( configuration, parentLayers, ClassLoader.getSystemClassLoader()) .layer(); } private static Configuration createConfiguration(

在创建应用程序层之后,加载应用程序的 Main 类并调用 main 方法

为 Twill 和 Cassandra 创建一个层,每个层都包含整个项目以及与它交互的模块

主要应用程序层从主模块开始解析,并将 twill 和 cassandra 层作为父层。

createLayercreateConfiguration方法与第 12.4.3 节中的方法类似。主要区别在于它们指定了解析的根模块(之前不是必需的,因为您依赖于服务绑定——这里则不需要)。

List<ModuleLayer> parentLayers, Path modulePath, String rootModule) { List<Configuration> configurations = parentLayers.stream() .map(ModuleLayer::configuration) .collect(toList()); return Configuration.resolveAndBind( ModuleFinder.of(), configurations, ModuleFinder.of(modulePath), List.of(rootModule) ); }

就这样!我承认这需要一些时间,你可能需要调整一段时间才能让它工作(我就是这样做的),但如果这是你唯一剩下的解决方案,那么尝试一下是值得的。

摘要

  • javacjar命令允许您使用--module-version ${version}选项记录模块的版本。它将给定的版本嵌入到模块声明中,其中可以使用类似命令的工具(例如,jar --describe-module)和反射 API(ModuleDescriptor::rawVersion)读取。堆栈跟踪也会显示模块版本。

  • 如果一个模块知道自己的版本,并且另一个模块针对它进行编译,编译器将在第二个模块的描述符中记录该版本。此信息仅在ModuleDescriptor::requires返回的Requires实例上可用。

  • 模块系统不会以任何方式对版本信息进行操作。如果模块路径包含多个版本,而不是尝试为模块选择一个特定版本,它会以错误信息退出。这避免了昂贵的版本选择算法出现在 JVM 和 Java 标准中。

  • 模块系统没有内置支持来运行同一模块的多个版本。其根本原因在于类加载机制,该机制假设每个类加载器最多只知道一个给定名称的类。如果你需要运行多个版本,你需要不止一个类加载器。

  • OSGi 通过为每个 JAR 创建一个单独的类加载器来实现这一点。创建一个类似通用的解决方案是一个具有挑战性的任务,但一个更简单的变体,针对你确切的问题进行定制,是可行的。要运行同一模块的多个版本,创建层和相关的类加载器,以便冲突的模块被分离。

14

使用 jlink 自定义运行时镜像

本章涵盖

  • 使用选定内容创建镜像

  • 生成原生应用程序启动器

  • 判断镜像的安全性、性能和稳定性

  • 生成和优化镜像

讨论 Java 模块化的关键动机之一一直是现在所说的物联网(IoT)。这对于 OSGi 来说是真的,它是 Java 最广泛使用的第三方模块系统,它在 1999 年启动,旨在改善嵌入式 Java 应用程序的开发,也适用于 Project Jigsaw,它开发了 JPMS,旨在通过允许创建仅包含(嵌入式)应用程序需要的代码的非常小的运行时来提高平台的可伸缩性。

这就是jlink发挥作用的地方。它是一个 Java 命令行工具(位于你的 JDK 的bin文件夹中),你可以使用它来选择多个平台模块并将它们链接到一个运行时镜像中。这样的运行时镜像的行为与 JRE 完全相同,但它只包含你选择的模块以及它们运行所需的依赖项(如requires指令所示)。在链接阶段,jlink可以用来进一步优化镜像大小并提高虚拟机性能,尤其是启动时间。

自从 Jigsaw 启动以来,已经发生了许多变化。一方面,嵌入式设备中的磁盘空间不再像以前那样珍贵。同时,我们也看到了虚拟化的兴起,尤其是 Docker,其中容器大小再次成为关注点(尽管不是主要问题)。容器化的兴起也带来了简化并自动化部署的压力,而今天,部署的频率已经提高了几个数量级。

此外,jlink在这里也有帮助。它不仅限于链接平台模块——它还可以创建应用程序镜像,这些镜像包括应用程序代码以及库和框架模块。这使得你的构建过程能够生成一个完全自包含的部署单元,该单元包含你的整个应用程序以及它需要的精确平台模块,根据你的需求优化镜像大小和性能,并且可以通过对本地脚本的简单调用启动。

如果你更倾向于桌面应用程序开发,当我提到 IoT 和 Docker 时,你的眼睛可能已经失去了光泽。但使用jlink,你可以非常容易地发送一个单一的 Zip 文件,用户可以无需任何进一步设置即可启动它。如果你一直在使用javapackager,你将很高兴地听到它现在内部调用jlink,这让你可以访问所有其功能(尽管我不会深入集成——javapackager文档已经涵盖了这一点)。

因此,让我们开始链接!我们将从创建由平台模块组成的运行时镜像(第 14.1 节)开始,并利用这个机会更详细地探讨链接过程,查看生成的镜像,并讨论如何选择正确的模块。接下来是包含应用程序模块和创建自定义启动器(第 14.2 节),然后我们讨论跨操作系统的镜像生成(第 14.3 节)。最后,我们将探讨大小和性能优化(第 14.4 节)。

为了跟随代码编写,请查看 ServiceMonitor 仓库中的feature-jlink分支。到本章结束时,你将知道如何为各种操作系统创建优化的运行时镜像,可能包括整个应用程序。这允许你构建一个单一的部署单元,该单元可以在你的服务器或客户的机器上直接使用。

14.1 创建自定义运行时镜像

jlink的一个大用途是创建只包含你应用程序所需的模块的 Java 运行时镜像。结果是定制的 JRE,它正好包含你的代码需要的模块,没有其他模块。然后你可以使用该镜像中的java二进制文件来启动你的应用程序,就像使用任何其他 JRE 一样。

自定义运行时有一些优点:你可以节省一些磁盘空间(更小的镜像)和可能的网络带宽(如果你远程部署),你更安全(类越少意味着攻击面越小),而且你甚至能得到一个启动速度略快的 JVM(更多内容请参考第 14.4.3 节)。

注意:尽管如此,jlink“仅仅”链接字节码——它不会将其编译成机器码。你可能听说过从版本 9 开始,Java 尝试了即时(AOT)编译,但jlink与此无关。要了解 Java 中的 AOT,请查看 Java 增强提案 295 (openjdk.java.net/jeps/295)。

重要的是:你可以在 Java 9+上运行你的应用程序后立即创建针对你的应用程序定制的运行时镜像。你不需要先对其进行模块化。

要了解如何使用jlink创建运行时镜像,我们将从最简单的镜像(第 14.1.1 节)开始,然后检查结果(第 14.1.2 节)。接下来,我们将讨论服务的特殊处理(第 14.1.3 节),最后在本节中添加一个实际用例:如何创建用于运行特定应用程序的镜像(第 14.1.4 节)。

14.1.1 使用 JLINK 入门

定义:jlink 所需的必要信息

要创建镜像,jlink需要三个信息点,每个信息点通过命令行选项指定:

  • 可用模块的位置(使用--module-path指定)

  • 要使用的模块(使用--add-modules指定)

  • 创建镜像的文件夹(使用--output指定)

最简单的运行时镜像只包含基本模块。以下列表展示了如何使用jlink创建它。

列表 14.1 创建只包含基本模块的运行时镜像

$ jlink --module-path ${jdk-9}/jmods ①``--add-modules java.base ②``--output jdk-base ③``$ jdk-base/bin/java --list-modules > java.base

模块的位置,在本例中为本地 JDK 安装的平台模块

需要添加到镜像中的模块,在本例中仅为 java.base

镜像的输出目录

从新镜像执行java --list-modules以验证它只包含基本模块

需要告诉jlink在哪里查找平台模块可能看起来有些奇怪。对于javacjava来说,这不是必需的,那么为什么jlink不知道它们在哪里呢?答案是跨平台链接,这是第 14.3 节讨论的内容。

注意 从 Java 10 开始,不再需要在模块路径上放置平台模块。如果没有,jlink会隐式地从目录$JAVA_HOME/jmods加载它们。

重要信息 无论平台模块是显式还是隐式引用,建议您只从与jlink二进制文件相同的精确 JVM 版本加载它们。例如,如果jlink的版本是 9.0.4,请确保它从 JDK 9.0.4 加载平台模块。

给定三个命令行选项,jlink按照第 3.4.1 节中描述的方式解析模块:模块路径内容成为可观察模块的宇宙,而--add-modules提供的模块成为解析过程的根。但是jlink有几个特性:

重要信息 默认情况下,服务(见第十章)没有被绑定。14.1.3 节解释了原因,并探讨了如何解决这个问题。

  • 带有requires static(见第 11.2 节)的可选依赖项不会被解析。它们需要手动添加。

  • 不允许自动模块。这一点在 14.2 节中非常重要,并在那里有更详细的解释。

除非遇到任何问题,如缺少或重复的模块,否则解析的模块(根模块加上传递依赖)最终会出现在新的运行时镜像中。让我们来看看它。

14.1.2 镜像内容和结构

首先:这个镜像仅占用大约 45 MB(在 Linux 上;我听说在 Windows 上更少),而完整的 JRE 占用 263 MB——这还不包括第 14.4.2 节中讨论的空间优化。那么这个镜像看起来是什么样子呢?第 6.3 节介绍了新的 JDK/JRE 目录结构;如图 14.1(part0029.html#filepos1712158)所示,用jlink创建的运行时镜像相似。这不是巧合:您可以下载的 JDK 和 JRE 是用jlink组合的。

图片

图 14.1 JDK(左)和用jlink创建的自定义运行时镜像(右)的目录结构比较。这种相似性并非偶然——JDK 是用jlink创建的。

注意,jlink将包含的模块融合到lib/modules中,然后从最终镜像中省略jmods文件夹。这与生成 JRE 的方式一致,它也不包含jmods。原始的 JMOD 文件仅包含在 JDK 中,以便jlink可以处理它们:将模块优化到lib/modules是一个单向操作,jlink不能从优化后的镜像生成进一步的镜像。

查看目录bin时,你可能想知道你可以在那里找到哪些可执行文件。实际上,jlink非常智能,它只为那些在镜像中包含了所需模块的可执行文件生成可执行文件。例如,编译器可执行文件javac包含jdk.compiler模块,如果该模块没有被包含,则不可用。

14.1.3 在运行时镜像中包含服务

如果你仔细观察列表 14.1,你会发现图像中只包含 java.base 这一点有些奇怪。在第 10.1.2 节中,你了解到基本模块使用了其他平台模块提供的大量服务,并且在模块解析期间绑定服务时,所有这些提供者都会被拉入模块图中。那么为什么它们最终没有出现在图像中呢?

定义:--bind-services

为了能够创建小而精心组装的运行时图像,jlink默认在创建图像时不执行服务绑定。相反,服务提供者模块必须通过在--add-modules中列出它们来手动包含。或者,可以使用选项--bind-services来包含所有提供其他解析模块使用的服务的模块。

让我们以 ISO-8859-1、UTF-8 或 UTF-16 这样的字符集为例。基本模块知道你日常需要的一些字符集,但有一个特定的平台模块包含了一些其他的字符集:jdk.charsets。基本模块和 jdk.charsets 通过服务解耦。以下是它们模块声明的相关部分:

module java.base { uses java.nio.charset.spi.CharsetProvider; } module jdk.charsets { provides java.nio.charset.spi.CharsetProvider with sun.nio.cs.ext.ExtendedCharsets }

当 JPMS 在常规启动期间解析模块时,服务绑定会拉入 jdk.charsets,因此其字符集并不总是存在于标准的 JRE 中。但是,当你使用jlink创建运行时图像时,这种情况不会发生,因此默认情况下图像不会包含字符集模块。如果你的项目依赖于它,你可能需要通过困难的方式才能发现这一点。

一旦你确定你依赖于一个通过服务与其他模块解耦的模块,你就可以使用--add-modules将其包含在图像中:

$ jlink --module-path ${jdk-9}/jmods --add-modules java.base,jdk.charsets --output jdk-charsets $ jdk-charsets/bin/java --list-modules > java.base > jdk.charsets

定义:--suggest-providers

手动识别服务提供者模块可能会很麻烦。幸运的是,jlink可以帮助你。选项--suggest-providers ${service}列出所有提供${service}实现的可观察模块,其中${service}必须使用其完全限定名指定。

假设你已经创建了一个只包含 java.base 的最小运行时图像,并且在执行你的应用程序时,由于缺少字符集而遇到问题。你使用java.nio.charset.spi.CharsetProvider追踪到 java.base 的问题,现在想知道哪些模块提供了该服务。这时,--suggest-providers选项就派上用场了:

$ jlink --module-path ${jdk-9}/jmods --suggest-providers java.nio.charset.spi.CharsetProvider > 建议提供者: > jdk.charsets > provides java.nio.charset.spi.CharsetProvider > used by java.base

另一个无声缺失模块的好例子是区域。除了英语区域外,所有区域都包含在 jdk.localedata 中,这使得它们可以通过服务提供给基础模块。考虑以下代码:

String half = NumberFormat .getInstance(new Locale("fi", "FI")) .format(0.5); System.out.println(half);

它会打印什么?Locale("fi", "FI")为芬兰创建区域,芬兰格式使用带有逗号的浮点数,所以结果将是0,5——至少,当芬兰区域可用时。如果你在不含 jdk.localedata 的运行时图像上执行此代码,比如你之前创建的那个,你会得到0.5,因为 Java 会静默地回退到默认区域。是的,这不是错误,但这是静默的错误行为。

和之前一样,解决方案是显式包含解耦的模块,在这个例子中是 jdk.localedata。但它会增加 16 MB 到图像大小,因为它包含大量的区域数据。幸运的是,正如你将在 14.4.2 节中看到的,jlink可以帮助减少这种额外的负载。

注意:当你的应用程序在通用下载的 Java 和定制运行时图像上运行时的行为不同时,你应该考虑服务。错误行为可能是由于 JVM 中某些功能不可用吗?也许它的模块通过服务解耦,现在在你的运行时图像中缺失。

这些是基础模块使用以及其他平台模块提供的你可能隐式依赖的一些服务:

  • 来自 jdk.charsets 的字符集

  • 来自 jdk.localedata 的区域

  • 来自 jdk.zipfs 的 ZIP 文件系统

  • 来自 java.naming、java.security.jgss、java.security.sasl、java.smartcardio、java.xml.crypto、jdk.crypto.cryptoki、jdk.crypto.ec、jdk.deploy 和 jdk.security.jgss 的安全提供者

作为手动识别和添加单个模块的替代方案,你可以使用通用的--bind-services

$ jlink --module-path ${jdk-9}/jmods --add-modules java.base --bind-services --output jdk-base-services $ jdk-base-services/bin/java --list-modules > java.base > java.compiler > java.datatransfer > java.desktop # 省略了大约三十多个模块

然而,这会将所有提供服务的模块绑定到基础模块,从而创建了一个相当大的图像——这个图像在优化之前大约有 150 MB。你应该仔细考虑这是否是正确的做法。

14.1.4 使用 JLINK 和 JDeps 调整图像大小

到目前为止,你只创建了包含 java.base 和一些其他模块的小型图像。但对于实际应用场景,你将如何确定需要哪些平台模块来维持一个大型应用?不能使用试错法,对吧?

答案是 JDeps。对于详细介绍,请参阅附录 D——这里只需知道以下咒语将列出你的应用程序依赖的所有平台模块:

jdeps -summary -recursive --class-path 'jars/*' jars/app.jar

为了使其工作,jars文件夹必须包含运行应用程序所需的所有 JAR 文件(包括你的代码以及依赖项;你的构建工具将帮助你完成这项工作),并且jars/app.jar必须包含你用来启动的main方法。结果将显示许多工件之间的依赖关系,但你也会看到显示对平台模块依赖的行。以下示例列出了 Hibernate Core 5.2.12 及其依赖项使用的平台模块:

antlr-2.7.7.jar -> java.base classmate-1.3.0.jar -> java.base dom4j-1.6.1.jar -> java.base dom4j-1.6.1.jar -> java.xml hibernate-commons-annotations-5.0.1.Final.jar -> java.base hibernate-commons-annotations-5.0.1.Final.jar -> java.desktop hibernate-core-5.2.12.Final.jar -> java.base hibernate-core-5.2.12.Final.jar -> java.desktop hibernate-core-5.2.12.Final.jar -> java.instrument hibernate-core-5.2.12.Final.jar -> java.management hibernate-core-5.2.12.Final.jar -> java.naming hibernate-core-5.2.12.Final.jar -> java.sql hibernate-core-5.2.12.Final.jar -> java.xml hibernate-core-5.2.12.Final.jar -> java.xml.bind hibernate-jpa-2.1-api-1.0.0.Final.jar -> java.base hibernate-jpa-2.1-api-1.0.0.Final.jar -> java.instrument hibernate-jpa-2.1-api-1.0.0.Final.jar -> java.sql jandex-2.0.3.Final.jar -> java.base javassist-3.22.0-GA.jar -> java.base javassist-3.22.0-GA.jar -> jdk.unsupported jboss-logging-3.3.0.Final.jar -> java.base jboss-logging-3.3.0.Final.jar -> java.logging slf4j-api-1.7.13.jar -> java.base

现在你需要做的就是提取这些行,删除… ->部分,并丢弃重复项。对于 Linux 用户:

jdeps -summary -recursive --class-path 'jars/*' jars/app.jar | grep '\-> java.\|\-> jdk.' | sed 's/^.*-> //' | sort -u

你最终会得到一个整洁的平台模块列表,你的应用程序依赖于这些模块。将这些模块输入到jlink --add-modules中,你将得到支持你的应用程序的最小可能的运行时镜像(参见图 14.2)。

图片

图 14.2 给定应用程序 JAR 文件(顶部)及其对平台模块的依赖关系(底部),jlink可以创建仅包含所需平台模块的运行时镜像。

基本信息 有几点需要注意:

  • JDeps 偶尔会报告… -> not found,这意味着某些传递依赖没有在类路径上。确保 JDeps 的类路径包含运行应用程序时使用的确切工件。

  • JDeps 无法分析反射,因此如果你的代码或依赖项的代码仅通过反射与 JDK 类交互,JDeps 将无法检测到这一点。这可能导致所需的模块没有包含在镜像中。

  • 如第 14.1.3 节所述,jlink默认不绑定服务,但你的应用程序可能隐式依赖于一些 JDK 内部提供者存在。

  • 考虑添加 java.instrument 模块,这是支持 Java 代理所需的。如果你的生产环境使用代理来观察运行中的应用程序,那么这是必须的;即使不使用,你也可能发现自己处于一种困境,即 Java 代理是分析问题的最佳方式。此外,它只有大约 150 KB,所以这几乎不是什么大问题。

注意 一旦为你的应用程序创建了运行时镜像,我建议你在其上运行单元测试和集成测试。这将让你有信心确实包含了所有必需的模块。

接下来是将在你的镜像中包含应用程序模块——但要做到这一点,你的应用程序及其依赖项需要完全模块化。如果这不是情况,并且你在寻找更直接适用的知识,请跳转到第 14.3 节以生成跨操作系统的运行时镜像,或第 14.4 节以优化你的镜像。

14.2 创建自包含的应用程序镜像

到目前为止,你已经创建了支持应用程序的运行时镜像,但没有任何理由就此停止。jlink使得创建包含整个应用程序的镜像变得容易。这意味着你最终会得到一个包含应用程序模块(应用程序本身及其依赖项)和支撑它的平台模块的镜像。你甚至可以创建一个漂亮的启动器,这样你就可以使用bin/my-app来运行你的应用程序了!分发你的应用程序现在变得容易多了。

定义:应用程序镜像

虽然这不是一个官方术语,但我将包含应用程序模块的镜像称为应用程序镜像(与运行时镜像相对),以清楚地界定我在谈论的内容。毕竟,结果更接近于应用程序而不是通用的运行时。

重要信息 注意,jlink只操作显式模块,所以依赖于自动模块(见第 8.3 节)的应用程序不能链接到镜像中。如果你绝对需要创建包含你的应用程序的镜像,请查看第 9.3.3 节了解如何使第三方 JARs 模块化,或者使用 ModiTect(github.com/moditect/moditect)这样的工具来为你完成这项工作。

这种对显式模块的限制没有技术依据——这是一个设计决策。应用程序镜像应该是自包含的,但如果它依赖于不表达依赖的自动模块,JPMS 就无法验证,可能会导致NoClassDefFoundError。这并不符合模块系统追求的可靠性。

在满足先决条件后,让我们开始吧。你首先会创建一个包含应用程序模块的镜像(第 14.2.1 节),然后通过创建启动器(第 14.2.2 节)来简化你的生活。最后,我们将探讨应用程序镜像的安全性、性能和稳定性(第 14.2.3 节)。

14.2.1 在镜像中包含应用程序模块

创建应用程序镜像所需的所有操作就是将应用程序模块添加到 jlink 模块路径,并从中选择一个或多个作为根模块。生成的镜像将包含所有必需的模块(但不含其他模块;参见图 14.3)并且可以使用 bin/java --module ${initial-module} 启动。

图片

图 14.3 给定应用程序模块(上方)及其对平台模块的依赖关系(下方),jlink 可以创建仅包含所需模块的运行时镜像,包括应用程序和平台代码。

例如,让我们再次转向 ServiceMonitor 应用程序。因为它依赖于自动模块 spark.core 和 hibernate.jpa,而 jlink 不支持这些模块,所以我不得不移除该功能。这使我们只剩下七个模块,它们都只依赖于 java.base:

  • monitor

  • monitor.observer

  • monitor.observer.alpha

  • monitor.observer.beta

  • monitor.persistence

  • monitor.rest

  • monitor.statistics

我将这些放入名为 mods 的文件夹中,并创建了一个镜像,如列表 14.2 所示。不幸的是,我忘记了观察者实现 monitor.observer.alpha 和 monitor.observer.beta 通过服务与应用程序的其他部分解耦,并且它们默认情况下没有绑定(参见第十章“服务”和第 14.1.3 节“jlink 如何处理它们”)。因此,我不得不在列表 14.3 中再次尝试,通过显式添加它们。或者,我本可以使用 --bind-services,但我不喜欢包含所有 JDK 内部服务提供者时镜像变得很大。

列表 14.2 创建包含 ServiceMonitor 的应用程序镜像

$ jlink --module-path ${jdk-9}/jmods:mods--add-modules monitor--output jdk-monitor $ jdk-monitor/bin/java --list-modules > java.base > monitor > monitor.observer> monitor.persistence > monitor.rest > monitor.statistics

除了平台模块外,我在 mods 中还指定了应用程序模块。在 Windows 上,使用 ; 而不是 :。

以 monitor 为起点开始模块解析

服务实现 monitor.observer.alpha 和 monitor.observer.beta 缺失。

列表 14.3 创建应用程序镜像,这次包括服务

$ jlink --module-path ${jdk-9}/jmods:mods --add-modules monitor, monitor.observer.alpha,monitor.observer.beta--output jdk-monitor $ jdk-monitor/bin/java --list-modules > java.base > monitor > monitor.observer > monitor.observer.alpha > monitor.observer.beta > monitor.persistence > monitor.rest > monitor.statistics

以初始模块(monitor)和所有所需服务(其他两个)为起点开始模块解析

定义:系统模块

总而言之,镜像中包含的平台模块和应用程序模块被称为系统模块。您将在下一分钟看到,在启动应用程序时仍然可以添加其他模块。

注意分辨率特性!

从第 14.1 节中记住,jlink创建了一个最小镜像:

  • 它不绑定服务。

  • 它不包括可选依赖项。

重要的信息:尽管您可能会记得检查您自己的服务是否存在,但您可能会忘记您的依赖项(例如,SQL 驱动程序的实现)或平台模块(地区数据或非标准的字符集)。同样适用于可选依赖项,您可能希望包含它们,但忘记它们仅仅因为它们存在于模块路径上而没有解决(参见第 11.2.3 节)。确保您最终获得了所需的全部模块!

ServiceMonitor 应用程序使用芬兰地区设置来格式化其输出,因此它需要在镜像中添加 jdk.localedata(参见以下列表)。这会使镜像大小增加 16 MB(至 61 MB),但第 14.4.2 节展示了如何减少这个大小。

列表 14.4 使用区域数据创建 ServiceMonitor 应用程序镜像

$ jlink --module-path ${jdk-9}/jmods:mods --add-modules monitor, monitor.observer.alpha,monitor.observer.beta, jdk.localedata--output jdk-monitor

平台模块的区域设置也添加到了镜像中。

在启动应用程序时使用命令行选项

一旦创建了镜像,您就可以像往常一样使用java --module ${initial-module}启动应用程序,使用镜像bin文件夹中的java可执行文件。但由于您已在镜像中包含了应用程序模块,因此不需要指定模块路径——JPMS 将在镜像内部找到它们。

jdk-monitor中创建 ServiceMonitor 镜像后,可以使用简短的命令启动应用程序:

$ jdk-monitor/bin/java --module monitor

如果您愿意,您可以使用模块路径。在这种情况下,请记住,系统模块(镜像中的模块)将始终覆盖模块路径上相同名称的模块——就好像模块路径上的模块不存在一样。您可以使用模块路径做的事情是向应用程序添加新模块。这些可能是额外的服务提供者,这允许您在仍然允许用户轻松本地扩展的情况下,与您的应用程序一起分发镜像。

假设 ServiceMonitor 发现了一种它需要观察的新类型的微服务,模块 monitor.observer.zero 就是这样做的。此外,该模块实现了所有正确的接口,并且其描述符声明它提供ServiceObserver。然后,如以下所示,您可以使用之前的相同镜像并通过模块路径添加 monitor.observer.zero。

列表 14.5 使用附加服务提供者启动应用程序镜像

$ jdk-monitor/bin/java --module-path mods/monitor.observer.zero.jar ①``--show-module-resolution ②``--dry-run --module monitor > root monitor jrt:/monitor # truncated monitor's dependencies > monitor binds monitor.observer.alpha jrt:/monitor.observer.alpha ③``> monitor binds monitor.observer.beta jrt:/monitor.observer.beta ③``> monitor binds monitor.observer.zero file://...

将服务提供者放置在模块路径上

而不是真正启动应用程序,查看模块解析以查看提供者是否被选中(此外,也可以看到这些选项如何与常规 JRE 一起工作)

jrt:字符串表明这些模块是从镜像内部加载的。

额外的模块从文件:指示的模块路径加载。

重要信息 如果你想替换系统模块,你必须按照第 6.1.3 节中描述的方式将它们放置在升级模块路径上。除了模块路径的特殊情况外,本书中展示的所有其他java选项在自定义应用程序镜像中工作方式完全相同。

14.2.2 为你的应用程序生成本地启动器

如果创建一个包含你的应用程序及其所需一切,但除此之外没有其他内容的镜像是一个蛋糕,那么添加自定义启动器就是糖霜。自定义启动器是图像bin文件夹中的一个可执行脚本(Unix 基于操作系统上的 shell,Windows 上的批处理),预先配置为使用具体的模块和主类启动 JVM。

定义:--launcher

要创建启动器,请使用--launcher ${name}=${module}/${main-class}选项:

  • ${name}是你为可执行文件选择的文件名。

  • ${module}是要启动的模块的名称。

  • ${main-class}是模块的主类名称。

后两个是你通常在java --module之后放置的内容。并且在这种情况下,如果模块定义了一个主类,你可以省略/${main-class}

如列表 14.6 所示,使用--launcher run-monitor=monitor,你可以告诉jlinkbin目录中创建一个名为run-monitor的脚本,该脚本以java --module monitor的方式启动应用程序。因为 monitor 声明了一个主类(monitor.Main),所以没有必要在--launcher中指定它。如果你想要的话,它将是--launcher run-monitor=monitor/monitor.Main

列表 14.6 使用启动器创建应用程序镜像(并一瞥)

$ jlink --module-path ${jdk-9}/jmods:mods --add-modules monitor, monitor.observer.alpha,monitor.observer.beta --output jdk-monitor --launcher run-monitor=monitor ②``$ cat jdk-monitor/bin/run-monitor ③``> #!/bin/sh > JLINK_VM_OPTIONS= > DIR=`dirname $0` > $DIR/java $JLINK_VM_OPTIONS -m monitor/monitor.Main $@ ⑤``$ jdk-monitor/bin/run-monitor

生成与列表 14.3 中所示相同的图像…

…除了添加一个名为 run-monitor 的启动器,该启动器启动模块 monitor(定义了主类)

只为好玩,看看脚本(cat 打印文件内容)

表示这是一个 shell 脚本

调用脚本时执行的命令

如何使用启动器

注意 您在列表 14.6 中发现了JLINK_VM_OPTIONS吗?如果您想为应用程序指定任何命令行选项——例如,调整垃圾收集器——您可以在这里放置它们。

虽然使用启动器确实有缺点:您尝试应用于启动 JVM 的所有选项都将被解释为放在--module选项之后,因此它们将成为程序参数。这意味着在使用启动器时,您不能即兴配置模块系统——例如,添加之前讨论的额外服务。

但我有好消息:您不必使用启动器,java命令仍然可用。列表 14.5 在创建启动器的情况下工作方式完全相同——只要您不使用它。

14.2.3 安全性、性能和稳定性

创建应用程序镜像可以通过最小化 JVM 中可用的代码量来提高应用程序的安全性,从而减少攻击面。正如第 14.4.3 节讨论的,您还可以期待启动时间的小幅提升。

虽然听起来很酷,但这仅适用于完全控制应用程序操作并定期重新部署的情况。如果您将镜像发送给客户或无法控制何时以及如何用新镜像替换旧镜像,情况就会逆转。

重要信息 使用jlink生成的镜像不是为修改而构建的。它没有自动更新功能,手动修补也不是一个现实场景。如果用户更新系统 Java,您的应用程序镜像将不会受到影响。综合来看,它将永远绑定到链接平台模块时的确切 Java 版本。

优点是 Java 补丁更新不会破坏你的应用程序,但更大的缺点是,你的应用程序将无法从新 Java 版本带来的任何安全补丁或性能改进中受益。请记住这一点。如果一个新 Java 版本中修复了关键漏洞,你的用户仍然会暴露,直到他们部署你发送的新应用程序镜像。

注意 如果你决定交付应用程序镜像,我建议将其作为额外的交付机制,而不是唯一的机制。让用户决定他们是否想要部署整个镜像,或者更愿意在自己的运行时上运行 JAR 文件,他们可以完全控制该运行时,并且可以独立更新它。

14.3 在不同操作系统间生成镜像

尽管你的应用程序和库 JAR 文件中的字节码与任何操作系统无关,但它们需要特定于操作系统的 Java 虚拟机(JVM)来执行。这就是为什么你需要下载针对 Linux、macOS 或 Windows(例如)的特定 JDK 和运行时。重要的是要认识到jlink是在特定于操作系统的层面上操作的!图 14.4 展示了特定于操作系统的组件。

图片

图 14.4 与应用程序、库和框架 JAR 文件(顶部)不同,应用程序镜像(右侧)是特定于操作系统的,就像 JVM(底部)一样。

当你思考这个问题时,这是显而易见的:jlink用来创建镜像的平台模块来自特定于操作系统的 JDK/JRE,因此生成的镜像也是特定于操作系统的。因此,运行时或应用程序镜像总是绑定到一个具体的操作系统。

这是否意味着你必须在一堆不同的机器上执行jlink来创建你需要的所有各种运行时或应用程序镜像?幸运的是,不必如此。正如你在 14.1.1 节中看到的,在创建镜像时,你需要将jlink指向它想要包含的平台模块。这里的关键是:这些模块不需要是你执行jlink的操作系统上的!

重要信息 如果你下载并解压了不同操作系统的 JDK,你可以在运行系统 JDK 的jlink版本时将它的jmods文件夹放置在模块路径上。链接器将确定要为该操作系统创建镜像,因此将创建一个在该操作系统上运行的镜像(但当然,不能在其他操作系统上运行)。所以,如果你有支持所有操作系统的 JDK,你可以在同一台机器上为每个操作系统生成运行时或应用程序镜像。

我正在运行 Linux,但假设我想为在 macOS 上运行的 ServiceMonitor 应用程序生成一个应用程序镜像。方便的是,jlink非常支持这种场景——你所需要的只是一个目标操作系统的 JDK。

结果证明,最难的部分是在未为其打包的操作系统上解包 JDK。在这种情况下,我必须进入 Oracle 为 macOS 分发的 *.dmg 文件——这里不会详细介绍,但您可以使用您选择的搜索引擎找到关于 {Linux, macOS, Windows} 与 {rpm/tar.gz, dmg, exe} 的每个非平凡组合的建议。最后,我在某个文件夹中有 macOS JDK,我将用 ${jdk-9-mac-os} 来表示它。

然后我必须做的与 14.2.1 节中的相同,只是用包含 macOS JDK 的文件夹(${jdk-9-mac-os})替换我的机器上的 JDK 9 文件夹(${jdk-9})。这意味着我正在使用 Linux JDK 的 jlink 可执行文件,以及 macOS JDK 中的 jmods 目录:

$ jlink --module-path ${jdk-9-mac-os}/jmods:mods --add-modules monitor, monitor.observer.alpha,monitor.observer.beta --output jdk-monitor --launcher run-monitor=monitor

带着这个去见我的老板应该没问题。(但如果不行,我甚至不能声称它在我的机器上能工作!)

14.4 使用 jlink 插件优化镜像

“先让它工作,再让它正确,最后让它快速,”Kent Beck,极限编程的创造者和《测试驱动开发:实例》(O’Reilly,2000)一书的作者说道。因此,在创建了运行时和应用镜像的螺母和螺栓(甚至跨操作系统)之后,我们将转向优化。这些优化可以显著减小镜像大小并略微提高运行时性能,尤其是启动时间。

jlink 中,优化由插件处理。因此,在使镜像更小(14.4.2 节)和更快(14.4.3 节)之前,首先讨论该插件架构(14.4.1 节)是有意义的。

14.4.1 JLINK 插件

jlink 的一个核心方面是其模块化设计。除了确定正确的模块并为它们生成镜像的基本步骤之外,jlink 将镜像内容的进一步处理留给其插件。您可以使用 jlink --list-plugins 查看可用的插件,查看docs.oracle.com/javase/9/tools/jlink.htm以获取官方支持的插件,或查看表 14.1 以获取选择(我们将在 14.4.2 和 14.4.3 节中查看每个插件)。

表 14.1 一些 jlink 插件的字母顺序表,指示它们主要减少镜像大小还是提高运行时性能

名称 描述 大小 性能
class-for-name Class::forName 替换为静态访问
compress 共享字符串字面量,并压缩 lib/modules
exclude-files 排除文件,例如本地二进制文件
exclude-resources 排除资源,例如来自 META-INF 文件夹
generate-jli-classes 预生成方法句柄
include-locales jdk.localedata 中移除除指定区域设置之外的所有区域设置
order-resources lib/modules中排序资源
strip-debug 从图像字节码中删除调试符号
system-modules 准备系统模块图以快速访问

注意:文档以及jlink本身还列出了 vm 插件,它允许你选择你想要包含在镜像中的几个 HotSpot 虚拟机(客户端、服务器或最小)之一。这是一个理论上的可能性,因为 64 位 JDK 只附带服务器 VM。对于大多数情况,这让你只剩下一个选择。

开发 JLINK 插件

在本书印刷时,只有支持的插件可用,但将来可能会发生变化,因为将添加更多实验性功能。在创建图像时优化图像的努力仍然相当年轻,这里正在进行大量工作。因此,插件 API 在未来可能会发生变化,并且没有在 Java 9+中标准化或导出。

这使得为jlink开发插件相当复杂^(1),意味着你将不得不等待一段时间,社区才开始真正贡献插件。这些插件能做什么呢?首先,编写jlink插件有点像编写代理或构建工具插件——这不是在典型应用程序开发期间完成的事情。这是一个专门的任务,用于支持专门的库、框架和工具。

^(1) 如果你对 Java 9 中探索 jlink 插件 API 的教程感兴趣,请参阅 Gunnar Morling 的博客文章“Exploring the jlink Plug-in API in Java 9” (mng.bz/xJ6B)。

但让我们回到社区提供的插件能做什么的问题。一个用例来自性能分析器,它们目前使用代理将性能跟踪代码注入正在运行的应用程序。使用jlink插件,你可以在链接时而不是在执行应用程序时支付仪器成本来完成这项工作。如果快速启动很重要,这可能是一个明智的选择。

另一个用例是增强 Java 持久化 API(JPA)实体的字节码。例如,Hibernate 已经通过代理来跟踪哪些实体被修改(所谓的脏检查),而不必检查每个字段。在链接时而不是在启动时进行这项工作是有意义的,这就是为什么 Hibernate 已经为构建工具和 IDE 提供了插件,这些插件在它们的构建过程中执行这项工作。

作为最后的例子,一个非常好的、潜在的jlink插件将是一个在链接时索引注解并在运行时提供该索引的插件。这可以显著减少扫描注解的 bean 和实体的模块路径的应用程序的启动时间。实际上,我在脚注中给出的插件教程正是这样做的。

使用 JLINK 插件

定义:插件 --${name} 命令行选项

理论部分已经讲完,让我们使用一些这些插件。但如何使用呢?其实很简单:jlink会根据每个插件的名字自动创建一个命令行选项--${name}。如何传递进一步参数取决于插件,并在jlink --list-plugins中描述。

移除调试符号是减小镜像大小的有效方法。要这样做,使用--strip-debug创建镜像:

$ jlink --module-path ${jdk-9}/jmods --add-modules java.base --strip-debug --output jdk-base-stripped

看这里:lib/modules的大小从仅基础模块的 23 MB 减少到了 18 MB(在 Linux 上)。

通过将更重要的文件放在前面来排序lib/modules的内容可以减少启动时间(尽管我怀疑效果是否明显):

$ jlink --module-path ${jdk-9}/jmods --add-modules java.base --order-resources=**/module-info.class,/java.base/java/lang/** --output jdk-base-ordered

这样,模块描述符首先出现,然后是java.lang包中的类。

现在你已经知道了如何使用插件,是时候测试几个插件了。我们将分为两个部分进行,第一部分关注大小缩减(第 14.4.2 节)和第二部分关注性能提升(第 14.4.3 节)。因为这个特性是不断发展的,并且相对较为专业,所以我不将详细说明——官方的jlink文档和jlink --list-plugins,尽管文字不多,但能更精确地展示如何使用它们。

14.4.2 减小镜像大小

让我们逐一查看这些大小缩减插件,并测量它们能带我们走多远。我本想在一个应用程序镜像上测试它们,但 ServiceMonitor 只有大约十几个类,所以这样做没有意义;而且我找不到一个真正免费且完全模块化的应用程序,包括其依赖项(记住,镜像中没有自动模块)。相反,我将测量对三个不同运行时镜像的影响(括号内为未修改的大小):

  • base —仅 java.base(45 MB)

  • services —java.base 加上所有服务提供者(150 MB)

  • java —所有 java.和 javafx.模块,但不包括服务提供者(221 MB)

有趣的是,与服务相比,Java 的大小更大,并不是由于字节码的数量(在 Java 中,lib/modules比服务小一点),而是由于原生库,尤其是为 JavaFX 的WebView捆绑的 WebKit 代码。这将帮助你理解插件在减小镜像大小时的行为。(顺便说一句,我在 Linux 上做这个,但比例在其他操作系统上应该相似。)

压缩镜像

定义:压缩插件

压缩插件旨在减小lib/modules的大小。它由--compress=${value}选项控制,该选项有三个可能的值:

  • 0—无压缩(默认)

  • 1—去重并共享字符串字面量(意味着String s = "text";中的"text"

  • 2—压缩lib/modules为 Zip 格式

可以通过--compress=${value}:filter=${pattern-list}包括一个可选的模式列表,在这种情况下,只有匹配模式的文件才会被压缩。

此命令创建了一个仅包含基础模块的压缩运行时镜像:

$ jlink --module-path ${jdk-9}/jmods --add-modules java.base --output jdk-base --compress=2

显然,你不需要尝试0。对于12,我得到了以下结果:

  • base —45 MB ⇝ 39 MB (1) ⇝ 33 MB (2)

  • services —150 MB ⇝ 119 MB (1) ⇝ 91 MB (2)

  • java —221 MB ⇝ 189 MB (1) ⇝ 164 MB (2)

你可以看到压缩率并不是在所有镜像中都是相同的。服务镜像的大小可以降低近 40%,但较大的 java 镜像只能降低 25%。这是因为压缩插件只作用于lib/modules,但正如我们讨论的那样,这两个镜像中的大小几乎相同。因此,绝对大小减少也相似:两个镜像都是大约 60 MB,这超过了lib/modules初始大小的 50%。

注意:使用--compress=2进行 Zip 压缩会增加启动时间——一般来说,镜像越大,增加的越多。如果你认为这很重要,请确保对其进行测量。

排除文件和资源

定义:exclude-files 和 exclude-resources 插件

插件exclude-filesexclude-resources允许从最终镜像中排除文件。相应的选项--exclude-files=${pattern-list}--exclude-resources=${pattern-list}接受一个模式列表,用于匹配要排除的文件。

当我比较服务和基础镜像的初始大小时,指出的是,主要是 JavaFX WebView的本地二进制文件使得 java 镜像变大。在我的机器上,这是 73 MB 的文件lib/libjfxwebkit.so。以下是使用--exclude-files排除它的方法:

$ jlink --module-path ${jdk-9}/jmods --add-modules java.base --output jdk-base --exclude-files=**/libjfxwebkit.so

结果镜像小了 73 MB。有两个注意事项:

  • 这与手动从镜像中删除文件的效果相同。

  • 这使得基本上只包含WebView的 javafx.scene.web 模块变得几乎无用,所以可能最好根本不包含该模块。

除了实验和学习之外,排除平台模块附带的内容是不良的做法。确保彻底研究任何此类决定的后果,因为这可能会影响 JVM 的稳定性。

这些插件的一个更好的用途是排除你的应用程序或依赖 JAR 中包含而你不需要在应用程序镜像中的文件。这些可能包括文档、不想要的源文件、对你不关心的操作系统的本地二进制文件、配置文件,或者任何其他无数聪明的开发者放入他们存档中的东西。比较大小减少也是没有意义的:你将节省被排除文件所占用的空间。

排除不必要的区域

区域设置是从平台模块中来的,确实有移除的必要。正如你在第 14.1.3 节中发现的那样,基础模块只能与英语区域设置一起工作,而 jdk.localedata 模块包含 Java 支持的所有其他区域设置的信息。不幸的是,这些其他区域设置加在一起大约有 16 MB。如果你只需要一个或甚至只有几个非英语区域设置,这有点过于奢侈。

定义:include-locales 插件

正是 include-locales 插件在这里发挥作用。它用作--include-locales=${langs},其中${langs}是一个由逗号分隔的 BCP 47 语言标签列表(例如en-USzh-Hansfi-FI),生成的镜像将只包含这些语言。

这只会在 jdk.localedata 模块被包含在镜像中时才有效,所以它不仅仅包括除了基础模块包含的额外区域设置,更多的是排除了 jdk.localedata 中的所有其他区域设置。

列表 14.4 为 ServiceMonitor 创建了一个包含所有 jdk.localedata 的应用程序镜像,因为该应用程序使用芬兰格式进行输出。这导致镜像大小增加了 16 MB,你现在知道如何将其推回。 列表 14.7 使用--include-locales=fi-FI来实现这一点。生成的镜像略大于没有 jdk.localedata 的镜像(精确到 168 KB)。成功!

列表 14.7 创建包含芬兰区域数据的 ServiceMonitor 应用程序镜像

$ jlink --module-path ${jdk-9}/jmods:mods --add-modules monitor, monitor.observer.alpha,monitor.observer.beta, jdk.localedata --output jdk-monitor --include-locales=fi-FI

区域设置的平台模块需要添加到镜像中——要么明确地(如这里所示),要么隐式地(通过需要或使用--bind-services)。

除了 fi-FI(芬兰语)之外的所有区域设置都被从 jdk.localedata 中移除。

通过排除区域设置可以减少多少镜像大小取决于你需要多少区域设置。如果你向全球受众提供国际化应用程序,你将无法节省太多,但我的猜测这并不是常见情况。如果你的应用程序只支持少数几种甚至十几种语言,排除其他语言将为你节省几乎所有那 16 MB。是否值得付出努力取决于你。

移除调试信息

当你在 IDE 中调试 Java 代码时,你通常会看到格式良好、命名清晰,甚至带有注释的源代码。这是因为 IDE 检索了属于该代码的实际源代码,将其与当前执行的字节码关联起来,并方便地显示它们。这就是最佳情况。

如果没有源代码,你仍然可能看到可读的代码,如果除了字段和方法参数名称(这些名称始终存在于字节码中)之外,你还看到了变量的正确名称(这些名称不一定存在)。这发生在反编译代码包含调试符号时。这些信息使调试变得容易得多,但当然会占用空间。而jlink允许你移除这些符号。

定义 strip-debug 插件

如果jlink插件 strip-debug 通过--strip-debug激活,它将从图像的字节码中移除所有调试符号,从而减小lib/modules文件的大小。此选项没有其他参数。

我在第 14.4.1 节中使用了--strip-debug,所以我就不再重复了。让我们看看它是如何减少图像大小的:

  • 基础 —45 MB ⇝ 40 MB

  • 服务 —150 MB ⇝ 130 MB

  • java —221 MB ⇝ 200 MB

这大约是总图像大小的 10%,但请记住,这仅影响到lib/modules,它减少了大约 20%。

重要信息 一个警告:在没有源代码和调试符号的情况下调试代码是一项痛苦的任务。如果你偶尔使用远程调试连接到正在运行的应用程序并分析问题所在,如果你已经放弃了那些调试符号,而你节省的那几兆字节对你来说并不重要,你不会感到高兴。请仔细考虑--strip-debug选项!

整合所有内容

虽然排除文件和资源最好留给应用程序模块,但其他选项在纯运行时图像上效果良好。让我们将它们全部整合起来,并尝试为模块的三种选择创建尽可能小的图像。以下是仅针对 java.base 的命令:

$ jlink --module-path ${jdk-9}/jmods --add-modules java.base --output jdk-base --compress=2 --strip-debug

下面是结果:

  • 基础 —45 MB ⇝ 31 MB

  • 服务 —150 MB ⇝ 75 MB(我还移除了除fi-FI之外的所有区域设置)

  • java —221 MB ⇝ 155 MB(或者如果你削弱了 JavaFX WebKit,则是 82 MB)

还不错,不是吗?

14.4.3 提高运行时性能

正如你所见,有相当多的方法可以减小应用程序或运行时图像的大小。不过,我的猜测是,大多数开发者都在热切期待性能改进,尤其是在 Spectre 和 Meltdown 事件夺走了他们一些 CPU 周期之后。

重要信息 很遗憾,关于这一点我没有太多好消息:使用jlink的性能优化还处于早期阶段,大多数现有的或预期的优化都集中在提高启动时间,而不是长期运行性能。

一个默认启用的现有插件是 system-modules,它预先计算系统模块图并将其存储以供快速访问。这样,JVM 就不需要在每次启动时解析和处理模块声明,验证可靠配置。

另一个插件,class-for-name,将字节码如Class.forName("some.Type")替换为some.Type.class,因此可以避免通过名称比较昂贵的基于反射的类搜索。我们简要地看了order-resources,但没有太多可以补充的。

目前唯一支持的其他与性能相关的插件是generate-jli-classes。如果配置得当,它可以将 lambda 表达式的初始化成本从运行时移动到链接时,但学习如何做到这一点需要很好地理解方法句柄,所以这里不会涉及。

关于性能改进的内容就这些了。我理解如果你对这个领域的大幅提升感到失望,但让我指出,JVM 已经相当优化了。所有低垂的果实(以及树上的许多其他果实)都已经摘取,要达到其他果实需要一些独创性、时间和巧妙的设计。jlink工具还比较年轻,我坚信 JDK 开发团队和社区会在适当的时候充分利用它。

Java 10 中的应用类数据共享

jlink没有直接关联的是 Java 10 引入的一种优化:应用类数据共享。2 实验表明,它可以导致应用启动速度提高 10%到 50%。有趣的是,你可以在应用程序镜像内应用这项技术,创建一个更加优化的部署单元。

2 要了解更多信息,请参阅我的博客文章“使用应用类数据共享提高 Java 10 的启动时间”,blog.codefx.org/java/application-class-data-sharing

14.5 jlink 的选项

为了方便起见,表 14.2 列出了本书讨论的所有jlink命令行选项。更多选项可以在官方文档docs.oracle.com/javase/9/tools/jlink.htm中找到,或者使用jlink --helpjlink --list-plugins

表 14.2 一个按字母顺序排列的jlink选项表,包括插件。描述基于文档,参考文献指向本书中解释如何使用这些选项的章节。

选项 描述 参考文献号
--add-modules 定义要包含在镜像中的根模块 14.1.1
--bind-services 包括所有已解析模块使用的服务提供者 14.1.3
--class-for-name Class::forName替换为静态访问(插件) 14.4.3
--compress, -c 共享字符串字面量,并压缩lib/modules(插件) 14.4.2
--exclude-files, --exclude-resources 排除指定的文件和资源(插件) 14.4.2
--generate-jli-classes 预生成方法句柄(插件) 14.4.3
--include-locales 从 jdk.localedata(插件)中删除除指定区域设置之外的所有区域设置 14.4.2
--launcher bin 中为应用程序生成原生启动器脚本 14.2.2
--list-plugins 列出可用的插件 14.4.1
--module-path, -p 指定查找平台和应用模块的位置 14.1.1
--order-resources lib/modules(插件)中排序资源 14.4.1
--output 在指定位置生成镜像 14.1.1
--strip-debug 从镜像的字节码中删除调试符号(插件) 14.4.2
--suggest-providers 列出指定服务的可观察提供者 14.1.3

摘要

  • 命令行工具 jlink 从选定的平台模块创建运行时镜像(使用 jdeps 确定应用程序需要哪些模块)。为了从中受益,应用程序需要运行在 Java 9+ 上,但不需要模块化。

  • 一旦应用程序及其依赖项已完全模块化(不使用自动模块),jlink 就可以使用它创建应用程序镜像,包括应用程序的模块。

  • 所有对 jlink 的调用都需要指定以下内容:

  • 查找模块的位置(包括平台模块),使用 --module-path

  • 解析的根模块,使用 --add-modules

  • 结果镜像的输出目录,使用 --output

  • 注意 jlink 如何解析模块:

  • 默认情况下,服务不受限制。

  • 带有 requires static 的可选依赖项不会解析。

  • 不允许自动模块。

  • 确保使用 --add-modules--bind-services 单独添加所需的服务提供者或可选依赖项,或者绑定所有提供者。

  • 注意可能没有意识到就隐式依赖的平台服务。一些候选者包括字符集(jdk.charsets)、区域设置(jdk.localedata)、Zip 文件系统(jdk.zipfs)和安全提供者(各种模块)。

  • jlink 生成的运行时镜像

  • 与使用 --module-path 选择平台模块构建的操作系统绑定

  • 与 JDK 和 JRE 具有相同的目录结构

  • 将平台模块和应用程序模块(统称为系统模块)融合到 lib/modules

  • 仅包含包含所需模块的二进制文件(在 bin 中)

  • 要启动应用程序镜像,可以使用 bin/java --module ${initial-module}(不需要模块路径,因为系统模块会自动解析)或使用 --launcher ${name}=${module}/${main-class} 创建的启动器。

  • 使用应用程序镜像,可以使用模块路径添加额外的模块(尤其是提供服务的模块)。具有与系统模块相同名称的模块路径上的模块将被忽略。

  • 当您无法轻松用新版本替换它们时,仔细评估交付应用程序镜像的安全、性能和稳定性影响。

  • 各种 jlink 选项,这些选项激活插件,提供减少图像大小(例如,--compress--exclude-files--exclude-resource--include-locales--strip-debug)或提高性能(主要是启动时间;--class-for-name--generate-jli-classes--order-resources)的方法。未来可能会有更多;这个领域仍处于早期阶段。

  • jlink 插件 API 尚未标准化,以简化其在早期阶段的演变,这使得开发和使用第三方插件变得更加困难。

15

将各个部分组合在一起

本章涵盖

  • ServiceMonitor 的铃声和哨声版本

  • 是否使用模块

  • 一个理想的模块可能的样子

  • 保持模块声明干净

  • 将模块系统与构建工具、OSGi 和微服务进行比较

现在我们已经涵盖了有关模块系统几乎所有的知识,是时候总结一下了。在本章的最后,我想将所有这些联系起来,并给出一些关于创建出色的模块化应用程序的建议。

第一步是向您展示如何通过将本书中讨论的各个功能的大部分应用于 ServiceMonitor 应用程序(第 15.1 节)来展示这些功能如何结合在一起。然后,我将深入探讨一些更一般的问题,这些问题将帮助您决定是否创建模块,在创建模块时应追求什么,以及如何仔细地演进模块声明,使它们保持干净(第 15.2 节)。我将通过回顾围绕模块系统的技术格局(第 15.3 节)以及我对 Java 模块生态系统的愿景(第 15.4 节)来结束。

15.1 向 ServiceMonitor 添加铃声和哨声

第二章展示了 ServiceMonitor 应用程序的结构。在第 2.2 节中,您创建了只使用简单的 requiresexports 指令的模块。从那时起,我们不仅详细讨论了这些内容,还探索了模块系统的更高级功能。我们逐一研究了它们,但现在我想将它们全部结合起来。

要欣赏 ServiceMonitor 应用程序的全部辉煌,请查看存储库的 features-combined 分支。以下列表包含 ServiceMonitor 中所有模块的声明。

列表 15.1 使用本书中介绍的先进功能的 ServiceMonitor

module monitor.observer { exports monitor.observer; exports monitor.observer.utils to monitor.observer.alpha, monitor.observer.beta; } module monitor.observer.alpha { requires monitor.observer; provides monitor.observer.ServiceObserverFactory with monitor.observer.alpha.AlphaServiceObserverFactory; } // [...] module monitor.statistics { requires transitive monitor.observer; ④``requires static stats.fancy; exports monitor.statistics; } module stats.fancy { exports stats.fancy; } module monitor.persistence { requires transitive monitor.statistics; ④``requires hibernate.jpa; exports monitor.persistence; opens monitor.persistence.entity; } module monitor.rest { requires transitive monitor.statistics; ④``requires spark.core; exports monitor.rest; } module monitor { requires monitor.observer; requires monitor.statistics; requires monitor.persistence; requires monitor.rest; uses monitor.observer.ServiceObserverFactory; }

monitor.observer.utils 主要针对观察者实现,因此它仅导出给(一些)它们(参见第 15.1.2 节)。

观察者 API 的消费者(monitor)和实现(例如,monitor.observer.alpha)通过服务解耦(参见第 15.1.3 节)。

monitor.observer.beta 和 monitor.observer.gamma 在此处未显示;它们看起来就像 monitor.observer.alpha。

一些模块在其 API 中公开了另一个模块的类型,并且没有该模块就无法使用,因此它们暗示了其可读性(参见第 15.1.1 节)。

stats.fancy 并非在每个部署中都存在,monitor.statistics 通过将其对该模块的依赖标记为可选来反映这一点(参见第 15.1.1 节)。

ServiceMonitor 使用的 Hibernate 和 Spark 版本都没有模块化,因此 hibernate.jpa 和 spark.core 是自动模块(参见第 15.1.5 节)。

monitor.persistence 将其持久化实体包含的包打开以供反射(参见第 15.1.2 节)。

如果您将此列表与列表 2.2 进行比较或查看图 15.1,您会发现 ServiceMonitor 的基本结构基本保持不变。但仔细观察,您会发现许多改进。让我们逐一过目。

图片 1 图片 2

图 15.1 根据功能使用情况比较 ServiceMonitor 应用的模块图。第一个变体仅使用普通的exportsrequires指令(a),而第二个变体充分利用了精炼的依赖关系、导出以及服务(b)。(基本变体已扩展,包括与高级变体相同的模块和包。)

15.1.1 多样化的依赖关系

容易发现的一个变化是requires transitiverequires optional指令。尽管在大多数情况下,普通的requires指令是正确的选择,但相当一部分依赖关系要复杂一些。

最明显的情况是可选依赖,其中某个模块使用来自另一个模块的类型,因此需要针对它进行编译,但依赖关系在运行时可能仍然不存在。这正是 monitor.statistics 和 stats.fancy 的情况,因此使用requires static指令建立了依赖关系。

模块系统将在编译 monitor.statistics 时强制 stats.fancy 的存在(这是有意义的,因为否则编译会失败),如果后者进入了模块图(这也是有意义的,因为否则 monitor.statistics 无法访问 stats.fancy 的类型),则将从 monitor.statistics 添加一个读取边到 stats.fancy。但 stats.fancy 可能不会进入模块图,在这种情况下,monitor.statistics 必须处理其不存在的情况。

列表 15.2 检查可选依赖 stats.fancy 是否存在

private static boolean checkFancyStats() { boolean isFancyAvailable = isModulePresent("stats.fancy"); String message = "Module 'stats.fancy' is" + (isFancyAvailable ? " " : " not ") + "available."; System.out.println(message); return isFancyAvailable; } private static boolean isModulePresent(String moduleName) { return Statistician.class .getModule() .getLayer() .findModule(moduleName) .isPresent(); }

可选依赖关系在 11.2 节中进行了详细讨论。

另一种情况比可选依赖稍微不明显,但并不少见——甚至可能更常见。例如,monitor.rest 模块在其公共 API 中就有这个方法:

public static MonitorServer create(Supplier<Statistics> statistics) { return new MonitorServer(statistics); }

Statistics来自 monitor.statistics,因此任何使用 rest 的模块都需要读取 statistics,否则它无法访问Statistics,因此无法创建MonitorServer。换句话说,rest 对于不也读取 statistics 的模块来说是无用的。在 ServiceMonitor 应用中,这种情况出人意料地经常发生:每个至少需要另一个模块并导出包的模块最终都会处于这种情况。

这在野外发生的频率要高得多,而且只有那么频繁,是因为这些模块非常小,几乎所有的代码都是公开的 API——如果它们不在自己的 API 中不断暴露其依赖项的类型,那将令人惊讶。所以尽管在实践中这种情况发生的频率较低,但你仍然可以期望每天都会看到这种情况——在 JDK 中,大约 20%的依赖项被暴露。

为了不让用户猜测他们需要显式要求的其他模块,这既麻烦又使模块声明膨胀,模块系统提供了requires transitive。因为 rest requires transitive统计信息,任何读取 rest 的模块也会读取统计信息,因此 rest 的用户免去了猜测的麻烦。隐含的可读性在第 11.1 节中进行了详细讨论。

15.1.2 降低可见性

与 2.2 节中应用程序原始版本相比的另一个变化是,它的模块更加努力地减少它们的 API 表面积。更新的模块使用了相当少的普通exports指令:

  • 多亏了服务,观察者不再需要导出它们的实现。

  • 通过使用限定导出,monitor.observer.utils包在monitor.observer中只能被选定的模块访问。

  • monitor.persistence打开其实体包而不是导出它,因此仅在运行时使其可用。

这些变化减少了任何随机模块可以轻松访问的代码量,这意味着开发者可以在模块内部更改更多代码,而无需担心对下游消费者的影响。以这种方式减少 API 表面积对于框架和库的维护性来说是一个福音,但具有许多模块的大型应用程序也可以从中受益。第 11.3 节介绍了限定导出,第 12.2 节探讨了公开包。

15.1.3 通过服务解耦

与 2.2 节相比,模块图的结构性变化仅在于monitor不再直接依赖于观察者实现。相反,它只依赖于提供 API 的模块monitor.observer,并使用ServiceObserverFactory作为服务。所有三个实现模块都提供该服务及其特定实现,模块系统连接了这两者。

这不仅仅是一个美学上的改进。多亏了服务,可以在启动时配置应用程序行为的某些方面——它可以观察哪些类型的服务。可以通过添加或删除提供该服务的模块来添加新实现和删除过时的实现——无需对monitor进行任何更改,因此可以使用相同的工件而无需重新构建它们。要了解有关服务的所有信息,请参阅第十章。

15.1.4 在运行时以层叠方式加载代码

尽管服务允许我们在启动时定义应用程序的行为,但我们甚至更进一步。这并没有在模块声明中体现出来,但通过启用监控模块创建新层,我们使得应用程序能够在运行时观察那些在启动时甚至没有ServiceObserver实现的服务的功能。按需,监控器将创建一个新的模块图,并使用新的类加载器加载额外的类,并更新其观察者列表。

列表 15.3 使用为这些路径上的模块创建的图创建新层

private static ModuleLayer createLayer(Path[] modulePaths) { Configuration configuration = createConfiguration(modulePaths); ClassLoader thisLoader = getThisLoader(); return getThisLayer() .defineModulesWithOneLoader(configuration, thisLoader); } private static Configuration createConfiguration(Path[] modulePaths) { return getThisLayer() .configuration() .resolveAndBind( ModuleFinder.of(), ModuleFinder.of(modulePaths), Collections.emptyList() ); }

这种行为对于不经常重新部署且重启不便的应用程序尤其有趣。复杂的桌面应用程序可以想到,但运行在客户场所并需要可理解配置的 Web 后端也可能符合条件。关于层是什么以及如何创建它们的讨论,请参阅 12.4 节。

15.1.5 处理普通 JAR 的依赖

从模块声明中不明显的一个细节是 ServiceMonitor 的第三方依赖的模块化状态。它所使用的 Hibernate 版本和 Spark 版本尚未模块化,它们仍然以普通 JAR 文件的形式发布。由于显式模块需要它们,因此它们需要位于模块路径上,尽管模块系统会将普通 JAR 文件转换为自动模块。

尽管 ServiceMonitor 完全模块化,但它仍然可以依赖于非模块化的 JAR 文件。从整个生态系统角度来看,其中 JDK 模块位于底层,而应用模块位于顶层,这实际上是一个自上而下的模块化努力。

自动模块在 8.3 节中有详细说明,但第八章的所有内容都适用。如果您想了解模块化策略,请查看 9.2 节。

15.2 模块化应用程序的技巧

在整本书中,我们花费了大量时间研究如何使用模块系统中的各种工具来解决个别问题。显然,这是关于 JPMS 的书籍最重要的任务,但我不打算让您在没有至少快速盘点整个工具箱的情况下离开。

第一个问题是你是否真的想使用这些工具?不使用比喻,你是否想创建模块(第 15.2.1 节)?一旦这个问题解决,我们将尝试定义一个理想的模块可能的样子(第 15.2.2 节)。然后我们将关注如何保持模块声明处于最佳状态(第 15.2.3 节)以及哪些更改可能会破坏用户的代码(第 15.2.4 节)。

15.2.1 模块化与否?

总的来说,你已经了解了模块系统的各个方面——它的特性、缺点、承诺和限制——也许你还在问自己是否应该模块化你的 JAR 文件。最终,只有你和你的团队能够为你的项目回答这个问题,但我可以给你一些关于这个话题的想法。

正如我在整本书中表达的那样,我坚信模块系统为库、框架和大多数非平凡应用程序提供了许多重要的好处。尤其是强大的封装、通过服务解耦(尽管没有模块也可以这样做,但不太方便),以及应用程序镜像,这些对我来说特别突出。

然而,我最喜欢的是模块声明本身:它们始终是项目架构的真实反映,并将为每个致力于系统这些方面的开发者和架构师提供相当大的好处,从而提高其整体的可维护性。(我在第 15.2.3 节中对此进行了更深入的探讨。)

重要信息由于这些原因,我的默认做法是使用模块系统来启动每个针对 Java 9+ 开发的全新项目。(理论上,特定项目的原因可能会让我改变主意,但我实在想不出任何可能的情况。)如果依赖项在模块路径上造成太多麻烦(例如,它们可能会分割包——参见第 7.2 节),那么通过使用类路径而不是模块路径,退出模块系统相对容易。如果你从一开始就使用模块,那么创建和演进模块几乎不需要时间,相对而言,而改进的可维护性将大大减少随着项目增长和老化需要解开的问题数量。

如果你还不信服,先试试看。用模块构建一个演示项目,或者更好的是,一个具有真实用户和需求的小型应用程序。非关键性的公司内部工具是绝佳的实验对象。

当涉及到模块化现有项目时,答案要复杂得多。“这取决于”的情况更多。需要完成的工作量更加明显,但好处同样明显。事实上,需要完成的工作越多,通常回报就越高。想想看:哪些应用程序最难模块化?那些由更多工件组成、更加纠缠、维护性更差的。但这些都是从调查和改进其结构中获益最多的。所以当有人假设现有项目的模块化成本低、收益高(或相反)时要小心。

关键信息最终,一个项目预期的剩余寿命可能是一个决定性因素。项目需要维护的时间越长,模块化的相对成本就越低,收益就越高。换句话说,剩余寿命越长,模块化就越有意义。

如果你正在开发一个有团队外用户的项目,比如库或框架,你也应该考虑他们的需求。即使模块化对你来说似乎不值得,他们也能从中获得相当大的好处。

15.2.2 理想模块

假设你已经做出了决定,并选择了模块。理想的模块是什么?你在切割模块和编写声明时追求的是什么?再次强调,没有一种适合所有情况的答案,但有一些信号你可以保持关注:

  • 模块大小

  • API 表面

  • 模块之间的耦合

在依次讨论这些内容之前,我想补充一点,即使你有一个理想模块的概念,你也不太可能一个接一个地创造出这样的模块。尤其是如果你从模块化现有项目开始,你可能会在过程中创建一些丑陋的模块。

如果你正在开发一个应用程序,你不必担心这一点——你可以轻松地在开发过程中重构模块。对于库和框架的开发者来说,生活更艰难。正如你将在 15.2.4 节中看到的,许多重构步骤可能会破坏用户的代码,因此你进化时拥有的自由度更小。

现在,让我们转向你可以观察到的三个信号来判断模块的质量:大小、表面和耦合。

保持你的模块(相对)小

模块声明为你提供了一个强大的工具来分析和塑造模块之间的边界,但它们对模块内部发生的事情相对盲目。包有循环依赖?所有类和成员都是公开的?它可能对开发造成伤害,但你的模块声明不会反映这一点。

这意味着你拥有的模块声明越多,你对代码结构的洞察和控制就越多(参见图 15.2)。另一方面,模块、JAR 文件和(通常是)构建工具项目之间存在一对一的关系,因此模块声明的数量越多,也意味着维护工作量的增加和构建时间的延长。这显然是一个权衡。

图片

图 15.2 这些包之间的关系可以说是有些混乱。只有两个模块(顶部)时,这一点并不明显。只有在尝试创建更多模块(底部)时,问题才会变得明显。额外的模块边界提供了这个洞见。

尽管如此,作为一个一般性的规则,还是倾向于选择较小的模块而不是较大的模块。一旦一个模块的代码行数达到五位数字,你可能想要考虑将其拆分;当模块的代码行数达到六位数字时,我建议你认真考虑这一点。如果达到七位数字,你很可能会面临一些重大的重构工作。(如果你在解决类之间的循环依赖关系方面遇到困难,可以查看第 10.2.5 节,那里介绍了使用服务来解决这个问题。)

重要信息 就这样说了,不要相信那些没有查看你的项目就告诉你模块正确大小的人。对于“模块应该有多小或多大?”这个问题,唯一的有效答案是,“这取决于。”每个模块都应该是针对特定问题的完整解决方案。如果这个问题恰好有一个大的解决方案,那也没关系——不要开始拆分本应属于一起的东西。

什么属于一起?当你将一个完整的模块拆分成两部分时,你很可能会在两部分之间得到一个非常大的 API 表面——这把我们带到了下一个需要讨论的方面。

保持 API 表面小

重要信息 模块的优势在于它们可以将其内部内容保留给自己。这允许在模块内部进行更轻松的重构,并更谨慎地演进其公共 API。考虑到这些好处,通常更倾向于使用较少的普通exports指令。对于有资格的导出也是同样的道理——越少越好。

普通导出和有资格导出如何比较?在一个项目中,两者之间并没有太大的区别。当涉及到将两个模块纠缠在一起时,导出是否有资格实际上并不重要。话虽如此,一个资格至少表明一个 API 可能不是为通用用途设计的,这是一个有用的信息,尤其是在较大的项目中。

与应用程序不同,库和框架总是需要考虑它们的导出如何影响依赖于它们的工程。在这种情况下,同一项目内其他模块的合格导出与没有导出是一样的,这绝对是一个胜利。总的来说,合格导出仍然对 API 表面有贡献:几乎与项目内部的常规导出一样多,但在项目边界之外则少得多。

尽量减少耦合

随机选择两段代码——无论是方法、类还是模块都无关紧要。在其他条件相同的情况下,依赖项更少的代码更容易维护。原因很简单:依赖项越多,越有可能出现破坏它的变化。

虽然这超出了普通依赖的范围:它更普遍地是一个耦合问题。如果一个模块不仅依赖于另一个,而且积极地使用它导出的所有十二个包,那么这两个模块的耦合就更加紧密。如果合格导出是混合的一部分,这更是如此,因为它们本质上是在说,“这不是一个支持良好的 API,但我仍然会让你使用它。”

重要信息 这不仅限于单个模块。要理解一个系统,你不仅需要理解其部分(在这里是模块),还需要理解它们之间的连接(在这里是依赖和耦合)。如果不小心,系统可能比部分有更多的连接(大约是模块数量的平方;参见图 15.3)。因此,松散耦合的部分是保持系统尽可能简单的一个关键因素。

图片

图 15.3 即使两个图具有相同数量的节点,它们的复杂度差异很大。左边的图大约有与节点数量相等的边,而右边的图大约每对节点有一条边。如果添加一个新节点,左边的图将增加一个或可能两个新边,而右边的图将增加大约六个。

一种解耦模块的好方法是使用服务,如第十章所述。它们不仅打破了模块之间的直接依赖,而且要求你有一个单一的类型,通过它可以访问整个 API。如果你不把这个类型变成连接到数十个其他类型的 kraken,这将大大减少模块之间的耦合。

重要信息 一个警告:服务很整洁,但它们比普通依赖更难以预测。你无法轻易地看到两段代码是如何连接的,当提供者缺失时,你也不会收到错误。所以不要过度使用服务。

这应该是一个试金石:你能创建一个具有合理小 API 的服务类型吗?它看起来可能被每个模块的多个模块使用或提供吗?

如果你不确定,可以查看一下 JDK。官方文档列出了模块使用或提供的服务,你可以使用你的 IDE 查看用户和实现代码。

倾听你的模块声明

基本信息 我们刚刚讨论过,模块应该是小的,应该有更小的 API 表面,并且应该与周围环境松散耦合。最终,这些建议可以归结为一个看似简单的公式:保持内聚度高,耦合度低。关注模块大小、exports数量、requires数量以及每个依赖项的强度可以帮助你做到这一点。

注意 就像任何一组目标数字一样,这些数字可以随意操作而不取得任何成果。比一些数字更重要的是一个经过深思熟虑的整体架构。尽管这本书为你提供了很多工具,甚至提供了一些实现该架构的技巧,但它并没有从基础开始教你。

还要注意,这三个信号(大小、表面和内聚)通常会相互对立。作为一个极端的例子,考虑一个只包含一个模块的应用程序。它很可能没有 API;并且只有一个工件,耦合度不高。在另一个极端,每个包都在自己的模块中的代码库充满了小型模块,这些模块具有小的 API 表面。当然,这些极端是荒谬的,但它们说明了问题:这是一个平衡行为。

基本信息 总结来说,这些信号只是,嗯,信号——你和你团队将始终需要根据它们提供的信息应用自己的良好判断。但你的模块声明可以帮助你做到这一点。如果它们变得复杂并且需要不断进行大量更改,它们正在试图告诉你一些事情。倾听它们。

15.2.3 注意模块声明

如果你正在构建一个模块化项目,模块声明可能是你代码库中最重要的一些.java文件。每个都代表一个完整的 JAR 文件,它可能包含数十、数百甚至可能成千上万的源文件。除了仅仅代表它们之外,模块声明还规定了模块如何与其他模块交互。

因此,你应该好好照顾你的模块声明!以下是一些需要注意的事项:

  • 保持声明整洁。

  • 注释声明。

  • 审查声明。

让我们逐一来看。

整洁的模块声明

模块声明是代码,应该像对待代码一样处理,所以请确保你的代码风格得到应用。一致的缩进、行长度、括号位置等等——这些规则对于声明来说和任何其他源文件一样有意义。

此外,我强烈建议你结构化你的模块声明,而不是将指令随机排序。JDK 以及本书中的所有声明都有以下顺序:

  1. requires,包括statictransitive

  2. exports

  3. exports to

  4. opens

  5. opens to

  6. uses

  7. provides

JDK 总是在块之间留一个空行以保持它们分开——我只在有几个指令以上时才这样做。

进一步来说,你可以定义如何在同一块中排序指令。字典序是一个明显的选择,尽管对于 requires,我首先列出内部依赖,然后是外部依赖。

注意 无论你如何决定,如果你有一个定义你的代码风格的文档,记录在那里。如果你有你的 IDE、构建工具或代码分析器为你检查这些事情,那就更好了。尽量让它跟上进度,以便它可以自动检查或应用你选择的风格。

注释模块声明

关于代码文档的意见,如 Javadoc 或内联注释,差异很大,这不是我提出为什么它很重要的地方。但无论你团队对注释的看法如何,都要将其扩展到模块声明中。

如果你喜欢每个抽象都有一个句子或一小段解释其意义和重要性的想法,考虑为每个模块添加 Javadoc 注释:

/** * 将服务可用性数据点聚合到统计信息中。 */ module monitor.statistics { // ... }

JDK 在每个模块上都有一个这样的注释或更长的注释。

即使你不喜欢记录模块的功能,大多数人都会同意记录为什么做出特定决策是有价值的。在模块声明中,这可能意味着添加一个内联注释

  • 对于可选依赖,解释为什么模块可能不存在

  • 对于合格的导出,为了解释为什么它不是公共 API,但仍然使特定模块可以访问

  • 对于公开包,解释哪些框架计划访问它

在 JDK 中,你偶尔会在 jdk.naming.rmi 中找到这样的注释:

// 在 NamingManager.getURLContext 使用 services exports com.sun.jndi.url.rmi 到 java.naming 之前临时导出;

一般而言,我的建议是:每次你做出一个不是立即明显的决策时,添加一个注释。每次审阅者询问为什么做出某些更改时,添加一个注释。这样做可以帮助你的同事——或者两个月后的你自己。

必要信息 模块声明提供了一个新的机会。以前从未如此容易地在代码中正确地记录项目工件之间的关系。

审查模块声明

模块声明是您模块结构的中心表示,检查它们应该是任何类型代码审查的组成部分。无论是提交前的更改审查、打开拉取请求前的审查、结对编程会话后的总结,还是在正式代码审查期间——任何检查代码的时候,都要特别注意 module-info.java

  • 真的有必要添加额外的依赖吗?它们是否与项目的底层架构相一致?是否应该使用 requires transitive 来暴露它们,因为它们的类型被用在模块的 API 中?

  • 如果一个依赖项是可选的,代码是否准备好在运行时处理其缺失?是否存在连锁反应,比如缺失的传递依赖项,而可选依赖项暗示了可读性?

  • 一个新的依赖项能否被一个服务取代?

  • 添加的导出是否真的有必要?新导出的包中的所有公共类都准备好公开使用了吗,或者它们需要重新排列以减少 API 表面积?

  • 如果导出是合格的,这有意义吗,或者这只是为了获取一个从未打算公开的 API 而找的借口?

  • 使用的类型是否设计为应用程序基础设施的组成部分?

  • 是否有任何更改可能对构建过程之外的下游消费者产生负面影响?(有关更多信息,请参阅 15.2.4 节。)

  • 模块声明是否按照团队的要求进行了样式化和注释?

仔细审查尤其重要,因为 IDE 提供快速修复功能,允许开发者通过导出包或通过简单命令添加依赖项来远程编辑声明。我欣赏这些功能,但它们使粗心编辑的可能性更大;因此,确保没有东西被忽视就变得尤为重要。

注意 如果您有一个代码审查指南、提交检查清单或任何其他有助于保持代码质量的文档,您可能希望添加关于模块声明的一项。

在审查模块描述符上投入时间可能听起来像是一项额外的繁重工作。首先,我会争论这算不算多,尤其是与投入开发和其他代码库审查的努力相比。更重要的是,我不认为这是一项额外的工作——相反,我认为这是一个机会。

重要信息 从前从未如此容易分析和审查您项目的结构。而且不是几年前拍照上传到团队维基的白板草图;不,我指的是真正的交易,您工件之间的实际关系。模块声明向您展示了赤裸的现实,而不是过时的良好意图。

15.2.4 通过编辑模块声明破坏代码

与任何其他源文件一样,更改模块声明可能会对其他代码产生意外和可能破坏性的影响。然而,更重要的是,声明是您模块公共 API 的提炼,因此其影响远大于任何随机类。

如果您开发的应用程序以及您模块的所有消费者都是同一构建过程的一部分,那么破坏性更改就不会被忽视。即使是框架和库,这样的更改也可以通过彻底的集成测试来检测。

重要信息 仍然,了解哪些更改更有可能引起问题,哪些通常是良性的,是有帮助的。以下是一个按问题严重程度排序的列表:

  1. 新模块名称

  2. 导出包更少

  3. 提供的服务不同

  4. 编辑依赖项

正如你所看到的,所有这些更改都可能导致下游项目中出现编译错误或意外的运行时行为。因此,它们应该始终被视为破坏性更改,所以如果你使用语义版本控制,就需要进行主要版本号的提升。这并不意味着在模块声明中做出其他更改不会引起问题,但它们发生的可能性要小得多;所以,让我们专注于这四个。

新模块名称的影响

改变模块的名称将立即破坏所有依赖于它的模块——它们需要更新和重建。但这只是它可能引起的问题中最小的一个。

更危险的是,当某些项目在旧名称和新名称下两次 transitively 依赖于你的模块时,它可能创建的模块钻石死亡(见第 3.2.2 节)。该项目将很难将其新版本包含在其构建中,并且可能不得不因为名称更改而放弃更新。

注意这一点,并尽量减少重命名。你仍然可能偶尔需要这样做,在这种情况下,你可以尝试通过创建一个具有旧名称的聚合模块来减轻影响(第 11.1.5 节中解释)。

导出更少包的影响

应该很明显,“取消导出”包会导致问题的原因:任何使用这些包中的类型的模块在编译时和运行时都无法访问它们。如果你想走这条路,你应该首先废弃那些包和类型,给用户时间从它们迁移开,在它们被移除之前。

这只完全适用于普通的 exports 指令:

  • 合格的导出通常只导出到你控制的模块,这些模块很可能是你的构建的一部分,因此会同时更新。

  • 开放式包通常针对特定的框架或代码片段,旨在反映它们。这段代码很少是用户模块的一部分,所以关闭包不会影响他们。

通常来说,我不会认为删除合格的导出或打开的包是破坏性更改。尽管如此,具体场景可能违反这一经验法则,所以要注意它们,并在做出此类更改时仔细思考。

添加和删除服务的影响

对于服务来说,情况要复杂一些。如第 10.3.1 节所述,服务消费者应该始终准备好处理服务提供者不存在的情况;同样,当突然返回额外的提供者时,它们也不应该崩溃。但这只真正涵盖了应用程序不应该因为服务加载器返回了错误数量的提供者而崩溃。

仍然有可能,甚至可能很可能会出现应用程序行为不当,因为服务在一个版本中存在,而在另一个版本中不存在。而且因为服务绑定发生在所有模块之间,这甚至可能影响到不直接依赖于你的代码。

编辑依赖项的影响

列表中的最后一点,所有形式的依赖项,也是一个灰色地带。让我们从requires transitive开始。第 11.1.4 节解释说,如果用户在您的模块的直接附近使用依赖项,他们应该只依赖您让他们阅读的依赖项。假设您停止公开依赖项的类型,并且您的用户更新了他们的代码,从exports指令中移除transitive不应该对他们产生影响。

另一方面,他们可能不知道或忽视这个建议,因此防止他们阅读那个依赖项仍然需要他们更新和重新构建他们的代码。因此,我仍然认为这是一个破坏性的变更。

还有可能出现这样的场景,即使移除或添加其他依赖项也可能导致问题,尽管从您的模块外部观察不到这些问题:

  • 添加或移除普通的requires指令会改变可选依赖项解析和服务绑定。

  • 将依赖项设置为可选(或相反)也可能改变哪些模块进入模块图。

因此,尽管requiresrequires static可以改变模块图并因此影响与您完全不相关的模块,但这不是一个常见的情况。默认情况下,我不会认为这样的更改是破坏性的。

注意:尽管所有这些都可能听起来很糟糕且复杂,但它并不比您编辑属于公共 API 的类更复杂。您只是还没有对模块声明更改如何影响其他项目的直觉。这将在一段时间后到来。

15.3 技术景观

在我在第 1.4 节首次介绍模块系统后,我认为您可能对它如何与生态系统中的其他部分相关有一些疑问。如您所回忆的,它们是这样的:

  • Maven、Gradle 和其他工具不是已经管理依赖项了吗?

  • 那么,关于 OSGi 呢?为什么不直接使用它呢?

  • 在每个人都编写微服务的时候,模块系统不是过度了吗?

我将在一分钟内回答这些问题,但首先我想向您介绍 Java 9+提供的模块系统之外的优点。毕竟,这些好处是一个套餐,如果您对其中一个持怀疑态度,也许其他的好处可以影响您。

15.3.1 MAVEN、GRADLE 和其他构建工具

Java 生态系统幸运地拥有几个强大、经过实战考验的构建工具,如 Maven 和 Gradle。当然,它们并不完美,但它们已经构建 Java 项目超过 10 年了,所以它们显然有一些优势。

正如其名所示,构建工具的主要任务是构建项目,这包括编译、测试、打包和分发。尽管模块系统涉及许多这些步骤并要求对工具进行一些更改,但它并没有为平台添加任何使其在这个领域与它们竞争的功能。因此,当涉及到构建项目时,Java 平台与其构建工具之间的关系仍然保持不变。

Java 9+上的构建工具

我不能代表所有构建工具,但 Maven 和 Gradle 已经更新以正确地与 Java 9+ 和模块系统协同工作。这些更改主要是内部的,创建模块 JAR 而不是普通 JAR 只需要在您的源文件夹中添加一个 module-info.java 文件。它们从那里开始,大部分只是做正确的事情。

关于您选择的构建工具如何与模块系统或其他新 Java 功能(如多版本 JAR——见附录 E)交互的详细信息,请查看其文档。我想明确提到的是,在迁移到 Java 9+ 时,你可能需要添加一些命令行选项,所以你可能想复习一下如何做。

如果你想了解更多关于 Gradle 的信息,可以查看 Manning 的非常实用的《Gradle in Action》(Benjamin Muschko,2014,www.manning.com/books/gradle-in-action)。不幸的是,我没有推荐任何一本我至少翻阅过的 Maven 书籍。

依赖项管理

构建系统通常执行另一个任务,现在 Java 9+ 也执行这个任务:依赖项管理。正如第 3.2 节所讨论的,可靠的配置旨在确保依赖项存在且无歧义,从而使应用程序更加稳定——Maven 或 Gradle 会为您做同样的事情。这意味着模块系统取代了构建工具吗?或者,这些功能来得太晚了,变得无用?表面上,模块系统似乎重复了构建工具的功能;但当你仔细观察时,你会发现重叠很小。

首先,模块系统没有方法来唯一标识或定位工件。最值得注意的是,它没有版本的概念,这意味着给定几个相同工件的几个不同版本,它无法选择正确的版本。这种情况将导致错误,正是因为它是模糊的。

尽管许多项目会选择一个可能独特的模块名称(比如反转与项目关联的域名),但没有像 Maven Central 这样的实例来确保这一点,这使得模块名称不足以唯一地标识一个依赖项。说到像 Maven Central 这样的远程仓库,模块系统没有连接到它们的任何能力。因此,尽管模块系统和构建工具都管理依赖项,但前者在过于抽象的层面上执行,无法取代后者。

构建系统确实存在一个相当大的缺点:它们确保在编译期间存在依赖项,甚至可以将它们送到你的门口,但它们并不管理应用程序的启动。如果工具不知道间接需要的依赖项(由于使用了 Maven 的 provided 或 Gradle 的 compileOnly),或者库在构建到启动的过程中丢失,你只能在运行时发现,很可能是通过应用程序崩溃。另一方面,模块系统不仅管理编译时的直接和传递依赖项,还管理运行时的依赖项,确保所有阶段的可靠配置。它还更有能力检测诸如重复的工件或包含相同类型的工件之类的歧义。因此,即使你聚焦于依赖项管理,这两种技术也是不同的;唯一的交集是它们都以某种形式列出依赖项。

封装、服务和链接

离开依赖项管理,我们很快就会找到构建工具无法竞争的模块系统功能。最值得注意的是强大的封装(见第 3.3 节),它使库能够在编译时和运行时隐藏实现细节,这是 Maven 或 Gradle 甚至无法梦想承诺的。这种严格性需要一段时间才能习惯,但从长远来看,JDK、框架、库甚至大型应用程序都将从明确区分受支持和内部 API 以及确保后者不会意外依赖中受益。在我看来,强大的封装本身就值得迁移到模块系统。

在查看更高级的功能时,有两个特别有趣的功能脱颖而出,它们超出了构建工具的范围。首先,模块系统可以作为服务定位模式中的服务注册表运行,允许你解耦工件,并实现易于使用插件的程序(见第十章)。其次是能够将所需的模块链接到一个自包含的运行时映像中,这为你提供了使部署更精简和更容易的机会(见第十四章)。

总结来说,除了在依赖项管理方面有微小重叠外,构建工具和模块系统并不竞争,而应该被视为互补。图 15.4 展示了这种关系。

图片

图 15.4 建筑工具(左)和模块系统(右)具有非常不同的功能集。唯一的相似之处是它们都记录依赖项(构建工具通过全局唯一标识符加上版本;JPMS 只通过模块名称)并且可以验证它们以进行编译。它们对依赖项的处理非常不同,除此之外,它们几乎没有任何共同之处。

15.3.2 OSGI

开放服务网关倡议(OSGi)是既指一个组织(OSGi 联盟)又指它创建的规范。它也被不精确地应用于该规范的各个实现,这就是我在本节中使用的用法。

OSGi 是建立在 Java 虚拟机之上的模块系统和平台,它与 JPMS 共享部分功能集。如果你对 OSGi 知之甚少或者一直在使用它,你可能想知道它与 Java 的新模块系统如何比较,也许还会想知道它是否被取代了。但你可能也会想知道为什么后者甚至被开发出来——Java 为什么不能直接使用 OSGi?

注意:如果你只是听说 OSGi 而不是真正了解它,本节可能有点难懂——这不是问题,因为这不是必读内容。如果你仍然想跟上来,首先想象一下 OSGi 类似于模块系统。本节的其余部分将阐明一些重要差异。

我不是 OSGi 专家,但在我的研究过程中,我翻阅了《OSGi 深入解析》(Alexandre de Castro Alves,2011,www.manning.com/books/osgi-in-depth),并且很喜欢它。如果你需要比 Java 平台模块系统提供的内容更多,可以考虑转向它。

为什么 JDK 不使用 OSGi?

为什么 JDK 不使用 OSGi?对这个问题的技术答案归结于 OSGi 实现其功能集的方式。它严重依赖于类加载器,这在 1.2 和 1.3.4 节中进行了简要讨论,而 OSGi 为其创建了自身的实现。它为每个包(在 OSGi 中模块被称为包)使用一个类加载器,并且以这种方式控制,例如,一个包可以看到哪些类(以实现封装)或者当包被卸载时会发生什么(OSGi 允许的——稍后会有更多介绍)。

可能看似技术细节的事情有着深远的影响。在 JPMS 之前,Java 对类加载器的使用没有限制,并且通过反射 API 按名称访问类是常见的做法。

如果 JPMS 需要特定的类加载器架构,Java 9+ 将会极大地破坏 JDK、许多现有的库和框架以及关键的应用代码。Java 9+ 仍然存在迁移挑战,但如果不兼容地更改类加载器 API 将会更具破坏性,并且不仅会取代这些挑战,还会在它们之上。因此,JPMS 在类加载器之下运行,如图 15.5 所示 figure 15.5。

图片

图 15.5 OSGi(左侧)建立在 JVM 之上,这迫使其使用现有功能,主要是类加载基础设施,以实现其功能集。另一方面,模块系统(右侧)是在 JVM 内部实现的,并且运行在类加载之下,保持其上构建的系统按原样工作。

使用类加载器进行模块隔离的另一个后果是,尽管 OSGi 使用它们来减少类的可见性,但它们不能减少可访问性。我这是什么意思呢?比如说,一个包含来自未导出包的Feature类型的库包。那么 OSGi 确保另一个包中的代码不能“看到”Feature,也就是说,例如,Class.forName("org.lib.Feature")将抛出ClassNotFoundException。(Feature是不可见的。)

但现在假设 lib 有一个返回Feature类型的Object的 API,在这种情况下,app 可以获取该类的实例。然后 app 可以调用featureObject.getClass().newInstance()并创建一个新的Feature实例。(Feature是可访问的。)

如第 3.3 节所述,JPMS 想要确保强封装,而 OSGi 提供的并不足够强大。如果你创建了一个类似早先的情况,有两个 JPMS 模块 app 和 lib 以及 lib 包含但未导出的类型Feature,app 可以成功通过Class.forName("org.lib.Feature")获取类实例(它是可见的),但不能调用其上的newInstance()(它不可访问)。表 15.1 并列了 OSGi 和 JPMS 的差异。

表 15.1 OSGI 的可见性和 JPMS 的可访问性限制

OSGi JPMS
限制可见性(Class::forName失败)
限制可访问性(Class::newInstance失败)

JPMS 能否取代 OSGi?

JPMS 能否取代 OSGi?不能。

JPMS 主要是为了模块化 JDK 而开发的。它涵盖了所有模块化的基本要素——其中一些,比如封装,可能比 OSGi 做得更好——但 OSGi 有很多 JPMS 不需要且没有的特性。

以几个为例,在 OSGi 中,由于其类加载器策略,你可以在几个包中拥有相同的完全限定类型。这也使得同时运行同一包的不同版本成为可能。在这方面,OSGi 的导出和导入可以是版本化的,允许包表达它们的版本以及它们的依赖应该是什么版本。如果需要同一包的两个不同版本,OSGi 可以使这成为可能。

另一个有趣的不同之处在于,在 OSGi 中,一个包通常表达对包的依赖,而不是对包的依赖。尽管两者都是可能的,但前者是默认的。这使得依赖性在替换或重构包时更加稳健,因为包的来源并不重要。(另一方面,在 JPMS 中,一个包必须位于所需的模块之一,所以将包移动到另一个模块或用具有相同 API 的另一个模块交换将导致问题。)

OSGi 的一大特性集围绕着动态行为,其作为物联网服务网关的根源明显,通过类加载器实现的功能也非常强大。OSGi 允许包在运行时出现、消失,甚至更新,提供了一个 API,让依赖项相应地做出反应。这对于跨多台设备运行的应用程序来说非常好,也可以为希望将停机时间减少到最低限度的单服务器系统提供便利。

核心问题是,如果你的项目已经使用 OSGi,那么你很可能依赖于 JPMS 没有的特性。在这种情况下,没有必要切换到 Java 的本地模块系统。

OSGi 取代了 JPMS 吗?

OSGi 取代了 JPMS 吗?不。

尽管我刚才提出的听起来 OSGi 在所有用例上都优于 JPMS,但 OSGi 从未得到广泛采用。它已经占据了一个细分市场,并在其中取得了成功,但它从未成为默认技术(与 IDE、构建工具和日志记录等相比,仅举几个例子)。

那种缺乏广泛采用的主要原因在于复杂性。无论是感知到的还是真实的,无论是模块化固有的还是 OSGi 偶然的,这都次要于大多数开发者将 OSGi 的复杂性视为不默认使用它的理由这一事实。

JPMS 处于不同的位置。首先,其减少的特性集(尤其是没有版本支持,以及依赖于模块而不是包)使其更简单。此外,它得益于内置在 JDK 中。所有 Java 开发者都程度不同地接触到了 JPMS,尤其是更资深的一些开发者会探索它如何帮助他们的项目。这种更频繁的使用也将促进良好的工具集成。

因此,如果一个团队已经拥有技能和工具,并且已经在 JPMS 上运行,为什么不彻底模块化整个应用程序呢?这一步骤建立在现有知识的基础上,增加了更少的复杂性,不需要新的工具,同时带来很多好处。

最后,即使是 OSGi 也能从 JPMS 中获益,因为 Java 9+将模块化提升到与 Java 8 将函数式编程提升到相同的地位。这两个版本都在向主流 Java 开发者展示新思想,并教会他们一套全新的技能。在某个时刻,当项目有望从函数式编程或更强大的模块化中获益时,其开发者将足够爬上学习曲线,以评估并可能使用“真正的”技术。

JPMS 和 OSGi 兼容吗?

JPMS 和 OSGi 兼容吗?在某种程度上,是的。使用 OSGi 开发的应用程序可以在 Java 9+上运行,就像在早期版本上一样。(更准确地说,它们将在未命名的模块中运行,第 8.2 节详细解释了这一点。)OSGi 不需要进行迁移工作,但应用程序代码面临与其他代码库相同的挑战。

在另一个意义上,结论尚未明朗。OSGi 是否会允许我们将包映射到 JPMS 模块,这仍然是一个悬而未决的问题。目前,OSGi 不使用 JPMS 的任何功能,继续自行实现其功能。是否将 OSGi 适应 JPMS 值得相当大的工程成本,这也不清楚。

15.3.3 微服务

模块化系统与微服务架构之间的关系有两个非常不同的方面:

  • 微服务与模块化系统是否竞争?它们是如何比较的?

  • 如果你选择微服务,模块化系统会令你担忧吗?

我们将在本节中探讨这两个方面。

如果你不太熟悉微服务架构,你可以安全地跳过这一节。如果你想了解更多,市面上有很多优秀的微服务书籍。为了支持我的观点,我浏览了 Manning 的《Microservices in Action》一书,并推荐它(Morgan Bruce 和 Paulo A. Pereira,2018 年,www.manning.com/books/microservices-in-action)。

微服务与 JPMS 的比较

通常来说,可以说模块化系统的优势在更大的项目中影响更大。所以当大家都在谈论微服务时,大型应用程序的模块化系统不就像是猪身上的口红吗?答案取决于最终有多少项目会被构建成微服务结构,这当然是一个巨大的讨论话题。

有些人认为微服务是未来,迟早所有项目都会以这种方式开始——全是微服务!如果你属于这个阵营,你仍然可以在 Java 9+中实现你的服务,模块化系统会对你产生影响,但当然,比它对单体项目的影响小得多。我们将在下一节中讨论这一点。

另一些人持更加谨慎的观点。像所有架构风格一样,微服务既有优点也有缺点,必须根据项目需求在这两者之间做出权衡。微服务在需要承受高负载的相对复杂的项目中特别闪耀,其扩展能力几乎无与伦比。

虽然这种可扩展性是以操作复杂性为代价的,因为运行众多服务需要比在负载均衡器后面放置相同服务的一小批实例更多的知识和基础设施。另一个缺点是,如果团队对领域的了解越少,服务边界错误的可能性就越大,在微服务中修复这种错误比在单体中更昂贵。

关键的观察结果是,对于复杂性(马丁·福勒称之为微服务溢价)的代价必须始终付出,但只有在项目足够大时才能获得收益。这个因素已经说服了许多开发人员和架构师,大多数项目应该从单体开始,并逐渐转向拆分服务,也许最终在需要时结束在微服务中。

例如,马丁·福勒(Martin Fowler)引用了他同事的以下观点(在martinfowler.com/bliki/MonolithFirst.html;强调是我的):

你不应该一开始就使用微服务来启动一个新项目,即使你确信你的应用程序足够大,足以使其变得值得。 [...] 合理的做法是仔细设计单体架构,注意软件内部的模块化,包括 API 边界和数据存储方式。做好这一点,转向微服务就相对简单了。

到现在为止,强调的短语应该已经熟悉了:仔细设计、模块化、边界——这些都是模块系统所促进的特性(参见第 1.5 节)。在微服务架构中,服务依赖关系应该是清晰的(提示可靠配置)并且理想情况下是解耦的(服务加载器 API);此外,所有请求都必须通过公共 API(强封装)。如果需要,仔细使用模块系统可以为成功迁移到微服务奠定基础。图 15.6 显示了这种仔细设计的重要性。

图 15.6 假设有两个将单体应用程序迁移到微服务的假设情况,你更愿意从一个大块的泥地(左侧)开始,还是从一个正确模块化的代码库(右侧)开始?

尽管模块系统侧重于大型项目,但即使是小型服务也能从采用模块中受益。

使用 JPMS 的微服务

如果你的项目采用了微服务,并且你正在使用 Java 9+实现其中的一些服务,因为你希望从改进的安全性和性能中受益,你必然会与模块系统交互,因为它是运行你的代码的 JVM 的一部分。一个后果是,第六章和第七章中讨论的潜在破坏性更改仍然适用于相关服务,需要修复。此外,随着时间的推移,你的大部分依赖项很可能会转换为模块,但正如第 8.1.3 节所描述的,这并不强迫你将你的工件打包为模块。

如果你决定将所有 JAR 文件放在类路径上,它们之间不会强制执行强封装。因此,在这个 JAR 文件集中,对内部 API 的访问以及反射(例如从框架到你的代码),例如,将继续工作。在这种情况下,你对模块系统的接触仅限于它对 JDK 的影响。

你可以采取的另一条路线是使用你的服务和依赖项作为模块,这时你将完全集成到模块系统中。其各种好处中,最相关的一个可能是第 1.5.5 节中简要描述并在第十四章中彻底探讨的可扩展平台,它允许你使用jlink

使用jlink,你可以创建一个包含恰好支持你的应用程序(包括你的模块)的平台模块的小型运行时映像,这可以减少映像大小高达 80%。此外,当链接所需的模块时,jlink可以利用它看到整个应用程序的知识来分析字节码,从而应用更激进的优化,导致映像尺寸更小,性能略有提升。你还可以获得其他好处:例如,确保你只使用依赖项的公共 API。

15.4 关于模块化生态系统的思考

Java 9+是一个巨大的版本。尽管缺乏新的语言特性,但它包含了大量强大的改进和新增功能。但所有这些改进都被 Java 平台模块系统所掩盖。它既是 Java 9+最受期待的功能,也是最具争议的功能,这不仅仅是因为它带来的迁移挑战。

尽管在迈向模块化未来的道路上有时会遇到一些波折,但知名库和框架迅速支持了 Java 9+,并且自那时起,这一趋势并没有放缓的迹象。那么,对于较老、支持度较低的项目呢?尽管有些人可能会找到新的维护者,即使只是为了让他们在当前的 Java 版本上工作,但 Java 项目的长尾可能会逐渐减少。

这肯定会令一些依赖此类项目的开发者感到不满。这是可以理解的——没有人喜欢在没有明显好处的情况下修改正在工作的代码。同时,一些既得利益者的流失将给其他项目带来机会,让他们吸引他们的用户。而且谁知道呢?他们最终可能会从转换中获得好处。

一旦 Java 9+的升级大潮过去,项目开始将基准提升到 Java 9+,你将开始看到越来越多的模块化 JAR 文件公开可用。多亏了模块化系统对增量化和去中心化模块化的支持,这个过程在项目之间需要相对较少的协调。这也给你提供了一个机会,让你现在就可以开始模块化你的项目。

目的是什么?与 Java 8 中的 lambda 表达式和流或 Java 10 中的局部变量类型推断等更吸引眼球的特性相比,模块化系统对你的代码库的影响将是微妙的。你不会仅仅通过查看几行代码就对其美感感到满意。你也不会突然发现自己在编码时更有乐趣。

不,模块化系统的优势在光谱的另一端。由于可靠的配置,你将能够更早地捕捉到错误。由于对项目架构有更深入的了解,你将避免误操作。你不会那么容易地使代码变得复杂,也不会意外地依赖于依赖项的内部实现。

JPMS 将会改善软件开发中那些情绪化的部分。模块化系统并非万能良药:你仍然需要付出辛勤的努力来正确设计和安排你的工件;但有了模块化系统在手,这个过程将会有更少的陷阱和更多的捷径。

随着生态系统中的工件越来越多地采用模块化,这种效果将会越来越明显,直到有一天我们会问自己,没有模块化系统我们是如何编码的?在 JVM 将我们精心设计的依赖图变成一团糟的那个时代,那会是什么样子?

回想起来会感觉有些奇怪。就像写一个没有 private 的 Java 类一样奇怪。你能想象那会是什么样子吗?

摘要

  • 仔细设计你的模块化系统。

  • 微服务与 JPMS 相互补充。

  • OSGi 和 JPMS 也相互补充。

现在——非常感谢您阅读这本书。为您写作是一种乐趣。我相信我们还会再见面的!

附录 A

类路径概述

一本讨论模块系统的书当然会关注模块路径(见第 3.4 节)。但类路径仍然完全有效;并且由于你可以与模块路径并行使用它,它在增量模块化期间发挥着重要作用。换句话说,了解它是如何工作的仍然是有价值的。

使用类路径加载应用程序 JAR 文件

定义:类路径

类路径是与编译器和虚拟机相关的一个概念。它们用于相同的目的:在列出的 JAR 文件中搜索它们所需的类型,但这些类型不在 JDK 中。(它也可以与类文件一起使用,但为了了解模块系统的目的,你可以忽略这种情况。)

让我们以这本书的示例 ServiceMonitor 应用程序为例。它由多个子项目组成,并有一些依赖项。在这种情况下,除了最后一个项目 monitor 之外的所有子项目都已经构建并存在于jars目录中。

下面的列表显示了如何使用类路径编译、打包和启动应用程序。除了某些命令行选项的新变体(例如,使用--classpath而不是-classpath)之外,这些命令与 Java 9 之前完全相同。

列表 A.1 使用类路径编译、打包和启动

javac --classpath "jars/*" ①``-d monitor/target/classes ②``${java-files} jar --create --file jars/monitor.jar ④``-C monitor/target/classes . java --classpath "jars/*" ①``monitor.Main

包含依赖项的 JAR 文件文件夹

编译后的类文件夹

列出或找到所有源文件,在这种情况下是monitor/src/main/java/monitor/Main.javamonitor/src/main/java/monitor/Monitor.java

命名新的 JAR 文件;将其放入 jars

包含应用程序主方法的类

编译器和运行时都会在类路径中搜索它们需要的类型。虽然这些类型可能不同:

  • 编译器——编译器需要编译的代码所引用的类型。这些是一个项目的直接依赖项,或者更精确地说,是直接依赖项中从编译的文件中引用的类型。

  • 虚拟机——JVM 需要所有执行的字节码所引用的类型。通常,这些是一个项目的直接和间接依赖项;但由于 Java 对类加载的懒惰处理,它可能比这少得多。只有实际运行的代码中引用的类型是必需的,这意味着如果使用它的代码没有执行,则可能缺少依赖项。JVM 还允许代码在 JAR 文件中搜索资源。

javacjava 都有命令行选项 -classpath-cp,以及自 Java 9 以来新增的 --classpath。它们通常期望一个文件列表,但可以使用路径和通配符,然后这些会被扩展成相应的列表。

自 Java 9 以来,类路径

关于 Java 9(及以后版本)的必要信息,重要的是要强调类路径不会消失!它的工作方式与早期 Java 版本完全相同,如果在此版本上编译的应用程序没有进行任何有问题的操作(参见第六章和第七章),它们将继续使用相同的命令在 Java 9 及以后的版本上编译。

考虑到向后兼容性,问题仍然存在:模块系统如何处理类路径上的类型。简而言之,它们最终都会进入未命名的模块,模块系统会即时创建这个模块。这是一个常规模块,但它有一些特性,其中之一是它会自动读取所有已解析的模块。这也适用于最终进入类路径的模块——它们将被视为普通的 JAR 文件,并且它们的类型也会进入未命名的模块,忽略模块声明中提到的任何内容。未命名的模块和类路径上的模块是迁移故事的一部分,第 8.2 节详细讲述了这一点。

附录 B

反射 API 的高级介绍

反射允许代码在运行时检查类型、方法、字段、注解等,并将如何使用它们的决策从编译时推迟到运行时。为此,Java 的反射 API 提供了ClassFieldConstructorMethodAnnotation等类型。有了它们,就可以与编译时未知类型进行交互:例如,创建未知类的实例并在其上调用方法。

反射及其用例可能会迅速变得复杂,我不会对其进行详细解释。相反,本附录旨在让你对反射是什么、在 Java 中看起来如何以及你可以或你的依赖项用它做什么有一个高级的理解。

之后,你将准备好开始使用它或学习更长的教程,例如 Oracle 的《反射 API 教程》docs.oracle.com/javase/tutorial/reflect。更重要的是,你将准备好理解模块系统对反射所做的更改,第 7.1.4 节和第十二章特别探讨了这一点。

而不是从头开始构建,让我们从一个简单的例子开始。下面的代码片段创建了一个 URL,将其转换为字符串,然后打印出来。在求助于反射之前,我使用了普通的 Java 代码:

URL url = new URL("http://codefx.org"); String urlString = url.toExternalForm(); System.out.println(urlString);

我在编译时(即,当我编写代码时)决定我想创建一个URL对象并在其中调用一个方法。尽管这不是最自然的方法,但你可以将前两行分成五个步骤:

  1. 引用 URL 类。

  2. 定位接受单个字符串参数的构造函数。

  3. http://codefx.org调用它。

  4. 定位toExternalForm方法。

  5. url实例上调用它。

下面的列表展示了如何使用 Java 的反射 API 实现这五个步骤。

列表 B.1 使用反射创建URL并在其上调用toExternalForm

Class<?> urlClass = Class.forName("java.net.URL"); Constructor<?> urlConstructor = urlClass.getConstructor(String.class); Object url = urlConstructor.newInstance("http://codefx.org"); Method toExternalFormMethod = urlClass.getMethod("toExternalForm"); Object methodCallResult = toExternalFormMethod.invoke(url);

要操作类的 Class 实例是反射的入口。

获取接受 String 参数的构造函数

使用它以给定的字符串作为参数创建一个新的实例

获取toExternalForm方法

调用之前创建的实例中的方法

使用反射 API 比直接编写代码要繁琐。但这种方式,以前通常嵌入到代码中的细节(如使用 URL 或调用哪个方法)变成了字符串参数。因此,你不必在编译时决定使用 URLtoExternalForm,而可以在程序运行时决定选择哪种类型和方法。

这种用法的大多数情况都发生在“框架化”环境中。以 JUnit 为例,它希望执行所有被 @Test 注解的方法。一旦找到它们,它就使用 getMethodinvoke 来调用它们。Spring 和其他 Web 框架在查找控制器和请求映射时也以类似的方式操作。希望在运行时加载用户提供的插件的可扩展应用程序是另一个用例。

基本类型和方法

反射 API 的入口是 Class::forName。在其简单形式中,这个静态方法接受一个完全限定的类名,并返回一个对应的 Class 实例。你可以使用这个实例来获取字段、方法、构造函数等。

要获取特定的构造函数,使用构造函数参数的类型调用 getConstructor 方法,就像我之前做的那样。同样,可以通过调用 getMethod 并传递其名称以及参数类型来访问特定的方法。

调用 getMethod("toExternalForm") 没有指定任何类型,因为该方法没有参数。这是 URL.openConnection(Proxy),它接受一个 Proxy 参数:

Class<?> urlClass = Class.forName("java.net.URL"); Method openConnectionMethod = urlClass.getMethod("openConnection", Proxy.class);

getConstructorgetMethod 调用返回的实例分别是 ConstructorMethod 类型。要调用底层成员,它们提供了如 Constructor::newInstanceMethod::invoke 这样的方法。后者一个有趣的细节是,你需要将方法要调用的实例作为第一个参数传递。其他参数将被传递给被调用的方法。

继续使用 openConnection 示例:

openConnectionMethod.invoke(url, someProxy);

如果你想调用一个静态方法,实例参数将被忽略,可以是 null

除了 ClassConstructorMethod 之外,还有一个 Field,它允许对实例字段进行读写访问。使用实例调用 get 方法可以检索该字段在实例中的值——set 方法在指定的实例中设置指定的值。

URL 类有一个实例字段 protocol,其类型为 String;对于 URL codefx.org,它将包含 "http"。因为它私有,所以像这样的代码无法编译:

URL url = new URL("http://codefx.org"); // 无法访问私有字段 ~> 编译错误 url.protocol = "https";

这是使用反射来完成相同任务的方法:

// `Class<?> urlClass` 和 `Object url` 与之前相同 Field protocolField = urlClass.getDeclaredField("protocol"); Object oldProtocol = protocolField.get(url); protocolField.set(url, "https");

虽然这可以编译,但它仍然会在 get 调用中导致 IllegalAccessException,因为 protocol 字段是私有的。但这并不意味着你不能继续。

使用 setAccessible 突破 API

反射的一个重要用例一直是通过访问非公共类型、方法和字段来突破 API。这被称为深度反射。开发者使用它来访问 API 不提供访问权限的数据,通过调整内部状态来解决依赖项中的错误,以及动态填充实例以正确的值——例如,Hibernate 就这样做。

对于深度反射,你需要在使用之前对 MethodConstructorField 实例调用 setAccessible(true)

// `Class<?> urlClass` 和 `Object url` 与之前相同 Field protocolField = urlClass.getDeclaredField("protocol"); protocolField.setAccessible(true); Object oldProtocol = field.get(url); protocolField.set(instance, "https");

当迁移到模块系统时,一个挑战是它剥夺了反射的超级能力,这意味着对 setAccessible 的调用更有可能失败。关于这一点以及如何补救,请参阅第十二章。

注解标记代码以供反射

注解是反射的重要组成部分。实际上,注解是为反射设计的。它们的目的是提供在运行时可以访问并在之后用于塑造程序行为的元信息。JUnit 的 @Test 和 Spring 的 @Controller 以及 @RequestMapping 是主要的例子。

所有重要的反射相关类型,如 ClassFieldConstructorMethodParameter 都实现了 AnnotatedElement 接口。它的 Javadoc 包含了关于注解如何与这些元素相关联的详细解释(直接存在、间接存在或关联),但它的最简单形式是这样的:getAnnotations 方法返回一个 Annotation 实例数组,该数组的成员可以被访问。

但在模块系统的背景下,你或你依赖的框架如何处理注解,这比它们仅通过反射来工作的基本事实要次要。这意味着任何带有注解的类在某个时刻都会被反射——如果这个类在模块中,这不一定能直接工作。

附录 C

使用统一日志记录观察 JVM

Java 9 引入了一种统一的日志架构。它通过单一机制将 JVM 生成的消息传递,并允许您通过复杂的命令行选项 -Xlog 选择显示哪些消息。

您可以使用它来观察 JVM 的行为,如果应用程序表现不佳则调试应用程序,或寻找可能的性能改进。正如您从自己的项目中了解的那样,日志记录有广泛而模糊的应用领域,因此我不会用一个单一用例来解释它,而是将整体机制作为研究对象。

使用 -Xlog 可能一开始会有些令人畏惧,但我们将一步一步地检查它,探索选项的每个方面。在这里,我们将查看机制的一般情况——第 5.3.6 节展示了如何使用它来调试模块化应用程序。

注意:此机制在 JVM 内部是通用的,并且除了监控模块系统之外还有许多其他应用。类加载、垃圾回收、与操作系统的交互、线程——您可以通过使用正确的标志来分析所有这些以及更多内容。请注意,这既不包括 JDK 消息,例如 Swing 记录的消息,也不包括您应用程序的消息——这完全是关于 JVM 本身。

什么是统一日志?

JVM 内部的统一日志基础设施类似于您可能用于应用程序的其他日志框架,如 Java Util Logging、Log4j 和 Logback。它生成文本消息,附加一些元信息,如标签(描述来源子系统)、日志级别(描述消息的重要性)和时间戳,并将它们打印到某个地方。您可以根据需要配置日志输出。

定义:-Xlog

java 选项 -Xlog 激活日志记录。这是关于此机制的唯一标志——任何进一步的配置都将立即附加到该选项。可配置的日志记录方面如下:

  • 记录哪些消息(通过标签和/或通过日志级别)

  • 包含哪些信息(例如,时间戳和进程 ID)

  • 使用哪种输出(例如,写入文件)

本附录的其余部分将逐一查看它们。

在做任何事情之前,让我们先看看 -Xlog 产生的消息类型,如图 C.1 所示。图 C.1。执行 java –Xlog 并查看输出——有很多输出。(你没有给出足够的 java 详细信息来启动应用程序,所以它很有帮助地列出了所有选项。为了摆脱这堵文本墙,我使用 -version 运行它,它输出了当前的 Java 版本。)

图片

图 C.1 许多 JVM 子系统(左侧)生成消息(中间),-Xlog 选项可用于配置要显示哪些消息、它们包含哪些信息以及它们出现在哪里(右侧)。

第一条消息告诉您 HotSpot 虚拟机开始工作:

$ java -Xlog -version # 截断了一些消息 > [0.002s][info][os ] HotSpot 正在运行 glibc 2.23, NPTL 2.23 # 截断了很多消息

它显示了 JVM 运行的时间(2 毫秒),消息的日志级别(info),其标签(只有os),以及实际的消息。让我们看看如何影响这些细节。

定义应该显示哪些消息

您可以使用日志级别和标签来精确定义日志应该显示的内容,通过定义<tag-set>=<level>的配对,这些被称为选择器。所有标签都可以使用all来选择,级别是可选的,默认为info。以下是使用方法:

$ java -Xlog:all=warning -version # 没有日志消息;太好了,没有警告!

让我们尝试另一个标签和级别:

$ java -Xlog:logging=debug -version > [0.034s][info][logging] 日志配置完全初始化。 > [0.034s][debug][logging] 可用日志级别:off, trace, debug, info, warning, error > [0.034s][debug][logging] 可用日志装饰器:[...] > [0.034s][debug][logging] 可用日志标签:[...] > [0.034s][debug][logging] 描述的标签组合: > [0.034s][debug][logging] logging: 日志框架本身的日志 > [0.034s][debug][logging] 日志输出配置: > [0.034s][debug][logging] #0: stdout [...] > [0.034s][debug][logging] #A: stderr [...]]

幸运的尝试!我不得不截断输出,因为输出太多了,但请相信我,这些消息中有很多有用的信息。尽管如此,您不必走这条路:-Xlog:help显示相同的信息,但格式更美观(您稍后会看到)。

一个令人惊讶的细节(至少最初是这样)是,消息只有在它们的标签与给定的标签完全匹配时才会匹配选择器。给定的标签?复数?是的,选择器可以通过使用+连接来命名多个标签。尽管如此,消息必须包含这些标签才能被选中。

因此,使用gc(垃圾回收)与gc+heap等,例如,应该选择不同的消息。这确实是情况:

java -Xlog:gc -version [0.009s][info][gc] 使用 G1 java -Xlog:gc+heap -version [0.006s][info][gc,heap] 堆区域大小:1M

您可以一次定义多个选择器——只需用逗号分隔即可:

java -Xlog:gc,gc+heap -version [0.007s][info][gc,heap] 堆区域大小:1M [0.009s][info][gc ] 使用 G1

使用这种策略,获取包含特定标志的所有消息会很麻烦。幸运的是,有一个更简单的方法来做这件事:通配符*,您可以使用单个标签来定义一个选择器,以匹配包含该标签的所有消息:

java -Xlog:gc*=debug -version [0.006s][info][gc,heap] 堆区域大小:1M [0.006s][debug][gc,heap] 最小堆 8388608 初始堆 262144000 最大堆 4192206848 # 截断大约二十条消息 [0.072s][info ][gc,heap,exit ] 堆 # 截断了一些显示最终 GC 统计信息的消息

使用日志和选择器,了解 JVM 的子系统有三个简单的步骤:

  1. java -Xlog:help的输出中查找有趣的标签。

  2. 使用-Xlog:${tag_1}*,${tag_2}*,${tag_n}*与它们一起显示所有标记了其中任何标签的信息消息。

  3. 使用-Xlog:${tag}*=debug选择性切换到较低的日志级别。

这样就确定了你会看到哪些消息。现在让我们看看它们可能去哪里。

定义消息应该去哪里

与非平凡选择器相比,输出配置很简单。你将其放在选择器之后(由冒号分隔),并且有三个可能的值:

  • stdout—默认输出。在控制台上,那是终端窗口,除非已重定向。在 IDE 中,它通常在它自己的选项卡或视图中显示。

  • stderr—默认错误输出。在控制台上,那是终端窗口,除非已重定向。在 IDE 中,它通常与stdout在同一选项卡/视图中显示,但以红色打印。

  • file=<filename>—定义一个文件将所有消息导入其中。包括file=是可选的。

与常见的日志框架不同,不幸的是,无法同时使用两个输出选项。

下面是如何将所有debug消息放入application.log文件中的方法:

java -Xlog:all=debug:file=application.log -version

有更多输出选项可供选择,允许根据文件大小和要旋转的文件数量进行日志文件轮转。

定义消息应该说什么

如我之前所说,每条消息都包含文本和元信息。JVM 将打印哪些额外的信息可以通过选择装饰器来配置,这些装饰器列在表 C.1 中。这发生在输出位置和另一个冒号之后。

假设你想要将时间戳、JVM 启动以来的毫秒数和垃圾收集调试消息的线程 ID 打印到控制台。下面是如何做到这一点的方法:

java -Xlog:gc*=debug:stdout:time,uptimemillis,tid -version # 截断的消息 [2017-02-01T13:10:59.689+0100][7ms][18607] 堆区域大小:1M

表 C.1 -Xlog选项可用的装饰器。信息始终按此顺序打印。描述基于文档。

选项 描述
level 与日志消息关联的级别
pid 进程标识符
tags 与日志消息关联的标签集
tid 线程标识符
time 当前时间和日期,ISO-8601 格式
timemillis System.currentTimeMillis()生成的相同值
timenanos System.nanoTime()生成的相同值
uptime JVM 启动以来的秒数(例如,6.567s)
uptimemillis JVM 启动以来的毫秒数
uptimenanos JVM 启动以来的纳秒数

配置整个日志管道

形式上,-Xlog选项具有以下语法:

-Xlog:<selectors>:<output>:<decorators>:<output-options>

-Xlog后面的每个参数都是可选的,但如果你使用了一个,你必须使用它之前的所有其他参数。选择器是标签集和日志级别的配对。这部分也被称为 what 表达式,当配置在语法上不正确时你会遇到这个术语。你可以使用output(简而言之,终端窗口或日志文件)来定义日志消息的目标位置,并使用装饰器来定义消息应包含的信息。(是的,令人烦恼的是,输出机制和进一步的输出选项是分开的,装饰器位于其中。)

更多详细信息,请参阅在线文档或使用java -Xlog:help命令的输出:

java -Xlog:help -Xlog Usage: -Xlog[:[what][:[output][:[decorators][:output-options]]]] 其中'what'是标签和级别的组合,形式为 tag1[+tag2...][*][=level][,...]。除非指定了通配符(*),否则只有带有指定标签的日志消息会被匹配。可用的日志级别:off, trace, debug, info, warning, error 可用的日志装饰器:time (t), utctime (utc), uptime (u), timemillis (tm), uptimemillis (um), timenanos (tn), uptimenanos (un), hostname (hn), pid (p), tid (ti), level (l), tags (tg) 装饰器也可以指定为'none'以不进行装饰。描述的标签组合:logging: 为日志框架本身进行日志记录 可用的日志标签:[...许多标签...] 指定'all'代替标签组合将匹配所有标签组合。可用的日志输出:stdout, stderr, file=<filename> 在文件名中指定%p 和/或%t 将分别展开为 JVM 的 PID 和启动时间戳。一些示例:[...一些有用的示例以帮助你开始...]

附录 D

使用 JDeps 分析项目的依赖关系

JDeps 是一个 Java 依赖分析工具,是一个命令行工具,它处理 Java 字节码—.class 文件或包含它们的 JAR 文件,并分析类之间的静态声明依赖关系。结果可以以各种方式过滤,并可以聚合到包或 JAR 级别。JDeps 也完全了解模块系统。

总的来说,这是一个非常有用的工具,可以检查我在这本书中经常提到的各种有时模糊不清的图表。更重要的是,它在迁移和模块化项目时具有具体的应用,例如分析其对 JDK 内部 API 的静态依赖关系(第 7.1.2 节)、列出分割包(第 7.2.5 节)和制定模块描述符(第 9.3.2 节)。

对于这次探索,我鼓励你跟随操作,最好是使用你自己的项目。如果你有一个项目 JAR 文件,旁边有一个包含所有传递依赖项的文件夹,这将是最容易的。如果你使用 Maven,你可以通过 maven-dependency-plugin 的 copy-dependencies 目标来实现这一点。如果你使用 Gradle,你可以使用一个 Copy 任务,将 from 设置为 configurations.compileconfigurations.runtime。快速搜索可以帮助你了解详细信息。

作为我的示例项目,我选择了 Scaffold Hunter:

Scaffold Hunter 是一个基于 Java 的开源工具,用于可视化分析数据集,重点关注来自生命科学的数据,旨在直观地访问大型和复杂的数据集。该工具提供各种视图,例如图形、树状图和绘图视图,以及分析方法,例如聚类和分类。

scaffoldhunter.sourceforge.net

我下载了 2.6.3 版本的 Zip 文件,并将所有依赖项复制到 libs 目录中。在显示输出时,我将包名中的 scaffoldhunter 和文件名中的 scaffoldhunter 缩写为 sh 以缩短输出。

了解 JDeps

让我们从了解 JDeps 开始:在哪里找到它,如何获取初步结果,以及哪里可以获得帮助。从 Java 8 开始,你可以在 JDK 的 bin 文件夹中找到 JDeps 可执行文件 jdeps。如果它可以在命令行上使用,这将是最容易的,你可能需要执行一些针对你操作系统的特定设置步骤。确保 jdeps --version 可以正常工作,并显示正在运行的是最新版本。

下一步是获取一个 JAR 文件,并使用 JDeps 对其进行分析。如果不使用其他命令行选项,它将首先列出代码所依赖的 JDK 模块,包括对于既不是 JAR 部分也不是 JDK 部分的所有代码的 not found 提示。接着是一个按包级别组织的依赖关系列表,格式为 ${package} -> ${package} ${module/JAR}

调用 jdeps scaffoldhunter-2.6.3.jar 会导致以下令人眼花缭乱的输出。您可以看到 Scaffold Hunter 依赖于模块 java.base(当然),java.desktop(它是一个 Swing 应用程序),java.sql(数据集存储在 SQL 数据库中),以及其他几个模块。在依赖列表之后,是一个长长的包依赖列表,这可能有点难以全部吸收:

$ jdeps scaffoldhunter-2.6.3.jar # 注意,"sh" 是 "scaffoldhunter"(在文件名中)和 "scaffoldhunter"(在包名中)的简称 > sh-2.6.3.jar -> java.base > sh-2.6.3.jar -> java.datatransfer > sh-2.6.3.jar -> java.desktop > sh-2.6.3.jar -> java.logging > sh-2.6.3.jar -> java.prefs > sh-2.6.3.jar -> java.sql > sh-2.6.3.jar -> java.xml > sh-2.6.3.jar -> not found ③``> edu.udo.sh -> com.beust.jcommander not found > edu.udo.sh -> edu.udo.sh.data sh-2.6.3.jar > edu.udo.sh -> edu.udo.sh.gui sh-2.6.3.jar > edu.udo.sh -> edu.udo.sh.gui.util sh-2.6.3.jar > edu.udo.sh -> edu.udo.sh.util sh-2.6.3.jar > edu.udo.sh -> java.io java.base > edu.udo.sh -> java.lang java.base > edu.udo.sh -> javax.swing java.desktop > edu.udo.sh -> org.slf4j not found # 省略了许多其他包依赖

JDK 项目所依赖的模块

JAR 内部和跨 JAR 的包依赖

“not found” 表示未找到依赖项,这并不奇怪,因为我没有告诉 JDeps 在哪里查找它们。

现在是时候使用各种选项调整输出了。您可以使用 jdeps -h 列出它们。

在分析中包含依赖项

JDeps 的重要特性之一是它允许您将依赖项分析为代码的一部分。实现这一目标的第一步是将它们放置在类路径上使用 --class-path,但这仅使 JDeps 能够跟踪依赖项 JAR 的路径,并消除了 not found 指示。要分析依赖项,您需要使用 -recursive-r 使 JDeps 递归进入它们。

为了包含 Scaffold Hunter 的依赖项,我使用 --class-path 'libs/*'-recursive 执行了 JDeps,其结果如下。在这个特定的情况下,输出以几个分割包警告开始,我现在将忽略这些警告。以下模块/JAR 和包依赖与之前相同,但现在都已找到,因此有更多:

$ jdeps -recursive --class-path 'libs/*' scaffoldhunter-2.6.3.jar # 截断拆分包警告 # 截断一些模块/JAR 依赖 > sh-2.6.3.jar -> libs/commons-codec-1.6.jar > sh-2.6.3.jar -> libs/commons-io-2.4.jar > sh-2.6.3.jar -> libs/dom4j-1.6.1.jar > sh-2.6.3.jar -> libs/exp4j-0.1.38.jar > sh-2.6.3.jar -> libs/guava-18.0.jar > sh-2.6.3.jar -> libs/heaps-2.0.jar > sh-2.6.3.jar -> libs/hibernate-core-4.3.6.Final.jar > sh-2.6.3.jar -> java.base > sh-2.6.3.jar -> java.datatransfer > sh-2.6.3.jar -> java.desktop > sh-2.6.3.jar -> java.logging > sh-2.6.3.jar -> java.prefs > sh-2.6.3.jar -> java.sql > sh-2.6.3.jar -> java.xml > sh-2.6.3.jar -> libs/javassist-3.18.1-GA.jar > sh-2.6.3.jar -> libs/jcommander-1.35.jar # 截断更多模块/JAR 依赖 > edu.udo.sh -> com.beust.jcommander jcommander-1.35.jar > edu.udo.sh -> edu.udo.sh.data sh-2.6.3.jar > edu.udo.sh -> edu.udo.sh.gui sh-2.6.3.jar > edu.udo.sh -> edu.udo.sh.gui.util sh-2.6.3.jar > edu.udo.sh -> edu.udo.sh.util sh-2.6.3.jar > edu.udo.sh -> java.io java.base > edu.udo.sh -> java.lang java.base > edu.udo.sh -> javax.swing java.desktop > edu.udo.sh -> org.slf4j slf4j-api-1.7.5.jar # 截断许多许多更多的包依赖

没有更多“未找到”的 JAR 依赖

没有更多“未找到”的包依赖源

这使得输出更加令人眼花缭乱,因此是时候看看如何从如此多的数据中找到意义了。

配置 JDeps 的输出

配置 JDeps 输出的方式有很多种。在分析任何项目的初步分析中,可能最好的选项是 -summary-s,它只显示 JAR 之间的依赖关系,如下所示:

$ jdeps -summary -recursive --class-path 'libs/*' scaffoldhunter-2.6.3.jar # 截断拆分包警告 # 截断一些模块/JAR 依赖 > sh-2.6.3.jar -> libs/javassist-3.18.1-GA.jar > sh-2.6.3.jar -> libs/jcommander-1.35.jar > sh-2.6.3.jar -> libs/jgoodies-forms-1.4.1.jar > sh-2.6.3.jar -> libs/jspf.core-1.0.2.jar > sh-2.6.3.jar -> libs/l2fprod-common-sheet.jar > sh-2.6.3.jar -> libs/l2fprod-common-tasks.jar > sh-2.6.3.jar -> libs/opencsv-2.3.jar > sh-2.6.3.jar -> libs/piccolo2d-core-1.3.2.jar > sh-2.6.3.jar -> libs/piccolo2d-extras-1.3.2.jar > sh-2.6.3.jar -> libs/slf4j-api-1.7.5.jar > sh-2.6.3.jar -> libs/xml-apis-ext.jar > sh-2.6.3.jar -> libs/xstream-1.4.1.jar > slf4j-api-1.7.5.jar -> java.base > slf4j-api-1.7.5.jar -> libs/slf4j-jdk14-1.7.5.jar > slf4j-jdk14-1.7.5.jar -> java.base > slf4j-jdk14-1.7.5.jar -> java.logging > slf4j-jdk14-1.7.5.jar -> libs/slf4j-api-1.7.5.jar # 截断更多模块/JAR 依赖

表 D.1 列出了各种过滤器,它们提供了对依赖关系的不同视角。

表 D.1 JDeps 输出的某些选项的简要描述

选项 描述
--apionly-apionly 有时,尤其是当你分析一个库时,你可能只关心一个 JAR 的 API。使用此选项,仅检查公共和受保护成员的签名中提到的类型。
-filter-f 后跟一个正则表达式,排除与匹配正则表达式的类相关的依赖。(注意:除非使用 -verbose:class,否则输出仍会显示包。)
-filter:archive 在许多情况下,工件内的依赖并不那么有趣。此选项忽略它们,只显示跨工件之间的依赖。
--package-p 后跟一个包名,仅考虑对该包的依赖,这是一个查看那些 utils 被使用所有位置的好方法。
--regex-e 后跟一个正则表达式,仅考虑与匹配正则表达式的类相关的依赖。(注意:除非使用 -verbose:class,否则输出仍会显示包。)

命令行上的输出是检查细节和深入挖掘有趣部分的好方法。但这并不是最直观的概览——图表在这方面做得更好。幸运的是,JDeps 有 --dot-output 选项,它为每个单独的分析创建 .dot 文件。这些文件是纯文本,但可以使用其他工具,如 Graphviz,从它们创建图像。请参阅以下列表和图 D.1 以获取示例。

列表 D.1 可视化工件依赖关系

$ jdeps -recursive --class-path 'libs/*' --dot-output dotsscaffoldhunter-2.6.3.jar $ dot -Tpng -O dots/summary.dot

指定 --dot-output dots 告诉 JDeps 在 dots 文件夹中创建 .dot 文件。

Graphviz 提供了 dot 命令,这里使用它来在 dots 中创建 summary.dot.png。

图 D.1 列表 D.1 的结果是大型、复杂但仍然可接近的依赖关系图。这只是其中的一部分。不必担心细节;为你的项目创建一个。

Dot 文件和 Graphviz

.dot 文件是纯文本,是一个很好的中间表示形式,可以对其进行编辑。使用一些正则表达式,例如,你可以从底部移除 java.base 模块(使图更简单)或从 JAR 名称中移除版本(使图更精简)。有关 Graphviz 的更多信息,请参阅 graphviz.gitlab.io

深入挖掘你的项目依赖关系

如果你想查看更多细节,-verbose:class 会列出类之间的依赖关系,而不是在包级别上聚合它们。有时候,仅列出包或类的直接依赖可能不够,因为它们可能不在你的代码中,而是在你的依赖中。在这种情况下,使用 --inverse-I 可能会有所帮助。给定一个特定的包或正则表达式进行查找,它会追踪依赖关系直到它们的最远点,并在过程中列出相关工件。不幸的是,似乎没有一种直接的方法可以在类级别而不是工件级别查看结果。

如果你只对库的公共 API 中暴露的依赖感兴趣,你可以使用 --apionly 来实现这一点。使用它,只会检查公共和受保护成员的签名中提到的类型。还有一些其他选项可能有助于你解决特定情况——如前所述,你可以使用 jdeps -h 列出它们。

JDeps 理解模块

正如编译器和 JVM 可以通过模块系统在更高层次上进行操作一样,JDeps 也可以。模块路径可以通过 --module-path 指定(注意 -p 是保留的:它不是此选项的缩写),初始模块通过 --module-m 指定。从那里,你可以进行与之前相同类型的分析:

$ jdeps -summary -recursive --module-path mods:libs -m monitor # 省略了一些模块依赖 > monitor -> java.base > monitor -> monitor.observer > monitor -> monitor.observer.alpha > monitor -> monitor.observer.beta > monitor -> monitor.persistence > monitor -> monitor.rest > monitor -> monitor.statistics > monitor.observer -> java.base > monitor.observer.alpha -> java.base > monitor.observer.alpha -> monitor.observer > monitor.observer.beta -> java.base > monitor.observer.beta -> monitor.observer > monitor.persistence -> java.base > monitor.persistence -> monitor.statistics > monitor.persistence -> hibernate.jpa > monitor.rest -> java.base > monitor.rest -> monitor.statistics > monitor.rest -> spark.core > monitor.statistics -> java.base > monitor.statistics -> monitor.observer > slf4j.api -> java.base > slf4j.api -> 未找到 > spark.core -> JDK 移除了内部 API > spark.core -> java.base > spark.core -> javax.servlet.api > spark.core -> jetty.server > spark.core -> jetty.servlet > spark.core -> jetty.util > spark.core -> slf4j.api > spark.core -> websocket.api > spark.core -> websocket.server > spark.core -> websocket.servlet # 省略了更多模块依赖

此外,还有一些 Java 9 和模块特定的选项。使用 --require ${modules},您可以列出所有需要命名模块的模块。如何使用 --jdk-internals 来分析项目的有问题依赖关系在第 7.1.2 节中解释。第 9.3.2 节解释了如何使用 --generate-module-info--generate-open-module 来创建模块描述符的第一稿。如前所述,JDeps 还会始终报告它找到的所有拆分包——这个问题在第 7.2 节中详细讨论。

一个有趣的选项是 --check,它提供了对模块描述符的不同视角(见图 D.2):

  • 它首先打印实际的描述符,然后是两个假设的描述符。

  • 其中第一个,被描述为建议的描述符,声明了对所有在检查模块中使用类型的模块的依赖。

  • 第二个,被描述为传递减少图,与第一个类似,但去除了由于暗示的可读性而可以读取的依赖(见第 9.1 节)。这意味着它是产生可靠配置的最小依赖集。

  • 最后,如果模块声明了任何合格的导出(见第 9.3 节),--check 将输出那些在可观察模块的宇宙中未使用的导出。

--check 创建的假设描述符也可以分别使用两个选项 --list-deps--list-reduced-deps 进行查看。它们也可以与类路径一起使用,在这种情况下,它们引用未命名的模块(见第 8.2 节)。

图片

图 D.2 在左侧,您可以看到 monitor.peek(在 11.1.1 节中介绍)及其传递依赖关系,其中一些暗示了在其他模块上的可读性。在右侧,JDeps 建议将 monitor.observer 包含在依赖列表中(因为其类型被直接引用)。此外,它列出了 monitor.peek 需要依赖的最小模块集,以充分利用暗示的可读性。

附录 E

使用多版本 JAR 针对多个 Java 版本

决定你的项目需要哪个 Java 版本从来都不容易。一方面,你希望给用户选择自由,所以支持几个主要版本而不是仅支持最新版本会很好。另一方面,你迫切希望使用最新的语言特性和 API。从 Java 9 开始,有一个新的 JVM 特性,即多版本 JAR(MR-JARs),它可以帮助你在某些情况下调和这些对立的力量。

MR-JARs 允许你在同一个工件中打包不同版本的 Java 字节码。然后你可以依赖 JVM 加载你为它支持的最新版本编译的类。从一个在最小要求版本上成功运行的工程开始,你可以通过使用更健壮和性能更高的 API 来选择性地在新 JVM 上改进它,而无需强制提高你项目的基线。

重要信息当然,你只有在没有完全控制运行你项目的 JVM 版本的情况下才需要考虑 MR-JARs。对于库和框架来说,这始终是这种情况,对于用户自己托管桌面应用程序或网络后端来说,通常也是如此。另一方面,如果你管理运行你应用程序的机器,你可以使用较新的 JVM,并放弃 MR-JARs 的复杂性。

在解决所有这些问题之后,让我们来探索这个实用的新特性。我们将从创建一个简单的 MR-JAR 开始,然后再看看它是如何内部结构的。最后,我们将提供一些关于何时以及如何使用 MR-JARs 的建议。

创建一个多版本 JAR

定义

多版本 JAR(MR-JARs)是特别准备的 JAR,包含几个主要 Java 版本的字节码。字节码的加载方式取决于 JVM 版本:

  • Java 8 及更早版本加载版本无关的类文件。

  • Java 9 及以后的版本如果存在版本特定的类文件,则加载这些类文件,否则回退到版本无关的类文件。

为了准备一个 MR-JAR,你需要根据目标 Java 版本分割源文件,为每个版本的源文件集编译相应的版本,并将生成的 .class 文件放入单独的文件夹中。当使用 jar 打包时,你通常添加基线类文件(直接或使用 -C;请参阅第 4.5.1 节)并为每个其他字节码集使用新的选项 --release ${release}

让我们来看一个例子。假设你需要检测当前运行的 JVM 的主要版本。Java 9 提供了一个很好的 API 来实现这一点,因此你不再需要解析系统属性。(第 6.5.1 节提供了一些关于它的信息,但细节在这里并不重要。)通过部署一个 MR-JAR,如果你在 Java 9 或更高版本上运行,你可以使用那个 API。

假设的应用程序有两个类,MainDetectVersion;目标是拥有两个 DetectVersion 的变体,一个用于 Java 8 及更早版本,另一个用于 Java 9 及以后版本。这两个变体需要具有完全相同的完全限定名(这可能会在 IDE 中使用它们时带来挑战)——假设你将它们放置在两个平行的源文件夹中,src/main/javasrc/main/java-9

图 E.1 展示了如何组织源代码,而 列表 E.1 展示了如何将它们编译和打包成 MR-JAR。注意有两个编译步骤和单独的输出文件夹。最终结果在 图 E.2 中展示。

列表 E.1 编译和打包不同 Java 版本的源代码到 JAR 文件

javac --release 8 -d classes src/main/java/org/codefx/detect/*.java javac --release 9 -d classes-9 src/main/java-9/module-info.java src/main/java-9/org/codefx/detect/DetectVersion.java jar --create ③``--file target/detect.jar ③``-C classes . ③``--release 9 ④``-C classes-9 .

将 src/main/java 中的代码编译成 Java 8(或更早版本)的类文件

将 src/main/java-9 中的代码编译成 Java 9 的 classes-9 类文件

当将字节码打包到 JAR 文件中时,包默认将类中的字节码作为通常一样处理。

包含专门针对 Java 9 的类

图 E.1 展示了 MR-JAR 的源代码布局方式。最重要的细节是,版本相关的代码,这里为 DetectVersion,在所有变体中都有相同的完全限定名。

这个简单的示例创建了两个 DetectVersion 的变体,一个用于最低要求的 Java 8,另一个用于 Java 9。将这正式化到创建具有多个类和多个版本的特性的通用情况是惊人地复杂和繁琐,所以我就不提供正式版本了。相反,E.3 节为你提供了一个经验法则。

图 E.2 由 列表 E.1 生成的 JAR 文件

MR-JAR 的内部工作原理

MR-JAR 的工作原理是怎样的?它非常直接:它在根目录中存储与版本无关的类文件(如通常一样),并在 META-INF/versions/${version} 中存储特定版本的文件。

重要的信息 JVMs 8 及更早版本对 META-INF/versions 一无所知,并从 JAR 根目录中的包结构加载类。因此,在 9 版本之前无法区分版本。

然而,较新的 JVM 首先查看 META-INF/versions,只有在那里找不到类时,才会查看 JAR 的根目录。它们从自己的版本开始向后搜索,这意味着 Java 10 JVM 会寻找 META-INF/versions/10 中的代码,然后是 META-INF/versions/9,然后是根目录。因此,这些 JVM 会用它们支持的最新的版本特定的类文件来覆盖版本无关的类文件。

除了 META-INF/versions 中的文件夹外,MR-JAR 还可以通过查看纯文本文件 META-INF/MANIFEST.MF 来识别:在 MR-JARs 中,清单有一个条目 Multi-Release: true

使用建议

现在您已经知道了如何创建 MR-JARs 以及它们是如何工作的,我想给您一些建议,如何最大限度地利用它们。更确切地说,我将就以下主题提供一些建议:

  • 如何组织源代码

  • 如何组织字节码

  • 何时使用 MR-JARs

组织源代码

基本信息我建议在组织 MR-JARs 的源代码时遵循以下两个指南:

  • 对于支持的最老版本的 Java 代码,放在项目的默认根目录中:例如,src/main/java,而不是 src/main/java-X

  • 该源文件夹中的代码是完整的,这意味着它可以编译、测试和部署,无需从特定版本的源树(如 src/main/java-X)中添加额外的文件。(注意,如果您提供仅在较新 Java 版本上工作的功能,仅抛出错误声明“在 Java X 之前不支持操作”的类也视为完整。我的建议是不要将其排除在外,从而导致不提供信息的 NoClassDefFoundError。)

这些不是技术要求;没有任何东西阻止您针对 Java 11,将一半的代码放在 src/main/java 中,另一半,甚至全部,放在 src/main/java-11 中。但这只会造成混淆。

通过坚持指南,您可以使源树的布局尽可能简单。任何查看它的人类或工具都会看到一个针对所需 JVM 版本完全功能的项目。版本相关的源树随后会针对新版本选择性地增强该代码。

您如何验证是否正确?正如我之前所说,正式描述很复杂,所以这里是您承诺的规则。为了确定您的特定布局是否有效,心理上(或实际上)执行以下步骤:

  1. 在最老支持的 Java 版本上编译和测试版本无关的源树。

  2. 对于每个额外的源树:

  3. 将版本相关的代码移动到版本无关的树中,替换具有相同完全限定名称的文件。

  4. 在较新版本的树上编译和测试该树。

如果这样做有效,那么您就做对了。

当然,您的工具也必须与您选择的源布局协同工作。不幸的是,在撰写本文时,IDE 和大多数构建工具对该布局的支持并不好,您可能被迫做出妥协。作为替代方案,考虑为每个 Java 版本创建单独的项目。

组织字节码

重要信息 从那个源树结构到我在 JAR 中组织字节码的建议是一条直路:

  • 对于最老的支持的 Java 版本的字节码放入 JAR 的根目录,这意味着它不会在 --release 之后添加。

  • JAR 根目录中的字节码是完整的,这意味着它可以不添加 META-INF/versions 中的额外文件即可执行。

再次强调,这些不是技术要求,但它们保证了查看 JAR 根目录的每个人都能看到为所需的 JVM 版本编译的完整项目,并在 META-INF/versions 中对较新的 JVM 进行选择性增强。

何时使用 MR-JAR

MR-JAR 如何帮助你解决选择所需 Java 版本的困境?首先,显然地,准备一个 MR-JAR 会增加相当多的复杂性:

  • 你的 IDE 和构建工具必须配置得当,以便能够轻松处理针对不同 Java 版本编译的具有相同完全限定名称的源文件。

  • 你需要保持同一源文件的多个变体同步,以确保它们具有相同的公共 API。

  • 单元测试变得复杂,因为你可能会编写只能在特定 JVM 版本上运行或通过的测试。

  • 集成测试变得更加繁琐,因为你需要考虑测试 MR-JAR 包含字节码的每个 Java 版本生成的结果工件。

重要信息 这意味着你应该仔细考虑是否要创建 MR-JAR。走这条路应该有相当大的回报。(也许你最终可以提升所需的 Java 版本。)

此外,MR-JAR 并不适合使用方便的新语言特性。正如你所看到的,你需要涉及源文件的两个变体,如果你必须保留具有不便变体的源文件,那么基于便利性的论据并不充分。语言特性也会迅速渗透到代码库中,导致大量重复的类。这不是一个好主意。

另一方面,API 是 MR-JAR 的最佳应用场景。Java 9 引入了一系列新的 API,它们以更高的鲁棒性和/或更好的性能解决了现有的用例:

  • 使用 Runtime.Version 而不是解析系统属性来检测 JVM 版本(见第 6.5.1 节)

  • 使用堆栈跟踪 API 分析调用栈而不是创建 Throwable(本书不涵盖该 API,但你的日志框架的开发者已经在使用它)

  • 用变量句柄替换反射(见第 12.3.2 节)

如果你想在新的 Java 版本上使用新的 API,你只需要封装你对它的直接调用到一个专用的包装类中,然后实现它的两个变体:一个使用旧 API,另一个使用新 API。如果你已经接受了之前概述的复杂性,那么这很简单。

posted @ 2025-11-15 13:06  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报