Java9-模块化编程-全-
Java9 模块化编程(全)
原文:
zh.annas-archive.org/md5/d2777356d21baa943efb404cc8890a36译者:飞龙
前言
模块化随着 Java 9 的到来而到来,这是一件大事!与其他 Java 版本一样,它们带来了语言的新特性,你可以选择在代码中使用,Java 平台模块化系统则是一次对我们思考、设计和编写 Java 应用程序方式的彻底改变。
本书是一本详细的指南和实战伴侣,帮助你了解并编写 Java 中的模块化代码。阅读并完成本书中的代码后,你将对 Java 9 模块化、其特性、对平台的影响以及如何使用这一新范式自己构建模块化应用程序有一个深入的理解。
这本书经过精心设计,旨在为读者提供一个温和的入门介绍,同时逐渐增加所涵盖主题的范围和复杂性。这不是一本参考书。例如,当我介绍一个概念或一个特性时,我不会提供所有可能的细节或语法组合,以试图做到全面。我在写这本书时的主要目标是帮助你掌握概念,并深入理解 Java 模块化。本书中主题和讨论的顺序已经精心编排,以确保你始终具备理解所涵盖主题的必要知识。因此,这本书通过顺序阅读可以获得很大的益处。话虽如此,如果你已经了解了一些主题,并希望跳转到特定的主题,你也应该能够做到。为此,本书末尾提供了一个方便的索引。
本书涵盖的内容
第一章,介绍 Java 9 模块化,涵盖了 Java 8 及之前代码结构和管理现状。它概述了随着时间的推移维护和组织大型代码库的挑战和陷阱,以一个大家熟悉的大型 Java 代码库为例——JDK。然后介绍了 Jigsaw 项目,并解释了模块化概念如何试图解决上一章中概述的挑战。它讨论了 JDK 结构基于模块的变更以及创建应用程序代码中模块的能力。
第二章,创建您的第一个 Java 模块,通过一个不使用模块构造的示例代码项目让你入门。然后逐步引导你创建第一个 Java 9 模块。它介绍了模块关键字、module-info.java 类以及如何定义自定义 Java 模块。然后它涵盖了模块的编译和运行。它解释了.class 文件的结构以及运行时如何使用相同的模块定义来推断模块结构。
第三章,处理模块间依赖,展示了模块在孤立状态下几乎无法良好工作。它们被设计成更大单元的一部分,并且旨在相互协作。本章涵盖了创建第二个模块以及模块之间相互依赖的内容。你将学习如何在 Java 9 中定义这种关系,以及如何编译和运行多模块应用程序。
第四章,介绍模块化 JDK,将我们的注意力转向 Java 平台,特别是 JDK。你将了解平台模块化带来的 JDK 的重要变化。你还将了解随 JDK 一起提供的模块。此外,你还将了解浏览和获取有关这些模块更多信息的工具和技术。
第五章,使用平台 API,通过动手操作的逐步指南教你如何通过示例应用程序使用平台模块。你将了解在依赖平台模块时可能会遇到的一些挑战以及如何解决它们。
第六章,模块解析、可读性和可访问性,深入探讨了模块间的依赖关系以及如何控制它们模块和库的封装级别。它通过增加两个影响 Java 元素封装和可用性的新标准来继续前一章的模块解析讨论——可访问性和可读性。
第七章,介绍服务,探讨了示例应用程序中两个模块之间存在的耦合关键要素以及它是如何防止新模块的可扩展性和“插入”的。它解释了新的 Java 9 服务,提供了封装实现类的逐步指南,并使用 ServiceLoader 来查找它们。
第八章,理解链接和使用 jlink,介绍了 Java 9 开发过程中的静态链接步骤以及在此期间发生的事情。它解释了链接在 Java 9 开发模块时扮演的重要角色。然后提供了创建示例项目的运行时映像、如何优化它以及如何执行映像的逐步指南。
第九章,模块设计模式和策略,涵盖了在 Java 中构建模块化应用程序时的几个最佳实践。现在,你已经对 Java 模块系统特性和其工作原理有了很好的理解,下一个问题是何时以及如何使用它们?你将学习如何建立模块范围和边界,如何定义良好的模块接口,以及如何在构建模块化应用程序时解决一些常见挑战。
第十章,为 Java 9 准备你的代码,将指导你将一个旧的示例代码库(用 Java 7 编写)准备好迁移到 Java 9。它说明了 Java 模块的可选性质以及基于类路径的代码是如何自动分配给一个“未命名的”模块的。然后,它提供了逐步指导,以使遗留代码在 Java 9 中编译和运行。它还展示了如何处理封装类型的使用问题,以及如何解决这些问题。
第十一章,将你的代码迁移到 Java 9,将指导你将遗留代码升级以使用 Java 9 的新模块化特性。你将学习如何为你的代码制定迁移策略,以及如何处理与 Java 9 不兼容的依赖项。你将学习如何使用 Java 9 中旨在协助此类迁移的特性,例如自动模块和命令行覆盖。
第十二章,使用构建工具和测试 Java 模块,涵盖了 Java 编程的两个重要方面——构建工具集成和单元测试。你将学习如何使用 Maven 来构建你的项目,并将 Maven 的多模块项目概念与 Java 9 模块化应用程序对齐。你还将学习如何使用 JUnit 测试 Java 模块。
你需要这本书的内容
要跟随本书中的示例代码,你需要一台运行合理最新版本的 Windows、macOS 或 Linux 的计算机。你还需要一个文本编辑器来编辑代码。我强烈推荐使用允许你同时打开多个文件并轻松切换它们的文本编辑器。
这本书面向的对象
如果你是一名 Java 开发者,一直在编写 Java 应用程序,并且想要了解 Java 9 中的新模块化特性,那么这本书正是为你准备的。你可能正在从事一个 Java 9 模块化项目,或者可能被分配迁移现有 Java 代码库到 Java 9 的任务,或者你可能对 Java 模块化的热议感到好奇并想了解更多;无论如何,这本书都是适合你的!
本书假设你熟悉 Java 编程语言,并且之前已经编写过一些 Java 代码。它还假设你对你选择的操作系统的命令行界面感到舒适。书中将提供你需要运行的命令。
规范
在本书中,您将找到许多不同的文本样式,以区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 标签显示如下:“我们首先将在ContactUtil实例上调用getContacts()方法来获取硬编码的Contact列表。”
代码块设置如下:
module packt.addressbook {
requires packt.sortutil;
}
任何命令行输入或输出都按如下方式编写。以$开头的行表示输入命令。输入命令可能被分成多行以提高可读性,但需要在提示符下作为一行连续输入:
$ export JAVA_HOME=$(/usr/libexec/java_home -v 1.8)
新术语和重要词汇以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,在文本中显示如下:“点击 File | New Project,您将在 Java 类别中看到一个带有新选项的 New Project 覆盖层——Java Modular Project。”
警告或重要注意事项显示如下。
技巧和窍门显示如下。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大收益的书籍。
如要向我们发送一般反馈,请简单地将邮件发送至 feedback@packtpub.com,并在邮件主题中提及书籍标题。
如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com上的账户下载此书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的 SUPPORT 标签上。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击“代码下载”。
文件下载完成后,请确保使用最新版本的软件解压缩或提取文件夹:
-
对于 Windows,请使用 WinRAR / 7-Zip。
-
对于 macOS,请使用 Zipeg / iZip / UnRarX。
-
对于 Linux,请使用 7-Zip / PeaZip。
本书的相关代码包也托管在 GitHub 上,网址为github.com/koushikkothagal/Modular-Programming-in-Java-9。我们还有其他来自我们丰富的图书和视频目录的代码包可供在github.com/PacktPublishing/找到。去看看吧!
勘误
尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者感到沮丧,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
盗版
互联网上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在网上遇到任何形式的非法复制我们的作品,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过copyright@packtpub.com与我们联系,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您对本书的任何方面有问题,您可以通过questions@packtpub.com联系我们,我们将尽力解决问题。
第一章:介绍 Java 9 模块化
本书涵盖了 Java 9 中的模块化特性——这是 Java 编程语言的一个重要新变化。我们将探讨它对 Java 开发的影响以及如何使用它来构建强大的模块化应用程序。Java 9 的发布还带来了一些其他变化,例如支持 HTTP 2.0 和一个名为jshell的 shell,它允许你在读取-评估-打印-循环(REPL)中运行 Java 代码片段。虽然这些都是令人兴奋的新变化,但它们并不是本书的重点。我们将主要关注模块化特性,这些特性在 Java 9 发布的新变化中可能是最重要和最强大的。
本章通过以下主题介绍了 Java 9 中的新模块特性:
-
检查在构建 Java 应用程序时遇到的两个重要的结构和组织问题,以及它们的影响
-
为什么 Java 甚至需要模块化特性?我们现在缺少什么?模块化能给我们带来什么?
-
介绍Java 平台模块系统(JPMS)
-
理解 Java 模块系统旨在提供的优势
Java 中的模块化
如果你已经是一名开发者一段时间了,你很可能已经意识到“模块”这个词可能是软件开发中最被过度使用的术语之一。模块可以指从一组代码实体、组件或 UI 类型到框架元素,甚至是完整的可重用库。有时,我们使用这个词在同一语境中暗示多个含义!
这是有充分理由的。在编写代码时,我们通常试图将代码库分解成更小的单元以管理复杂性。对于非常简单的程序之外,拥有一个单体代码库并不是一个好主意。这就是为什么模块化编程通常是一个受欢迎的软件开发方法。软件开发的模块化通常实现以下两个重要目标,具体如下:
- 分而治之的方法
当你需要解决一个庞大且看似无法克服的问题时,你会怎么做?你将其分解!你很可能将其拆分成更小的问题并逐一解决。
模块化的原则鼓励将大型代码库分解成更小的封装功能单元,然后组合起来作为一个更大的单元协同工作。这很好地符合我们人类通常用来解决大型问题的方法。此外,一旦你拥有了一组具有专门关注点的较小模块,你可以使用这些模块来解决各种其他问题。因此,我们也实现了可重用性!
- 实现封装和定义良好的接口
当你构建模块时,你有能力将模块的内部实现隐藏给模块的使用者。这些隐藏的实现细节通常被称为封装,而你向模块使用者暴露的部分通常被称为模块的接口。
尽管多年来 Java 开发者已经利用了许多不同的模式和最佳实践来编写和构建模块化和可维护的代码,但 Java 语言本身从未有过创建模块单元和构建模块化应用程序的原生支持,直到 Java 9。随着 Java 9 的推出,Java 开发者现在可以使用一种称为Java 模块的新结构来创建更小的代码单元,并将它们像积木一样组合起来,以构建更大的应用程序。除了向语言引入这一特性外,Java 9 还带来了对核心 Java 代码库的可能是最大的改进。Java 运行时环境(JRE)和Java 开发工具包(JDK)已经被重写,以使用模块化的概念,从而使核心 Java 平台本身实现了模块化。
当学习 Java 9 模块功能时,了解这些新功能与语言已有的其他功能相比增加了什么功能是很重要的。我们难道不能在 Java 8 中编写有组织的代码吗?事实上,面向对象编程的一个好处确实是将功能分解成称为对象或类的子单元的想法。我们自从 Java 1 版本以来就是这样编写代码的。每个 Java 类都包含整体应用程序功能的一部分,这些功能碰巧属于一起。我们有能力将某些功能封装为类内部的(作为private),而将其他一些功能作为外部(或public)。
然后还有介于protected之间的一些内容,这得益于包的概念。
重新思考使用包的 Java 开发
考虑一下为什么我们在 Java 中使用包。我们完全可以编写整个 Java 应用程序而不创建任何包,从而仅使用默认的未命名包。这也可以工作!然而,除非它是一个简单或一次性应用程序,否则这不是一个好主意。包的概念是将你的 Java 类型分组到命名空间中,这些命名空间表示这些类型之间的关系,或者可能是这些类型之间的一个共同主题。这使得代码更容易阅读、理解和导航。
以下图表展示了类在包中组织的一个示例。将所有类添加到单个包中(左侧)并不是一个好的实践。我们通常将相关的类分组到具有良好命名的包中,这些包描述了其中类的本质(右侧):

实际上并没有关于在包中哪些类型属于一起的规则。然而,通常人们认为,当你创建一个包并将一些 Java 类型放入其中时,这些类型通常以某种方式相关。你完全可以随意在同一个包中编写任何随机的类型集,编译器也不会在意。然而,最终会有人在你代码上工作,他们可能会永远讨厌你,所以这不是一个明智的做法!在公共包中有相关类型也有好处,即这些类型能够访问彼此的保护成员。这是封装的另一个层次--任何保护成员或方法都被封装在包的类型中。(尽管,有一个例外,即继承类能够跨包访问私有成员。)
那么,如果你认为模块化编程是将代码和功能分解成封装单元的想法,那么在 Java 9 之前,你可以在某种程度上很好地进行某种模块化编程。
下表展示了在 Java 9 之前,你可以用各种方式在 Java 中封装代码:
| 要封装的内容 | 如何封装 | 封装边界 |
|---|---|---|
| 成员变量和方法 | private修饰符 |
类 |
| 成员变量和方法 | protected修饰符 |
包 |
| 成员变量、方法和类型 | 无修饰符(默认包 - protected) | 包 |
这不是足够好吗?实际上并不够。前面的表格显示了语言模块化能力的一个限制。注意“要封装的内容”这一列。这些修饰符提供的多数封装功能都集中在控制成员变量和方法访问上。真正保护类型访问的唯一方法是将它设置为包保护的。不幸的是,这最终使得即使是你的库代码访问该类型也变得困难,你被迫将所有访问该类型的代码移动到同一个包中。如果你想要更多呢?
为什么呢?使用 Java 8 和更早版本中可用的先前的范式来处理模块化存在一些问题。让我用两个故事来解释这两个问题。
一个库开发者的不幸故事
见杰克。他是一家中型企业组织的 Java 开发者。他是编写数据处理代码的团队的一员。有一天,杰克编写了一些 Java 代码来按字母顺序排序用户名列表。他的代码运行良好,没有任何错误,杰克为自己的工作感到自豪。由于这是其他开发者可以在组织中使用的,他决定将其构建为一个可重用的库,并以打包的 JAR 文件的形式与同事分享。以下是杰克库的结构:

他的代码属于两个包--acme.util.stringsorter 和 acme.util.stringsorter.internal。主要的实用类是 StringSorterUtil,它有一个方法--sortStrings。该方法反过来内部调用并委托排序责任给 acme.util.stringsorter.internal 包中的一个类中的 BubbleSortUtil.sortStrings() 类。BubbleSortUtil 类使用流行的冒泡排序算法对给定的字符串列表进行排序。
任何开发者所要做的只是将 jar 文件放入类路径中,并通过传递需要排序的字符串列表来调用 StringSorterUtil.sortStrings() 方法。他们确实这么做了!杰克的小型库变得非常受欢迎!他的同事们非常喜欢他的库提供的便利性,并开始用它来排序许多东西,比如名字、令牌、地址等等。
几个月后,杰克偶然在饮水机旁遇到了达里尔,像往常一样,他们的对话转向了关于他们当前最喜欢的排序算法的讨论。达里尔无法停止谈论他对哈希排序的新发现之爱。他说他发现它的性能比冒泡排序要好得多,而且他毫不掩饰地宣称这是他新的最爱算法!杰克感到很感兴趣。他回到自己的办公桌前进行了一些测试。达里尔是对的!在大多数测试中,哈希排序都优于冒泡排序。杰克当时就知道他必须更新他的排序实用工具以使用哈希排序。他在 acme.util.stringsorter.internal 包中添加了一个新的类 HashSortUtil 并移除了 BubbleSortUtil。
以下是杰克库变更后的结构:

幸运的是,他有一个单独的内部类用于排序,所以调用 StringSorterUtil.sortStrings() 实用工具的过程不会改变。每个人都可以简单地放入新的 JAR 版本,一切都会正常工作。
但事实并非如此!他公司中的一些代码构建开始失败。结果发现罪魁祸首是杰克库的新版本。杰克不敢相信这一点。他没有遗漏任何东西,是吗?嗯,没有。所有只使用 StringSorterUtil 类的项目都运行得很好。然而,结果发现一些开发者最终直接使用了内部包中的 BubbleSortUtil 类。它在类路径中可用,所以他们只是导入并使用了它。现在,由于那个类在新 jar 中不再存在,他们的代码无法编译!
杰克发送了一封电子邮件,指示所有使用BubbleSortUtil的人更新他们的代码,改用StringSorterUtil。然而,那时BubbleSortUtil类已经被多处使用,要全部更改并非易事。“杰克就不能把BubbleSortUtil类恢复回来吗?”他们问道。杰克屈服于他们的请求,下一个版本的库同时包含了SortUtil类(并且可能会持续到可预见的未来),尽管它内部只使用了这两个类中的一个。
烟尘散去后,杰克坐在他的办公桌前,思考出了什么问题。他本可以做些什么来防止这个问题?显然,将包命名为内部并没有阻止开发者使用它。一个解决方案可能是将内部冒泡排序类型写成包保护,并将外部类型移动到同一个包中。这样,他就可以利用前面封装表中的第三个机制。然而,他喜欢将冒泡排序类分离成自己的类型和包的想法。此外,想象一下,如果这是一个更大的库,并且有一个应该内部共享的公共类。在这种情况下,几乎那个库中所有需要内部类型的类型都必须存在于与内部类型相同的包中!难道没有更好的封装内部类型的方法吗?
部署工程师的艰巨任务
来认识阿米特,他是另一家企业技术公司的一名部署工程师。他的工作是确保在每次产品发布时,组织的代码库在生产环境中正确编译和部署。在每次发布中,他都会拉取应用程序代码和所有必要的 jar 文件,并将它们放置在类路径中。然后他启动应用程序,这将导致Java 虚拟机(JVM)加载所有类并初始化执行。
一天晚上,有一个主要产品功能的发布。代码有很多更改,都计划一起部署和发布。阿米特确保所有新代码都正确编译,并且他在类路径中有了所有必要的 jar 文件。然后他必须启动应用程序。在点击按钮启动构建之前,阿米特想知道是否有办法确保一切正常,并且应用程序在没有运行时类错误的情况下能够工作。
可能出错的另一件事是他可能遗漏了在类路径中添加某个类或 jar 文件。他是否有办法在实际上运行应用程序之前静态地验证所有类是否可用?
每个 JAR 文件都包含了一组包中的类型。其中每个类型都可能从同一 JAR 文件或其他 JAR 文件中导入其他类型。为了确保他在类路径中有所有的类,他必须对每个类进行检查,以确认所有导入都在类路径中。考虑到他的应用程序中的类数量达到数千个,这是一项艰巨的任务。
下面的图是示例部署的 Java 应用程序的简化版本:

上图中有四个 JAR 文件,每个文件都包含其中的包和类。Jar 1 部署在Classpath A中,Jar 2和Jar 3在Classpath B中,Jar 4在Classpath C中。假设每个 JAR 文件中有两个类,如较小的白色方框所示。这三个路径被配置为 Java 运行时的类路径,因此运行时知道要查看所有三个路径以扫描和拾取类。
扫描完所有类路径后,这就是 Java 运行时看到的结构:

注意到运行时并不关心包/类型所在的目录或类路径。它也不关心包/类型捆绑在哪个 JAR 文件中。对 Java 运行时来说,它只是一个包中类型的扁平列表!
在 Java 中,类路径只是一组路径。这些位置中的任何一个都可能包含应用程序运行所需的所有 JAR 文件和类。你可以立即看到事情为什么会变得如此容易出错!总有可能应用程序使用的某些类不在类路径中。可能是一个缺失的 JAR 文件或库。如果运行时不具有它需要的特定类,应用程序可能会正常运行,但稍后抛出NoClassDefFoundError异常。同样,只有在执行到达实际需要缺失类的点时,才会抛出这个异常。
这是目前大型 Java 应用程序中一个巨大且非常现实的问题。为了解决这个问题,已经出现了一个完整的解决方案生态系统。例如,工具和构建实用程序,如 Maven 或 Gradle,标准化了指定和获取外部依赖项的过程。基于流程的解决方案,如持续集成,旨在解决跨各种开发环境构建不可预测性的问题。然而,这些工具所能做的只是使过程可预测。它们无法验证它们帮助组装的结果的有效性或准确性。一旦获取了依赖项,这些工具就无能为力,无法检测类路径中缺失或重复的类型。
回到 Amit 的故事。由于无法在部署前验证所有类是否可用,Amit 寄希望于最好的情况并部署了应用程序。应用程序启动正常,运行了几个小时没有出现任何错误。然而,仍然无法确定他是否做对了。也许有一个类还没有被执行,但一旦执行,JVM 可能会意识到它找不到其中一个导入。或者,也许在类路径中有相同类的重复版本,JVM 选择了它找到的第一个副本。难道没有更好的方法来确保任何给定的 Java 应用程序在部署/运行之前都能可靠地工作吗?
类路径问题
我们在 Jack 和 Amit 的故事中看到了两个问题。Jack 需要一个有效的方法来封装他库的一部分,但他做不到。Amit 需要一个方法来确保他的应用程序在没有实际执行的情况下可靠地执行。由于 Java 中类路径解析的方式,Jack 和 Amit 都没有真正解决他们的问题。我们有时可能会错误地将 JAR 文件视为在 Java 中构建可重用模块的方法,但不幸的是,情况并非如此。JAR 文件只是类的一个方便的捆绑包。仅此而已!一旦在类路径中,JVM 对待 JAR 中的类与同一根目录中的所有单独类文件没有区别。在运行时,对 JVM 来说,应用程序只是一个包的扁平列表中的类集。
更糟糕的是,一旦一个类在类路径中,它就是公共的。任何开发者都可以非常容易地使用他们不应该使用的类型,或者在他们编译/部署/运行时可能可用的类型。或者,在两个不同的类路径位置可能有相同类的多个副本甚至多个版本,这使得在执行期间无法预测运行时将选择哪个版本。有一个常见的问题被称为JAR 地狱,它指的是由于 JAR 文件中不匹配和不正确的类和版本而产生的一系列问题。
在拥有数十万个类的巨大代码库中,这个问题更为严重。想象一下,你的应用程序中的所有这些类都像是一个没有结构的扁平列表!维护和组织起来就像是一场噩梦。代码库越大,问题越大。为了说明这一点,让我们以一个经典的例子来说明,这是一个用 Java 编写的代码库,它非常庞大且复杂,并且已经存在多年。它可能是有史以来最古老的 Java 代码库之一,而且它仍然以相当快的速度持续增长和变化。有什么猜测吗?好吧,它是 Java 平台本身!
Java - 20 年的代码库
来谈谈单体架构吧!自 1996 年首次发布以来,Java 已经走了很长的路。JDK 的第一个主要版本有超过 500 个公共类。与 2014 年发布的 JDK 8 相比,这相去甚远,JDK 8 有超过 4,200 个公共类和超过 20,000 个总文件。
以下命令提取了 rt.jar 文件,这是 JDK 8 中捆绑的库 JAR 文件,并计算了其中的类数量。在我的机器上安装的 Java 8 版本中,计数为 20651:

JDK 和运行时,即 JRE,多年来一直在增长。语言中添加了许多功能,因此这种增长是可以理解的。然而,Java 语言也以其不遗余力地保持向后兼容性而闻名,并且除非绝对必要,否则它不愿意弃用功能。因此,从某种意义上说,当前运行时的大小略大于理想情况下的理想大小。
通常情况下,大多数应用程序开发者不需要担心 JDK 代码库。他们只需关注自己的应用程序代码。然而,由于打包方式的原因,运行时的内容对于应用程序的执行确实很重要。传统上,每个 JRE 都会将所有必要的运行时类打包成一个名为 rt.jar 的单个 JAR 文件,该文件位于 lib 目录中。正如你可能猜到的,名称 rt 代表 运行时。
不仅这个庞大的类库在大小上不必要地庞大,它还增加了 Java 虚拟机管理的性能开销。而且,这是所有应用程序执行环境必须支付的价格,无论是否使用了所有这些类。
旧类
一个不需要的类的例子是 JRE 中与 CORBA 相关的类集。你听说过 CORBA 吗?如果你没有,不要绝望。这有一个原因!它是一种老技术,早在 Java 1.4 版本中就被引入到 Java 运行时中。从那时起,它已经基本上不再被广泛使用。考虑到大多数应用程序不再使用 CORBA 技术,如果应用程序可以与不包含不必要 CORBA 类的 JRE 打包在一起,那岂不是很好?
很遗憾,这不可能,同样是因为 rt.jar。由于所有内容都被打包到一个单独的运行时 JAR 中,你无法选择你需要的功能。每个人都会得到一切。由于运行时的大小在增加,因此独立的可部署应用程序的大小也在增加。当运行时需要在资源有限的较小设备上使用时,这是一个更大的挑战。如果你将运行时与一个简单的 Hello World 应用程序捆绑在一起,该应用程序只使用了运行时中的一小部分类,你除了将 rt.jar 中的大量未使用类与之捆绑外别无选择。而且,是的,甚至那些旧的 CORBA 类也加入了这次旅行!
Java 8 引入了配置文件的概念,因此从技术上讲,你可以部署更小的运行时。但它们确实有一些缺点。此外,这个特性只是 Java 9 中引入模块化特性的第一步。我们将在第四章 介绍模块化 JDK 中详细探讨紧凑配置文件。
内部 API
记得 Jack 与他自己的BubbleSortUtil类遇到的问题吗?这是一个他写的 Java 类,目的是使其仅限于他的库。然而,尽管它最初是一个私有的内部类,但它最终变成了一个公共类,因为其他开发者只是决定使用它。
那只是一个小的库。现在,想象一下一个像 Java 运行时那样大且广泛使用的库。Java 运行时显然捆绑了其功能所需的内部类,这些类并不打算被应用程序开发者使用。然而,考虑到其使用的规模,一些内部类无意中被开发者使用并不令人惊讶。
这的一个经典例子是sun.misc包中的一个名为Unsafe的类。这个听起来不祥的类已经成为了每个主要 JDK 版本的一部分。你能猜到它做什么吗?它包含了一组方法,根据类的作者,执行低级不安全操作。是的,它实际上在类的注释中这样说的!例如,它有一个从内存地址获取值的方法。这不是 Java 应用程序开发者典型的一天的工作!你不会,理想情况下也不应该作为应用程序开发者做这样的事情。这就是为什么这个类被标记为内部 API。你想查找它的 Javadoc 来使用它吗?你不会在那里找到它。你想创建这个类的新实例吗?它的构造函数被标记为私有。如果你 somehow 使用了它并编译了你的代码,从 Java 6 以来的每个 Java 编译器都会给你一个讨厌的警告,劝阻你使用这个类。而且,如果你还需要更多避免使用它的理由,最好的办法就是看看类的名字!
你现在肯定已经猜到了接下来会发生什么。尽管 Java 运行时作者已经采取了所有预防措施,但sun.misc.Unsafe类已经被许多开发者用于多个项目中,以执行那些非常低级的操作。有人可能会争辩说,它实现了在其他地方不常见的功能,对于一个需要做类似事情的开发者来说,没有什么能比在类路径中找到并准备好使用它更好了。当然,Unsafe并不是唯一以这种方式使用的内部 API。还有一些其他内部类,许多在sun.*包中,多年来开发者们一直在使用,尽管他们不应该这样做。此外,只要开发者继续使用这些 API,就越来越难以从运行时中移除它们。这最终导致这些类在运行时的后续版本中继续存在,从而允许更多的开发者使用它们!
这些关于 Java 运行时和库系统的限制已经存在了一段时间。我之前概述的所有问题都存在,因为 Java 缺乏创建模块化代码单元的能力。这种结构在语言中至今尚未存在。社区对此需求强烈。
多年来,人们提出了多个为 Java 创建模块系统的建议,包括 2005 年的 JSR-277(jcp.org/en/jsr/detail?id=277)和 2006 年的 JSR-294(jcp.org/en/jsr/detail?id=294)。在面临了多次挑战后,模块化终于在 2017 年 Java 9 的发布中到来,伴随着 JSR-376(jcp.org/en/jsr/detail?id=376)的发布,该规范名为 Java 平台模块系统,以及 Project Jigsaw。
缩写提醒:JCP 和 JSR
JCP:Java 语言规范长期以来一直是社区拥有的资产。没有一个人中央权威机构对语言如何发展拥有完全的控制权和决策权。作为 Java 开发者,我们每个人都可以对语言如何改变和成长发表意见。Java 社区过程(JCP)是一种机制,于 1998 年引入,允许任何对语言规范未来感兴趣的人注册、提供反馈并参与技术规范过程。访问jcp.org了解更多信息。
JSR:假设你是 Java 社区过程的一部分,并且你有一个关于语言规范变化的绝佳想法。你所做的是创建一个Java 规范请求(JSR)——一份正式文件,描述了拟议的更改。JSR 在成为最终版本之前,作为社区过程的一部分进行审查和投票。一旦 JSR 成为最终版本,它就会被开发,并最终成为语言规范的一部分。
有趣的事实:Java 社区过程本身是语言的重要组成部分,因此对其的更改也像对语言的任何其他更改一样处理——通过提交一个针对它的 Java 规范请求。是的,有一个描述 JCP 的 JSR!
Java 平台模块系统
Java 9 中的模块化特性统称为Java 平台模块系统(JPMS)。它引入了一种新的语言结构来创建可重用的组件,称为模块。Java 平台模块系统使得开发者能够创建具有明确依赖其他模块的封装单元或组件变得容易。使用 Java 9 模块,你可以将某些类型和包组合成一个模块,并为其提供以下信息:
-
其名称:这是模块的唯一名称
-
其输入:模块需要什么并使用什么?给定模块要编译和运行需要什么?
-
其输出:这个模块输出或导出到其他模块的是什么?
我很快会解释输入和输出配置,但非常简单地说,在创建新模块时,您通常需要提供这三条信息。每当开发者需要创建任何旨在可重用的组件时,他们可以创建新的 Java 模块,并提供这些信息来创建一个具有清晰接口的代码单元。由于模块可以正式指定其输入和输出,与迄今为止我们批评的类路径方法相比,它增加了一系列优势。
现在我们一步一步地看看创建模块的过程。我们现在从概念层面来探讨它,而关于语法的内容将在第二章“创建您的第一个 Java 模块”中进行介绍。假设您想创建一个可重用的库,并且已经决定将您的代码放在一个 Java 9 模块中。以下是您需要遵循的步骤:
-
创建模块并为其命名:每个模块都与一个名称相关联,这是为了显而易见的目的——引用它。您可以给模块起任何您传统上会给类型起的名字。您已经熟悉的有关 Java 包名的所有规则都适用于这里(所以某些字符,如
'/'或'-'是不允许的,但'_'或'.'是可以的)。命名模块的推荐方法是使用反向域名约定,类似于您命名包的方式。例如,如果 Acme 公司的人编写了一个分析模块,他们可能会将模块命名为com.acme.analytics。 -
定义模块输入:实际上,很少有模块可以真正自给自足。您通常会需要导入不在您模块中的类型。这就是模块输入配置发挥作用的地方。当您创建一个模块时,您需要明确声明哪些其他模块对您的代码运行是必需的。您通过指定您的模块需要哪些模块来完成这项工作。
-
定义模块输出:我们已经看到,在传统的 JAR 文件系统中,将 Java 类型放入 JAR 文件实际上并没有什么意义,并且每个公共类型都可以被类路径中的任何其他类型访问,无论它位于哪个 JAR 中。模块的行为不同。默认情况下,您放入模块中的每个 Java 类型只能被同一模块中的其他类型访问。即使该类型被标记为 public!为了将类型暴露在模块之外,您需要明确指定您想要导出的包。从任何模块中,您只能导出该模块中存在的包。一旦导出了一个包,该包中的所有类型都可能对模块外部可访问。这使得每个 Java 模块可以清楚地分离和隐藏仅用于模块内部的内部包,并仅暴露旨在外部使用的类型。如果一个 Java 类型位于未导出的包中,那么模块外部的任何其他类型都无法导入它,即使该类型是 public!
注意从模块导出的内容(即包)与导入或要求的内容(即其他模块)之间的区别。由于我们正在以包级别从模块导出类型,为什么不要求包呢?原因很简单。当模块要求另一个模块时,它自动获得访问该模块导出所有包的权限。这样,你不必指定你的模块需要的每个包。只需指定你依赖的模块的名称就足够了。我们将在 第三章,处理模块间依赖关系中更详细地探讨访问机制。
以下图表说明了典型模块的输入和输出定义:

JPMS 是基于两个主要目标设计的:
-
强封装:我们已经看到了每个公共类对类路径中每个其他类都可见的危险。由于每个模块都声明了哪些包是公共的,并隔离了内部包,Java 编译器和运行时现在可以强制执行这些规则,以确保没有内部类在模块外部被使用。
-
可靠的配置:由于每个模块都声明了它需要什么,运行时可以在应用程序启动和运行之前很好地检查每个模块是否拥有它所需的一切。不再需要希望和期望所有必需的类都存在于类路径中。
你可以想象 Jack 和 Amit 听到这个消息会有多高兴!多亏了 强封装,Jack 只需将他的所有 StringSorter 代码放入一个模块中,并导出他的公共包。这样,他的 内部 包就会被隐藏,默认情况下不可访问。而且,多亏了 可靠的配置,Amit 总是可以在运行应用程序之前自信地说,给定的模块集是否满足所有依赖项。
除了这两个核心目标之外,模块化系统还被设计为具有另一个重要的目标——即使在巨大的单体库中也能实现可扩展性和易于使用。为了验证这一点,Java 9 团队继续对几乎所有他们能接触到的最古老和最大的 Java 代码库进行了模块化——即 Java 平台本身。这项任务,最终涉及了大量努力,被命名为 Project Jigsaw。
Project Jigsaw
Oracle 的 Java 平台组成员 Alan Bateman 在 2016 年 9 月的 Java One 会议上说:
模块化开发从模块化平台开始。
无论应用程序的内容是什么,都有一组库是每个 Java 程序无疑都会使用的——Java 平台。为了使 Java 开发者能够编写模块化 Java 代码,核心 Java 平台和 JDK 库本身也必须是模块化的。在 Java 9 之前,JDK 中的所有类和类型都有如此复杂的相互依赖关系,它们就像一大碗意大利面。
不仅最终的rt.jar捆绑包不必要地大,它还使得 JDK 代码库本身更难更改和演进。考虑到这样一个庞大的代码库中的任何类型都可能被平台中的其他数千个类型使用,我不愿意去那里对那段代码进行任何重大更改。平台本身的问题之一是它始终缺乏隐藏和封装内部平台 API(如sun.misc.Unsafe)的方法。平台本身完全可以利用 JPMS 给我们带来的强大封装和可靠配置的好处。
使用 Java 9,我们终于得到了一个模块化 JDK,可以在其之上构建。各种不同的相关 JDK 类被捆绑到单独的模块中,每个模块都有自己的导入和导出。例如,SQL 相关类型在名为java.sql的模块中。XML 功能已进入java.xml模块,等等。我们将在第三章“处理模块间依赖”中更详细地查看这些即用型模块。
以下是新 Java 9 平台模块的一个子集的示例。不必担心个别名称。我们将在第四章“介绍模块化 JDK”中详细介绍平台模块:

Project Jigsaw 声称以下为其主要目标。在学习平台模块化的影响时,请记住这一点:
-
可扩展的平台:从单一运行时转向,并使平台能够扩展到更小的计算设备。
-
安全和可维护性:更好地组织平台代码,使其更易于维护。隐藏内部 API 和更好的模块化接口,以提高平台安全性。
-
改进的应用性能:平台更小,只包含必要的运行时,从而实现更快的性能。
-
更便捷的开发体验:模块系统和模块化平台的结合,使开发者更容易创建应用程序和库。
这对应用程序开发者意味着什么?最直接的区别是,现在不是所有 JDK 中的类型都可以在您的代码中访问。我们看到的相同机制也适用于 Java 模块。每次您依赖于平台类时,您都必须将包含该类的正确平台模块导入到您的模块中。即使如此,您也只有在类已被模块导出且是公共的时才能使用该类。
这样,JDK 代码库也获得了 JPMS 承诺的强大封装和可靠配置的所有优势。尽管如此,也存在潜在的向后兼容性问题。如果您在 JDK 8 或更早版本中使用了现在属于模块封装类的 JDK 类,会发生什么?这些代码在 Java 9 中将无法工作!平台使用封装功能来保护某些内部 JDK 类免受外部使用。因此,任何依赖于这些类在 Java 8 或更早版本的代码,在迁移到 Java 9 之前必须先移除该依赖。从 Java 8 或更早版本迁移到 Java 9 存在一些挑战。我们将在第九章“模块设计模式和策略”中探讨与 Java 9 迁移相关的挑战和最佳实践。
模块化的重要方面之一,大多数模块化平台都必须处理,而我们之前还没有涉及的是版本控制。模块是否可以版本化?您能否声明模块之间的依赖关系,指定需要一起工作的模块版本?您不能!Java 平台模块系统今天不支持版本控制。我们将在第三章“处理模块间依赖关系”中简要探讨其原因。
摘要
在本章中,我们从高层次的角度探讨了使用 JAR 文件在 Java 中构建可重用组件的传统方式的局限性。我们看到了将库打包到 JAR 文件中不允许开发者封装内部 API 和类型。也没有可靠的方法来确定给定应用程序是否在类路径中包含所有必要的类。我们了解到,开发者在其代码中遇到的问题不仅存在于 JDK 代码库本身,实际上是一个更大规模的问题。我们了解了 Java 平台模块系统及其设定的两个主要目标——强大的封装和可靠的配置。我们了解了Project Jigsaw以及使用与开发者在其代码中可用的相同模块化范式对核心 JDK 进行模块化的努力。
到目前为止,你可能想知道模块化的概念如何在 Java 语言中体现。Java 模块是什么样的?
在下一章中,我们将通过创建我们的第一个 Java 9 模块来回答这些问题,并开始我们的示例应用程序项目,我们将在这本书的整个过程中对其进行工作。
第二章:创建您的第一个 Java 模块
在上一章中,我们详细探讨了 Java 9 之前模块化 Java 代码时遇到的问题,以及 Java 9 中的新模块构造和Project Jigsaw。在我们检查 Java 模块化如何解决这些问题之前,您首先需要了解 Java 模块的外观。在本章中,您将创建您的第一个 Java 9 模块,并学习在 Java 模块中构建和执行代码所需的内容。以下是本章您将学习的顶级主题:
-
配置 Java 9 的 JDK
-
创建一个新的 Java 9 模块
-
定义模块(使用
module-info.java) -
编译和执行模块
-
处理可能的错误
在学习与模块化相关的不同概念的过程中,您将在这本书中构建一个示例 Java 9 应用程序。您将构建的应用程序是一个地址簿查看器应用程序,它按姓氏排序显示一些联系人。我们将从简单开始,并在进行中增强此应用程序。完成本章后,您将构建您的第一个 Java 9 模块,并学习如何编译和执行它。让我们首先安装 JDK。
配置 JDK
为了编写 Java 9 代码,您首先需要下载并安装 Java 9 SDK(称为 Java 开发工具包或 JDK)。在这本书中,我们将使用位于jdk.java.net/9/的OpenJDK构建。当您导航到该 URL 时,您将看到基于您所使用的平台提供的可用下载列表,如下所示:

确保您在JDK列中选择适合您平台的下载,而不是JRE列。接受许可协议后,您应该能够下载适合您平台的安装程序。运行安装程序并选择默认选项;之后,您应该在您的机器上安装了 JDK 9:

安装完成后,验证 JDK 安装和配置过程是否成功完成是个好主意。您可以通过打开命令提示符或终端窗口来完成此操作。输入命令java -version以输出当前PATH中java命令的版本:
注意,安装程序会将已安装的 Java 二进制文件的路径添加到您的系统PATH变量中,这就是为什么这个命令可以工作。

此外,请确保已设置JAVA_HOME值。
在 macOS/Linux 上,输入命令echo $JAVA_HOME并确保返回的是 JDK 9 安装路径:

在 Windows 上,右键单击“我的电脑”,点击“属性”,然后切换到“高级”选项卡。在这里,点击“环境变量”并查看变量JAVA_HOME的值。它应指向您选择的 JDK 安装位置。例如,位置可能是类似C:\Program Files\Java\jdk9的东西。
通过这种方式,您现在已成功安装了 JDK 9,并准备好开始用 Java 9 进行编码!
在 JDK 之间切换
一旦你安装了比已安装的早期版本更新的 JDK,就可以切换当前选定的版本。
在 macOS 和 Linux 上,你通过切换JAVA_HOME的值来完成此操作
以下命令将当前 Java 平台切换到 Java 8:
$ export JAVA_HOME=$(/usr/libexec/java_home -v 1.8)
要切换到 Java 9,请使用以下命令:
$ export JAVA_HOME=$(/usr/libexec/java_home -v 9)
使用此命令,你正在将所选的 Java 版本传递给-v参数。但是,请注意,Java 8 和 9 之间的格式不同。对于 Java 8,版本字符串是1.8。对于 Java 9,版本字符串只是9。传统上,Java 一直使用1.X的版本格式,例如,Java 版本 7 的版本字符串是1.7。从 Java 9 开始,这种格式正在改变。想法是,Java 的后续版本将放弃1.X格式,只使用一个数字来表示格式。所以是 Java 9,而不是 Java 1.9。
是时候改变这个了!想象一下 Java 10 会造成的混乱!
在 Windows 上,通过更改JAVA_HOME和PATH变量来切换 JDK 版本。遵循前面的步骤进入环境变量部分。更新JAVA_HOME的值,使其指向所需版本安装的位置。同时,确保PATH已更新,以指向你想要切换到的 Java 版本的相应文件夹。
设置 NetBeans IDE
为了编写和遵循本书中的代码,你不必使用任何集成开发环境(IDEs)。本书将涵盖使用命令行手动编写、编译和执行代码。你可以使用你选择的文本编辑器编写代码。本书附带的代码示例也适用于本书中展示的步骤和命令。
你也可以跟随 IDE 进行操作。在撰写本文时,NetBeans 和 IntelliJ Idea 对 Java 模块化项目有越来越多的支持,Eclipse 的支持正在开发中。本章概述了在 NetBeans 中创建模块化项目的步骤,如果你选择使用 NetBeans IDE。为了设置它,除了遵循设置 Java 的步骤外,请确保通过访问netbeans.org/downloads/并选择 Java SE 或 Java EE 版本进行下载来安装 NetBeans 的最新版本,以便支持 Java 9 模块:

注意,如果你在阅读此内容时,NetBeans 的发布版本尚不支持 Java 9,你可能必须在此处下载早期访问副本:bits.netbeans.org/download/trunk/nightly/latest/。一旦下载安装程序并执行它,你应该在你的计算机上有一个全新的 NetBeans 副本,供你使用。
大多数经验丰富的 Java 程序员都会承认,他们已经很久没有手动为 Java 项目创建文件夹结构了。多年来,Java 语言受益于极其有用的工具支持。例如,Eclipse、NetBeans 和 IntelliJ IDEA 等 IDE 使得创建源和包文件夹变得非常容易,以至于开发者甚至不太经常考虑代码组织的这些方面。然而,在本章中,实际上在本书的其余部分,我们将学习如何手动构建文件夹和文件,以及通过在命令行中手动执行命令来编译和运行代码。这是因为尽管 IDE 可能很方便,但它们往往隐藏了代码结构和编译过程的细节和运作。我们的目的是检查 Java 9 模块是如何工作的,以及代码结构的基本运作,以及编译和链接命令对于掌握这些概念是至关重要的。
虽然在没有 IDE 编写 Java 代码的前景可能看起来令人畏惧,但请让我向您保证,这将是您很快就会习惯的事情。在本章中,您将学习构建和执行模块化 Java 9 代码的步骤,然后只需根据代码库本身的复杂性应用相同的步骤即可。
Java 9 模块
当在 Java 9 中编写应用程序时,理想情况下您正在创建一个模块化应用程序。需要注意的是,模块化 Java 应用程序并不仅仅是一个带有额外模块功能的常规 Java 应用程序(就像我们这些年一直在构建的那样),它实际上要求我们以全新的方式思考编写和结构化代码库。在我们开始创建 Java 9 模块之前,让我们快速回顾一下 Java 9 之前的传统 Java 代码结构。
传统 Java 代码结构
传统上,编写 Java 应用程序从创建一个或多个源目录开始。这些目录具有两个特殊目的——首先,它们是 Java 源代码的根位置,其次,这些目录的内容被添加到类路径中。因此,组织源代码的步骤通常如下:
-
创建一个或多个源文件夹。
-
在源文件夹中,创建与包名相对应的包文件夹。
-
将
.java文件放置在正确的包文件夹中:

许多 Java 开发者使用src/main/java目录结构作为源目录。例如,包com.acme.app中的Main.java类的完整路径将是src/main/java/com/acme/app/Main.java:

这就是多年来开发者通常如何组织 Java 源代码的方式。随着 Java 9 模块的出现,我们在结构和编写代码的方式上发生了变化。让我们转换一下思路,看看 Java 9 中的模块是什么样的。
什么是模块?
模块是 Java 9 编程语言中引入的新结构。将模块视为一等公民,以及语言中的新程序组件,就像类或包一样。Java 9 模块是一个命名、自我描述的代码和数据集合,你可以在 Java 应用程序中创建和使用它。模块可以包含任意数量的 Java 包,这些包又包含 Java 代码元素,如类或接口。Java 模块还可以包含资源文件或属性文件等文件。应用程序是通过将这些模块组合在一起来构建的。构建积木的类比在这里适用得很好——模块是一个独立的构建块,但也可以成为更大整体的一部分。
使用 Java 9 模块化编程,我们不再以单体代码库的形式构建应用程序,而是将问题分解为模块。换句话说,与其拥有一个庞大的 Java 项目,不如创建几个模块单元,它们共同协作形成一个应用程序。
这在很大程度上影响了你的应用程序设计和编码方式。在 Java 8 及之前版本中,你的设计过程涉及将问题分解为类和包。在 Java 9 中,你首先将问题分解为模块。这些模块理想情况下是具有清晰接口(输入和输出)的可重用组件,解决问题的特定部分。然而,在每个模块内部,设计和编写代码的过程,或多或少,与使用包、类、接口等常规操作相同。
创建模块
这里是创建 Java 9 模块的步骤。在你开始进行步骤 1之前,我应该提到一个明显的起点——步骤 0:了解模块的目的。在开始创建模块之前,你应该清楚地知道模块的用途。记住模块化开发的重要原则!与其为应用程序拥有一个大的代码库,不如将问题分解为可重用的子项目。想想可重用库。主要区别在于,这些库不仅仅是作为 Java 包和类型的集合的独立 JAR 文件,我们正在利用 Java 模块的概念来分组这些包和类型:
- 指定模块名称:创建模块的第一步是为模块想出一个独特的名称。理想情况下,该名称应描述模块的内容和解决的问题。Java 模块名称应是一个有效的 Java 标识符,因此不能使用某些字符,例如连字符和斜杠。有效的包名也是有效的模块名称。但除此之外,任何名称都可以,只要它在应用程序中是唯一的。然而,为了避免名称冲突,你不希望将模块命名为非常通用的名称,例如
util。
建议使用多年来在 Java 中为包名所采用的相同约定--使用反向域名模式。还建议您使用全小写命名模块,就像包一样。例如,如果您为 Acme 公司编写一个字符串实用模块,模块的名称可以是com.acme.stringutil。
-
创建模块根文件夹:每个 Java 模块都位于自己的文件夹中,该文件夹作为模块的最高级文件夹,包含所有模块资产。这个文件夹被称为模块根文件夹。模块根文件夹的名称与模块相同。因此,上述示例模块的根文件夹名称为
com.acme.stringutil。它的命名与模块名称完全相同,包括任何点。 -
添加模块代码:模块根文件夹内包含属于模块的代码。这从包开始,因此您从模块根文件夹开始创建包文件夹。所以,如果您的模块
com.acme.stringutil在com.acme.util包中有StringUtil.java类,文件夹结构应该如下所示!
注意与之前我们查看的 Java 9 之前的文件结构的不同。在 Java 早期版本中直接放入源文件夹的内容现在放入了模块根文件夹。从下面的表格中可以看出,在 Java 9 中,只是多了一个模块根文件夹的额外文件夹级别。从模块根文件夹开始,Java 类型组织方式上没有新的变化:
| Java 8(及之前版本)的方式 | Java 9 的方式 |
|---|---|
1. 创建一个或多个源文件夹。2. 在源文件夹中创建包文件夹以反映包名。3. 将.java文件放置在正确的包文件夹中。 |
1. 创建一个或多个源文件夹。2. 在源文件夹中为每个模块创建一个模块文件夹。3. 在模块文件夹中创建包文件夹以反映包名。4. 将.java文件放置在正确的包文件夹中。 |
这是一个表示模块代码结构的图示:

- 创建和配置模块描述符:这是最后一步!每个模块都附带一个描述它的文件,并包含有关模块的元数据。这个文件被称为模块描述符。该文件包含有关模块的信息,例如它需要什么(模块的输入)以及模块导出什么(模块的输出)。模块描述符始终位于模块根目录中,并且始终命名为
module-info.java。没错!模块描述符文件实际上是一个.java文件。这个模块描述符文件的内容看起来是什么样子?
这是一个示例模块的简单且最基础的模块描述符--com.acme.stringutil:
module com.acme.stringutil {
}
文件以module关键字开头,后面跟着模块名称和大括号。大括号结构类似于你应该熟悉的其他 Java 类型声明。请注意,模块的名称(在module关键字之后)应该与模块根文件夹的名称完全匹配。
在大括号内,你可以选择性地指定模块的元数据(输入和输出)。在上面的例子中,模块描述符基本上是空的,大括号之间没有任何内容。对于你创建的任何实际模块,你很可能会在这里添加一些元数据来配置模块的行为。我们将在第三章“处理模块间依赖”中更详细地介绍这些元数据,但你在例子中看到的是模块描述符所需的最基本且足够的内容。
在模块根目录中,这是我们的简单模块的文件夹和文件结构:

对于习惯于编写 Java 类的开发者来说,module-info.java文件的名称一开始可能看起来有点奇怪。这是因为几个原因:
-
这里的
-字符在 Java 类型名称中不是一个有效的标识符,也不适用于.java文件的名称 -
.java文件的名称通常与文件中包含的公共类型名称相匹配,但在这个例子中并不如此
然而,一些 Java 开发者可能也会觉得这个文件名很熟悉,因为自从 Java 1.5 以来,Java 中就使用了类似外观的文件名——package-info.java。package-info.java文件用于指定包级别的配置和注解,并且已经使用了多年,尽管并不广泛。module-info.java和package-info.java文件都故意被赋予了无效的 Java 类型名称,以传达它们的特殊含义和目的,并将它们与其他 Java 类型和文件区分开来,这些类型和文件是你通常在构建应用程序的过程中创建的。
上述方法中的步骤1到4是创建模块所需的四个必要步骤。让我们通过使用 Java 9 模块化应用程序方法创建一个地址簿查看器应用程序来将这些步骤付诸实践。这是一个简单的应用程序,可以帮助你查看一组联系信息。没有太多复杂的功能,但足以让我们将本书中学到的所有 Java 模块化概念付诸实践!
创建你的第一个 Java 模块
让我们从创建我们的第一个 Java 9 模块开始,并逐步介绍编码、编译和执行模块的过程。理想情况下,任何应用程序都应该由多个模块组成,但在这个章节中,我们将从小处着手。在这个章节中,我们将创建一个名为packt.addressbook的模块。这将是我们第一个 Java 模块。在接下来的几章中,我们将将其分解成多个模块。
我们显然需要从所有代码所在的文件夹开始。在这本书的截图里,我选择了路径<home>/code/java9,但你可以自由选择任何你喜欢的路径。在这本书中,我会将这个文件夹称为项目根文件夹。
我们刚刚学习了创建任何 Java 模块所需的四个步骤。现在,让我们为addressbook模块运行这四个步骤:
-
命名模块:我们已经完成了这个步骤!我们的
addressbook模块的名称是packt.addressbook。 -
创建模块根文件夹:现在,你需要为每个打算编写的模块创建一个模块文件夹。由于我们正在创建一个名为
packt.addressbook的模块,我们创建一个具有相同名称的文件夹。我建议将所有 Java 源文件放在项目根文件夹中的单独文件夹src中。然后,你将在src文件夹中创建所有模块根文件夹。由于我的项目根文件夹是~/code/java9,模块根文件夹位于~/code/java9/src/packt.addressbook。这个packt.addressbook文件夹是模块中所有包和 Java 文件所在的地方。 -
向模块添加代码:对于 Java 开发者来说,这一步是常规操作。从模块根目录开始,你的文件夹结构反映了你的包。对于我们第一次尝试,让我们编写一个简单的Hello World应用程序。在
packt.addressbook包中创建一个名为Main.java的 Java 文件。Main.java的完整路径是~/code/java9/src/packt.addressbook/packt/addressbook/Main.java。让我们添加一个主方法,它只是将一条消息打印到控制台:
package packt.addressbook;
public class Main {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
注意,与之前的 Java 8 目录结构相比,实际的不同之处在于引入了模块根文件夹。将每个模块视为某种类型的子项目是有帮助的。包结构从模块根文件夹开始。
- 创建模块描述符:创建一个名为
module-info.java的文件,并将其直接放置在模块根目录中。我们将在第三章“处理模块间依赖关系”中详细介绍这个文件中可以配置的内容,但现在,创建这个文件并包含以下内容:
module packt.addressbook {
}
关键字module后面跟着模块的名称,在我们的例子中是packt.addressbook。目前在大括号之间没有内容,因为我们还没有在这个文件中指定任何关于模块的细节,除了模块名称。然而,将此文件添加到模块目录中对于 Java 编译器将其视为模块是至关重要的。
这样,你就完成了!这四个步骤就是创建一个简单的 Java 9 模块所需的所有步骤。完成之后,文件夹结构应该如下所示:

现在,让我们继续编译和执行这个模块。
编译你的模块
在 Java 9 模块中编译代码始终需要使用javac命令,但这次有一些不同的选项。要在 Java 9 中编译模块,您需要向javac命令提供以下信息:
-
您模块的位置。这是包含您应用程序中所有模块的模块根文件夹的目录。在我们的例子中,这是
src文件夹。在其中,我们只有一个模块根文件夹。 -
需要编译的 Java 文件的路径和名称。在我们的例子中,只有一个文件——
Main.java。 -
编译器需要输出编译后的
.class文件的目标位置。这可以是任何位置,但我建议选择位于项目根文件夹直接下方的名为out的文件夹。
要编译您编写的模块,请转到项目根目录(在我们的例子中,它是~/code/java9)并运行以下命令:

在这里,您使用--module-source-path命令选项指定模块源路径(1),使用-d命令选项指定编译后的类的输出目录(2),并通过直接在命令中指定它们来指定 Java 源文件列表(3)(在这种情况下,Main.java和module-info.java)。
如果编译器成功,控制台没有输出。out目录应包含编译后的类:

注意,源代码和编译后的类之间存在一对一的映射。甚至模块描述符module-info.java也被编译成了.class文件--module-info.class。这有一个非常重要的原因。模块配置不仅为编译器在编译时提供有关模块的元数据信息,而且还在 JVM 运行时提供。多亏了module-info.class,JVM 也拥有了关于每个 Java 模块的所有信息,从而使得运行时能够在执行过程中充分利用模块系统的许多好处。
执行您的模块
再次执行编译后的代码使用熟悉的java命令,但有一些新的选项。以下是您需要告诉java命令的信息:
-
编译后的模块的位置——也称为模块路径。
-
包含需要启动执行的
main方法的类的模块。 -
在前面的模块中具有需要启动执行的
main方法的类。
要执行您在上一步中编译的代码,您需要在同一目录下运行以下命令:

这里,你正在使用 --module-path 标志指定编译模块的位置(1)。我们在上一步中告诉编译器将我们的编译代码放在 out 文件夹中,因此这就是我们需要提供的值。你使用 --module 选项指定模块(2)和带有主方法的类(3)。注意值的格式--它是以 <module-name>/<fully-qualified-classname> 的形式。在这里,我们的代码只包含一个类,所以指定这个似乎是不必要的,但你可以想象一个包含多个模块和类的代码库,其中许多可能包含主方法。对于运行时来说,知道它需要从哪个模块的哪个类的哪个主方法开始执行是很重要的。
许多这些选项都有备选的名称。例如,你不必使用 --module-path,可以直接使用 -p,而 --module 可以用 -m 替代。
如果执行成功完成,你应该会在控制台上看到打印的消息 Hello World!。
你已经学到了两个新的参数,--module-source-path 和 --module-path。它们大致对应于我们长期以来在 Java 中使用的 -sourcepath 和 -classpath 选项。
Java 8 及更早版本:
sourcepath:告诉编译器需要编译的源文件在哪里。
classpath:告诉编译器/运行时需要包含在 classpath 中的编译类型在哪里,以便编译/运行代码。
Java 9:
module-source-path:告诉编译器模块的源文件在哪里。
module-path:告诉编译器/运行时需要考虑编译/运行代码的编译模块在哪里。
使用 NetBeans 创建模块
现在你已经学会了如何使用命令提示符创建、编译和执行模块,让我们看看如何使用 NetBeans IDE 做同样的事情:
- 在 NetBeans IDE 中通过点击工具栏中的
或通过菜单文件 | 新建项目,你将看到一个带有 Java类别中新选项的新项目覆盖层--Java 模块项目:

- 选择它并点击下一步。在下一个对话框中,你可以指定项目的名称(我选择了
addressbookviewer)和项目位置,然后点击完成:

- 一旦新项目加载到你的 IDE 中,你可以在项目标签页中右键单击项目名称,并选择创建新模块的选项:

- 在新建模块对话框中,输入模块名称
packt.addressbook并点击完成:

就这样,你已经创建了一个新的模块!注意 NetBeans 已经自动为你创建了模块描述符:

-
现在剩下的就是通过在模块上右键单击并完成向导来添加
Main.java类:![图片]()
-
在添加了
Main.java类之后,你可以通过右键单击该类并点击运行文件来编译和执行它。你应该在底部的控制台面板上看到消息Hello World。

恭喜!你已经创建了、编译并执行了你的第一个 Java 模块!这是一个只有一个 Java 类型Main.java的模块。在这本书中,你将要编写的代码大多数都会让你遵循你刚才所做过的相同步骤。当然,会有几个变体,但我们将随着对模块了解的深入来探讨这些差异。
地址簿查看应用程序
现在你已经熟悉了创建、编译和执行简单的 Java 9 模块,让我们更新它并开始添加地址簿查看功能。
下面的非正式类图显示了我们将如何设计应用程序类:

主类有一个main()方法,该方法按lastName属性升序显示联系人的列表。它通过调用ContactUtil.getContacts()方法来获取联系人的列表,并使用SortUtil.sortList()对其进行排序。然后它将联系人列表显示到控制台。
我们将从一个新的模型类Contact开始,它代表一条联系信息。除了显而易见的与联系相关的私有成员变量和 getter 和 setter 之外,这个类还有一些将在以后派上用场的附加功能:
-
带有参数的构造函数:这使得我们能够轻松地创建联系对象。这很有用,因为我们一开始会硬编码我们的联系列表。
-
toString()方法:当我们向控制台打印
Contact对象时,它提供了可读的输出。 -
compareTo()方法:由于我们需要对
Contact对象进行排序,我们将有一个实现了Comparable接口的Contact类,以及一个compareTo()方法,该方法通过lastName属性比较Contact实例。
下面是Contact类的样子:
package packt.addressbook.model;
public class Contact implements Comparable {
private String id;
private String firstName;
private String lastName;
private String phoneNumber;
public Contact(String id, String firstName,
String lastName, String phoneNumber) {
this.id = id;
this.firstName = firstName;
this.lastName = lastName;
this. phoneNumber = phoneNumber;
}
// Getters and setters omitted for brevity
public String toString() {
return this.firstName + " " + this.lastName;
}
public int compareTo(Object other) {
Contact otherContact = (Contact)other;
return this.lastName.compareTo(otherContact.lastName);
}
}
让我们继续到ContactUtil类。我们需要一些联系数据作为编码的起点,所以现在我们将硬编码几个Contact对象并将它们添加到一个列表中。ContactUtil.getContacts()方法目前只是为这个硬编码的列表做准备:
package packt.addressbook.util;
public class ContactUtil {
public List<Contact> getContacts() {
List<Contact> contacts = Arrays.asList(
new Contact("Edsger", "Dijkstra", "345-678-9012"),
new Contact("Alan", "Turing", "456-789-0123"),
new Contact("Ada", "Lovelace", "234-567-8901"),
new Contact("Charles", "Babbage", "123-456-7890"),
new Contact("Tim", "Berners-Lee", "456-789-0123")
);
return contacts;
}
}
接下来是SortUtil类。通常你不需要编写排序方法,因为 Java 提供了几个开箱即用的良好集合库。然而,在这种情况下,我们将为了了解模块的目的实现我们自己的排序算法,因为它将帮助我们在这本书的整个过程中说明一些重要的用例。我们不会创建一个专门用于排序Contact实例的方法,而是编写一个通用的冒泡排序方法来排序任何实现了Comparable接口的类型。由于Contact实现了Comparable接口,我们应该能够使用SortUtil来排序其实例:
public class SortUtil {
public <T extends Comparable> List<T> sortList(List<T> list) {
for (int outer = 0; outer < list.size() - 1; outer++) {
for (int inner = 0; inner < list.size()-outer-1; inner++) {
if (list.get(inner).compareTo(list.get(inner + 1)) > 0) {
swap(list, inner);
}
}
}
return list;
}
private <T> void swap(List<T>list, int inner) {
T temp = list.get(inner);
list.set(inner, list.get(inner + 1));
list.set(inner + 1, temp);
}
}
现在让我们在Main.java中将所有内容整合在一起。我们首先在ContactUtil的一个实例上调用getContacts()方法来获取硬编码的Contact列表。然后我们将它传递给SortList的一个实例上的sortList()方法。然后我们将使用System.out.println()在控制台上打印排序后的列表:
package packt.addressbook;
public class Main {
public static void main(String args) {
ContactUtil contactUtil = new ContactUtil();
SortUtil sortUtil = new SortUtil();
List<Contact> contacts = contactUtil.getContacts();
sortUtil.sortList(contacts);
System.out.println(contacts);
}
}
这样,我们就完成了,并准备好编译我们的代码。我们看到了编译你的代码的命令看起来是这样的:
$ javac --module-source-path src -d out <all-java-classes-here>
我们需要指定所有 Java 类的部分可能会变得繁琐。我们目前只有一些类,而且我已经不想被麻烦地输入所有类名(包括路径!)在命令中。当我们有多个模块在模块源路径中,并且我们想要编译它们时,这会变得更糟。幸运的是,有一个快捷方式。编译器还有一个--module选项,允许你指定需要编译的模块名称。你可以在其中指定多个模块名称,用逗号分隔。编译器会在模块源路径中查找这些模块,并编译这些模块中的所有类。正如你所想象的那样,使用这个命令要容易得多!
由于我们在这里只编译一个模块,这就是我们将为--module参数指定的值:
$ javac -d out --module-source-path src --module packt.addressbook
编译应该在没有错误的情况下完成。现在,让我们继续执行你的代码。命令保持不变:
$ java --module-path out --module packt.addressbook/packt.addressbook.Main
这是硬编码联系人列表的排序输出:
[Babbage 123-456-7890, Lovelace 234-567-8901, Dijkstra 345-678-9012,
Turing 456-789-0123, Berners-Lee 456-789-0123]
如果你展开输出目录,你又会注意到.java文件和.class文件之间的一对一映射:

处理可能的错误
在遵循我们之前概述的步骤时,你可能会遇到以下一些可能的错误,以及一些解决方案:
- 编译时错误:
javac: invalid flag: --module-source-path
这可能是因为你没有切换到 JDK 9,仍然在使用 Java 8 编译器。--module-source-path选项是从版本 9 开始新引入到javac中的。
- 编译时错误:
error: module not found: packt.addressbook
这个错误是因为 Java 编译器无法找到module-info.java文件。请确保它在正确的目录路径中。
- 运行时错误:
Error occurred during initialization of VM
java.lang.module.ResolutionException: Module packt.addressbook
not found
这个错误表明模块文件不在提供的模块路径中可用。请确保路径已正确指定,并且packt.addressbook文件夹包含编译后的module-info.class文件。
摘要
在本章中,你已经学习了创建 Java 9 模块的最基本步骤。你从头开始创建了一个简单的模块,并在模块中编译和执行了代码。你还了解了一些可能的错误场景以及如何处理它们。
虽然如此,还有一些东西是缺失的!由于我们在这里处理的是一个单独的模块,所以我们实际上并没有真正利用模块化的概念。模块化的概念只有在有多个模块相互交互时才会发挥作用。你将在下一章中看到这一点,当你创建第二个 Java 模块并设置模块间依赖时!
第三章:处理模块间依赖
在上一章中,我们创建了我们的第一个 Java 9 模块,并设置了地址簿应用程序的初步设置。然而,我们构建了整个应用程序作为一个单一模块,因此,我们没有真正利用模块化的任何新特性。在本章中,你将学习以下内容:
-
你将把应用程序拆分为两个独立的模块,从而创建你的第二个 Java 模块
-
你将学习如何在这两个模块之间建立依赖关系
-
你将学习更多关于配置模块描述符以连接两个独立模块所需的内容。
-
你将重新审视具有多个模块的编译和执行步骤
所有这一切都始于将我们的单体地址簿查看器应用程序拆分为两个模块。将单个模块拆分为两个依赖模块有两个后果:
-
将应用程序拆分为模块化部分,使得这些模块化部分有可能在多个其他应用程序中重用。
-
这是一个定义模块接口的机会。它允许你,作为模块作者,定义模块导出什么以及如何使用和消费它。
我们将在本章中探讨这两个方面。
创建第二个模块
让我们从将地址簿应用程序拆分为两个独立的模块开始。显然,将排序逻辑移动到其自身的模块是一个合适的候选。在这个阶段,关于排序类SortUtil的任何内容都与地址簿无关。我们已经设计了该类以通用性为目标,并提供对任何列表进行排序的功能。这在一般情况下是好的实践,但在将其拆分为独立的模块时更有意义。我们将要做的是将有关排序的代码移动到一个全新的模块中,该模块称为packt.sortutil。以下是高层次上的步骤:
-
创建一个名为
packt.sortutil的新模块。 -
将与排序相关的代码移动到这个新创建的模块中。
-
配置
packt.sortutil模块以定义其接口——它导出什么以及如何使用该模块。 -
配置
packt.addressbook模块以使用新的packt.sortutil模块。
让我们从创建一个新的模块开始。我们已经在前面的第二章,“创建你的第一个 Java 模块”中探讨了创建模块的四个步骤。我们知道模块的名称。接下来,模块结构需要创建源文件夹中的模块根目录。就像packt.addressbook文件夹位于src文件夹中并包含packt.addressbook模块的所有内容一样,packt.sortutil模块需要在相同的src位置创建一个名为packt.sortutil的文件夹。使这个文件夹成为模块根文件夹的是模块描述符module-info.java的存在:
module packt.sortutil {
}
到目前为止的文件夹结构如下:

现在我们有了模块,我们可以将必要的类从packt.addressbook模块移动到packt.sortutil模块。只有一个与排序相关的类SortUtil.java。由于包文件夹位于模块文件夹中,移动类后的文件夹结构应该如下所示:

在创建模块时,你通常会配置接口,即定义模块的输入和输出。我们在第二章“创建你的第一个 Java 模块”中没有对packt.addressbook模块这样做,因为它作为一个独立模块存在。然而,在本章中情况不再如此,因为我们现在需要两个模块packt.addressbook和packt.sortutil协同工作。这涉及到更新两个模块的module-info.java文件以提供这些信息。但现在我们先跳过这一步,先观察默认行为。让我们观察如果我们不添加任何模块配置,并且使用空的模块定义文件编译两个模块会发生什么。
编译两个模块
现在模块路径(src目录)有两个模块。我们现在可以运行javac命令来编译两个模块中的所有类,因为我们提供了src作为模块源路径。
在这里,packt.sortutil是两个模块中较简单的一个。由于它没有任何外部依赖,这应该就像前一章中packt.addressbook模块所做的那样工作。让我们看看packt.addressbook模块。这里的情况更有趣。由于我们将排序相关的类移动到了packt.sortutil模块,packt.addressbook模块不再有packt.util包中的类。但模块中仍有代码在使用它。注意当我们尝试使用与上一章相同的命令编译这两个模块时会发生什么。这次,由于我们正在编译两个模块,我们指定了两个模块名称到--module参数,用逗号分隔。
$ javac -d out --module-source-path src --module packt.addressbook,packt.sortutil
你得到的错误应该看起来像这样:
./src/packt.addressbook/packt/addressbook/Main.java:6: error: SortUtil is not visible because package packt.util is not visible
import packt.util.SortUtil;
^
./src/packt.addressbook/packt/addressbook/Main.java:13: error: cannot find symbol
SortUtil sortUtil = new SortUtil();
^
symbol: class SortUtil
location: class Main
./src/packt.addressbook/packt/addressbook/Main.java:13: error: cannot find symbol
SortUtil sortUtil = new SortUtil();
^
symbol: class SortUtil
location: class Main
首先,请注意错误仅来自编译packt.addressbook模块。这意味着packt.sortutil模块的编译是成功的!前一个输出中的三个错误特别关于Main.java文件。编译器显然没有看到SortUtil类,并抱怨它缺失。但它真的缺失了吗?我们确实在src文件夹中有一个公共的SortUtil类,但由于它位于不同的模块文件夹中,编译器似乎没有看到它,尽管这个类本身是public的。这引发了一个与 Java 9 中类可见性相关的重要新变化。
仅因为一个类在 Java 9 中被标记为public,并不意味着它对所有其他类型都是可见的。
这与 Java 8 及之前版本中public类型的操作方式有根本性的不同。在 Java 9 之前,如果你在类路径中有公共类或接口,它对类路径中的任何其他类型都是可用的,可以导入和使用。但在 Java 9 的模块源路径中,情况不再是这样了!
配置模块依赖
将模块想象成一个默认的围栏。默认情况下,模块中的任何 Java 类型都只能被同一模块中的类型访问。之前,SortUtil类位于packt.addressbook模块中,因此该模块中的其他类型可以访问它。但是,如果将其移动到不同的模块,它就不再对原始模块中的类型可访问了。
对于两个模块,A和B,要使模块 A 中的任何类型访问模块 B 中的类型,需要满足两个条件:
-
模块 A 需要声明它对模块 B 的依赖
-
模块 B 需要声明它允许其他模块外部访问该类型
如果这两个条件中的任何一个没有满足,那么在模块 B 中被访问的类型就被说成是模块 A 不可读的。我们将在第六章中详细讨论可读性和可访问性的主题,模块解析、可访问性和可读性,但现在请注意,这些是两个重要的要求。让我们将这些应用到packt.addressbook和packt.sortutil模块上。
首先,我们需要让packt.addressbook模块声明它依赖于packt.sortutil模块。它正在使用该模块中的一个类,没有它是无法编译或运行模块的。声明对模块的依赖的方式是使用requires关键字:
module packt.addressbook {
requires packt.sortutil;
}
在requires关键字之后是所需模块的名称。是的,你只能要求其他模块,而不能是包或类。这一行就足够编译器和运行时在需要使用这些类型时从被要求的模块中查找类型了。一个模块可能依赖于多个其他模块。因此,在模块声明中通常会有多个这样的requires行代码。
当编译器尝试编译一个模块时,想象一下它会查看由requires子句指定的模块列表,并说:“好的,我明白这个模块需要所有这些其他模块。在我编译这个模块的过程中,每次我看到在模块代码中使用属于所需模块的类型时,我会查看包含该类型的模块,并确保它导出了正在使用的类型。”由于每个依赖模块可能还需要其他模块,这是一个递归过程。
那么,一个模块导出一个类型是什么意思呢?这就引出了前面提到的两个条件中的第二个。每个模块都需要明确声明其模块中哪些类型可以在模块外部使用。在我们的例子中,packt.sortutil模块需要声明SortUtil类可以在其模块外部使用。这是在sortutil模块的声明文件中完成的,使用exports关键字后跟你想导出的包。当一个模块导出一个包时,该包中所有的类型都可以在模块外部访问:
module packt.sortutil {
exports packt.util;
}
在我们的例子中,我们想要导出packt.util.SortUtil类。因此,我们导出包packt.util,从而导出其内部的类。
再次提醒,你需要要求模块和导出包。语言设计者决定使用exports语法而不是让开发者单独导出类型,这有几个原因。最明显的原因当然是,这比在单个类型级别上逐个导出每个类要方便得多。
到目前为止,我们已经介绍了一些新的关键字——module、requires和exports。你可能想知道,从 Java 9 开始,这些是否是 Java 语言中的保留字。如果你今天一直在代码中使用这些作为变量名,你会遇到麻烦吗?答案是:不会!这些以及其他我们将在本书中学习的与模块相关的关键字被称为受限关键字。你仍然可以在代码中使用它们,但只有在模块描述符的上下文中使用时,编译器才能理解它们的含义,并相应地处理这些关键字。
现在类型跨模块访问的条件都已满足,让我们再次从模块路径上一级目录编译代码:
$ javac -d out --module-source-path src --module packt.addressbook,packt.sortutil
再次强调,--module-source-path参数指定了编译器可以找到所有必需的 Java 模块的位置,以便编译你的代码。而--module参数表示要编译的两个模块——packt.addressbook和packt.sortutil。编译器在模块源路径中找到这两个模块的根文件夹,并将它们编译成相应的类。
编译应该会静默成功。就像上次一样,你会注意到out文件夹中有与每个模块中的每个.java文件对应的.class文件,包括模块描述符,两个module-info.java文件。
你可以像以前一样在packt.addressbook模块中执行Main类。为了说明,我将使用更简洁的-m选项(而不是--module)来指定模块和要启动执行的类。它们的意思相同,可以互换使用。
$ java --module-path out -m packt.addressbook/packt.addressbook.Main
[Babbage 123-456-7890, Lovelace 234-567-8901, Dijkstra 345-678-9012, Turing 456-789-0123, Berners-Lee 456-789-0123]
在这里,--module-path选项指定 Java 运行时需要查找的位置,以找到执行代码所需的模块。运行时检测到它需要以Main类开始执行,而Main类是packt.addressbook模块的一部分,并且该模块依赖于另一个模块(packt.sortutil),因此它搜索module-path选项指定的位置(这里的out目录)以找到依赖的模块。多亏了我们的编译步骤将编译后的模块放在相同的位置,运行时找到了它,并且执行继续进行。
你应该看到联系人已按姓氏成功排序。请注意,为了运行此代码,你只需指定了packt.addressbook模块和Main类,我们不需要向运行时提供任何有关依赖的packt.sortutil模块的信息。这是因为 Java 运行时正在读取相同的模块描述符(这次是module-info.class文件),以了解需要packt.sortutil模块,因此根据需要从两个模块中利用正确的类文件。
下面是一个解释两个模块行为的图示:

我们所做的是将排序功能独立到一个自己的模块中,以便其他需要排序功能的 Java 9 模块化应用可以使用。任何 Java 9 模块要使用packt.sortutil,只需做以下事情:
-
在需要排序功能的模块定义中添加
requires packt.sortutil行。 -
导入
packt.util.SortUtil类并调用sortList()方法,以对任何List对象进行排序,只要这些对象是Comparable的。
这很好,但在我们分享新的packt.sortutil库供全世界使用之前,让我们先思考一下库的 API。
我所说的库的 API 是指消费者为了访问库而需要使用的代码。例如,在简单的packt.sortutil库中,API 是packt.util.SortUtil类上的单个方法sortList()。显然,功能更强大的库有多个类和方法,这些类和方法可能被消费者调用。
当你创建一个库,在允许其他人使用它之前,你必须非常仔细地定义和最终确定库的 API。原因是,一旦其他人开始使用你的库,就很难对库的公共 API 进行更改。你库未来版本中 API 的任何更改都意味着要求所有使用你库的消费者更新他们的代码以与新 API 兼容。
目前,packt.sortutil模块只包含一个类。我们将在接下来的章节中逐步完善该模块,但就目前而言,我想进行的一个改动是使SortUtil类尽可能轻量。该类作为packt.sortutil库的编程接口,因此确保该类尽可能简单,代码行数尽可能少,使其在未来可能的变化中更加稳健。
实现这一目标的一种方法是将实际的排序功能移动到一个实现类中,并让SortUtil仅将排序逻辑委托给该类。
假设我们创建一个BubbleSortUtil类,其结构与迄今为止的SortUtil类相同:
public class BubbleSortUtilImpl {
public <T extends Comparable> List<T> sortList(List<T> list) {
...
}
private <T> void swap(List<T>list, int inner) {
...
}
}
然后,我们可以更新SortUtil,使其仅调用BubbleSortUtilImpl类的sortList方法进行委托:
public class SortUtil {
private BubbleSortUtilImpl sortImpl = new BubbleSortUtilImpl();
public <T extends Comparable> List<T> sortList(List<T> list) {
return this.sortImpl.sortList(list);
}
}
这样做更好,因为如果你想要更改库的结构或排序逻辑,只要保持SortUtil的结构不变,你的库的用户就不需要更改他们的代码。SortUtil类仍然与BubbleSortUtil紧密耦合,因为它直接实例化它,但我们在下一章中会改进这一点,所以现在我们就先这样吧。
在这个阶段运行代码应该会产生与上次相同的输出。现在,在我们向世界宣布packt.sortutil模块准备投入使用之前,回顾一下杰克在第一章中遇到的问题,介绍 Java 9 模块化。他创建了一个具有与类相关的相同设计的排序库 JAR 文件——一个内部的BubbleSortUtilImpl和一个外部的SortUtil。他遇到的问题是,某个类是内部的还是外部的,这仅仅是一个惯例,如果没有杰克将他的代码打包到一个单独的包中以便利用包私有机制,这是无法强制执行的。使用他的库的开发者开始使用他们不应该使用的BubbleSortUtilImpl类。让我们看看我们是否有与sortutil模块相同的问题,如果有,是否有更好的工具可以使用 Java 9 的模块系统来保护某些类。
答案很简单。是的,我们遇到了杰克遇到过的同样的问题。任何使用sortutil模块的消费者都可以轻松地直接使用BubbleSortUtilImpl。这是因为该类位于一个从模块导出的包中。我们希望通过封装这个类来防止它在模块外部被使用。我们该如何做呢?很简单!只需将这个类移动到另一个包中!就像我们已经看到的,Java 平台模块系统期望我们指定哪些包可以在模块外部可见。如果任何类型可以在任何模块外部访问,那仅仅是因为它属于该模块中导出的包。这就是为什么将类型重构到新包中是一个潜在的解决方案。只要新包在模块的定义文件中没有出现在exports子句中,该包中的类就有效地对外部使用隐藏,就像我们已经看到的:

这是我们最新更改后重新审视的模块图:

记住,Java 中的包不是分层的。在这个例子中,包packt.util和packt.util.impl是两个完全独立的包,它们之间没有任何关系。仅仅因为你导出了packt.util包,并不意味着你自动导出了packt.util.impl。也不意味着packt.util.impl以某种方式位于packt.util内部。这并不是 Java 中包的工作方式。这些是完全不同的两个包,在包语义上完全无关。
此时代码的状态是在捆绑的源代码中。像以前一样编译和执行代码应该会给我们相同的结果。然而,我们已经解决了我们在第一章,“介绍 Java 9 模块化”中讨论的与类封装相关的一个主要问题。
想想这种封装对库开发者的潜在影响。在 Java 9 之前,任何在库中发布的public类型都可以被库的消费者使用和访问。现在,有了 Java 9 模块,库开发者可以完全控制哪些类可以被使用,哪些只是内部的。因此,库开发者可以重构内部库代码,而无需担心它们可能被使用。多亏了模块契约,它们是不可访问的,因此在模块外部的代码中保证不会被使用。
模块版本化
你可能已经注意到,我们之前所涵盖的内容中缺少了模块依赖的一个方面——版本化。当你声明一个模块定义时,你能为模块指定一个版本号吗?同样,当你指定某个模块需要另一个模块时,你也能指定它需要的模块版本吗?两个问题的答案都是否定的。由于各种原因,模块版本化不是 Java 模块系统中的一个功能。这不是 Java 平台试图解决的问题。
模块版本化的最大优点和实用性体现在依赖管理上。想想像 Maven 或 Gradle 这样的工具。这些工具允许你配置外部依赖项的特定版本,然后它们可以自动从某个远程仓库为你下载,并将其添加到类路径中。Java 平台并不试图做这件事,或者解决任何依赖管理问题。它假设所有依赖项都已经可用,可能是由 Maven 或 Gradle 这样的工具组装的。在第一章“介绍 Java 9 模块化”中,我提到了像 Maven 或 Gradle 这样的构建工具如何实现依赖组装的可预测性和一致性,但它们无法验证所组装内容的准确性或完整性。这就是 Java 模块系统介入的地方。平台假设所有必要的源文件和类都已经存在!无论你的构建工具是什么,无论你采取什么方式来组装你的代码和库,Java 平台都会在你得到的结果上工作,然后确保模块合约得到满足。
这是一个微妙的话题,其推理可以广泛辩论,但重要的是要记住,模块版本化在 Java 9 中是不可用的。为了管理多个模块版本,你可以自由地使用你之前用来拉入 jar 和库的任何工具或流程。在第十二章“使用构建工具和测试 Java 模块”中,我们探讨了如何将 Maven 多模块项目与 Java 9 模块化应用程序集成,以利用两者的最佳之处。
重新思考包结构
你需要从模块中导出包名的这一事实,对我们在 Java 9 中组织类型到包中的方式有着有趣的含义。从历史上看,包结构一直被 Java 开发者用来为 Java 类型提供命名空间。虽然由包名创建的这些命名空间在理论上至少可以防止类型名称冲突,并影响包私有成员变量和类型的可见性,但它们还服务于一个稍微不那么正式的目的,即为了代码的可搜索性和可维护性而将相关类型分组。
Java 9 中的包具有额外的意义,这会影响你如何将类和其他类型分组到包中——模块外的可见性。例如,如果你需要在一个模块内隐藏类型 A 并将类型 B 导出至模块外,你本质上需要将类型 A 和 B 放置在两个不同的包中。在大多数情况下,典型的库内部类在 Java 9 之前的世界中已经与不同的命名空间和包相关联,所以这不应该对我们做这件事的方式造成太大改变。但重要的是要注意,这个新的变化可能会影响我们决定将类放在一个包还是另一个包中的决策。
现在我们已经看到了两个模块和模块间的依赖关系,我想向你介绍模块图的概念。你可能会在未来的许多 Java 文档中看到它,作为表示模块之间关系的好方法。绘制模块图的方法是将模块作为节点,用箭头表示模块之间的关系。如果模块 A 依赖于模块 B,箭头将从 A 指向 B。
这是到目前为止地址簿应用程序中的代码的模块图:

目前这很简单,但随着我们更多地了解模块系统并改进我们的代码,我们将回到这个模块图并添加更多细节。
理解模块路径参数
我们的示例应用程序由两个 Java 模块组成。我们有两个模块的源代码在模块源路径中。实际上,通常我们会处理依赖于作为依赖项拉入的编译第三方模块的模块源代码。在这种情况下,你需要向编译器提供包含模块源文件的模块源路径,以及编译依赖项的模块路径。编译器需要在需要编译的代码(--module-source-path)和编译依赖项和库的位置(--module-path)之间进行区分。当涉及到执行时,你只需传递指向编译模块的 --module-path。
这里是传递给编译器和运行时的命令行参数值:
--module-source-path |
--module-path |
|
|---|---|---|
javac |
所有源模块的位置 | 源模块所依赖的编译模块的位置。 |
java |
<未提供> | 所有编译模块的位置 - 包括应用程序模块和编译模块依赖项 |
回顾类路径
我们已经看到,新的 Java 9 平台提供了一些新的能力来进行模块级别的编译和执行,使用 --module-source-path 和 --module-path 参数。现在它是模块感知的,并且知道如何编译和执行 Java 9 模块。随着开发者接受 Java 模块化,这些标志可能会得到更广泛的使用。
同时,有一个熟悉的编译器参数,随着时间的推移,其使用频率将逐渐降低——那就是-classpath参数。多年来,类路径一直是 Java 编程中一个至关重要的概念,但现在大部分情况下不再需要了!
在过去的二十年中,Java 类路径扮演了至关重要的角色,成为任何给定 Java 应用程序中所有类的家园。显然,任何 Java 应用程序都由多个类组成,通常分布在多个 jar 文件中。作为一个 Java 开发者,你只需将类添加到类路径中,就可以让它发挥作用。这会保证运行时能够看到它。当然,仍然有private、public和protected这样的访问修饰符来控制谁可以访问给定的 Java 类型。但即便如此,类文件在类路径中的存在就足以使其对运行时可用。
现在,考虑一个新世界,其中没有独立的类文件,一切都是由模块组成的。Java 9 模块化应用程序由多个相互依赖的不同模块组成。给定模块路径,编译器和运行时现在可以知道所有类的位置。此外,多亏了模块约定,属于模块的类位于模块文件夹本身中。在这种情况下,为什么 Java 还需要其他信息来访问应用程序所需的类?类路径的概念甚至不再需要了!
等等!在你将关于类路径的所有知识从记忆中抹去之前,我应该告诉你,我所描述的是理想情况。是的,理想情况下,类路径在 Java 中将可能变得不那么重要。然而,我们还没有完全结束它。在这本书中,我们还将多次回顾类路径,特别是在处理旧代码库和将代码迁移到 Java 9 时。更多内容请参阅第十章,为 Java 9 准备你的代码。
再次审视类路径问题
在第一章,介绍 Java 9 模块化中,我们探讨了我们的朋友杰克和阿米特面临的问题:
-
杰克无法轻易封装内部库类型,防止在库外部使用该类型,同时保留在库内部自由使用它们的能力
-
阿米特无法可靠地组装一组编译后的 Java 代码,并保证在程序实际运行时遇到依赖之前,所有这些类型及其依赖和导入都得到了充分的满足
我们是否通过模块系统解决了这些问题?幸运的是,是的!
我们已经看到 Java 模块的封装如何防止某些类型在模块外部被访问,即使该类型是public的,除非它们所属的包被明确导出。实际上,我们应用了相同的概念来隐藏BubbleSortUtilImpl类的外部使用。当升级我们的库时,如果我们觉得需要修改(甚至删除)该类,我们可以放心,唯一使用该代码的消费者就在库本身。
关于第二个问题——运行时验证?结果证明,Java 运行时引用相同的module-info模块描述符(这次是.class格式)来解决这个问题。您在packt.addressbook模块中运行了Main类,并且它运行正常,因为运行时在模块路径中找到了依赖模块packt.sortutil。让我们看看如果没有找到会发生什么。如果我要删除out目录中的编译sortutil目录,并再次运行packt.addressbook/packt.addressbook.Main,请注意您会得到的错误:
$ java --module-path out --module packt.addressbook/packt.addressbook.Main
Error occurred during initialization of VM
java.lang.module.ResolutionException: Module sortutil not found,
required by addressbook
at java.lang.module.Resolver.fail(java.base@9/Resolver.java:841)
at java.lang.module.Resolver.resolve(
java.base@9/Resolver.java:154)
...
错误信息指出未找到packt.sortutil模块,但这里需要注意的重要事情是错误抛出的 时间。错误并不是在类加载和运行时尝试查找依赖类型时遇到的,正如消息清楚地提到的,错误发生在虚拟机初始化时间。这对于 Java 代码的可靠性来说是一个巨大的优势。如果有潜在的错误,运行时会在初始化时捕捉到,而不是在执行过程中的某个任意时间点。
模块系统被设计用来满足的一个要求是达到 跨阶段的一致性。这是什么意思呢?您已经看到模块描述符如何让您构建模块、封装类型,并通过允许您指定每个模块的合约来验证必要依赖项的可用性。这些好处不仅影响您代码的编译过程,也影响运行时过程。同一个模块定义让编译器知道当缺少所需的模块时有什么错误,也可以向运行时提供相同的信息!由于模块描述符被编译成类文件,Java 运行时也可以读取相同的描述符,并提前知道需要运行的代码所依赖的每个模块是否可用。您在编译和执行阶段都能获得相同的行为和错误检查。
摘要
在本章中,我们创建了一个第二个 Java 模块,并建立了模块间的依赖关系。我们学习了如何在模块定义文件中设置requires和exports。有了两个模块,我们终于能够看到试图解决我们在第一章“介绍 Java 9 模块化”中讨论的代码组织和管理的某些问题的模块化特性。
现在我们已经对如何创建 Java 9 模块以及在其他模块中使用它们有了基本了解,让我们将注意力集中在平台本身上。Java 9 不仅带来了旨在供开发者使用以创建模块的模块系统,还带来了一个完全重制的 JRE 和 JDK,它们自身也是模块化的。在下一章中,你将了解平台的模块化,它如何影响开发者,以及它在某种程度上对于将模块化支持引入 Java 语言是至关重要的。
第四章:介绍模块化 JDK
在前两章中,你已经了解了 Java 9 的模块化 API 以及如何创建自己的自定义模块。Java 9 模块的内容远不止这些!Java 中模块化的引入不仅为开发者提供了一个新的特性来使用;它导致了 Java 平台本身的重大变化。实际上,Java 9 还可能对内部 Java 代码库进行了有史以来最大的改组。Java 9 不仅为开发者提供了创建自己的模块的能力,整个 Java 平台本身也被模块化了。在本章中,让我们检查这些重要的变化,既要了解变化是什么,也要理解为什么会有这些变化。现在,你已经通过编写模块来熟悉了 Java 模块,是时候戴上我们的思考帽,真正理解导致 Java 语言这一重大变化的问题和需求。这不仅将帮助我们更好地欣赏这些变化,学习如何以及为什么 JDK 被模块化,还将帮助我们学习如何在第十一章“将您的代码迁移到 Java 9”中迁移自己的 Java 代码。
本章我们将涵盖以下主题:
-
我们将从检查 Java 9 之前的 JDK 的两个方面以及它们的一些问题开始。了解过去的事情对我们来说很重要,这样我们才能完全理解新变化的影响。
-
你将学习模块化如何改变 Java 平台,并介绍内置模块。
-
你将学习如何浏览内置模块并获取它们模块定义的信息。
-
你将学习如何理解模块图中的模块关系。
检查遗留的 JDK
Java 已经存在二十多年了。在其生命周期的大部分时间里,有一些事情并没有改变。让我们关注 Java 8 或更早版本的 JDK 的两个方面:
-
JRE 结构:当在计算机上设置时,Java 运行时环境(JRE)安装的文件和目录结构
-
API 封装的状态:公共 Java API 与内部平台类之间的差异
JRE 结构
当你在任何机器上安装 Java 8 运行时环境并检查安装目录时,你会在其他文件和文件夹中看到以下两个重要的目录:
-
一个包含可执行文件的
bin目录,其中之一是 Java 可执行文件,它允许你运行 Java 程序 -
包含一些关键
.jar文件的lib目录,包括至关重要的rt.jar
当你在 Java 中编码时,你可能不需要直接处理rt.jar,但你应该知道它是运行时最重要的jar文件。你能猜到它是用来做什么的吗?
理解 rt.jar
假设你构建了一个使用一些核心库类(如集合和线程)的 Java 应用程序。当你编译并将你的应用程序分发到另一个安装了 Java 的机器上运行时,你可以只打包你在应用程序中编写的类,而无需包含编译后的Collection和Thread类。这是因为每个运行时都自带所有编译的平台类,这样每个开发者就不必将它们与他们的应用程序一起分发。这些类捆绑到 JRE 中的方式是通过一个文件——rt.jar。你将你的应用程序类放在运行时的类路径中以便查找,但对于任何平台类,它只是在rt.jar中找到它们。rt这个名字代表运行时,这个单一的 JAR 文件包含了整个 Java 运行时的所有编译类。是的,你没听错!rt.jar本质上包含了 Java 平台中的所有编译类,全部捆绑在一个 JAR 文件中。每一个。单个。类。
这个模型的问题
这种情况已经持续了多个 Java 版本。将所有平台类捆绑到单个 JAR 文件中的决定是在 Java 生命早期做出的,当时可能是个好主意。但考虑到平台已经增长了多少,以及多年来添加到平台中的新类有多少,显然这已经不再是个好主意了。Java 8 中,rt.jar的大小接近 60 MB。即使你现在可能觉得这还可以忍受,想象一下,如果 Java 在未来 10 年内以良好的速度继续增长,并最终增加了几千个新类,那会怎样?我们还会对将它们全部捆绑到单个 JAR 文件中感到满意吗?
除了平台开发者必须处理单体 JAR 文件之外,这个模型还带来了另一个物流挑战。对于应用开发者来说,将他们的应用程序分发的常见做法是将应用程序安装程序与 Java 运行时环境捆绑在一起。任何 Java 应用程序都需要运行时,但无法确定 Java 应用程序的用户在安装应用程序之前是否已经安装了运行时。因此,将应用程序与运行时捆绑在一起是确保任何安装应用程序的人都有必要的运行时并能成功执行应用程序的绝佳方式。这不仅适用于经典的桌面应用程序安装程序,也适用于创建自包含微服务的较新做法。构建微服务的开发者会创建包含编译后的微服务和 Java 运行时的可分发文件,这样就可以通过单个命令在任何云虚拟机上启动微服务实例。
运行时的大小显然是一个问题。无论你的微服务或应用程序的复杂性如何,无论你在平台上使用了多少个类,你都必须捆绑包含所有已知 Java 平台类的rt.jar完整的 Java 运行时。所以,无论你的实际应用程序有多小,捆绑其中的至少有 60 MB 的rt.jar内容!这也影响了在资源受限的较小设备上运行的 Java 应用程序,如 IOT 和移动设备。我们在第一章,“介绍 Java 9 模块化”中简要讨论了 Java 平台中的 CORBA 类以及它存在于所有 Java 运行时中的情况,尽管似乎没有人再使用它了。作为一个创建自包含 Java 可执行文件的人,提出这样的问题是非常合理的——为什么我需要包含所有这些类在运行时中,而我不需要它们?
尝试的解决方案 - 紧凑配置文件
在 Java 8 中,引入了一个名为紧凑配置文件的新概念,试图解决这个问题。紧凑配置文件本质上是一个更小的 Java 运行时版本,它不需要包含rt.jar的全部内容。运行时在包级别上被分解,以确定一组封闭的核心包和类,这些类只依赖于自身,这样其余的就可以被分割并移除。引入了三个不同的配置文件,它们在移除的内容量上有所不同。最小且最基础的配置文件被称为compact1。这个版本的运行时包含了一些基本语言特性,如 IO、集合、util、安全和并发。如果你的需求不能通过compact1满足,你也可以选择compact2或compact3,如果这三个都不适合你,你将使用完整的运行时:

最小的配置文件,compact1,其大小仅为大约 11 MB,相较于完整的rt.jar的 60 MB 来说,这是一个显著的改进。但很明显,我们仍未消除在运行时捆绑不需要的类的问题。我们只是最小化了它们,无论你选择哪个配置文件,你的应用程序都不会使用其中的某些类,但你仍然必须包含它们。此外,如果你有一个主要使用compact1配置文件中的类的应用程序,但只需要compact2中的几个类,那么,你除了捆绑更大的compact2配置文件外别无选择。
然而,我可能会犹豫是否称这为解决这个问题的失败尝试。事实上,运行时配置文件实际上是 Java 平台在运行时模块化旅程中的第一步。然而,重要的是要注意,这是在 Java 9 之前我们能做的最好的事情,以解决庞大单体运行时的问题。
API 封装的状态
为了最好地解释 API 封装(及其相关问题)的情况,我们只需要回顾一下第一章,“介绍 Java 9 模块化”,杰克在排序库上的麻烦。在他的库中有一个内部public类,这个类并不是为了外部使用,但它最终还是被使用了。如果一个只有两个类的微小库都可能遇到这个问题,想象一下 Java 运行时成千上万个类的情况!Java 语言为开发者提供了文档化的 API。但它也包含许多支持类,以促进这些 API 的内部运作,这些类不应该被开发者使用。然而,足够的是,它们都是rt.jar的一部分,所以没有任何阻止开发者使用这些类的。
理解内部 API
Java 运行时中存在几个开发者不应该使用的内部类。它们不是作为语言规范的一部分进行文档化的,但对于运行时的内部运作是必要的,也许是为了其他已经文档化的类。在 Java 开发者在在线社区中广泛讨论的一个很好的例子是名为sun.misc.Unsafe的类,我们在第一章,“介绍 Java 9 模块化”中简要提到了它。
类sun.misc.Unsafe从未打算供公众使用。它始终是一个仅用于 Java 运行时的内部类。它没有文档。没有公共构造函数。该类的源代码充满了关于使用该类的危险警告。有趣的是,即使类有一个不祥的名字Unsafe,也没有阻止一些开发者使用它。当然,sun.misc.Unsafe是少数几个被 Java 开发者误用的内部类之一。但如果你只是少数几个类,你可能会问,这有什么大不了的?
这种模型的弊端
Java 通常被认为是一种非常向后兼容的语言。虽然该语言本身在多年中经历了重大变化,但几乎所有变化都是对语言的补充,同时仍然保留了旧版本的功能。假设你使用 Java 1.3 编写的 Java 代码库,并用 Java 8 编译和运行它。如果你发现它没有问题地运行,你会感到惊讶吗?可能不会!这正是 Java 具有向后兼容声誉的原因。在我看来,这是一件好事,因为它给采用者带来了信心,即每次重大升级都不需要太多的努力和重写。这是 Java 相对于其他几个开发平台的一个优势。
这种向后兼容性是有代价的,特别是当你考虑到内部 API 缺乏封装时。如果由于缺乏封装,开发者意外地使用了内部 API,那会怎么样?为了保持向后兼容性,语言团队被迫不对那些内部 API 进行破坏性更改,尽管按照定义,它们是内部的。因此,向后兼容性的负担对语言来说变得更重了——不仅公共 API 需要向后兼容,甚至内部运行时类也需要!
尝试的解决方案 - 弃用、警告和文档
我们在我们的代码中通常从 java.* 和 javax.* 等包中导入类,因为这些包中的类包含了 Java 语言中大部分面向公共消费的公共 API。然而,在其他包,如 sun.* 中,也有一些 JDK 内部类,你不会在任何 API 文档中找到它们。许多这些类自 1.0 版本以来一直是 Java 的一部分。
由于语言没有防止使用这些内部 API 的功能,已经尝试了其他几种方法来阻止使用。Sun 网站上有一篇文章,标题为 Why developers should not write programs that call 'sun' packages。Sun 网站已经不再存在,但多亏了 WayBack Machine,文章的原始网页仍然被保存下来,并可在以下链接找到:web.archive.org/web/19980215011039/http://java.sun.com/products/jdk/faq/faq-sun-packages.html:

注意文章中提到的年份!是的,它自 1996 年以来一直是官方文档的一部分(当时在 java.sun.com 上)。顺便说一句,Oracle 文档网站上仍然有这个警告。显然,20 年来要求开发者不要使用某些类并没有解决问题!你可能会问,如果没有人注意它,这样的警告在文档中有什么用?好吧,除了文档中的警告之外,自 JDK 6 以来,编译器如果检测到你的应用程序使用了 sun.* 包中的任何 API,就会抛出警告。然而,这也是开发者可以轻易忽略的事情。
这些方法显然还不够好。我们需要能够强制执行这些规则,并为开发者提供护栏,以防止他们使用内部 API。到目前为止,这种功能在语言中并不存在。语言只能不断进化。
进入 Project Jigsaw
Project Jigsaw 是将模块化的概念和特性应用于 Java 平台本身的努力。平台的模块化本质上解决了之前描述的这两个问题。我们将在本章后面探讨这一点,但让我们先看看为 Project Jigsaw 做的工作以及它如何影响与 Java 平台交互的开发者。
该项目本身是一个巨大的努力,涉及以下高级步骤:
-
对平台源代码进行重新组织,使其更有利于模块化。
-
定义和构建具有预定义输入和输出接口以及清晰的依赖映射的模块。
-
封装内部类,并允许仅使用公共API。
-
提供生成更小、更模块化的运行时镜像的工具,作为对单体
rt.jar的替代。
现在让我们深入探讨这些变化,看看它对我们有什么影响。
平台模块化
在 Java 9 中,整个 Java 平台,包括其中的每个类,都被隔离并分组到模块中。是的,从Collections和Thread到Connection和Logger的所有平台 Java 类!实际上哪个都无关紧要;每个平台类现在都存放在与运行时和 JDK 一起提供的全新 Java 模块中。平台团队通过检查公共 API 和内部类,根据通常很好地一起使用且自包含的类型进行分组,并将此类相关类捆绑到模块中,实现了这一点。
以 Java 日志为例。Java 中的原生日志功能由java.util.logging包中的一组类组成。这些类现在已被归类到一个新创建的模块中,称为java.logging。与 JDBC 和 SQL 相关的类都进入了一个名为java.sql的新模块。与 XML 相关的类进入了java.xml模块。以下是 Java 9 自带的一些模块的更多示例:
-
java.scripting:为 Java 脚本引擎提供脚本 API -
java.desktop:提供 Java 桌面 API,包括awt和swing包 -
java.transaction:在javax.transaction包中提供事务相关 API
此外,还有一个名为java.base的特殊模块。java.base模块包含 Java 平台的基本 API 和类,没有这些,就不可能编写任何 Java 代码。java.base模块包含来自java.lang、java.io、java.util等包的 API。正如你所见,它涵盖了大多数 Java 应用程序使用的许多基本 Java API。那么,为什么我会称这个模块为特殊呢?请记住这个想法!我们稍后会回到这个问题。现在,让我们继续:
平台模块化的影响
如你所想,这种变化的冲击确实很大,并且几乎影响了从现在开始我们编写所有 Java 代码的方式。在 Java 8 及之前,你不必过多考虑使用任何 Java API。你所要做的就是将所需的类型导入到你的代码中。由于 JVM 知道在哪里找到rt.jar,必要的类总是由运行时找到。但在 Java 9 中就不再是这种情况了。记得在第三章中,处理模块间依赖,当你需要从packt.sortutil模块中获取packt.addressbook模块中的类时?你无法只是将类导入到你的代码中并使用它。你必须去packt.addressbook模块的定义中,并使用requires子句指定对该模块的依赖。这正是你需要为本地 Java 平台类型所做的事情!
需要使用 Java SQL API?它们位于java.sql模块中,因此在你需要它们的模块中,你需要在模块描述符中指定这一点:
module mymodule {
requires java.sql;
}
一旦你要求了必要的平台模块,它的 API 就准备好在你的模块中使用。由于java.sql模块是基于你用来编写代码的相同的 Java 模块系统构建的,你可以确信在java.sql模块的module-info文件中存在代码,它导出了包含 Java SQL API 的java.sql和javax.sql包。
在使用模块之前必须要求模块的需求不仅适用于我们自己的模块,也适用于 Java 模块。例如,java.sql模块需要日志功能(出于明显的原因)。而日志 API 位于java.logging模块中。因此,在java.sql模块的module-info.java文件中有一个requires声明来指定这个需求。这是java.sql模块中的代码能够导入和使用日志 API 的唯一方式。
这就是java.sql模块的module-info.java文件应该看起来像什么样子,以便启用我们之前讨论过的配置:
module java.sql {
...
requires java.logging;
exports java.sql;
exports javax.sql;
...
}
模块图
这些单独的 Java 模块相互依赖的结果是,我们现在可以绘制一个完整的依赖图,其中模块作为节点,节点之间的关系作为模块依赖。这种图片被称为模块图,它是你追踪和管理 Java 9 模块化应用程序中模块依赖的新最佳伙伴。我们在上一章中查看了一个地址簿应用程序的简单模块图。这是一个简单的模块图,它指定了一小批 Java 平台模块之间的依赖关系:

从模块A到模块B的线条表示模块A requires 模块B。所以,正如这个图所示,模块java.transaction需要java.rmi,而java.rmi又需要java.base。如前所述,由于java.base包含对语言基本而言的 API,它是每个其他模块都肯定需要的模块。这就是为什么这个模块被稍微特殊对待的原因。
java.base模块
在第二章《创建您的第一个 Java 模块》和第三章《处理模块间依赖》中,您编写了一些使用了一些 Java API(如Collection和System.out)的 Java 9 模块。这些 API 恰好来自java.base模块。您可能已经注意到这里图片中的某些问题。由于这些核心 Java API 位于其自己的模块中,我们不应该在使用它们之前在所有模块描述符中添加requires java.base;行吗?为什么没有它编译也能成功?好吧,让我向您保证,这里没有涉及任何诡计。它之所以能工作,是因为java.base模块的特殊性质。
上次您在 Java 类中写import java.lang.*是在什么时候?我希望是永远不会!您不需要从java.lang包导入类,因为这个包默认导入并可供您的 Java 代码使用。这是因为该包中的类使用得非常普遍,因此默认总是导入这个包中的类型是合理的。
Java 9 为要求java.base模块提供了一个类似的快捷方式。这个模块包含了许多基本的 Java API,例如java.lang包,几乎所有 Java 模块在编写时都离不开这些 API。因此,并不是所有存在的 Java 模块都必须要求java.base模块,它默认就是必需的,所以你不需要显式指定它。现在,由于java.lang包位于java.base模块中,两种情况下的默认行为都是无缝的!看看这是怎么工作的?
然而,请记住,这是唯一具有这种行为的模块。存在于任何其他地方的 Java 模块,无论是平台模块还是其他类型的模块,如果需要作为依赖项,都必须显式要求。
由于始终存在对java.base的隐式依赖,在编写模块图时,通常的做法是跳过对这个基础模块的依赖,以便使内容更易于阅读。其思路是,只有当给定模块的唯一依赖是java.base时,才显示模块依赖于java.base。如果一个模块依赖于其他模块,我们只显示这些依赖,并跳过java.base依赖。以下是使用这种方法之前模块图的样子:

我希望你会同意这个图表看起来有点更整洁。这似乎是人们越来越遵循的实践,所以当你遇到这样的模块图时,不要忘记隐含的java.base依赖。它始终存在!
模块浏览
现在你已经查看了一些 Java 附带的一些模块,你可能想知道如何获取更多关于它们的信息。你如何找到与平台一起提供的模块列表?给定一个 API,你如何知道哪个平台模块包含它?以及给定一个模块,你如何知道它导出了哪些包?
答案是向java命令添加几个新的参数。第一个是一个名为--list-modules的选项。这个命令让你检查从平台可用的模块。
当你从命令提示符运行以下命令时,你会看到以下内容:
$ java --list-modules
java.activation@9
java.annotations.common@9
java.base@9
java.compiler@9
java.corba@9
java.datatransfer@9
java.desktop@9
...
你看到的那张列表是所有 Java 平台模块的列表!当你滚动列表时,注意我们之前讨论的模块都在那里!这是浏览并了解可用内容的好方法。
另外还有一个新的选项 -d,它有助于检查单个模块的详细信息。语法是:
$ java -d <module-name>
你也可以使用较长的形式--describe-module来完成同样的操作:
$ java --describe-module <module-name>
例如,如果你想查看java.base模块的更多详细信息,运行以下命令:
$ java -d java.base
module java.base@9
exports java.io
exports java.lang
exports java.lang.annotation
...
为了简洁,输出已被截断,但当你向下滚动命令的结果时,你会注意到关于列出的模块的各种细节。
下面是运行在java.sql模块上的命令输出:
$ java -d java.sql
java.sql@9
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
在这些模块级别的输出中,你会注意到以下几类信息显示:
-
导出:这是模块导出的所有包的列表。在
java.base模块中,你会看到熟悉的包,如java.io和java.lang。java.sql模块导出包java.sql和javax.sql。这些标记为exports的包是该模块导出的所有包。因此,当你require模块时(默认情况下使用java.base),你的模块将能够访问属于那些exported包的required模块中的类型。在输出中,你会注意到一些形式为exports <package-name> to <module-name>的语句。这些被称为限定导出。我们将在第六章中介绍限定导出,模块解析、可读性和可访问性。 -
包含:这是模块包含但不导出的内部包列表。根据定义,这些包在模块外部是不可见的。对于我们开发者来说,了解属于内部模块 API 的包仍然很有用,因为其中许多曾是 Java 8 中的 API,因此以前是公开可用的。例如,看看
java.base模块中的jdk.internal.*和sun.util.*包。由于它们不在exports列表中,因此现在实际上被封装在模块中。 -
需要:这是给定模块需要的模块列表。
java.sql模块需要另外三个模块--java.base、java.logging和java.xml。java.base的需求是默认的,因此它带有requires mandated子句。请注意,你不需要在自己的模块中这样做,因为这是默认行为。现在忽略requires transitive子句。这与使模块对依赖模块可用相关,我们将在第六章“模块解析、可访问性和可读性”中详细探讨。显然,java.base不需要任何其他模块。 -
使用和提供:这与服务概念相关,我们将在第七章“介绍服务”中探讨。
我建议使用java --list-modules和java -d <module-name>命令来探索平台中的其他模块。当你开始在 Java 9 中编写模块化代码时,你通常会需要导入平台类,这需要识别导出它们的模块。最初,你需要使用这些命令来找到正确的模块,但当你这样做几次后,你将记住常见的包和包含它们的模块,因此你不再需要这样做。模块的直观名称也有帮助。需要使用 SQL 类?你只需知道它们在java.sql中!当然,一些包可能不是那么直观,但仍然,遵循的命名约定在很大程度上有助于开发者快速找到正确的模块。
模块类型
说到命名约定,当你运行java --list-modules命令时,你可能已经注意到了模块名称的不同前缀。平台模块有三个前缀--java.、javafx.和jdk.。前缀表示模块的性质:
-
java:表示核心 Java 平台模块。这些在官方文档中被称为标准模块。 -
javafx:表示 Java FX 模块,这是用 Java 构建桌面应用程序的平台。 -
jdk: 表示核心 JDK 模块。这些模块不属于语言规范的一部分,但包含对 Java 开发者有价值的 API 和工具,包括jdk.compiler和jdk.javadoc,以及调试和服务性工具和 API,如jdk.jdi和jdk.jconsole。 -
oracle: 如果你下载了 Oracle Open JDK,你可能会看到一些以这个前缀开始的模块。记住,这些是非标准模块,特定于这种 JDK 实现的版本,并且在其他实现中不可用。因此,完全忽略这些模块是一个好主意。
前缀为 java. 的模块本身可以分为三类:
-
核心 Java 模块:这些模块对于核心 Java 功能是必要的。例如
java.base、java.xml等模块,通常被称为核心 Java SE API。这与下一类企业 API 相区别。 -
企业模块:这一类别包含如
java.corba这样的模块,它利用 CORBA 技术提供 API,以及java.transaction,在企业应用程序环境中通常需要提供数据库事务 API。请注意,这与 Java EE 完全不同的规范。然而,Java SE 和 Java EE SDK 之间始终存在一些重叠。为了避免这种重叠,从 Java 9 开始,这些企业模块已被标记为已弃用,并且可能在未来的 Java 版本中删除。 -
聚合模块:这些模块本身不包含任何 API,而是作为将多个模块捆绑在一起的一种方便方式。在聚合模块上指定
requires依赖项相当于分别指定聚合模块所汇集的所有单个模块的requires依赖项。你将在 第六章 中学习如何构建自己的自定义聚合模块,模块解析、可访问性和可读性。现在,请注意,平台附带了一些聚合模块。它们是:-
java.se: 这是一个方便的聚合模块,它将所有 标准 Java SE 模块汇集在一起。 -
java.se.ee: 这个聚合模块汇集了所有java.se模块,并添加了与 Java EE 规范重叠的 API。
-
虽然聚合模块提供了一定的便利性,但我建议您谨慎使用。在编写 Java 应用程序时,可能会诱使您仅仅 require java.se 模块,例如,以拉取整个 Java SE 平台。这样,您就不必麻烦地识别包含您想要的 API 的平台模块,从而需要导入。只需一行——requires java.se,您就可以拥有整个平台。但这样,您就失去了平台模块化的几个优势。您最终会得到一个庞大的 Java 平台,包含不必要的类,与 Java 8 及更早版本没有区别。聚合模块是为了便利性提供的,仅在必要时使用。因此,请确保您正确使用它们。
检查平台文件结构
现在我们来检查这些对平台的影响如何在安装的文件结构中体现。历史上,Java 平台有两种类型——Java 运行时环境(JRE)和Java 开发工具包(JDK)。JDK 是超集,因为它包含了 JRE。以下是一个经典的 JDK 的高层次结构,为了简化只显示了几个重要的文件:

Java 9 JDK 的外观非常不同。您可以使用 cd $JAVA_HOME 命令导航到该目录。以下是新 JDK 的样子,同样只显示了几个重要的文件:

有几个重要的区别需要注意:
-
主文件夹中不再有 JRE 文件夹。结构现在已合并到一个公共文件夹中。JDK 9 放弃了 JRE 和 JDK 之间的区别,以创建一个共同的运行时二进制文件结构。此结构在顶级包含
bin、lib和conf文件夹,没有嵌套的运行时文件夹。这一变化是为了提供创建自定义运行时图像的能力,现在在 Java 9 中得到了支持。您将在第八章理解链接和使用 jlink 中了解更多关于生成此类图像的信息。 -
有一个名为
jmods的新文件夹,其中包含我们迄今为止所了解的所有打包的平台模块。随着 Java 9 的到来,是时候告别rt.jar了。不再有一个单一的、包含整个平台的单体 jar 文件。每个平台模块在jmods文件夹中都有一个对应的文件。因此,原本是一个单独的rt.jar文件现在被分割成单独的模块文件,每个平台模块一个。 -
注意模块文件的
.jmod文件扩展名。难道它们不应该是.jar文件吗?从 Java 9 开始,引入了一种新的格式,称为 JMOD,作为开发时间特定库捆绑的新方式,而不是运行时。传统的 JAR 格式非常适合捆绑用于运行时的类,但既然它们只是编译类的 ZIP 文件,那么在开发时使用它们并不是非常有用。新的 JMOD 格式在 JAR 格式的基础上增加了捆绑本地代码和配置文件等功能,这使得它对于分发用于开发用途的库非常有用。这是 JDK 用于捆绑所有内置平台模块的格式。该格式的详细信息超出了我们在此所涵盖的范围。只需将其视为 JAR 格式的仅开发时间替代方案即可。
可观察模块
我们向javac和java命令传递了两个模块路径值——模块源路径(传递给javac),其中包含未编译的 Java 源模块,以及模块路径(传递给javac和java),其中包含编译后的 Java 模块。这些选项指向所有可供编译器和运行时查找和使用的位置。请注意,我们不必添加 Java 平台模块的路径。这是因为 Java 运行时模块默认包含在javac编译和java执行时的模块路径中。你只需添加不属于平台但打算用于编译或执行的模块的路径。
这个完整的模块集包括你提供给平台的模块以及自动可用的开箱即用的平台模块,统称为可观察模块。正如其名所示,这些是平台为了满足模块依赖而观察的模块。如果所需的模块不在可观察模块集中,平台会抱怨该模块缺失。
你如何知道可观察模块是什么?当我们使用java --list-modules命令列出平台模块时,我们实际上是在列出所有可观察模块。默认情况下,只有平台模块是可观察的。你还可以找出给定模块路径的可观察模块。你可以通过指定命令的--module-path选项来完成此操作。你可以指定一组目录位置,这些位置形成模块路径。在这种情况下,命令将显示该模块路径的可观察模块列表,其中包括所有平台模块以及该模块路径中的任何编译模块。
例如,如果你要运行带有模块路径的命令,该路径是上一章(03-two-modules)中out文件夹中的编译模块,你将看到以下内容:
$ java --module-path out --list-modules
java.activation@9
java.base@9
java.compiler@9
...
addressbook file:///Users/koushik/code/java9/03-two-
modules/out/addressbook/
sortutil file:///Users/koushik/code/java9/03-two-modules/out/sortutil/
注意,除了平台模块外,列表中还有两个我们自己的模块。这是因为它们位于我们传递给命令的模块路径中,因此被添加到该模块路径的观察模块列表中。对于非平台模块,输出还包括模块的路径。这使得在传递多个目录给--module-path选项时定位模块变得方便。
重新审视两个问题
我们在本章开始时探讨了 Java 平台存在的两个问题:
-
单体运行时
-
缺乏 API 封装功能
有趣的是,Java 平台的模块化提供了解决这两个问题的方案。
解决单体运行时
了解您的应用程序属于哪个平台模块具有明显的优势。这是它不需要哪些平台模块的明确指示——即应用程序中不使用的任何模块!例如,如果您的应用程序包含仅require平台模块java.base和java.logging的模块,您实际上可以创建一个只包含这两个模块的 Java 平台的小子集。这个平台的部分就是您的应用程序运行所需的所有内容。如果您正在将运行时与应用程序捆绑在一起,现在您就知道需要捆绑的确切运行时部分,不多也不少!
Java 9 带来了全新的静态链接步骤,允许您仅使用应用程序需要的模块创建自定义运行时映像。这导致应用程序分发、微服务可执行文件等变得更小、更精简。您将在第八章“理解链接和使用 jlink”中了解更多关于链接阶段以及如何创建自己的运行时的信息。
解决 API 封装问题
多亏了 Java 平台模块利用模块化的封装概念,平台现在有了有效保护内部类免受外部使用的方法。平台可以进化,修改或甚至完全删除和替换内部 API,只要公开导出的 API 保持不变,就可以确保向后兼容。我相信这使 Java 平台的演变更好、更快,我们都能从中受益。
另一方面,请记住,在早期的 Java 版本中,有一些公开可访问的类现在被封装在 Java 平台模块中。这意味着,可能存在一些与之前依赖于那些内部 API 的应用程序不兼容的问题(即使它们实际上不应该这样)!当我们将用 Java 8 或更早版本编写的代码迁移到 Java 9 时,这将是许多人都必须解决的问题。我们将在第十章“为 Java 9 准备您的代码”中了解更多关于这一点,以及处理此类情况的战略。
摘要
在本章中,我们涵盖了大量的内容。我们开始探讨 Java 平台早期版本中的一些问题,以及语言本身并没有提供足够的功能来解决这些问题。然后我们学习了 Java 平台是如何模块化的,模块是什么,以及如何浏览和获取它们的信息。最后,我们总结了新的模块化 Java 平台如何有效地解决了我们在本章开始时提到的两个主要问题。
在下一章中,你将通过将 Java 平台模块连接到地址簿应用程序来将这些概念付诸实践,并熟悉在自定义 Java 代码中使用平台模块和 API 的过程。
第五章:使用平台 API
在上一章中,我们探讨了 Java 平台的模块化过程,模块的形态以及如何导航和查找更多关于它们的信息。在本章中,我们将通过使用一些平台 API 来实际操作,并在地址簿查看器应用程序中实现和扩展功能。在这个过程中,我将向您展示查找和使用平台 API 的典型流程,以及如何组织应用程序模块以实现更好的重用性和模块化。
在本章中,我们将对地址簿查看器应用程序进行以下增强:
-
我们将使用 Java 日志记录 API 将日志逻辑添加到应用程序中。这并不是一个面向用户的功能,但对于开发者来说,能够从应用程序中记录信息和错误消息是非常方便的。
-
我们将使用 XML API 从 XML 文件中读取联系信息。当前的应用程序中有一堆硬编码的用户。我们希望将其更新为从用户提供的路径上的 XML 文件中读取。
-
我们将通过显示姓名列表来为应用程序添加图形用户界面。点击列表中的姓名将显示该联系人的详细联系信息。我们将使用 Java FX API 来构建这个功能。
所有的上述功能都需要使用 Java 平台 API。通过实现上述三个目标,您将已经在建立对 Java 平台模块的依赖和使用方面有了良好的实践。请注意,本章的重点在于使用平台 API,而不是学习具体的 API。因此,即使您不打算使用或学习 Java XML API 或 Java FX API,我仍然建议您打开您的编辑器并亲手完成本章中涵盖的步骤。在本章中使用这些特定的 API 只是为了学习如何一般性地使用平台 API。完成本章的工作后,您将能够更好地浏览和使用其他 Java 平台 API。
我们有很多内容要介绍,所以让我们开始吧!
添加日志功能
让我们从在 Java 中使用日志记录 API 将消息记录到控制台开始。作为一个例子,我们希望在应用程序启动和完成时能够记录一些示例消息。
在 Java 8 或更早版本中,我们通常会直接导入必要的日志类并开始使用日志 API。也就是说,从 java.util.logging 包中的 Logger 类。然而,正如我们在上一章中学到的,Java 9 中有一个额外的步骤。直接使用日志 API 将导致编译错误。这是因为日志 API 不在 java.base 模块中可用。我们已经看到,使用 java 命令的 --list-modules 参数是搜索模块的最佳方式。让我们运行它并查看是否能找到与日志相关的模块:
$ java --list-modules
...
java.jnlp@9
java.logging@9
java.management@9
java.naming@9
java.prefs@9
如你所见,有一个名为 java.logging 的模块,看起来很有希望!下一步是看看这个模块是否导出了我们需要的 API:
$ java -d java.logging
module java.logging@9
exports java.util.logging
requires mandated java.base
provides jdk.internal.logger.DefaultLoggerFinder with
sun.util.logging.internal.LoggingProviderImpl
contains sun.net.www.protocol.http.logging
contains sun.util.logging.internal
contains sun.util.logging.resources
好消息!是的,java.logging 模块导出了 java.util.logging 包,这正是我们所需要的。让我们首先将这个模块作为依赖项添加到 packt.addressbook 模块中:
module packt.addressbook {
requires java.logging;
requires packt.sortutil;
}
现在,我们可以自由地在代码中使用日志记录 API。在 Main.java 类中,首先导入 Logger 类,并通过使用类名初始化 Logger 来创建一个静态的 logger 变量:
package packt.addressbook;
...
import java.util.logging.Logger;
...
public class Main {
private static final Logger logger =
Logger.getLogger(Main.class.getName());
...
}
接下来,我们可以使用记录器在应用程序的开始和结束时记录一条消息:
public static void main(String[] args) {
logger.info("Address book viewer application: Started");
...
System.out.println(contacts);
logger.info("Address book viewer application: Completed");
}
在项目根目录中运行此命令来编译模块:
$ javac --module-source-path src -d out --module
packt.addressbook,packt.sortui
使用 java 命令执行,你应该得到类似以下的输出:
$ java --module-path out -m packt.addressbook/packt.addressbook.Main
Mar 27, 2017 7:41:51 PM packt.addressbook.Main main
INFO: Address book viewer application: Started
[Charles Babbage, Tim Berners-Lee, Edsger Dijkstra, Ada Lovelace, Alan Turing]
Mar 27, 2017 7:41:51 PM packt.addressbook.Main main
INFO: Address book viewer application: Completed
通过这种方式,我们已经成功地将日志记录 API 集成到我们的应用程序中。这是本章我们将要探讨的三个用例中最简单的一个。使用日志平台 API 的部分涉及:
-
声明需要平台模块——使用
requires语句 -
在 Java 源中使用平台模块 API
使用替代的编译命令
随着我们开始创建更多模块,编译命令将继续增长。这是因为我们需要在命令中直接指定每个需要编译的模块在模块路径中的位置。
如果你使用的是 macOS/Unix/Linux 操作系统,有一个替代的方法可以编译所有模块,我发现这个方法更短,更容易。记住在 第二章,创建你的第一个 Java 模块中,我们直接在编译器的命令中列出了所有 Java 类。现在,我们不再需要手动在命令中输入所有模块中的所有类,而是使用 Unix 的 find 命令和命令行通配符来获取所有具有 .java 扩展名的文件名,并将其直接插入。以下命令说明了这是如何工作的:
$ javac --module-source-path src -d out $(find . -name '*.java')
命令 $(find . -name '*.java') 一次性展开当前文件夹(由 . 指定)中所有 Java 文件的文件名(由 -name '*.java' 指定),包括嵌套的子文件夹。由于这个命令更短,更容易阅读,所以我将从现在开始使用这个版本。这种格式还有一个额外的优势,就是一致性。你可以基本上复制粘贴这个命令来编译当前目录中的所有模块,无论你有多少个模块。如果你使用的是 Windows,或者你更喜欢使用 --module 格式,确保你指定所有单个模块名称,紧随 --module 选项之后。
从 XML 文件中读取联系人
我们接下来的改进是将地址簿查看器应用程序读取 XML 文件以获取联系人信息,而不是使用硬编码的列表。这个集成有几个细微之处,所以现在让我们试试看!
地址簿查看器显示的联系人列表来自ContactUtil类中的硬编码数据。以下是我们要从中读取的 XML 文件的示例结构:
<?xml version="1.0"?>
<addressbook>
<contact>
<firstname>Edsger</firstname>
<lastname>Dijkstra</lastname>
<address>
<street>5612</street>
<city>AZ</city>
<state>Eindhoven</state>
<country>Netherlands</country>
</address>
<phone>345-678-9012</phone>
</contact>
<contact>
...
</contact>
...
</addressbook>
根节点是addressbook,其中包含几个contact子节点。每个contact节点都有firstname、lastname和一个嵌套的address节点,如下所示。
为了让应用程序从 XML 文件中读取,我们想做的如下:
-
从模块
packt.addressbook中移除现有的硬编码逻辑。 -
在一个单独的模块中实现打开和读取 XML 文件的功能。这个模块将包含从源 XML 文件读取并返回联系人列表的代码。
-
更新主地址簿模块,使其依赖于这个新的模块以获取联系人列表。
需要做#1 的事实是显而易见的。但为什么是#2 和#3?为什么将代码的这一部分移动到自己的模块中?嗯,正如你可以想象的那样,这个问题没有正确的答案。
模块化的目标是实现将单体代码分离成可自由重用和替换的模块化构建块。考虑这样一个场景,在未来,你决定不使用 XML 文件来存储数据,而是从 JSON 文件、数据库或甚至 REST API 中读取。无论新的数据源是什么,事实是存在这样一个独立的模块,它作为联系人列表的提供者,使得更改相对容易。你所需做的就是移除我们现在创建的基于 XML 的模块,并插入一个新的模块,该模块从新的数据源读取。当然,你仍然需要更改消费者以依赖于新的模块。但更改将最小化,任何副作用的影响范围也将缩小。
移除硬编码的联系人列表
让我们先移除硬编码的联系人数据源。这相当简单。你需要删除packt.addressbook.util包中的ContactUtil.java类。现在我们在Main.java中剩下几行代码已经不再有效。让我们也移除这两行:
ContactUtil contactUtil = new ContactUtil();
List<Contact> contacts = contactUtil.getContacts();
创建模块
现在让我们创建一个新的模块,该模块包含作为联系人信息源的代码。我将这个模块命名为packt.contact。你现在应该很熟悉这个过程了。在src文件夹中创建一个名为packt.contact的新模块根文件夹,就在其他模块根文件夹所在的位置。接下来,在模块根文件夹中创建一个module-info.java模块描述符。
我们需要哪些模块?由于这个模块需要 XML API,我们必须使用requires语句来声明它。我们在上一章中看到有一个名为java.xml的模块,其中包含 XML API。当然,使用java --list-modules命令来搜索正确的模块也是一种方法。以下是添加了这个依赖关系的模块描述符。
module packt.contact {
requires java.xml;
}
本模块的源代码属于两个类。首先是位于 packt.contact.util 包中的 ContactLoader 类。这个类包含一个 loadContacts 方法,它接受要读取的 XML 文件名,并返回 Contact 对象的列表。这是模块消费者将调用的方法:
package packt.contact.util;
public class ContactLoader {
public List<Contact> loadContacts(String fileName) {
...
}
注意,该方法的名称是通用的 loadContacts,并且没有特别指出它从 XML 文件中加载。这再次有助于实现抽象,并且该模块完全有可能在未来更改其功能,从其他来源或文件格式读取联系人。
第二个类 XMLUtil 包含一些通用的但实用的 XML 工具方法。这些是 ContactLoader 类将用于读取和解析 XML 的方法。由于处理 XML 的这一方面不是本模块的 目的,因此该类将位于单独的 packt.contact.internal 包中,以便可以防止在模块外部使用:
package packt.contact.internal;
public class XmlUtil {
...
}
现在是这个模块的文件和文件夹结构:

编写 XmlUtil 类
现在我们将第一个方法添加到 XmlUtil 中——一个 loadXmlFile() 方法,它接受一个 XML 文件名作为 String,解析它,并返回一个 XML 文档对象。
代码涉及打开文件以获取 File 对象。然后,使用 DOM XML API,我们创建一个 DocumentBuilderFactory。有了这个,我们创建一个新的 DocumentBuilder。然后,我们解析输入文件。
以下是该方法的全部内容:
public Document loadXmlFile(String fileName) throws
ParserConfigurationException, SAXException, IOException {
File inputFile = new File(fileName);
DocumentBuilderFactory dbFactory =
DocumentBuilderFactory.newInstance();
DocumentBuilder dBuilder = dbFactory.newDocumentBuilder();
Document doc = dBuilder.parse(inputFile);
doc.getDocumentElement().normalize();
return doc;
}
注意该方法抛出的异常。这三个异常是使用文件 API 打开文件和 XML API 解析文件的结果。而不是让该方法捕获异常,因为它实际上不知道该如何处理,它抛出了这些异常。这些异常对模块化有一个有趣的含义,我们将在稍后探讨。
第二个方法是 getElement(),它接受一个 XML 节点和元素名称,以返回该节点中该元素的值。如果没有找到值,则返回一个空字符串。这完全是 XML API 特定的,对我们来说在本章的上下文中并不太有趣,所以这里是该方法的全部内容:
public String getElement(Node nNode, String tagName) {
if (nNode.getNodeType() == Node.ELEMENT_NODE) {
Element eElement = (Element) nNode;
return eElement.getElementsByTagName(tagName)
.item(0).getTextContent();
}
return "";
}
使用这种方法,我们就完成了 XmlUtil。现在,我们将继续探讨更有趣的 ContactLoader 类。
编写 ContactLoader 类
我们已经看到 ContactLoader 应该有一个单一的方法 loadContacts(),它接受文件名作为参数,并返回一个 Contacts 列表:
public List<Contact> loadContacts(String fileName) {
}
在该方法中,我们初始化 XmlUtil 的新实例,并使用 loadXmlFile 方法获取 XML 文档对象:
XmlUtil xmlUtil = new XmlUtil();
Document doc = xmlUtil.loadXmlFile(fileName);
现在剩下的是处理生成的 DOM 对象结构,并构建我们需要的模型类型中的联系人列表。为了避免过多地详细介绍 XML,我将仅向您指出捆绑的源代码位于 05-jdk-modules/src/packt.contact。
目前packt.contact模块存在一些问题--共享类和依赖泄露。其中之一你可能已经注意到了。
共享类
这是两个问题中更明显的一个。我们创建了一个新的模块,并在一个类中设计了一个方法来返回Contact实例的列表。问题是Contact和Address模型类不在模块中!它们在packt.addressbook模块中。现在编译这个模块将导致关于这两个类未找到的编译错误。我们该如何解决这个问题?
这里有一个想法。我们是否可以依赖于包含这些类的模块?packt.contact模块需要packt.addressbook模块中可用的Contact和Address类。我们能否让packt.contact模块要求packt.addressbook模块?当然,我们还需要双向的依赖。packt.addressbook需要requires packt.contact以获取联系人列表。这能行吗?结果是不能,因为这引入了循环依赖,而 Java 模块系统不允许循环依赖。
循环依赖是指两个或多个模块以这种方式相互依赖,以至于依赖图形成一个循环。下面图中的两个例子都代表了循环依赖。
下面的图显示了两种循环依赖场景。模块A和B相互依赖,形成一个循环依赖。在第二个例子中,右侧,模块Z读取X,X读取Y,而Y反过来读取Z。这也是一个循环依赖,现在是在三个模块之间。如果 Java 平台遇到这样的循环依赖,它会抛出一个错误并无法工作:

Java 模块系统不允许循环依赖,因为这会破坏模块具有有向依赖的概念。回想一下我们一直在绘制的模块图。在某种意义上,依赖关系是有方向的,当一个模块依赖于另一个模块时,会从前者到后者画出一个有向箭头。存在循环依赖,或者如它们通常所说的,循环依赖意味着两个模块之间联系如此紧密,以至于将它们分成两个独立的模块的想法变得没有意义。如果一个模块没有另一个模块就无法存在,那么将它们作为独立模块的意义又在哪里呢?
拥有一个有向无环图的模块图是很重要的。这种图,正如其名所示,是有向的,也就是说你可以以这样的方式排列所有节点,使得所有依赖关系都有一个自上而下的方向,并且它是无环的,也就是说没有循环。
由于我们不能实现循环依赖,我们只剩下两个选项来解决这个问题。第一个选项是将模型类从packt.addressbook移动到packt.contact,并让packt.contact导出它们。这样,由于packt.addressbook无论如何都需要packt.contact,它也可以使用模型类。
第二种选择是为模型类创建一个单独的模块,并让packt.addressbook和packt.contact都要求使用它们。这也允许其他模块可能使用模型类。为了简单起见,我将采用第一种方法,并将模型类暂时移动到packt.contact中。在类似的真实世界用例中,你可能需要考虑这些共享类的预期使用情况,以决定它们是否需要单独的模块。
在packt.contact模块中,模型类是这样的:

需要更新module-info.java以export util 和 model 包:
module packt.contact {
requires java.xml;
exports packt.contact.model;
exports packt.contact.util;
}
依赖泄露
这是我们在构建packt.contact模块时遇到的第二个问题,这个问题可能并不那么明显。这是ContactLoader中我们希望模块消费者调用的方法的签名:
public List<Contact> loadContacts(String fileName)
throws ParserConfigurationException, SAXException, IOException
消费者模块需要做什么才能访问这个方法?首先,消费模块需要require packt.contact。有了这个,他们就可以访问他们模块中的ContactLoader。然后,他们可以在他们的某个类中调用loadContacts方法。但是等等!由于loadContacts()抛出了三个异常,消费方法也需要捕获它们!
try {
contacts = contactLoader.loadContacts("input.xml");
} catch (ParserConfigurationException | SAXException |
IOException e) {
// Handle error here
}
但问题就出在这里。消费模块的代码被迫使用 XML 异常类来捕获它们。IOException来自java.lang,因此所有模块都因为它隐含的java.base依赖而获得它。但是,消费模块并不自动有权访问ParserConfigurationException或SAXException,因为它们是来自java.xml模块的类。loadContacts()能够被其他模块使用,唯一的办法是它们在每次使用packt.contact时也require java.xml。即使它们自己没有使用任何 XML API。这样封装 XML 功能就太好了!
虽然这是一个可行的解决方案,但我们不希望构建强制依赖这样的模块。理想情况下,一个模块应该是自给自足的,并且不应该需要其他对等依赖才能使其可用。解决这个问题有几个方法。一种方法是在packt.contact模块中建立所谓的传递性依赖关系。传递性依赖关系是模块系统允许你配置模块以声明自动对等依赖关系的一种方式。例如,你可以对packt.contact有一个依赖关系,同时也自动建立对java.xml的依赖关系,这样任何对前者有依赖关系的模块也会得到后者。我们将在第六章中了解更多关于传递性依赖关系的信息,模块解析、可访问性和可读性。
然而,在这种情况下,这也不是最佳选择。我们希望将所有与 XML 相关的功能都放入packt.contact中,并且不要有任何 XML 类泄漏到消费模块中。因此,在这种情况下,我们将创建一个自定义异常,并在出现任何错误时抛出它。我们将确保异常位于一个导出包中,这样消费模块就可以自动获取异常。
我们将类命名为ContactLoadException并将其放置在packt.contact.util包中:
package packt.contact.util;
public class ContactLoadException extends Exception {
...
public ContactLoadException() {
super();
}
public ContactLoadException(String message) {
super(message);
// TODO Auto-generated constructor stub
}
}
现在ContactLoader需要捕获 XML 异常并抛出自定义异常:
public List<Contact> loadContacts(String fileName) throws
ContactLoadException {
...
Document doc;
try {
doc = xmlUtil.loadXmlFile(fileName);
} catch (ParserConfigurationException | SAXException |
IOException e) {
throw new ContactLoadException("Unable to load
Contact file");
}
太好了!现在我们已经完全将 XML 相关的功能隔离到packt.contact中,并且使用它的任何模块都不需要处理 XML API。
通过这种方式,我们完成了packt.contact模块。现在我们可以继续进行到packt.addressbook并使用此模块。
消费新的模块
首先,我们在packt.addressbook中建立一个依赖关系。以下是module-info.java文件:
module packt.addressbook {
requires java.logging;
requires packt.sortutil;
requires packt.contact;
}
然后,在Main.java中,我们创建一个新的ContactLoader实例,并通过传递 XML 文件的路径来调用loadContacts方法。使用与源代码捆绑的input.xml文件,以下是读取文件并返回Contact实例的步骤:
try {
contacts = contactLoader.loadContacts(
"/Users/koushik/code/java9/input.xml");
} catch (ContactLoadException e) {
logger.severe(e.getMessage());
System.exit(0);
}
catch块使用之前创建的logger实例来记录异常消息并退出应用程序。
这是包含这些更改的完整Main方法:
public class Main {
private static final Logger logger =
Logger.getLogger(Main.class.getName());
public static void main(String[] args) {
logger.info("Address book viewer application: Started");
List<Contact> contacts = new ArrayList<>();
ContactLoader contactLoader = new ContactLoader();
SortUtil sortUtil = new SortUtil();
try {
contacts = contactLoader.loadContacts(
"/Users/koushik/code/java9/input.xml");
} catch (ContactLoadException e) {
logger.severe(e.getMessage());
System.exit(0);
}
sortUtil.sortList(contacts);
System.out.println(contacts);
logger.info("Address book viewer application: Completed");
}
}
现在编译并执行应用程序将产生以下输出:
$ java --module-path out -m packt.addressbook/packt.addressbook.Main
Mar 28, 2017 3:25:41 PM packt.addressbook.Main main
INFO: Address book viewer application: Started
[Charles Babbage, Tim Berners-Lee, Edsger Dijkstra, Ada Lovelace, Alan Turing]
Mar 28, 2017 3:25:41 PM packt.addressbook.Main main
INFO: Address book viewer application: Completed
干得好!在将 XML 功能添加到地址簿查看器应用程序的过程中,你处理了一些模块相关的问题和设计考虑。让我们继续本章的第三个目标——与 Java FX API 集成以创建地址簿查看器应用程序的 UI。
使用 Java FX 添加 UI
现在我们将创建一个 UI 应用程序,允许我们点击并浏览联系人信息。我们将使用 Java FX API 来创建和显示 UI。与上一节一样,我应该强调这里的重点不是让我们学习 JavaFX API 本身。实际上,在本节中,我将略过大多数 Java FX API 的细节,因为那超出了本书的范围,尽管完整的源代码是可用的,如果你感兴趣的话。这个练习的目的是让我们学习如何使用 Java API 以及如何处理与之相关的不同使用场景和细微差别。
我们将在地址簿应用程序中添加 UI 的步骤如下:
-
创建一个名为
packt.addressbook.ui的新模块,其中包含用于在 Java FX 用户界面中显示地址簿的代码。 -
让
packt.addressbook.ui模块依赖packt.contacts以获取Contact实例的列表。同时让模块依赖packt.sortutil以按姓氏对Contact实例进行排序。
创建模块
让我们先创建新的模块packt.addressbook.ui。和之前一样,在项目文件夹中创建具有相同名称的模块根文件夹,然后创建模块描述符module-info.java。我们已经知道我们需要依赖packt.contacts和packt.sortutil,所以让我们先添加这两个依赖项:
module packt.addressbook.ui {
requires packt.sortutil;
requires packt.contact;
}
在这个模块中,我们需要使用 JavaFX 库,因此我们需要在模块描述符中使用requires子句来指定这个依赖项。我们如何知道需要哪些库?答案是和之前一样——使用java --list-modules和java -d <module-name>。但在我们浏览依赖的模块之前,我们应该知道我们需要哪些 API!让我们看看我们需要编写的代码来构建 UI。
我们将在packt.addressbook.ui包中创建一个Main.java类。这个类将启动 UI。与任何 Java FX 应用程序一样,启动应用程序的类必须扩展javafx.application.Application。然后我们重写start方法,并在其中添加构建 UI 的功能。这个方法由 JavaFX 框架调用以启动我们的应用程序。记住这个方法!我们将在执行代码时很快再次回到这里:
public class Main extends Application {
public static void main(String[] args) {
launch(args);
}
@Override
public void start(Stage primaryStage) throws Exception {
// Build JavaFX UI and application functionality
}
}
在start方法中,获取Contact实例并进行排序的逻辑与packt.addressbook模块中的命令行应用程序完全相同:
ContactLoader contactLoader = new ContactLoader();
SortUtil sortUtil = new SortUtil();
try {
contacts = contactLoader.loadContacts(
"/Users/koushik/code/java9/input.xml");
} catch (ContactLoadException e) {
logger.severe(e.getMessage());
System.exit(0);
}
sortUtil.sortList(contacts);
在这个案例中,与以往不同的地方在于我们对排序后的Contacts列表的处理方式。我们不仅仅将其打印到控制台。相反,我们将构建一个 JavaFX ListView来显示这个列表。我们还会为列表中的每个元素添加一个点击处理程序,这样当点击一个名字时,我们就可以在列表右侧显示该联系人的详细信息。以下是我们希望 UI 看起来像什么:

在不深入探讨 JavaFX 控件的构建和显示细节的情况下,这里的核心功能是从 sortutil 排序后的列表中构建 Contacts 列表,并处理列表项上的点击事件:
// Create a new JavaFX ListView
ListView<String> list = new ListView<String>();
// Collect a String list of Contact names in lastName,
firstName format
List<String> listContactNames = contacts.stream()
.map(c -> c.getLastName() + ", " + c.getFirstName())
.collect(Collectors.toList());
// Build an ObservableList from the list of names
ObservableList<String> obsContactNames =
FXCollections.observableList(listContactNames);
// Pass that to ListView to have them displayed in a list
list.setItems(obsContactNames);
// Add listener to handle click events
list.getSelectionModel()
.selectedItemProperty()
.addListener((obs, oldVal, newVal) -> {
// Get the selected index in the ListView
int selectedIndex =
list.getSelectionModel().getSelectedIndex();
name.setText(newVal);
// Get the Contact instance which was clicked
Contact contact = finalContactList.get(selectedIndex);
// Set the values to each of the labels on the right
street.setText(contact.getAddress().getStreet());
...
上述代码将我们已熟悉的逻辑(ContactLoader 和 SortUtil)连接到显示数据的 JavaFX 代码中,以便在 UI 中浏览。我们在这里使用了相当多的 JavaFX API,就像我们通常在构建类似这样的 JavaFX 应用程序时一样。现在我们已经知道了我们需要使用的 API,接下来我们需要找到导出这些 API 的模块,并在 packt.addressbook.ui 模块中设置依赖关系。
使用 java --list-modules 命令,我们可以看到与 JavaFX 相关的多个模块。这些模块都是以 javafx. 前缀开始的:
$ java --list-modules
...
javafx.base@9
javafx.controls@9
javafx.deploy@9
javafx.fxml@9
javafx.graphics@9
javafx.media@9
javafx.swing@9
javafx.web@9
...
要知道我们使用的包,我们只需要查看 Main.java 类中的导入列表。我们可以检查每个 JavaFX 模块的包级别信息,以获取所有我们需要包的模块集合。
例如,javafx.base 导出了我们在 Main.java 中使用的 javafx.collections。因此,这是一个我们需要添加的模块。以下是更多我们感兴趣的模块。左侧列出了我们需要的包,以及在我们 Java 代码中使用的情况。右侧列出了导出该包的 Java 平台模块(我们通过运行 java -d <module-name> 找到):
Package Module
------------------------------------------
javafx.collections javafx.base
javafx.scene.control javafx.controls
javafx.application javafx.graphics
javafx.scene.layout javafx.graphics
javafx.geometry javafx.graphics
基于此,我们需要添加的三个模块是 javafx.base、javafx.controls 和 javafx.graphics。让我们使用 requires 子句将这些三个模块添加到 packt.addressbook.ui 模块定义中。完成之后,这是 module-info.java 文件的内容:
module packt.addressbook.ui {
requires java.logging;
requires javafx.base;
requires javafx.controls;
requires javafx.graphics;
requires packt.sortutil;
requires packt.contact;
}
尽管我们已经找到了模块导出的包,并且我们在技术上所要求的并没有错误,但这一步可以做得更好。实际上,我们只需要在这里要求一个 JavaFX 模块!这要归功于一个名为 transitive 的特定限定符。我们将在第六章 Module Resolution, Accessibility, and Readability 中更详细地介绍这个限定符是什么以及它如何影响我们的依赖关系。但由于我们还没有介绍它,现在我们先添加所有三个 JavaFX 模块。
如果你觉得通过运行
--list-modules 命令很繁琐,不过,你并不孤单!我希望一旦 IDE 支持了 Java 9,这很快就会变得不再必要。理想情况下,IDE 应该能够根据我们导入到 Java 应用程序中的包来帮助我们识别模块,并且最好能够自动将模块添加到模块描述符中。这个功能在你阅读本文时可能已经在大多数标准 IDE 中可用!
好的,所以,有了这些,我们已经建立了所有的依赖关系。让我们试试看!使用 javac 命令编译所有模块:
$ javac --module-source-path src -d out $(find . -name '*.java')
代码应该能够无错误地编译。让我们尝试执行它。由于我们正在运行位于新模块packt.addressbook.ui中的Main.java文件,请确保这次在命令中指定它。请注意,当我们运行代码时,我们会得到一个错误:
$ java --module-path out -m packt.addressbook.ui/packt.addressbook.ui.Main
Exception in Application constructor
Exception in thread "main" java.lang.reflect.InvocationTargetException
...
Caused by: java.lang.IllegalAccessException: class com.sun.javafx.application.LauncherImpl (in module javafx.graphics) cannot access class packt.addressbook.ui.Main (in module packt.addressbook.ui) because module packt.addressbook.ui does not export packt.addressbook.ui to module javafx.graphics
...
错误表明,模块javafx.graphics正在尝试访问我们的Main类,但由于我们的模块packt.addressbook.ui没有导出它,因此无法访问它!你可能会想知道模块javafx.graphics与我们编写的类有什么关系!为什么它需要访问我们的类?
结果表明,答案是 JavaFX 的工作方式。记得我提到过Main.java中的start()方法以及 JavaFX 框架如何调用该方法来启动应用程序。框架使用反射来识别扩展Application类的类。然后,框架使用这些信息通过调用start()方法来启动 JavaFX 应用程序。
这就是我们的问题。在packt.addressbook.ui的模块描述符中,我们没有导出Main所在的包,即packt.addressbook.ui。因此,Main对模块外部的任何代码都是不可访问的,所以 JavaFX 无法启动应用程序!应用于模块外部类型静态访问的封装在运行时反射访问中仍然有效!
解决这个问题的方法之一是将Main设置为公共的。我们只需要导出类型所在的包。这足以让 JavaFX 访问它。这也实际上允许任何模块访问它!这可能是你想要的,也可能不是。但就目前而言,让我们导出这个包,并使Main.java对外可用。我们也会在第六章“模块解析、可访问性和可读性”中重新审视这个问题,并找到更好的解决方案。
下面是最终的module-info.java文件:
module packt.addressbook.ui {
exports packt.addressbook.ui;
requires java.logging;
requires javafx.base;
requires javafx.controls;
requires javafx.graphics;
requires packt.sortutil;
requires packt.contact;
}
再次编译并运行应用程序,这次应该一切正常。一个 GUI 窗口应该加载带有按姓氏排序的联系人列表。点击一个名字应该在右侧显示详细信息。你可以通过点击标题栏上的关闭按钮来关闭应用程序。
摘要
我们对地址簿应用程序进行了几项改进,以利用一些平台 API,并从中学习了一些关于如何查找和使用平台模块的教训,以及如何处理沿途出现的一些棘手场景。以下是到目前为止我们所做的工作:
-
我们使用
java.logging模块为packt.addressbook模块添加了日志功能。 -
我们使用了
java.xml模块并创建了一个新的自定义模块,该模块读取并解析 XML 文件以返回模型对象的列表。 -
我们遇到了两个问题——共享代码和依赖泄露,并实施了一种策略来绕过这些问题。
-
我们使用了 JavaFX 模块来构建地址簿的用户界面。我们创建了一个新的模块,该模块利用我们现有的联系人和排序模块来构建这个 UI。我们了解了模块化对反射的影响。我们通过仅导出框架需要访问的类来解决这个问题,尽管我们将在下一章学习更好的方法来做这件事。
我还想强调一点,在本章中,我们是如何利用我们已经构建的模块来创建一个新的自定义 GUI 模块的。请注意,我们不必与现有代码纠缠,也不需要用 if 语句在它们上面堆砌逻辑和功能。我们轻松地创建了一个新的 GUI 模块,多亏了其他核心功能是独立的模块,我们只需将它们作为构建块来创建一个全新的模块,这个模块包含了我们需要的所有功能。packt.contact和packt.sortutil并不关心它们被用在何处,所以从技术上讲,它们并不在乎!
在下一章中,我们将探讨 Java 模块系统的一些更多技巧!我们还将深入研究与可读性相关的概念,并了解使不同模块之间相互访问的更强大方式。
第六章:模块解析、可读性和可访问性
在上一章中,我们通过利用多个平台 API 来添加额外功能,显著增强了地址簿查看器应用程序。我们使用了 JavaFX 实现了日志记录、XML 解析和 UI 模块。地址簿查看器应用程序已经从简单的 Hello world 原始版本发展了很多。在这个过程中,你已经获得了大量关于 Java 模块系统的知识,你应该具备构建任何类似复杂性的 Java 应用程序所需的知识和工具。
本章你将学习以下内容:
-
你将介绍两个重要概念及其相关术语--可读性和可访问性
-
你将了解一些关于使模块可读和包可访问的细微差别
-
你将学习一些强大的新方法来调整指定依赖项的默认方法--隐式依赖和限定导出
-
你将应用这两种新方法在地址簿查看器应用程序中调整和优化依赖项,使用聚合模块和限定导出
在本章中,我们将对我们在学习过程中仅以高层次了解的一些概念进行深入探讨。虽然你可以使用我们迄今为止所涵盖的一切来构建各种不同的应用程序,但了解所涵盖的某些主题的细微差别将有助于学习和理解。了解这些概念将帮助你以强大的新方式使用模块系统。此外,在本章中我们将学习的某些术语将帮助你理解和描述每次使用模块系统时在底层运行的概念和过程。
可读性
可读性是模块化中的一个重要概念,它描述了模块如何相互协作。我们一直使用模块依赖关系,让一个模块要求另一个模块并使用其 API。每当一个模块依赖于另一个模块时,第一个模块就被说成是读取第二个模块。同样,第二个模块也被说成是可被第一个模块读取。当一个模块读取另一个模块时,它有权访问第二个模块导出的类型。换句话说,两个模块之间的可读性关系是我们迄今为止所看到的模块图中的箭头。
例如,在地址簿查看器应用程序中,模块 packt.addressbook 读取 packt.sortutil、packt.contact 和 java.logging。这是因为它在模块描述符中requires了这些模块。
考虑一个例子。以下图表显示了三个模块 A、B 和 C 之间的关系:

模块 A 需要 模块 B。因此,模块 A 读取 模块 B。模块 B 可被 模块 A 读取。模块 C 也读取 模块 B。然而,模块 C 不读取 模块 A,反之亦然。
如你所见,读取关系不是对称的。如果模块 A读取模块 B,并不意味着模块 B会读取模块 A。实际上,在 Java 模块系统中,我们可以保证模块之间的读取关系是不对称的。为什么?因为如果两个模块相互读取,最终我们会得到一个循环依赖,这是不允许的(参见第五章,使用平台 API)。所以总结一下,首先,可读性关系是通过使用requires子句建立的。其次,如果一个模块读取另一个模块,我们可以保证第二个模块不会读取第一个模块。
然而,有两个例外,你可能已经猜到了它们是什么,因为我们已经讨论过它们了。首先,每个模块都会读取java.base模块。这种依赖关系是自动的,没有使用requires限定符的显式使用。其次,每个模块根据定义都会读取自身,因为模块默认自动有权访问模块中的所有公共类型。
可读性关系对于实现 Java 模块系统的两个主要目标之一--可靠配置是基本的。我们希望能够可靠地保证应用程序中所有模块的依赖关系都得到满足。随着时间的推移,我们还将看到这些正式模块关系的性能优化优势。运行时不再需要扫描整个类路径来查找给定的类型。运行时可以通过某些方式最优地找到正确的模块以及找到类型的定位。这是一个巨大的胜利!
可访问性
可访问性是 Java 模块化硬币的另一面。如果可读性关系指定了哪些模块可以读取给定模块,可访问性则表示它们实际读取的内容。并非模块中的所有内容都对读取它的其他模块可访问。只有标记了exports声明的包中的公共类型才是可访问的。
因此,为了使模块 B 中的类型在模块 A 中可访问,需要发生以下情况:
-
模块 A 需要读取模块 B
-
模块 B 需要导出包含类型的包
-
类型本身应该是
public
让我们来看一个例子,并检查可读性和可访问性关系。考虑两个模块,app和lib。模块app对模块lib有requires限定符。模块lib导出其包lib.external:
module app {
requires lib;
}
module lib {
exports lib.external;
}
假设lib模块具有以下结构:

它有两个包--lib.external和lib.internal。这两个包都包含一个公共接口和一个包私有实现。
注意:本例中的实现类在类声明中没有使用public关键字,这使得它们只能在同一包中可见。它们具有*default*包私有访问级别。
让我们尝试回答以下问题:
- 模块
app是否读取模块lib?
这个问题应该很简单。答案是肯定的,因为 requires 限定符的存在。
- 模块
lib是否读取模块app?
答案是否定的。希望同样简单!
- 模块
lib中的类型LibApi是否对模块app可访问?
让我们验证可访问性的两个要求。类型是 public 吗?是的。类型是否在模块导出定义中导出的包中?是的。所以,答案是 LibApi 对模块 app 是可访问的。
- 模块
lib中的类型InternalService是否对模块app可访问?
不。尽管类型是 public,但它属于在 lib 模块的模块定义中没有导出的包。类型 InternalImpl 也不对 app 模块可访问。
- 模块
lib中的类型LibApiImpl是否对模块app可访问?
答案是否定的,因为它没有满足要求——类型是 public 吗?由于 LibApiImpl 是包私有的,它不在模块外部可访问,因为它不是 public。即使类型属于导出包,这也是正确的。然而,这种情况却有一些我们可以从中学习的重要经验教训。让我们详细看看它们。
接口和实现的可访问性
在 Java 9 中,一个接口对模块可访问意味着什么?很明显,这意味着你可以在该模块的代码中使用接口类型。然而,没有实现,接口几乎没有意义。这意味着当你导出一个公共接口(如 LibApi),但不导出实现(LibApiImpl)时,实现对于模块外部来说基本上是无用的吗?并不完全是这样!
假设我们在 LibApi 接口中添加一个静态方法来创建 LibApiImpl 的实例。我们还会在接口中添加一个方便的 testMethod() 方法,以便我们从另一个模块调用它来验证其是否工作。注意,当 createInstance 方法创建 LibApiImpl 的新实例时,其返回类型是接口,而不是实现。这很重要,我们稍后会看到:
package packt.lib.external;
public interface LibApi {
static LibApi createInstance() {
return new LibApiImpl();
}
public void testMethod();
}
让我们构建一个简单的实现类,它将消息打印到控制台。注意类声明前缺少 public 关键字。这意味着这个类是包私有的,而不是 public。所以,尽管它位于由模块导出的包中,但它对模块外部不可访问:
package packt.lib.external;
class LibApiImpl implements LibApi {
public void testMethod() {
System.out.println("Test method executed");
}
}
如果我们在 lib 模块外部访问这些类型会发生什么?让我们来看看!让我们在模块 app 中创建一个名为 App.java 的类。让我们首先尝试创建 LibApiImpl 的实例:
package packt.app;
import packt.lib.external.LibApiImpl;
public class App {
public static void main(String[] args) {
LibApiImpl api = new LibApiImpl();
api.testMethod();
}
}
如果我们编译这个会发生什么?
$ javac --module-source-path src -d out $(find . -name '*.java')
./src/app/packt/lib/external/App.java:3: error: LibApiImpl is not public in packt.lib.external; cannot be accessed from outside package
import packt.lib.external.LibApiImpl;
^
...
正如我们所想。包私有的类不可访问,即使它位于导出包中。我们是否可以使用接口来获取其实例?
package packt.app;
import packt.lib.external.LibApi;
public class App {
public static void main(String[] args) {
LibApi api = LibApi.createInstance();
api.testMethod();
}
}
我们现在使用接口LibApi的createInstance()方法来创建LibApi的实例。然后在该实例上调用testMethod。我们知道LibApi正在创建一个新的LibApiImpl实例,我们也知道这个类是不可访问的。现在这会工作吗?
$ javac --module-source-path src -d out $(find . -name '*.java')
$ java --module-path out -m app/packt.app.App
Test method executed
它确实工作了!因为我们没有在代码中直接引用类型LibApiImpl,编译器和运行时都乐于允许通过接口访问该实例。这在模块中是一个很有价值的模式,因为它允许你提供公共 API,同时仍然能够管理和重写底层的实现。这不仅适用于导出包中的非公共实现类(如本例所示);它也适用于位于未导出包中的公共类型,因此同样不可访问。所以,让我们重新审视这个问题。现在LibApiImpl在模块外部可访问吗?答案是仍然不可访问。然而,这里的重要教训是,可访问性规则适用于类型的用法,并不适用于运行时类型的动态实例。这是设计的一部分,并且是一个实现级别封装的好模式。
分割包
这是一些人可能已经提出的问题。LibApiImpl类是包私有的。所以,它不可能被它所在的packt.lib.external包外的任何类型访问。所以,我们尝试在完全不同的包packt.app.App中访问该类型的尝试注定会失败!实际上,即使在 Java 8 或更早的版本中也会失败!如果我们尝试从另一个模块中的相同包中访问它会怎样呢?如果我们要在app模块中创建相同的包packt.lib.external并在其中创建一个新的类,那么这个类能够访问LibApiImpl吗?在这种情况下,消费类在同一个包中。让我们试一试!你不必走得太远。仅仅从一个模块到另一个模块创建相同的包是不行的。假设你在app模块中重新创建这个包,并在其中添加任何任意的 Java 类型:
package packt.lib.external;
public class App {
public static void main(String[] args) {
System.out.println("Test");
}
}
我们在这里甚至没有使用LibApiImpl!我们只是在其他模块中使用了相同的包。编译步骤将因以下错误而失败:
$ javac --module-source-path src -d out $(find . -name '*.java')
./src/app/packt/lib/external/App.java:1: error: package exists in another module: lib
package packt.lib.external;
^
1 error
是的!一个包不能同时在两个模块中存在。好吧,至少不能在两个可观察的模块中同时存在!换句话说,给定一个应用程序中的包,它应该是模块路径上仅一个模块的一部分!
这与我们在 Java 中传统上对库的看法有显著不同。既然我们已经走到这一步,我就不必强调模块与传统 JAR 库的不同。但这里还有一个方面打破了传统的库范式。传统上,类路径中的多个 JAR 可以包含相同的包。我们已经在第一章,“介绍 Java 9 模块化”中看到了这个例子:

由于模块不允许共享包,或者如常所说,分割包,我们现在面临一个新的层次结构。顶层是模块,下面是包,然后是包下的类型,如下面的图示中几个示例模块所示:

这现在导致我们对包的思考方式发生了另一项变化。包不再是整个应用程序中类型的分组。包只是单个模块内类型的分组。当你创建一个包时,你被迫选择所有类型都应该在哪个模块中。如果我们设计得好的话,这种方法的一个优点是组织更清晰。还有性能优化。Java 类加载器内部将每个包映射到单个模块,因此当它查找要加载的类型时,它立即知道在哪里(以及哪个模块)可以找到给定类型。
避免分割包的限制在将遗留 Java 代码库迁移到 Java 9 模块时将变成一个痛苦的头痛问题,我们将在第十一章“将您的代码迁移到 Java 9”中讨论。此外,请注意,有方法可以通过使用多个类加载器来绕过这个限制,但这超出了我们在这里讨论的范围。
调整模块化
我们已经探讨了两个可以在模块描述符中使用的语言结构--要求模块和导出包。它们共同为你提供了足够的控制权来管理模块的接口,并解决了模块化的两个主要目标--可靠的配置和强大的封装。然而,在许多实际情况下,你可能会发现仅这两个还不够来实现你想要完成的事情。例如,你可能只想从模块中导出一个包,仅供某个特定的其他模块使用,而不是公开使用。为了处理许多这样的特殊情况,模块系统有一些强大的功能,我们将在本章的这一部分进行探讨。
隐含的可读性
我们在第五章“使用平台 API”中探讨了依赖泄露的问题。你依赖的模块可能包含需要你使用另一个模块的 API。以下是一个例子:

module A {
requires B;
}
module B {
requires C;
}
模块 A“要求”模块 B,而模块 B 反过来“要求”模块 C。我们知道,由于模块依赖不是传递性的,A 不会读取 C。但如果是这种情况呢?例如,如果 B 有一个 API,其返回类型在模块 C 中。
一个很好的例子可以在平台模块本身中找到。假设你编写了一个自定义模块,该模块读取java.sql。你希望使用该模块中的Driver接口。Driver接口有一个名为getParentLogger()的方法,它返回类型Logger。以下是Driver接口中该方法的模样:
Logger getParentLogger() throws SQLFeatureNotSupportedException
这是你在自定义模块中调用 java.sql API 的代码:
Logger myLogger = driver.getParentLogger();
要使这生效,你只需要在你的模块定义中添加 requires java.sql,然后你应该就可以正常使用了,对吧?但等等!考虑一下返回类型 Logger。实际上,这个类型是从 java.logging 来的,就像我们之前看到的。java.sql 模块依赖于 java.logging 来实现日志功能,所以对那个模块来说没问题。但你的模块呢?

由于 yourmodule 没有直接要求 java.logging,为了使用 java.sql API,你必须也 require java.logging 模块!

如你所见,这并不太方便。如果使用某个模块需要使用其他模块,这只会增加 API 的复杂性。在这里,你需要为开发者提供一些文档说明,例如:“如果你恰好使用 java.sql,别忘了也要要求 java.logging。”
有没有更好的方法?尽管依赖默认不是传递性的,但我们希望有选择性地仅使某些依赖传递性可用,以应对这种情况。幸运的是,Java 9 通过使用 transitive 关键字实现了这一点。当你在一个模块上声明 requires 时,你也可以让该模块对任何依赖于你的模块的模块可用和可读。使用此功能的办法是这样的——requires transitive <module-name>;
在下面的例子中,模块 A 需要 B。但模块 B 需要传递性 C:
module A {
requires B;
}
module B {
requires transitive C;
}
现在,模块 C 不仅可被模块 B 读取,而且可被所有读取模块 B 的其他模块读取。所以在这里,A 也可以读取 C!
注意,transitive 关键字为 requires 关键字添加了额外的语义。现在 requires transitive C 使得 C 可被所有读取 B 的模块读取,同时仍然保留我们一直知道的 requires 的意义——即 B 需要读取 C!
这对我们刚才讨论的 可读性 关系有何影响?我们知道 A 读取 B 是因为显式的 requires 关系?但 A 是否也 读取 C?答案是肯定的,这种可读性关系被称为 隐式可读性。这种关系不是 显式 的,因为 A 没有直接声明对 C 的依赖。这种可读性是由于其传递性而隐含的。
这个功能在 java.sql 模块中被利用来解决 Logger 返回类型的问题。如果你在 java.sql 上运行 java -d,你会看到如下内容:
$ java -d java.sql
module java.sql@9
exports java.sql
exports javax.sql
exports javax.transaction.xa
requires transitive java.logging
requires transitive java.xml
requires mandated java.base
uses java.sql.Driver
注意到java.sql所需的两个模块java.xml和java.logging都被标记为transitive。正如我们刚才看到的,这意味着任何需要java.sql的模块都将自动获得java.xml和java.logging中的 API 访问权限!这是平台团队做出的决定,因为使用java.sql中的许多 API 也需要使用其他两个模块。因此,为了避免所有开发者都记得require这些模块,平台已经将其设置为自动。这就是为什么任何依赖于java.sql并调用Driver.getParentLogger()的模块在使用Logger类型时不会有任何问题,因为这个模块将对java.logging具有隐含的可读性:

注意,你需要在你的模块中谨慎添加大量的传递依赖。我在第五章“使用平台 API”中提到了依赖泄露,并且建议将你模块的所有依赖限制在模块内部使用。模块暴露的 API 的使用应该只需要处理在相同模块中暴露和可用的类型。从某种意义上说,传递依赖的概念似乎与那种哲学相悖。多亏了transitive,任何依赖泄露都可以通过将包含泄露类型的模块标记为requires transitive来轻松处理。但这是一条滑梯。想象一下,你需要依赖一个模块,却意外地得到了一打其他的模块依赖,因为它们在你需要的模块中都被标记为requires transitive!这种模块设计显然违反了模块化的原则,我强烈建议除非绝对必要,否则避免使用它们。
然而,有一种非常有趣且实用的传递依赖的使用方法,这将帮助库开发者。那就是聚合模块。
聚合模块
聚合模块是一种不提供任何自身功能的模块,而是它的唯一目的是收集和捆绑其他模块。正如其名所示,这些模块聚合了几个其他模块。
假设你有一组你最喜欢的库模块,你经常在应用程序中一起使用。假设这是一个核心库的列表,每次你编写一个模块时,你很可能几乎会使用列表中的每一个库。现在,在任何一个自己的模块中使用列表中的每个模块都涉及到使用 requires 子句来指定每个模块。根据列表的大小,指定每个模块描述符中的相同核心依赖可能很繁琐。即使你只做一次,也很难更改核心库的列表,可能需要添加一个新的库。你唯一的选择是再次遍历所有模块描述符并做出更改。如果你能创建一个捆绑所有核心库的新模块,那岂不是很好?这样你就可以在一个地方找到这个列表,而且你不必在其他任何地方指定完整的列表。现在,任何需要所有这些库的模块都可以表达对这个新的 模块捆绑包 的依赖!你可以通过创建一个本质上 空 的模块,并带有所有依赖项的传递性来实现这一点。
考虑以下示例:
module librarybundle {
requires transitive core.foo;
requires transitive core.foo;
requires transitive core.baz;
}
这里有一个名为 librarybundle 的模块,实际上并没有导出任何内容,因为在模块描述符中没有指定任何 exports 包。这个模块实际上并不需要包含一个单独的 Java 类!然而,它所做的确实是 requires transitive 三种其他库。因此,任何依赖于 librarybundle 模块的模块都会自动读取这三个库。
Java 平台聚合模块
Java 平台有几个聚合模块来表示 完整 的 JRE,至少在我们知道 Java 8 及更早版本时是这样。java.se 模块基本上重新导出了整个 Java SE 平台。java.se.ee 模块包含与 Java EE 重叠的平台子集,并包含诸如 Web 服务、事务和遗留 CORBA API 等 API。
在 java.se 模块上运行 java -d 命令可以显示其实现方式:
$ java -d java.se
java.se@9
requires java.scripting transitive
requires java.xml transitive
requires java.management.rmi transitive
requires java.logging transitive
requires java.sql transitive
requires java.base mandated
...
再次,我希望你抵制在创建的任何新的 Java 9 代码中使用这些聚合模块的诱惑。你几乎可以在所有的模块定义中抛入 requires java.se,并且再也不必担心做任何其他的 requires!但这样又违背了模块化的目的,你又会回到使用 Java 平台的方式,就像我们在 Java 8 及更早版本中做的那样——依赖于整个平台 API,而不考虑你真正需要的是哪一部分。这些聚合模块主要用于遗留代码迁移目的,而且也只作为临时措施,试图最终达到更细粒度的依赖。
尽管java.se.ee模块已被弃用,不鼓励使用,但通过检查它,我们可以得出一个有趣的观察。虽然java.se.ee是一个超集,包括java.se中的所有模块以及一些额外的模块,但其模块定义并没有重新声明平台中所有模块的整个列表。它所做的只是简单地要求传递性地需要java.se模块:
$ java -d java.se.ee
java.se.ee@9
requires java.corba transitive
requires java.base mandated
...
requires java.se transitive
你可以在自己的模块中使用这种方法来创建其他聚合模块的聚合模块!非常强大!
合格导出
在上一节中,我们探讨了传递性依赖,它让我们可以调整模块之间的可读性关系来处理一些特殊用例。在本节中,你将了解到一种可以调整某些特殊情况下访问性关系的方法。这可以通过一个称为合格导出的功能来实现。让我们来了解一下它们是什么。
你已经了解到exports关键字允许你指定模块中哪些包可以在模块外部使用。导出的包构成了模块的公共契约,任何读取此类模块的模块都会自动获得对这些导出包的访问权限。
但是这里有一个问题!理想情况下,你可能希望将你的模块和 API 设计成独立的实体,并且始终清楚地知道模块应该导出什么。但你也可能遇到现实世界中的场景,情况并非如此。有时你可能需要设计模块,以便它们能够与其他模块良好地协同工作,这会带来一些有趣的成本。
假设你已经构建了一个名为B的库模块,它被消费者模块A所使用:

模块 A 的开发者现在显然可以调用 B 模块导出的 API。但随后,他们还需要 B 模块中尚未导出的另一个 API。你最初不想从 B 模块中导出那个私有包,因为它不是 B 外部通常需要的,但结果证明,只有一个模块 A 真正需要它!所以,为了让模块 A 的开发者满意,你将那个私有包添加到 B 模块的exports列表中:
module B {
exports moduleb.public;
exports moduleb.privateA; // required only for module A
}
过了一段时间,一个新的模块,模块 C,依赖于模块 B。它也有一个有趣的使用场景,需要从模块 B 中获取另一个私有 API。很可能只有 C 模块会在 B 外部需要那个 API,但为了让模块 C 工作,你除了将那个包添加到 B 模块的导出包中别无选择:
module B {
exports moduleb.public;
exports moduleb.privateA; // required only for module A
exports moduleb.privateC; // required only for module C
}
我希望你已经注意到了这个问题。现在,模块 B 中原本是私有的两个包现在对读取 B 的每个模块都是公开的,尽管导出这些包的意图是为了满足两个非常小且具体的用例。如果继续这样下去,你模块中的导出 API 最终会变成确保每个消费者模块都满意的 API 的最大公约集。在这个过程中,你失去了封装的优势。现在,一个内部包被导出,尽管意图是为了满足一个模块,但它对任何模块都是可用的。如果当导出包到一个模块时,你可以选择性地指定哪些模块需要导出这些包,那岂不是很好?如果是这样,那么只有那些选定的模块才能访问那些特别导出的包。所有其他模块将只能获得公开导出的包。
这可以通过有资格的导出来实现。模块定义中的exports子句允许你指定需要导出包到的模块。如果你这样做,export就不再是公开的了。只有你指定的模块可以访问它。语法是:
exports <package-name> to <module1>, <module2>,... ;
将此概念应用于我们的示例模块 B,我们可以通过选择性地给予模块 A 和 C 访问它们各自需要的,来更好地封装我们的私有包:
module B {
exports moduleb.public; // Public access to every module
that reads me
exports moduleb.privateA to A; // Exported only to module A
exports moduleb.privateC to C; // Exported only to module C
}
通过这个变更,包moduleb.privateA对 A 是可访问的,但对 B 或任何读取 B 的其他模块则不是。同样,moduleb.privateC仅由 C可访问。现在,尽管私有 API 尚未完全封装,但你至少可以确切知道它们被哪些模块访问,因此任何更改都更容易管理。
在 Java 平台中,此功能的示例用法位于java.base模块中。此模块包含许多被认为是内部的核心内部包,我们 Java 开发者理想上不应该使用它们。不幸的是,其他平台模块仍然需要使用它们,而这些内部包的封装也阻止了对这些平台模块的访问!因此,你会看到很多这些有资格的导出,其中内部 API 仅导出到需要它们的平台模块。你可以在java.base上运行java -d命令来查看这些实例:
$ java -d java.base
module java.base@9
...
exports jdk.internal.ref to java.desktop, javafx.media
exports jdk.internal.math to java.desktop
exports sun.net.ext to jdk.net
exports jdk.internal.loader to java.desktop, java.logging, java.instrument, jdk.jlink
记住,使用有资格的导出通常是不推荐的。模块化的原则建议一个模块不应该知道其消费者是谁。有资格的导出,根据定义,在两个模块之间增加了一定程度的耦合。这种耦合不是强制的——如果你有一个有资格的导出到某个模块,而这个模块甚至不在模块路径中利用它,那么不会有错误。但耦合确实存在,因此除非绝对必要,否则不建议使用有资格的导出。
将此概念应用于地址簿查看器
我们已经了解了几种强大的方法,可以调整 Java 9 中模块依赖项的 默认 行为。现在让我们动手操作,将这些方法应用到我们的地址簿查看器应用程序中。
创建自定义聚合器模块
注意,在地址簿查看器应用程序中,我们有两个模块提供了地址簿的视图。packt.addressbook 模块在命令行中显示简单的联系人列表。packt.addressbook.ui 模块以 UI 形式显示地址簿联系人和详细信息。这两个模块恰好都使用了两个实用模块来获取联系人列表(packt.contact)和排序(sort.util)。在这里,我们只有两个模块,所以将这两个模块的要求描述符添加到两个地方并不是什么大问题。但是,想象一下如果有更多的库和更多的消费者!你将不得不多次重复列表。
为了避免这种情况,让我们创建一个聚合器模块,它将 packt.contact 和 sort.util 模块捆绑并重新导出。然后,packt.addressbook 和 packt.addressbook.ui 模块可以直接依赖于聚合器模块。
让我们将聚合器模块命名为 packt.addressbook.lib。此模块充当所有 addressbook 模块的 库。在 src 文件夹中创建一个以模块名称命名的目录,并在其模块描述符中添加以下代码:
module packt.addressbook.lib {
requires transitive packt.contact;
requires transitive packt.sortutil;
}
实际上,这个模块只需要一个文件。它不提供任何自己的 API。它只是有一个模块描述符,该描述符 requires transitive 所有它想要重新导出的模块。在这里,我们选择重新导出我们创建的两个自定义实用模块。我们还有选择在这里添加 requires transitive 到一些平台模块,如 java.logging。但我们将暂时坚持使用我们的自定义模块。
下一步是进入消费者模块,并将直接依赖项更改为聚合器模块。
这是两个地址簿模块的模块描述符:
module packt.addressbook {
requires java.logging;
requires packt.addressbook.lib;
}
module packt.addressbook.ui {
exports packt.addressbook.ui;
requires java.logging;
requires javafx.base;
requires javafx.controls;
requires javafx.graphics;
requires packt.addressbook.lib;
}
编译并执行这两个模块,你应该仍然看到之前的输出。以下是我们的地址簿应用程序现在更新的依赖关系图,不包括平台模块。注意,传递依赖项用虚线箭头表示,以传达虽然依赖项不是直接的,但它仍然存在!

优化模块导入
在上一章中,我们创建了需要构建 UI 所必需的 Java FX 模块的 GUI 地址查看器模块。以下是模块描述符的样子:
module packt.addressbook.ui {
exports packt.addressbook.ui;
requires java.logging;
requires javafx.base;
requires javafx.controls;
requires javafx.graphics;
requires packt.addressbook.lib;
}
现在,我们将看到并非所有导入的模块都是实际需要的,我们可以利用我们对传递依赖项的新知识来优化这个列表。在 javafx.controls 上运行 java -d 给出:
$ java -d javafx.controls
module javafx.controls@9
...
requires transitive javafx.base
requires transitive javafx.graphics
结果表明,javafx.base和javafx.graphics模块已经是javafx.controls的传递依赖。所以,任何读取javafx.controls的模块也会读取javafx.base和javafx.graphics!因此,我们可以移除这两个模块,只需声明我们对javafx.controls的依赖,因为这个模块本身拉入了我们需要的所有依赖。以下是packt.addressbook.ui的更新模块描述符:
module packt.addressbook.ui {
exports packt.addressbook.ui;
requires java.logging;
requires javafx.controls;
requires packt.addressbook.lib;
}
你应该能够重新编译并执行 UI 模块,以确保一切仍然按预期工作。
优化模块导出
在上一章的结尾,我们很不情愿地在packt.addressbook.ui模块的模块描述符中为包含主 JavaFX 类的包添加了exports限定符。我们这样做是因为 JavaFX 框架的工作方式,它需要能够访问扩展javafx.application.Application并启动 UI 应用程序的类。我们说过这不是一个理想的解决方案,因为我们不仅将包导出到 JavaFX 框架,实际上是将它导出到全世界,也就是说,任何读取packt.addressbook.ui的模块。
基于我们对限定导出的新知识,我们正好有这个问题的解决方案!我们不必全局导出packt.addressbook.ui,而可以使用限定导出仅将其导出到 JavaFX 模块。在这里,需要访问该类的模块是java.graphics模块。我们移除了显式依赖,尽管依赖仍然隐式存在!通过到java.graphics的限定导出,模块描述符看起来是这样的:
module packt.addressbook.ui {
exports packt.addressbook.ui to javafx.graphics;
requires java.logging;
requires javafx.controls;
requires packt.addressbook.lib;
}
再次强调,你应该能够编译并运行代码以确保一切正常工作。通过这个变更,你保留了Main类的封装性,同时仍然使其对需要访问它的正确 JavaFX 框架模块可用。
如果问题仅与反射访问有关,我们可以通过使用opens关键字来稍微更好地实现这一点。我们可以使用开放模块的概念。我们将在第九章详细介绍开放模块,模块设计模式和策略。
摘要
在本章中,你已经学习了一些与模块化相关的重要概念和术语,包括可读性和可访问性。你还了解了如何根据特定的专业需求调整模块系统的默认行为——使用隐式依赖和限定导出。更重要的是,你理解了在某些场景下可能需要这些需求的情况,比如聚合模块和某些封装挑战,以及这些调整在这种情况下可能如何有用。然后我们查看了一些在地址簿查看器应用程序中这些调整如何帮助我们优化和简化依赖关系,同时提高封装性的地方。
在下一章中,你将学习到一种全新的处理依赖抽象的方法,这是通过 Java 模块化中的一个强大概念——使用服务来实现的。
第七章:介绍服务
在本章中,我们将学习 Java 模块化中另一个令人兴奋的新特性,称为服务。与直接依赖相比,服务在模块之间提供了额外的间接层,我们将看到它们为我们如何使模块协同工作提供了额外的灵活性。在本章中,你将:
-
理解我们迄今为止所做事情中模块依赖的一个限制
-
理解服务是什么以及它们如何解决这个问题
-
学习如何创建和公开服务
-
学习如何使用
ServiceLoaderAPI 消费服务
在我们学习服务是什么之前,让我们检查并理解它们被创造出来要解决的问题。这是模块之间直接依赖的紧密耦合问题,至少是我们迄今为止连接它们的方式。
耦合问题
编程中的短语紧密耦合指的是两个实体之间高度依赖的情况,为了改变它们的行为或关系,需要对其中一个(或经常是两个)实体进行实际的代码更改。另一方面,松散耦合术语指的是相反的情况——实体之间没有高度依赖。在这种情况下,实体理想上甚至不知道彼此的存在,但仍然可以相互交互。
考虑到这一点,你认为 Java 模块系统中两个模块的耦合可以怎么称呼?当一个模块依赖于另一个模块时,这两个模块是紧密耦合还是松散耦合?答案很明显,它们是紧密耦合的。考虑以下适用于模块关系的事实:
-
模块需要明确声明它们依赖的其他模块。从这个意义上说,每个模块都意识到它需要的其他模块的存在。
-
模块还与依赖模块公开的 API 耦合,并且意识到这些 API 的存在。如果模块 A 读取模块 B 并调用一个 API,它是通过使用模块 B 中可用并导出的实际 Java 类型来实现的。因此,模块 A 知道模块 B 的内部结构,至少与模块 B 导出并由模块 A 使用的类型一样多。
由于这两个因素,很明显,这种紧密耦合导致模块在运行时表现出非常严格和僵化的行为。考虑地址簿查看器应用程序。编译的模块集合就是运行时涉及的模块集合。一旦模块被编译,你就无法移除其中的任何一个模块,用其他东西替换它,然后执行它们。涉及的模块必须完全相同。尽管我们有一种印象,即 Java 9 模块是构建块,可以组装成多种组合,但这种优势仅适用于开发时间。到目前为止,我们所看到的是,一旦模块被编码并且建立了依赖关系,结果就是一个相当紧密、不可更改的单一实体。
现在你可能会想,“嗯,这不就是我们想要的吗?”可靠配置的好处需要严格的检查以确保我们打算拥有的确切模块都存在,不是吗?嗯,是的,但我们仍然可以在不放弃可靠配置的情况下拥有运行时灵活性。一个类似的情况可以在 Java 语言本身中找到。尽管 Java 是严格类型化的,但你可以通过使用多态的概念来实现强大的运行时灵活性和类型之间的松散耦合。想法是类不直接相互依赖。相反,它们依赖于抽象类或接口。在运行时,这些接口的实例可以被动态初始化并在接口被使用的地方使用。我们能否在模块中拥有类似的东西?如果可以,它将如何工作?
让我给你举一个例子。我们有一个名为 packt.sortutil 的排序实用模块,它有一个用于排序列表的 API。我们已经配置了该模块以导出接口并封装实现,但在现实中,这种区分目前是无用的。它只有一个实现,现在该模块能做的只是冒泡排序。如果我们想有多个排序模块,并且让消费模块选择使用哪种排序算法呢?
当前:

我们想要的:

我们希望能够在我们的应用程序中使用提供不同排序实现的多个模块。然而,由于紧密耦合,为了一个模块能够使用另一个模块,它必须要求它。这意味着消费者packt.addressbook模块必须在其可能需要的每个不同实现模块上声明requires,即使在任何时候,它可能只使用一个。如果有一种方法可以定义一个接口类型并在运行时仅依赖于该接口,那不是很好吗?然后不同的提供者模块提供接口的实现,你可以在运行时插入这些实现,而不需要显式依赖,并且没有实际消费者和各个实现模块之间的耦合?
以下图显示了我们的期望。而不是让packt.addressbook需要所有提供实现逻辑的模块,它需要的是一个充当接口的单个模块,并且具有某种动态获取实现的机制:

到现在为止,你可能已经猜到,每当我问“不是很好吗...”这样的问题时,这通常意味着这样的功能已经存在!至少在这个案例中,这是真的。这就是服务介入的地方。服务和 Service API 的概念一起,在您迄今为止所学的现有模块化概念之上增加了一个全新的间接层。让我们深入了解细节。
理解服务
让我们从您作为 Java 开发者应该非常熟悉的概念开始我们的服务理解之旅——多态。它从一个接口和(可能多个)该接口的实现开始。尽管接口对于服务来说不是严格必需的,但它们仍然是一个好的起点。假设您定义了一个名为MyServiceInterface的服务接口,其外观如下:
package service.api;
public interface MyServiceInterface {
public void runService();
}
现在您可以拥有包含实现此接口的类的多个模块。由于所有这些模块都需要访问此接口,让我们将这个接口放入一个名为service.api的单独模块中,并公开接口MyServiceInterface所在的包。然后每个实现模块都可以要求service.api模块并实现MyServiceInterface。
假设有三个模块实现了MyServiceInterface,分别位于三个相应的模块中。由于它们需要接口来首先实现它,所有三个实现模块读取service.api模块以访问MyServiceInterface。想象一下每个模块都这样做,并且每个模块都包含一个实现了MyServiceInterface的类:

现在,消费者模块需要调用这些实现之一来实际运行服务。这里的目的是不希望消费者模块直接读取各种实现模块,因为那样会导致紧密耦合。我们希望消费者模块只读取接口模块service.api,并且只处理接口类型,但仍然能够以某种方式访问该接口实现的实例。记住,我们不希望消费者需要单独的实现模块(以下图中所示Xs):

服务注册表
为了在消费者和实现之间建立桥梁而不直接紧密耦合,想象一个位于它们之间的层,称为服务注册表。服务注册表是模块系统提供的一个层,用于记录和注册给定接口的实现作为服务。把它想象成一种电话簿或黄页,但针对服务。任何实现服务的模块都需要在服务注册表中注册自己。一旦完成,服务注册表就拥有了关于接口的不同服务实现的所有信息。以下图表说明了这种交互:

现在当消费者需要实现时,它使用服务 API 与服务注册表通信,并获取可用实现的实例。以下图表说明了这种交互:

消费者模块得到的是所有可用实现实例的Iterable。当然,不一定需要多个实例。可能只有一个实现!无论如何,服务 API 在访问时将可用的实例交给消费者。
如您所见,我们打破了提供者和消费者之间的耦合。这个图中的每个模块只读取一个共同的模块——包含接口的模块。接口是所有这些不同模块唯一共同的实体,因为那是它们之间交互的手段。由于其他模块对彼此一无所知,您可以几乎移除一个实现模块并替换另一个。只要它做正确的事情——也就是说,实现正确的接口并在服务注册表中注册——它就可以供消费者使用。
现在您已经从高层次上理解了服务的概念,让我们深入了解具体细节。模块如何注册它们的实现?消费者模块如何从注册表中访问实例?让我们看看实现细节。
创建和使用服务
这里是创建、注册和实现服务的端到端步骤:
- 创建定义服务的 Java 类型:每个服务都基于一个单一的 Java 类型,该类型定义了服务 API。这个类型可以是接口、抽象类,甚至是普通的 Java 类。大多数情况下,服务类型将是接口。拥有一个接口是理想的,因为这样你可以为它提供多个实现类型。
服务类型是提供者和消费者之间交流的手段。这引发了一个问题:服务类型应该在哪个模块中?由于它被多个模块共享,而且我们不希望提供者和消费者紧密耦合,理想的解决方案是在一个单独的模块中创建服务类型。这个模块导出服务类型,并由提供者和消费者模块读取。
在我们的例子中,这将是模块service.api。它导出service.api包,从而导出接口:
module service.api {
exports service.api;
}
该模块包含之前显示的service.api.MyServiceInterface接口。这个完全限定的接口类型名称本身是服务类型。在 Java 模块系统中,服务没有特殊名称。它们只是通过充当服务的 Java 类型的名称来引用。
-
创建一个或多个实现模块,这些模块读取接口模块并实现接口:例如,如果您需要有两个模块
service.implA和service.implB,它们提供MyServiceInterface的两个实现,那么这两个模块都将需要service.api模块来访问接口。它们各自都有一个MyServiceInterface接口的实现。每个服务接口的实现被称为服务提供者。 -
让实现模块注册自己作为服务提供者:这是实现模块告诉服务注册它们希望注册其接口实现的那个部分。这是通过在模块描述符中使用一个新的关键字
provides并指定接口和实现类型信息来完成的。其语法如下:
provides <interface-type> with <implementation-type>;
例如,如果模块service.implA有一个实现类packt.service.impla.MyServiceImplA,它实现了MyServiceInterface,那么模块定义应该如下所示:
module service.implA {
requires service.api;
provides service.api.MyServiceInterface with
packt.service.impla.MyServiceImplA;
}
这对于模块系统来说足够了,它知道这个模块希望注册MyServiceImplA类作为为MyServiceInterface接口提供服务的服务。
这里有一些观察结果:
-
注意接口和实现类型的完全限定名称。这对于避免名称冲突并确保 Java 平台确切知道您所引用的类型非常重要。
-
注意在
provides子句中引用的接口类型不属于该模块本身。它位于一个完全不同的模块中,该模块通过requires子句读取!但是,这没关系;它仍然可以工作。然而,对于实现类位于模块描述符所属的模块中这一点很重要。当你考虑到使用provides代码行时,模块实际上是在声明提供所提到的实现这一事实时,这一点就变得有意义了。因此,它最好拥有它! -
这是对前一个模块描述符中未包含的内容的观察。请注意,我们没有在这里添加
exports子句,以便使MyServiceImplA类对其他模块可访问。在提供服务时,您不必公开实现类并使其可访问。这是因为这个类不是通过我们迄今为止一直在使用的常规模块可读性和可访问性关系来访问的。消费者模块将通过服务 API 获取实现类的实例,而不是直接读取模块。这正是服务的作用所在,避免这种紧密耦合。
通过这一步,我们现在已成功将服务提供者注册到模块系统中。现在,所有执行此操作的实现都映射到服务名称上,这在所有实际目的上相当于完全限定的接口名称--service.api.MyServiceInterface。
现在,让我们将注意力转向消费者模块。让消费者模块能够访问服务实现实例是一个两步的过程:
- 让消费者模块将自己注册为服务的消费者:就像服务提供者注册他们提供实现的意图一样,服务消费者需要注册他们需要使用服务的事实。需要实例的模块必须在模块描述符中正式声明这种需求。这是使用
uses关键字完成的。语法如下:
uses <interface-type>;
在我们的例子中,如果我们有一个名为 consumer 的模块需要MyServiceInterface的实例,该模块的定义将如下所示:
module consumer {
requires service.api;
uses service.api.MyServiceInterface;
}
这对于模块系统来说足够了,它知道这是一个将使用指定接口的服务实现实例的模块。
一些观察结果:
-
消费者模块也需要
requires暴露接口的模块。它必须这样做,因为当模块从服务 API 请求服务实例时,它将得到与接口相同类型的实例。 -
这里,
uses子句指的是模块中不可用的类型。 -
如您所料,对任何实现模块都没有直接依赖。松散耦合获胜!
在这一步之后,一方面,提供者模块将它们的实现注册到服务注册表中。另一方面,消费者模块注册它自己是服务的消费者。现在,消费者模块中的代码如何获取提供者实现实例的访问权限?这是最后一步,这涉及到调用ServiceLoader API。
- 在消费者模块的代码中调用 ServiceLoader API 以访问提供者实例:由于没有直接依赖,服务实现类型对消费者来说是完全未知的。它所拥有的只是接口类型。因此,它无法使用
new来实例化类型。
为了访问所有注册的服务实现,你需要在消费者模块的代码中调用 Java 平台 API ServiceLoader.load()方法。以下是获取接口MyServiceInterface的所有注册服务提供者实例的代码:
Iterable<MyServiceInterface> sortUtils =
ServiceLoader.load(MyServiceInterface.class);
API 返回的是在服务注册表中注册的所有可用服务实现的Iterable。由于返回类型是Iterable,你可以遍历实例并从中选择一个。或者,你甚至可以使用所有!这完全取决于应用程序的需求和它试图做什么。重要的是要注意,这个Iterable中的每个对象都是之前在创建服务的步骤 3 中注册的提供者实现类型的实例。可能有其他几个实现该接口的类,但如果它们没有使用provides语法进行注册,则不会在这个过程中被考虑。
在许多企业 Java 框架中存在一个常见的模式,用于处理服务的各种实现,并通过接口访问。它被称为依赖注入。这种模式在 Spring 等框架以及 EJB 等 Java EE 技术中都是可用的。这种模式要求消费者类简单地声明对服务的依赖。然后框架执行创建实例并自动注入到消费者类的任务。
我们在这里所做的不是这样。没有自动将实例注入到消费者代码中。正如你所注意到的,你必须编写使用ServiceLoader来查找提供者实例的代码。这是设计的一部分,并且与那些其他模式相比,这是一个重要的区别。这是一个依赖查找,而不是依赖注入。
实现排序服务
现在我们已经了解了如何创建和消费服务,让我们在地址簿查看器应用程序中将其付诸实践。我们将创建多个排序实现模块,并将这些实现注册为服务。然后,我们将更新packt.addressbook模块以使用ServiceLoader API 获取排序实例,然后使用这些实例之一对联系人列表进行排序。让我们回顾一下我们刚刚学到的五个步骤来实现这一点:
- 创建定义服务的 Java 类型:我们将保留接口
SortUtil作为各种实现类型将使用的通用接口。packt.sortutil模块现在包含接口和实现类型。我们将移除实现类型,只保留接口。我们还将移除对BubbleSortUtilImpl的默认静态依赖,使其成为一个纯净的抽象接口:
package packt.util;
import java.util.List;
public interface SortUtil {
public <T extends Comparable> List<T> sortList(List<T> list);
}
这将是packt.sortutil模块中唯一的类型。该模块导出packt.util包,以便提供者和消费者都可以使用该接口。以下是module-info.java文件:
module packt.sortutil {
exports packt.util;
}
- 创建一个或多个实现模块,该模块读取接口模块并实现接口:让我们创建几个实现模块--
packt.sort.bubblesort,它提供了冒泡排序的实现,以及packt.sort.javasort,它使用 Java 集合的默认排序 API 提供实现:

确保你不会在两个模块中将实现类放在同一个包中。例如,实现类不能都在packt.util.impl包中,因为这样会导致包分割问题,两个模块都包含相同的包,运行时会抛出错误。我们已经在第六章,“模块解析、可访问性和可读性”中讨论了包分割问题。
两个模块requires模块packt.sortutil以访问packt.util.SortUtil接口。每个模块都有一个接口的实现。
这是BubbleSortUtilImpl,这是我们之前已经看到的一个类,所以这里提供的是截断版本:
public class BubbleSortUtilImpl implements SortUtil {
public <T extends Comparable> List<T> sortList(
List<T> list) {
...
return list;
}''
}
这是JavaSortUtilImpl,它简单地使用了Collections.sort API:
public class JavaSortUtilImpl implements SortUtil {
public <T extends Comparable> List<T> sortList(
List<T> list) {
Collections.sort(list);
return list;
}
}
- 让实现模块将自己注册为服务提供者:让我们使用
provides关键字将两个实现模块都注册为提供者。服务类型是接口packt.util.SortUtil,而实现类型分别是两个模块中的两个实现类。
这是packt.sort.bubblesort模块的module-info.java文件:
module packt.sort.bubblesort {
requires packt.sortutil;
provides packt.util.SortUtil
with packt.util.impl.bubblesort.BubbleSortUtilImpl;
}
这是packt.sort.javasort模块的module-info.java文件:
module packt.sort.javasort {
requires packt.sortutil;
provides packt.util.SortUtil
with packt.util.impl.javasort.JavaSortUtilImpl;
}
- 让消费者模块将自己注册为服务的消费者:在
packt.addressbook和packt.addressbook.ui中,我们需要SortUtil的实例。在这里,我将仅展示packt.addressbook模块中的步骤,因为它涉及的内容相对较少。但步骤是相同的,并且需要应用到两个模块中。
这是packt.addressbook模块的模块描述符,其中包含uses子句:
module packt.addressbook {
requires java.logging;
requires packt.addressbook.lib;
uses packt.util.SortUtil;
}
- 在消费者模块的代码中调用 ServiceLoader API 以访问提供者实例:在
Main.java中,使用ServiceLoaderAPI 获取所有SortUtil的提供者实例:
Iterable<SortUtil> sortUtils =
ServiceLoader.load(SortUtil.class);
现在,迭代遍历,你可以访问每个实例。我将通过循环使用两种排序实现来对列表进行排序。这显然是不必要的,但只是为了说明:
for (SortUtil sortUtil : sortUtils) {
System.out.println("Found an instance of SortUtil");
sortUtil.sortList(contacts);
}
我们已经完成了!你已经使用了服务机制来创建、注册和使用排序服务实现。
确保你也将相同的更改应用到packt.addressbook.ui模块中!如果不这样做,该模块的编译步骤将因明显的原因而失败。
现在编译并运行代码应该不会出现任何错误:
$ javac --module-source-path src -d out $(find . -name '*.java')
$ java --module-path out -m packt.addressbook/packt.addressbook.Main
Apr 09, 2017 12:03:18 AM packt.addressbook.Main main
INFO: Address book viewer application: Started
Found an instance of SortUtil
Found an instance of SortUtil
[Charles Babbage, Tim Berners-Lee, Edsger Dijkstra, Ada Lovelace, Alan Turing]
Apr 09, 2017 12:03:19 AM packt.addressbook.Main main
INFO: Address book viewer application: Completed
如您从输出中可以看到,ServiceLoader 返回了两个 SortUtil 实例,对应于我们已注册的两个实现。
绘制模块图
让我们绘制表示服务提供者和消费者模块之间交互的模块图。我们知道如何表示 可读性 关系。那么服务消费者和提供者依赖关系呢?以下图表使用箭头表示 使用 和 提供 依赖关系,并使用标签来识别它们:

消费者模块和提供者模块之间没有依赖关系是使用服务实现松耦合的关键。
高级服务
到目前为止所涵盖的服务概念应该可以解决很多典型用例,但还有一些额外的功能和概念可能在某些特殊场景中很有用。在本节中,我们将探讨几个这样的概念。
支持单例和工厂提供者
假设您的服务实例不能简单地通过构造函数创建。如果需要重用实例,比如拥有一个单例提供者实例?或者每当创建一个新的服务实例时执行一些逻辑?服务有一个方便的特性,允许您创建工厂方法来获取服务实例。您需要做的只是在其提供者类中添加一个名为 provide() 的方法。该方法需要是一个公共静态方法,并且不应该接受任何参数。此外,返回类型应与提供的服务类型相同。如果 ServiceLoader 看到了这个方法,它会调用它,并使用方法的返回值作为服务实例。如果没有找到这样的方法,它会调用我们之前看到的公共无参构造函数。这允许您在提供者实例创建步骤中挂钩,在这里您可以执行任何必要的代码,同时有机会创建 ServiceLoader 将用作提供者实例的对象。
实现服务优先级
在最后一个例子中,我们得到了两个 SortUtil 实例,并且对它们做了些荒谬的事情--我们对列表进行了两次排序,一次对应于每个实现!您通常不会这样做。当您在应用程序中接收到多个服务实现时,您最可能需要做的是一件更复杂的事情。您需要选择一个!
很遗憾,您无法为服务实现指定不同的优先级。因此,您无法说像BubbleSortUtilImpl这样的排序实现是您最喜欢的,并且当它可用时,无论找到其他什么实现,都应该使用这个实现。根据设计,指定优先级不是服务实现的职责。决定如何处理从ServiceLoader接收到的多个实现是消费者的工作,因为最适合这项工作的服务实现通常取决于消费应用程序本身。对某个消费者来说最好的服务实现可能对另一个消费者来说并不理想。
现在,消费者如何在所有可用的提供者中选择一个?他们看到的是同一接口的一堆实例!因此,我们增强接口以包含消费者可以使用的方法来查询提供者实例。消费者调用这些方法来了解更多关于提供者类的信息,从而做出明智的决定,选择它想要的提供者实例。
以SortUtil实现为例。假设我们想根据列表的大小来使用排序算法。例如,假设我们只想在列表非常小的情况下使用冒泡排序,而对于较大的列表则使用Collections API 排序。
我们可以做的就是在SortUtil接口中添加一个名为getIdealInputLength()的方法。然后,每个实现都提供一个整数长度,表示它可以理想地处理的长度。
冒泡排序绝不是您能使用的最佳排序算法。它是许多编程课程用来教授排序的算法,但在现实中它效率极低。为了我们简单的示例,让我们说我们只在列表有四个或更少的元素时使用冒泡排序,而对于其他情况则使用Collections排序。我必须承认这是一个人为的例子,但它将使我们能够实现选择提供者的基本策略。在现实中,您几乎总是想使用Collections API 来排序列表。
下面是带有新方法声明的SortUtil:
public interface SortUtil {
public <T extends Comparable> List<T> sortList(List<T> list);
public int getIdealMaxInputLength();
}
下面是BubbleSortUtilImpl实现此方法,返回4作为输入列表的理想最大大小:
public class BubbleSortUtilImpl implements SortUtil {
...
public int getIdealMaxInputLength() {
return 4;
}
JavaSortUtilImpl对任何列表大小都无所谓,所以对于理想的最大值,我们只需返回最大整数值:
public class JavaSortUtilImpl implements SortUtil {
...
public int getIdealMaxInputLength() {
return Integer.MAX_VALUE;
}
现在每个提供者都有一个可以用来选择一个实现而不是另一个的方法,消费者可以使用这个方法来识别它想使用的实现。
下面是Main.java(在pack.addressbook和packt.addressbook.ui模块中)的章节,它遍历提供者以选择一个:
Iterable<SortUtil> sortUtils =
ServiceLoader.load(SortUtil.class);
for (SortUtil sortUtil : sortUtils) {
logger.info("Found an instance of SortUtil with ideal
max input: " + sortUtil.getIdealMaxInputLength());
if (contacts.size() < sortUtil.getIdealMaxInputLength()) {
sortUtil.sortList(contacts);
break;
}
}
给定我们想要排序的contacts列表的大小,我们将检查每个提供者,看列表大小是否大于提供者理想情况下希望处理的最大的大小。然后我们选择第一个通过这个检查的提供者,使用该实例来排序列表,并break出循环。
运行代码并观察输出。如果BubbleSortUtilImpl是迭代器中的第一个实例,逻辑会跳过它并移动到JavaSortUtilImpl并使用它进行排序:
$ java --module-path out -m packt.addressbook/packt.addressbook.Main
Apr 09, 2017 8:01:20 PM packt.addressbook.Main main
INFO: Address book viewer application: Started
Apr 09, 2017 8:01:20 PM packt.addressbook.Main main
INFO: Found an instance of SortUtil with ideal max input: 4
Apr 09, 2017 8:01:20 PM packt.addressbook.Main main
INFO: Found an instance of SortUtil with ideal max input: 2147483647
[Charles Babbage, Tim Berners-Lee, Edsger Dijkstra, Ada Lovelace, Alan Turing]
Apr 09, 2017 8:01:20 PM packt.addressbook.Main main
INFO: Address book viewer application: Completed
这是一个简单的例子,说明了提供者实现如何向任何消费者提供线索关于实现的信息。不同的消费者可以根据他们独特的需求和业务问题选择不同的实现。
服务接口提供者查找
获取服务实例的查找逻辑现在在packt.addressbook和packt.addressbook.ui模块的Main类中。这并不理想。我们不希望在多个地方重复查找逻辑。解决这个问题的方法之一是将逻辑移动到一个所有服务消费者都可以访问的通用位置。现在,共享每个服务消费者的模块是什么?是导出接口的模块。将依赖查找逻辑移动到接口中并作为一个默认方法隐藏起来,这不是一个好主意吗?这样,就没有消费者模块需要直接与ServiceLoader API 打交道。他们只需要调用正确的接口方法来查找实例。
让我们在SortUtil接口上创建两个新方法。一个用于获取所有服务提供者实例,另一个根据列表的大小(这是一个影响选择哪个实例的标准,就像我们已经看到的)获取单个实例。
这里是SortUtil上的两个新静态方法:
public static Iterable<SortUtil> getAllProviders() {
return ServiceLoader.load(SortUtil.class);
}
public static SortUtil getProviderInstance(int listSize) {
Iterable<SortUtil> sortUtils =
ServiceLoader.load(SortUtil.class);
for (SortUtil sortUtil : sortUtils) {
if (listSize < sortUtil.getIdealMaxInputLength()) {
return sortUtil;
}
}
return null;
}
如果没有找到符合我们要求的服务实例,我们将返回null。这可以很容易地增强以提供默认服务,以防找不到合适的实例。
现在,Main不需要再与ServiceLoader通信并遍历实例了:
SortUtil sortUtil = SortUtil.getProviderInstance(contacts.size());
sortUtil.sortList(contacts);
我希望你会同意,现在服务的消费已经变得简单多了。
你还需要做的另一件事是将uses子句从packt.addressbook和packt.addressbook.ui模块移动到packt.sortutil模块。这是因为服务现在是从packt.sortutil模块消费的,并且ServiceLoader API 也是从该模块调用的:
module packt.sortutil {
exports packt.util;
uses packt.util.SortUtil;
}
编译和运行代码应该会给出与之前相同的输出。但这次,服务查找逻辑已经被重构为一个所有消费者都可以使用的通用模块。
选择性服务实例化
在前面的示例中,我们已经查询了ServiceLoader API 以获取所有提供者实例的Iterable。然后我们遍历它们并选择一个。这在这里不是问题,因为我们的服务是简单且轻量级的 Java 类。但这并不总是理想的。想象一下,如果服务更复杂且需要时间和资源来实例化。在这种情况下,当你知道你不会使用所有服务时,你不想实例化每个服务提供者。
Java 模块系统在管理应用程序中的服务实例方面非常智能。首先,所有服务实例都是延迟加载的。换句话说,服务提供者实例在应用程序启动时不会自动实例化。运行时仅在需要类型时创建服务实例,例如,当某些消费者使用ServiceProvider.load()请求服务实例时。
其次,在应用程序的生命周期内创建的任何服务实例都始终被缓存。服务加载器维护这个缓存并跟踪所有已创建的服务实例。当第二个消费者请求服务时,实例直接从缓存中获取。它还智能地确保返回的服务实例顺序始终包括缓存实例。
服务实例的缓存是自动的。如果你想在应用程序执行期间清除整个服务提供者缓存,可以使用ServiceProvider.reload() API。
ServiceLoader API 有一个选项可以流式传输一个称为Provider的中间类型实例,然后可以使用它来创建服务提供者实例。你不会直接获取所有服务实例,而是获取Provider实例——每个找到的服务实现都有一个实例。然后,你可以通过在这些实例上使用Provider.get()方法来实例化你想要的服务提供者。
以SortUtil上的getProviderInstanceLazy()方法为例。我们不是直接使用ServiceLoader.load(SortUtil.class),而是可以使用ServiceLoader.load(SortUtil.class).stream(),它返回一个Provider实例的Stream:
Stream<Provider<SortUtil>> providers =
ServiceLoader.load(SortUtil.class).stream();
然后,可以检查Provider实例以获取诸如注解和其他类型信息等。在这里,我们只是按类型名称对它们进行排序,这很愚蠢,但作为一个最小示例它是有效的:
Stream<Provider<SortUtil>> providers =
ServiceLoader.load(SortUtil.class).stream()
.sorted(Comparator.comparing(p -> p.type().getName()));
在此时刻,尚未创建任何服务实例。服务提供者类型的实际实例化发生在调用Provider.get时:
SortUtil util = providers.map(Provider::get)
.findAny()
.orElse(null);
在前面的代码中,我们通过 map 函数对每个提供者实例调用Provider.get()并选择一个。这样,我们可以延迟实例的创建,并通过仅在需要的实例上调用Provider.get来选择性地实例化提供者类型。
服务和模块系统目标
由于服务是 Java 模块系统的一部分,它们如何与模块系统的两个目标——强封装和可靠配置——相一致?
让我们从强封装开始。服务提供了一种模块中类型之间交互的替代方式,这种方式不需要将类型暴露给所有消费模块。服务提供者包不需要被导出,因此它们甚至被封装在包含服务的模块读取者之外!同时,它们作为服务类型的实现被发布,因此可以被那些甚至不读取服务实现模块的模块使用。所以,从某种意义上说,类型仍然被封装,尽管不是我们之前看到的那种方式。
那可靠配置呢?由于服务提供者和服务消费者声明了他们分别提供和消费服务的事实,运行时和ServiceProvider API 可以很容易地确保正确的消费者获得正确的服务。然而,你可以轻松地编译一大堆模块,而模块路径中没有任何服务实现可用。
例如,你可以从你的源代码中删除packt.sort.bubblesort和packt.sort.javasort模块,并编译其余的模块。它工作得很好!你可以执行Main模块。尽管ServiceProvider API 没有找到任何服务实例,它仍然可以工作。在我们的例子中,我们返回null,但我们可以很容易地通过提供默认服务实现(假设有一个名为DefaultSortImpl的默认实现)来处理这种情况,以防找不到任何实现:
SortUtil util = providers.map(Provider::get)
.findAny()
.orElse(new DefaultSortImpl());
为什么会这样呢?当一个模块明确声明自己是服务消费者时,为什么编译器和运行时不会检查是否至少有一个服务实现可用?原因是,这是设计上的考虑。服务依赖关系旨在是可选的。记住我们在本章开头提到的松耦合概念。我们希望能够在运行时插拔服务消费者和提供者。这对于服务来说工作得非常好。
然而,当服务模块没有满足其某些依赖关系时,平台的可靠配置检查确实会发挥作用。例如,假设我们有一个服务消费者模块C。你可以编译并执行这个模块,而不需要服务提供者的存在。在下面的图片中,Java 平台不会抱怨:

然而,如果你确实添加了一个提供者模块,你需要确保它满足了所有依赖关系。例如,假设你添加了一个提供者模块P,它为C所需的服务提供了实现。现在这个提供者模块需要遵循所有可靠的配置规则。如果这个模块读取模块D,而模块D作为一个可观察的模块不存在,平台会抱怨:

看起来很奇怪,当没有提供者模块可用时,平台运行良好,但当存在提供者模块但未满足依赖关系时,它却会抱怨。为什么它不能忽略模块 P 呢?答案是,又是可靠的配置。没有提供者模块可能是故意的。但如果平台发现了一个它技术上可以使用但无法使用(因为未满足依赖关系)的模块,这表明是一个 损坏 的状态,因此它会相应地出错。即使通过服务允许松耦合,平台也在尽其所能为我们提供可靠的配置。
摘要
在本章中,我们详细探讨了 Java 模块系统中的服务功能。我们学习了模块紧密耦合的缺点,以及松耦合如何提供更多灵活性。然后我们深入研究了创建和使用服务的语法,并实现了具有两个提供者实现的排序服务。接着,我们探讨了与服务相关的一些高级概念,例如优先级服务实例的机制、使用服务接口本身来处理服务查找,以及在重量级服务的情况下使用 Provider 类型来延迟服务实例的创建。
然后,我们回顾了模块化的两个目标——强封装和依赖注入——并评估了服务功能对这些目标的影响。
在下一章中,我们将了解由于模块化特性而新应用于 Java 开发的 链接 阶段。我们还将回顾在 第一章 中讨论的单一 JDK 的问题,介绍 Java 9 模块化,我们如何能做得更好?我们如何利用模块化的概念来创建更精简、性能更优的运行时?这些问题的答案以及更多内容将在下一章中找到!
第八章:理解链接和使用 jlink
在前面的章节中,我们学习了与 Java 模块化相关的一些高级概念,包括处理模块关系的可读性和可访问性,以及强大的服务概念。在本章中,我们将继续进行任何应用程序开发的最后一步——构建和打包你的应用程序。
本章你将学习以下内容:
-
你将了解模块解析过程,这是一个在每次编译或执行模块化 Java 应用程序时都会发生的重要过程。
-
你将了解开发过程中的一个新阶段——链接。链接,或静态链接,是 Java 9 模块化开发中的一个新步骤。它位于你应已熟悉的熟悉的编译和执行阶段之间。在本章中,你将了解链接是什么以及这一步骤的好处。
-
你将学习如何使用
jlink,这是一个内置到平台中的新工具,用于简化链接阶段并帮助构建运行时镜像。 -
你将了解一些
jlink插件,这些插件可以优化由jlink创建的运行时镜像。 -
你将学习如何构建一个模块化的 JAR 文件,这是分发编译库模块以供其他应用程序使用的一种替代方式。
模块解析过程
在我们深入了解链接过程的细节以及它能为我们做什么之前,让我们了解一个重要的步骤,这个步骤在每次编译和执行模块化 Java 应用程序时都会发生。这是一个称为模块解析的步骤。
传统上(Java 9 之前),Java 编译器和 Java 运行时会查看一组文件夹和 JAR 文件,这些文件夹和 JAR 文件构成了类路径。类路径是一个可配置的选项,你在编译期间将其传递给编译器,在执行期间传递给运行时。为了使任何类文件都处于编译器或运行时的管辖之下,你首先需要将其放置在类路径中。一旦它在那里,每个 Java 类型都对编译器或运行时可用。
与模块不同。我们不再需要使用通用的类路径。由于每个模块都定义了其输入和输出,现在有一个选项可以确切地知道在任何时候需要代码的哪个部分。
考虑以下模块依赖图:

假设你在模块路径中有模块 A、B、C、D 和 E。让我们想象你正在扮演 Java 运行时的角色,并想要执行模块 C 中的 main 方法。为了实现这一点,需要哪些最小集合的模块?显然你需要模块 C,因为那里有 main 方法。接下来你需要它的依赖项,模块 B 和 D。然后你还需要这些模块的依赖项,在这个例子中是模块 A,它是 B 依赖的:

使用这个流程,我们可以肯定地说,执行模块 C 中的 main 类型所需的最小模块集是 A、B、C 和 D——模块 E 是不必要的。
让我们重复这个练习,但这次是在模块 E 中执行类型。这次,我们只需要模块 E 和 D;所有其他模块都可以跳过:

现在我们为什么要这样做?找到这个 最小模块集 的优势是什么?与较旧的 classpath 模型进行对比,其中 classpath 中的每个类型都是应用程序的一部分,任何类型都可能被 使用。编译器和运行时无法确定给定类型的位置,除非它扫描整个 classpath。现在不再是这种情况了!由于编译器和运行时现在都对代码库的哪些部分是 需要 执行任何内容的以及哪些部分不是有了一个精确的了解,这使得这个优势得到了很好的利用,正如我们很快就会看到的。但是,为了获取这些信息,平台运行了一个解析模块的过程,这个过程在原则上与我们在前面的例子中所做的是相似的。这个过程被称为模块解析过程。
在图论中,这个过程被称为寻找 传递闭包。给定一个图,我们的想法是找到一个从给定节点可以 到达 的节点集。我们执行传递闭包的图应该是所谓的 有向无环图(DAG)。图应该是 有向的,即节点之间的关系是方向性的(带有箭头),并且 无环的,即不应该有循环关系。这个 DAG 让你想起了你最近看到的任何图吗?是的!Java 模块图是一个有向无环图的绝佳例子!
模块解析步骤
这里是平台在解析模块时运行的高级步骤:
-
将根模块添加到已解析的模块集合中。记住,当你执行代码时,你需要指定包含主方法及其所属的模块的类型。这个模块是根模块,它是模块解析过程的起点。请注意,这个起点不一定是单个模块——可能会有多个模块,正如我们很快就会看到的。
-
识别已添加模块的所有
requires依赖项。在这里,每个模块的描述符文件被查找以识别它所 读取 的所有模块。这包括requires和requires transitive。 -
从步骤 2 的列表中删除所有已在已解析的模块集合中的模块。
-
将剩余的模块添加到已解析的模块集合中。对此列表重复步骤 2。
如您所想象的那样,这是一个递归图操作,从一个或多个模块开始,最终得到所需作为依赖的最小模块集。由于平台在每次编译和运行时阶段都会执行此操作,因此它利用这个机会来检查多种不同类型的错误。事实上,我们迄今为止遇到的许多模块错误都是由于模块解析过程期间和周围的检查引起的。以下是一些例子:
-
不可用的依赖模块:这一点很明显。在查找依赖项时,如果某个模块在可观察模块中找不到,则进程会出错。正如我们所见,这是可靠配置的关键。
-
多个模块:不仅所有依赖模块都必须可用,而且每个模块只能有一个。如果模块路径中恰好有两个具有相同名称的模块(即使它们的内容完全不同),平台会立即捕捉到这一点并抛出一个错误。
-
循环依赖:如果有两个或更多模块相互依赖,并在模块图中形成一个闭环循环,平台会抛出一个错误,正如我们之前所看到的。
-
分割包:平台假设每个包只在一个模块中可用。类加载器维护一个映射,将每个包映射到它所在的模块。因此,如果有多个模块包含相同的包,则进程会因错误而终止。
检查模块解析的实际操作
Java 新增了一个命令选项,用于打印描述模块解析过程的调试信息。您可以通过传递选项 --show-module-resolution 来激活它。当传递此选项时,java 命令会为模块解析的每个步骤打印控制台消息。您可以使用此功能查看运行时解析所有模块的过程,就像我们在前面的练习中所做的那样。
这就是您在上一章中运行命令行地址簿模块的方式,但没有使用标志:
$ java --module-path out -m packt.addressbook/packt.addressbook.Main
这是您启用模块解析诊断时运行它的方法:
$ java --show-module-resolution --module-path out -m
packt.addressbook/packt.addressbook.Main
详细输出清楚地表明了这里发生的事情。一切从预期的根模块 packt.addressbook 开始:
root packt.addressbook file:///Users/koushik/code/java9/07-
services/out/packt.addressbook/
接下来,它从模块描述符中找到依赖模块。对于它找到的每个模块,输出会列出模块的名称、找到它的位置(路径)以及原因(哪个模块需要它):
packt.addressbook requires packt.addressbook.lib file:///Users/koushik/code/java9/07-services/out/packt.addressbook.lib/
packt.addressbook requires java.logging jrt:/java.logging
它还根据声明自己为消费者的模块来确定服务提供者:
packt.sortutil binds packt.sort.bubblesort file:///Users/koushik/code/java9/07-services/out/packt.sort.bubblesort/
packt.sortutil binds packt.sort.javasort file:///Users/koushik/code/java9/07-services/out/packt.sort.javasort/
packt.addressbook binds packt.sort.bubblesort file:///Users/koushik/code/java9/07-services/out/packt.sort.bubblesort/
packt.addressbook binds packt.sort.javasort file:///Users/koushik/code/java9/07-services/out/packt.sort.javasort/
运行时继续在遍历模块图节点时查找后续依赖项。在将所有必要的模块添加到解析集合后,它然后执行主方法,并在控制台上打印出预期的程序输出:
Aug 16, 2017 11:07:02 PM packt.addressbook.Main main
INFO: Address book viewer application: Started
[Charles Babbage, Tim Berners-Lee, Edsger Dijkstra, Ada Lovelace, Alan Turing]
Aug 16, 2017 11:07:03 PM packt.addressbook.Main main
INFO: Address book viewer application: Completed
现在你已经了解了模块解析的过程以及它如何有助于编译时和运行时可靠配置的执行,现在让我们看看它还能解决的其他问题。我们在第一章,“介绍 Java 9 模块化”中简要介绍了单体 JDK 的问题。我们将快速回顾这个问题,然后了解为什么在 Java 9 中它不再是问题。
回顾 JDK 的状态
在第一章,“介绍 Java 9 模块化”中,我们探讨了 JDK 的庞大体积,无论是从rt.jar的文件大小还是包含其中的类数量来看。通常,在开发 Java 应用程序时,你不会考虑 JDK。一旦你在开发机器上安装了 JDK,它就会坐在你的硬盘的远程角落的$JAVA_HOME中,不会打扰你。然而,确实有一些情况下你需要担心 JDK 的大小,尤其是在捆绑应用程序可执行文件时。以下是一些这样的情况:
-
嵌入式设备的运行时捆绑包:众所周知,Java 可以在便携式和嵌入式设备上运行,例如紧凑型音乐播放器、微波炉和洗衣机。许多这些设备在内存和处理能力方面硬件资源有限,为了在这些设备上运行 Java,运行时显然应该是安装应用程序的一部分。Java SE 运行时的体积如此之大,以至于有一个专门的平台(Java ME)用于此类场景。
-
微服务的运行时镜像:近年来,在云中部署轻量级微服务已成为一种常见趋势。不再有一个集中式的、能够完成所有功能的 Web 应用程序,而是将应用程序拆分为运行在不同机器实例上的独立较小服务,并通过网络相互通信。每个实例的运行时镜像是一组自给自足的二进制文件,包括应用程序类和 Java 运行时。这些微服务理想状态下是无状态的、可扩展的、可丢弃的,因此它们理想上需要轻量级和高效。捆绑一个 75MB 的运行时文件,并使其成为每个实例的一部分,实际上并没有真正帮助。
想想为什么这个问题甚至存在。嗯,这是因为 Java 先前版本中的类路径模型。任何代码都可能潜在地引用类路径中的任何其他 Java 类型,而且无法确定需要什么和不需什么,所以我们别无选择,只能添加整个平台。
在模块化编程中,这种情况不再存在!我们已经看到,在模块解析中,给定一个起点,我们可以精确地识别出执行它所需的模块。这同样适用于应用程序模块和平台模块,因为它们都遵循相同的契约。正因为如此,我们现在可以应用模块解析过程,并得出一个独特的最小平台模块集和应用程序模块集,这是您运行任何应用程序所需的。因此,当分发带有运行时的应用程序时,例如,您不必包含整个平台。相反,您只需包含必要的平台模块,而我们正是通过引入一个全新的步骤来实现这一点,这是我们在之前的 Java 应用程序开发中所没有的--链接。
使用 jlink 进行链接
JDK 9 随带了一个名为 jlink 的新工具,它允许您构建自己的完整运行时镜像,该镜像包含执行给定应用程序所需的一切。记住我们在第四章中讨论的 JDK 的新结构,介绍模块化 JDK:

使用 jlink,您可以创建一个类似的自定义镜像来分发您的应用程序。生成的镜像包含:
-
您所编写的或添加的您的应用程序和库模块的最小集
-
为使您的应用程序正常运行所需的最小平台模块集
这实际上解决了在运输平台的同时产生的巨大开销问题。您所运输的只是应用程序所需的,不多也不少。这个过程还有其他好处,但在我们深入探讨之前,让我们学习如何使用 jlink 创建此镜像。
jlink 命令
jlink 命令需要以下输入:
-
模块路径:这是它需要查找模块的地方。这是编译好的模块可用的目录(或目录)。
-
起始模块:这是模块解析过程开始的地方。这可能是一个或多个,由分隔符分隔。
-
输出目录:这是它存储生成的镜像的位置。
使用方式大致如下,命令被分成单独的行以提高可读性:
jlink --module-path <module-path-locations>
--add-modules <starting-module-name>
--output <output_location>
要在我们的示例代码库上运行此命令并生成地址簿 UI 模块的镜像,我们首先需要以通常的方式编译模块:
$ javac --module-source-path src -d out $(find . -name '*.java')
在这里,out 是编译好的模块的位置,因此,这是 jlink 的模块路径。作为起点的模块是 packt.addressbook.ui。
在我们运行 javac 命令的同一目录中,我们现在可以运行 jlink。要运行该命令,请确保 $JAVA_HOME/bin 目录已添加到您的操作系统的路径中,或者直接使用路径访问 jlink:
$ $JAVA_HOME/bin/jlink --module-path out
--add-modules packt.addressbook.ui --output image
注意,我们会遇到错误:
Error: Module java.base not found, required by packt.addressbook.ui
我们缺少平台模块!请注意,java.base是一个核心平台模块,并且在我们之前命令中指定的模块路径中不可用。平台模块不会得到特殊处理;它们的模块位置需要显式地指定给jlink命令!
我们已经看到核心 Java 模块在$JAVA_HOME/jmods中可用。让我们将其添加到--module-path参数中。和之前一样,为了指定多个路径,我们需要用冒号符号(在 Windows 操作系统中是分号)分隔路径:
$ $JAVA_HOME/bin/jlink --module-path $JAVA_HOME/jmods:out
--add-modules packt.addressbook.ui --output image
jlink现在将开始工作,并为我们静默地生成运行时映像。让我们看看生成的映像结构:

这现在看起来应该很熟悉了。结构与我们之前看到的 JDK 文件结构非常相似。一个值得注意的例外是jmods文件夹不在这里。这是有道理的,因为这是一个运行时映像,而jmods格式并不是为运行时设计的。由于这个映像只包含必要的模块,它们都被打包到了lib文件夹中的公共modules文件中。
现在映像包含了运行时和编译的应用程序模块,它是一个自给自足的包。你可以在没有安装 Java 运行时的计算机上部署这个映像,并且可以无任何问题地执行它。
现在,为了从运行时映像中执行我们的模块,我们需要执行映像bin目录中的java可执行文件,而不是$PATH中的那个。这次你也不必指定--module-path,因为模块解析已经完成了!生成的映像已经包含了它需要的每一个模块,因此已经知道它们的位置在哪里:
$ cd image/
$ bin/java -m packt.addressbook.ui/packt.addressbook.ui.Main
你应该看到地址簿 UI 应用程序弹出。那很好,但你可能已经察觉到有些不对劲:

名称没有排序。你能猜到为什么吗?是因为排序模块还没有被打包进去!如果你在映像中的java可执行文件上运行java --list-modules,你可以看到所有被打包进来的模块。请注意,排序服务模块不包括在内:
$ bin/java --list-modules
java.base@9
...
packt.addressbook.lib
packt.addressbook.ui
packt.contact
packt.sortutil
记住,模块解析过程遍历模块图,并使用requires子句添加具有直接依赖的模块。服务,按照定义,是松散耦合的,因此不需要任何模块。正因为这个原因,服务模块--packt.sort.bubblesort和packt.sort.javasort--都没有被包括在内。jlink的这种行为是有意为之的。服务模块的打包需要显式地告诉命令。
注意,java --list-modules 命令显示的是在运行时图像上可观察到的模块,该命令是在运行时执行的。一直以来,我们都是在已安装的 JDK 上运行此命令,因此它列出了所有(且仅有的)平台模块。这次,我们是在生成的运行时图像上运行此命令,该图像是我们应用程序模块和少数几个平台模块的组合。因此,命令的输出相应地反映了这一点。
我们可以采取几种方法来解决问题。第一种方法是通过使用 --add-modules 选项将服务模块添加到模块解析的起始模块列表中。可以为此选项指定多个模块名称,名称之间用逗号分隔。然后,这些模块及其依赖项也将被捆绑到图像中,因为模块解析过程将从每个这些模块开始运行,并将它们添加到解析集中:
$ $JAVA_HOME/bin/jlink --module-path $JAVA_HOME/jmods:out
--add-modules packt.addressbook.ui,packt.sort.bubblesort,packt.sort.javasort
--output image
现在,在生成的图像中运行 java --list-modules 应该会显示排序后的模块。此外,当执行应用程序时,UI 应该现在会显示按姓氏排序的联系人列表:
$ bin/java --list-modules
java.base@9-ea
...
packt.addressbook.lib
packt.addressbook.ui
packt.contact
packt.sort.bubblesort
packt.sort.javasort
packt.sortutil
在服务捆绑的另一种选择是使用 jlink 的 --bind-services 选项:
$ $JAVA_HOME/bin/jlink --module-path $JAVA_HOME/jmods:out
--add-modules packt.addressbook.ui --bind-services --output image
此参数在通过模块解析过程检查每个模块时自动识别模块所消耗的任何服务。然后,所有声明自己是这些服务提供者的可观察模块将被自动捆绑。这是一个更简单的选项,因为你不必自己明确提及服务模块,但有可能你会拉入比实际需要的更多模块。假设模块路径中有一个随机模块,你的应用程序没有使用它,但它恰好实现了你使用的一种服务类型。那么,这个模块就会通过这个选项被拉入!
链接阶段优化和 jlink 插件
将 Java 代码执行分为两个步骤——编译步骤(使用javac)和执行步骤(使用java)。在编译步骤中,javac不仅仅尝试将 Java 代码转换为字节码,还尝试执行任何可能的优化,并生成尽可能最优和高效的字节码。多年来,Java 编译器已经学会了几个新的技巧和策略来更好地优化生成的字节码。但一直存在一个挑战——编译器一次只处理几个类,它没有机会通过查看整个应用程序来了解全局情况,这可能会帮助它实现更好的优化。这个选项在运行时是可用的,但一些优化在运行时执行起来成本过高。随着在编译和执行阶段之间引入新的链接步骤,为我们的 Java 字节码进行应用程序级优化的机会就出现了。事实上,平台团队在链接阶段的一个目标就是进行全局优化——跨越应用程序中多个类和模块的优化,因为它们已经获得了更大的视角。
记住,链接步骤是可选的。在前面的章节中,我们已经执行了应用程序,而不需要使用jlink,因此没有进行任何此类优化。然而,如果您使用jlink,就有机会进行优化——例如压缩图像、识别并删除不可达的代码和类型、预优化代码和方法,其中可能的输入是常数且在事先已知。这些优化可以在运行jlink时进行,并且使用一系列transformers来完成,这些transformers作为插件构建。
下面是jlink如何工作以创建运行时图像的近似描述:

该过程从收集所有必要的资源开始,并通过一系列可以执行各种任务和优化的transformers运行它们。jlink工具有一个插件 API,这样任何人都可以创建自定义插件,将其钩入此过程,并有机会转换生成的图像。在transformers完成后,图像被写入输出目录,在那里,同样可以编写插件来控制发生的事情。
Java 9 平台自带内置插件,可以执行这些优化中的几个。事实上,其中一些优化非常重要,影响巨大,因此默认已启用!在您生成运行时图像时,了解一些可用的选项是很重要的:
-
使用 system-modules 插件优化模块描述符:在 Java 模块的初始开发过程中,发现 Java 运行时花费了大量时间和资源来检查、解析和验证系统图像中每个模块的模块描述符(
module-info.class)。由于要捆绑到图像中的模块集在链接时是已知的,因此扫描和验证所有模块描述符的过程也可以在链接时完成。平台捆绑了一个名为system-modules的jlink插件,用于执行此操作。此插件生成一个预处理的预验证的系统模块图,以便运行时不需进行。这个过程带来了如此显著的性能提升,以至于在运行jlink时默认启用。 -
使用 compress 插件压缩图像:此插件允许您对生成的图像应用压缩以减小输出运行时图像的大小。此插件可以对生成的图像执行两种类型的压缩。第一种类型识别所有 UTF-8 字符串的使用,并生成一个全局字符串表,以便任何常见的
String值只需存储一次,并且可以在应用程序中重复使用。第二种压缩类型是对结果图像中文件的 ZIP 压缩,以缩小其整体大小。
与系统模块不同,此插件默认未启用。要使用它,您需要将--compress选项传递给jlink。您可以传递三个值--0表示不压缩,1表示启用字符串共享,以及2表示 ZIP 压缩。
在生成地址簿图像时运行两个级别的压缩,展示了生成图像大小的增益:
$ $JAVA_HOME/bin/jlink --module-path $JAVA_HOME/jmods:out
--add-modules packt.addressbook.ui --bind-services
--compress=1 --output image1
$ $JAVA_HOME/bin/jlink --module-path $JAVA_HOME/jmods:out
--add-modules packt.addressbook.ui --bind-services
--compress=2 --output image2
检查结果图像的大小显示压缩为我们节省了多少空间。以下表格总结了运行这些命令后我机器上的文件夹大小:
| 压缩 | 地址簿图像大小 |
|---|---|
| 无 | 167.5 MB |
| 级别 1 | 129.4 MB |
| 级别 2 | 95.9 MB |
- 使用 include-locales 插件包含区域信息:默认情况下,运行时图像捆绑了所有已安装的区域信息。如果您只为某些区域设置的应用程序运行时目标,可以使用
--include-locales选项并传递所需的逗号分隔的区域列表。只有那些区域将被包含在结果图像中,从而释放更多空间:
$ $JAVA_HOME/bin/jlink --module-path $JAVA_HOME/jmods:out
--add-modules packt.addressbook.ui --bind-services
--compress=2 --include-locales=en --output image3
上述命令以完整压缩方式重新生成图像,并包含英语区域设置(--include-locales=en)。在我的机器上,这进一步节省了空间,图像大小为 88.2 MB。
JDK 中还包含几个实用的插件。要了解可用的插件以及如何使用它们,可以在运行 jlink 时使用 --list-plugins 选项。将显示已安装的插件完整列表,包括我们刚刚了解的插件以及更多:
$ $JAVA_HOME/bin/jlink --list-plugins
构建模块化 JAR 文件
我们已经了解了创建完整的模块化运行时镜像,并学习了链接过程的优势,但有时那可能不是你想要的。假设你是一个库开发者,你只想将单个实用模块打包成一个 jar 文件。当从一个模块构建 jar 文件时,你可以选择创建一个 模块化 JAR 文件。模块化 jar 文件就像任何其他 jar 文件一样,但在根目录中有一个 module-info.class 文件。你可以使用这个文件将编译好的模块作为单个文件分发,而不是整个模块文件夹。你可以在运行 java 命令时将模块化 JAR 文件放入模块路径,它的行为就像我们一直在处理的编译好的模块文件夹一样。
为了说明这一点,让我们将地址簿应用程序的 out 文件夹中的几个模块替换为模块化 JAR 文件。
创建模块化 JAR 文件的方法是使用 jar 工具。为了将 packt.contact 模块转换为模块化 JAR 文件,我们运行以下命令:
$ jar --create --file out/contact.jar --module-version=1.0
-C out/packt.contact .
--create 选项告诉 jar 工具它需要创建一个 JAR 文件。-C 选项指定了它可以从哪里找到类。其值格式为 <folder> <file>。在我们的例子中,文件夹是 out/packt.contact。这后面跟着一个 ".",表示需要包含该位置下的所有文件。--module-version 选项为 jar 指定一个模块版本。最后,--file 选项提供了 JAR 输出文件名。
运行此命令将创建一个模块化 JAR 文件,根目录中有 module-info.class 文件。还有一个包含记录指定版本的清单文件的 META-INF 文件夹:Manifest-Version: 1.0。
即使带有可执行 main 方法的模块也可以转换为模块化 jar。以下是如何将 packt.addressbook.ui 模块转换为模块化 JAR 的示例:
$ jar --create --file out/addressbook-ui.jar --module-version=1.0
--main-class=packt.addressbook.ui.Main -C out/packt.addressbook.ui .
大多数参数在这里应该是熟悉的,除了 --main-class。正如其名所示,此选项指定了具有 main 方法的类型。
将这两个模块转换为模块化 JAR 文件并放置在模块路径中后,你现在可以删除相应的编译模块文件夹,并且仍然可以执行应用程序。运行时将模块化 JAR 文件视为与展开的模块文件夹相同:
$ rm -rf out/packt.contact/
$ rm -rf out/packt.addressbook.ui/
$ java --module-path out
-m packt.addressbook.ui/packt.addressbook.ui.Main
应用程序应该仍然以相同的方式工作,尽管这次它正在执行 jar 文件中的两个模块。
摘要
本章探讨了在编写代码完成后应用程序开发过程的部分——构建和打包应用程序以进行分发。我们通过检查模块解析过程开始本章。然后我们研究了链接过程,该过程使用模块解析来构建只包含给定应用程序所需模块的运行时镜像。接着我们查看了一些内置插件,这些插件允许我们优化和预处理生成的镜像。最后,我们学习了如何生成模块化 JAR 文件,以便在其他应用程序中使用可重用的库模块。
我们已经探讨了众多特性及其工作原理。在下一章中,我们将开始探讨如何在现有的遗留 Java 代码库的背景下使用 Java 9 模块。你将学习如何编写在不同 Java 版本之间互操作的代码。你还将学习将使用 Java 8 或更早版本编写的现有代码迁移到 Java 9 的新模块化应用程序开发的技巧和策略。下一单元再见!
第九章:模块设计模式和策略
在前几章中,我们深入探讨了 Java 9 中的几个模块化特性,这些特性使您能够使用模块构建 Java 应用程序。您已经学习了如何使用依赖声明和服务来建立模块依赖关系。您还学习了如何使用 jlink 构建可分发的模块化运行时。在本章中,我们将探讨一个稍微不那么客观的问题——如何在 Java 中使用模块构建良好的应用程序。现在您已经掌握了创建和使用模块的知识,那么推荐的实现方式有哪些?您应该使用哪些最佳实践和模式?以下是本章我们将涵盖的内容:
-
理解如何设计模块——一个模块理想上应该是什么样子,以及模块边界应该在哪里划分
-
理解如何为这些模块设计良好的 API
-
了解创建模块以及使用模块系统有效性的几个最佳实践
本章讨论的许多模式都附带可用的代码示例,这些示例位于文件夹09-module-patterns中。每个示例都经过精心制作,旨在以最小的信息开销帮助解释正在讨论的模式。在探索这些模式时,请随意使用和实验这些代码示例。此外,记得在您的 Java 9 模块化之旅中重新阅读本章,以巩固对这些模式的了解。通常情况下,在任何时候重新阅读和思考设计模式都会为当时面临的挑战提供新的视角。
让我们开始吧!
设计模块
在过去几年中,设计 Java 应用程序的第一步通常涉及包和类的设计,以及它们之间的交互。也许还涉及设计一些共享库。例如,您会将可重用代码移动到单独的项目中,并将其打包成 JAR 文件添加到您的应用程序中。随着 Java 9 模块的出现,您现在需要考虑一个新的方面,这将对设计过程产生重大影响。无论您是从头创建新应用程序还是将现有的类路径应用程序迁移到模块化应用程序,您都需要回答一些共同的问题——您如何提出模块化设计?您如何选择模块应该包含什么?一个模块应该包含多少代码和功能?在特定场景中,您在哪里划分模块边界?例如,您可能会遇到的一个问题是——给定一个功能或一组类和包,它们应该属于模块 A还是模块 B?您如何实现模块如可重用性、可扩展性和可维护性的良好设计原则?
与大多数软件设计挑战一样,没有适用于所有场景的正确答案。事实上,一些-ility 因素相互对立,大多数设计都涉及到做出权衡。然而,有一些指导和最佳实践应该能帮助我们开始应对这些挑战,然后可以根据具体用例进行细化和调整。
在设计和构建模块化应用程序的第一步中,对模块和 API 有一个高层次的想法是有帮助的。对于简单的项目,直接打开 IDE 开始编码可能很有吸引力。但对于任何严肃的项目,花些时间设计一个涉及模块的粗略图,它们的公共 API 以及它们之间的依赖关系,在开始编码之前是有帮助的。让我们开始看看在设计模块和决定它们应该做什么时需要考虑的一些原则和因素。
范围
决定一个模块是什么的最明显的方法可能是基于它的内容。换句话说,它的范围。如果你要分解任何合理复杂的应用程序,你会立即想到一些高级功能区域,它们开始相互分离。例如,一个银行应用程序可能有一个与账户相关的功能,与信用功能等其他功能分开。在现有的 Java 应用程序中,这些不同的关注点可能已经属于不同的顶级包。在新的应用程序中,商业问题的各个方面可以给我们提供线索。不同的商业领域突出形成初始的高级分类,然后可能进一步分解成更小的部分。因此,在我们的银行示例中,账户功能可能被分为储蓄账户和支票账户功能、账户转账相关代码、账单支付等。这种使用业务关注点来分解应用程序问题域的方法是一种很好的策略,可以将应用程序分解成更小的单元,这些单元可能是模块的良好候选者。你可能需要通过几个级别的分解范围,直到你到达每个单元感觉可以单独成为模块的水平。
注意,虽然这是一种从上到下开始分解问题的好策略,但它并不能帮助你决定何时停止分解!你如何知道你的模块是否太大?或者太碎片化?你必须查看本节中的其他因素来帮助做出这个决定。
团队结构
在软件设计中有一种流行的谚语称为康威定律,它表明任何组织的软件设计都受到该组织团队结构以及他们相互沟通方式的影响。如果你这么想,这完全是有道理的。软件开发团队往往倾向于在隔离的库和代码库(或者至少,代码区域)上工作,这些库和代码库与其他团队开发的库和代码库进行“交流”。从某种意义上说,团队和他们工作的代码资产集之间存在几乎一对一的映射。这可以是一个有价值的因素,影响模块边界的划分以及模块设计如何开始相互分离。每个团队负责一个或多个模块,因此一个团队会工作的所有代码都成为他们模块集的一部分。
这对各种原因都很有用。团队可以在他们的代码上工作,并独立地发布功能。他们被分配了模块的所有权,因此可以处理错误修复或代码维护。此外,当开始一个新的项目时,当两个团队需要在一个团队依赖另一个团队的模块上工作时,两个团队可以共同制定一个他们同意的API 合约。然后,每个团队可以分别和并行地工作在他们自己的模块上,尽管依赖的模块尚未可用。
可重用性
模块化的一个原则是可重用性。我们之前用积木的比喻来描述模块。将可重用模块组合起来创建不同应用的想法确实非常强大!为了使模块可重用,它们理想上应该具有以下特征:
-
它们应该有一些专业和有限的职责。一个做太多事情的模块很难有效地重用。
-
它们应该是独立的--如果一个模块依赖于许多其他模块才能运行,那么重用该模块本身就会变得更加困难。
-
它应该是可配置的--如果模块可以调整并能够适应不同的用例,这将使模块更容易重用。
这些特性确实带来了一些权衡!以下是一些例子:
-
拥有更小、更专业的或单一用途的模块有时会导致非常小且碎片化的模块,通常需要多个模块才能完成任何工作。
-
模块中的可配置 API 通常会导致更复杂的 API,因为消费者必须处理更多的旋钮和杠杆来调整和操作,以便与您的模块一起工作。解决这个问题的方法之一是提供合理的默认值,并提供配置作为覆盖。
按关注点进行模块化
在设计模块时,你会很快发现并非所有模块都相同。模块可以根据各种特征被分类为多种不同类型。一种分类方法可以根据其功能或解决的问题进行分类。在最高层次上,你可以将模块分为两种不同类型:
-
纵向关注点:业务和应用特定的功能。解决业务域中的特定问题。例如,银行应用程序中的账户模块。
-
横向关注点:非业务或应用程序特定的交叉功能。相反,它们提供低级功能或与业务无关的框架。例如,日志或安全模块。
在设计模块时,一个值得尝试遵循的好规则是不要混合这两个关注点。如果你专门为这两个关注点之一设计模块,这将提高清晰度和重用性。例如,在地址簿应用程序中,packt.sortutil 模块提供了通用的排序能力,同时对其排序内容完全不知情,从而解决了横向关注点。packt.addressbook 模块处理特定的地址簿应用程序功能,从而解决了纵向关注点。
如你所想,模块的类型会影响其设计。例如,当设计解决纵向关注点的模块时,重用性可能不是主要关注点,但在设计具有交叉关注点的模块时,它是至关重要的。
层次化模块化
另一个有助于我们确定模块边界的有用因素是应用程序层。一个典型的应用程序涉及功能层或级别,如 UI 层、业务层、数据层等。在查看其他类别以进一步模块化之前,这是一种绘制一些初始模块边界的好方法。理想情况下,模块不应是多个应用程序层的组成部分。有时,不同的层部署在不同的物理硬件上。因此,在部署时,具有层分离来指导模块边界在物流上也是有意义的。
通过变更模式进行模块化
我发现分析应用程序新功能添加的模式有助于验证模块边界。如果你对代码库所做的典型更改大多需要修改多个模块,这可能是一个线索,表明你的模块过于碎片化,或者模块的分离可以更好。如果你在平均代码更改或增强时需要修改多个低级模块(即具有横向关注点的模块),这是可以接受的。但是,如果你的应用程序的每个更改或增强都需要你修改具有纵向关注点的多个模块,你可能需要重新审视模块边界,看看这些不同的模块是否包含应该理想地放在同一模块中的代码。
设计 API
我们已经查看了一些关于绘制模块边界和创建模块的指南和技巧。那么创建 API 的过程呢?这可能会显得很明显,但分离公共 API 和私有实现的最佳实践仍然适用。
设计模块 API 的目标是公开一个标准、一致且可能不会改变的编程接口,作为模块的 公共 API。模块内部实现的细节应该被封装。在原则上,这与类的方法和成员变量封装没有区别。为你的模块设计一个公共 API,让消费者可以与之交互。实现细节隐藏在封装的包中,并有两个目的:
-
减少和简化消费者的工作量,使他们不必了解内部细节
-
允许模块在不影响模块消费者的情况下更改和演进实现
Java 模块编码模式和策略
我们到目前为止已经查看了一些适用于 Java 模块化的通用高级、几乎是常识性的模式。现在让我们深入代码。让我们看看一些代码模式和策略,这些可以帮助你在开始设计模块及其 API 时。每个策略都附有关于模式的解释,以及为什么和何时应该考虑使用它们。其中许多都有伴随的代码示例供你参考。其中一些可能很明显。事实上,我们在构建本书中工作的示例地址簿应用程序时也应用了一些模式。我希望这个综合列表为你提供了在设计构建模块化应用程序时参考这些模式的好参考。
模式 1 - 公共接口、私有实现和工厂类
将公共 API 与内部封装的实现分离(我们已经在本书早期关于地址簿查看器应用程序的排序实用模块中看到了这种策略的实施)。此外,公开一个工厂 API 方法,该方法创建接口的新实例。
这就是它的工作原理:
-
导出的类型是尽可能轻量级的接口或抽象类。
-
包含实际逻辑的类实现了导出包中的接口。这些类型没有被导出。因此,所有实现细节都安全地隐藏起来。
-
在公开的包中存在工厂类,这些类构成了 API。它们创建正确的实现类的新实例。这些工厂 API 的返回类型仅处理接口。
示例
该模式的示例实现可在文件夹 09-module-patterns/01-seperate-interface-impl 中找到。模块 pattern.one 在 pattern.one.external 包中公开了接口 PublicInterface,而实现类 PrivateImplA 和 PrivateImplB 则在封装的 pattern.one.internal 包中:
module pattern.one {
exports pattern.one.external;
}
我们希望消费模块通过公共接口类型访问私有实例。为此,模块提供了一个名为Factory的工厂类。这个类有一个公共方法getApiInstance,它接受一个参数并根据该值返回正确的实现类。在示例代码中,有一个简单的boolean标志,它影响是否返回一个实现实例而不是另一个,但在实际模块中,这个选择标准对返回的内容更有意义,因为消费者正是根据他们的需求选择正确的 API 实例。工厂方法的返回类型是公共接口的实例。因此,消费模块不知道实现细节:
public class Factory {
public PublicInterface getApiInstance(boolean selector) {
if (selector) {
return new PrivateImplA();
}
return new PrivateImplB();
}
}
优点:
-
隐藏了细节,以便消费者不必了解内部情况
-
允许在不改变消费者与模块交互方式的情况下更改实现或添加新的实现类型。
模式 2 - 多个动态实现的服务
抽象实现类型的一个替代方法是使用它们作为服务。这是对之前模式的扩展,但同时也确保了更松散耦合和动态的实现。
这是它的工作原理:
-
存在一个服务接口模块,它公开了服务接口类型。
-
一个或多个服务实现模块使用模块定义中的
provides子句为该服务提供实现。 -
消费者模块不直接依赖于实现模块。它
requires只服务接口模块,并声明它uses服务。 -
消费者模块使用
ServiceLoaderAPI 查找服务实例。
示例
示例代码实现可在09-module-patterns/02-services处找到。pattern.two.service模块导出PublicInterface。它不包含任何自己的实现类:
module pattern.two.service {
exports pattern.two.external;
}
两个实现模块pattern.two.implA和pattern.two.implB都包含使用模块声明中的provides声明的服务实现。这两个实现模块都require服务模块pattern.two.service以访问接口。以下是其中一个实现模块的模块定义:
module pattern.two.implA {
requires pattern.two.service;
provides pattern.two.external.PublicInterface with
pattern.two.implA.ImplA;
}
消费者模块也依赖于服务接口模块,而不是实现模块。模块定义中的uses子句表明模块将需要查找服务:
module consumer {
requires pattern.two.service;
uses pattern.two.external.PublicInterface;
}
优点:
-
在服务消费者和提供者逻辑之间提供了一层额外的抽象。
-
消费者模块与实现模块之间完全松散耦合。没有对实现模块的硬编码
requires依赖。 -
在实现选项中的灵活性。模块可以在运行时添加到模块路径中,只要它们实现了并提供服务接口,就可以将其连接到应用程序。
模式 3 - 可选依赖
我们已经看到,直到 Java 9,Java 没有可靠的配置选项。你可以在运行之前添加或删除某些类和 JAR 文件到类路径中,应用程序仍然可以执行(或者至少,开始执行)!Java 中有一些实用库和框架很好地利用了这种灵活性。
以 Spring 框架为例。Spring 是一个流行的应用程序框架,它通过扫描类路径中可用的库来使用和编排许多其他依赖库和框架的功能。如果你想让 Spring 使用这些支持的库,只需将某些必要的 JAR 文件添加到类路径中就足够 Spring 检测并使用其功能。如果不使用,Spring 框架仍然可以工作,尽管没有可选功能。
这种灵活性在框架的易用性中起着重要作用。现在,随着 Java 9 和对模块依赖的严格要求,我们不会失去这种灵活性吗?现在不再可以随意将 JAR 文件添加到类路径中!事情现在变得更加严格和受控。给定模块需要的每个模块都应该在模块定义中使用 requires 子句明确指定。考虑到这种新的情况,你该如何构建这样的模块,它们是可选的并且具有 即插即用 的灵活性?
答案是可选依赖。在 Java 9 中,你可以使用 requires static 修饰符指定一个给定的模块依赖为可选。语法是:
module <module-name> {
...
requires static <optional-module-dependency>;
}
static 修饰符告诉模块系统被 required 的模块是可选的。模块在编译时仍然应该可用(因为 javac 需要编译代码和引用!)。但在运行时它是可选的。如果在运行时模块不可用,java 不会像只有 requires 子句那样对模块的不可用性进行抱怨。它会继续执行,假设你知道你在做什么。这个新特性使你能够拥有带有许多 require static 可选依赖的模块,这些模块可以自由地添加到模块路径中。
这里是如何工作的:
-
当你有一个模块可选地依赖于一个或多个模块时,使用
requires static子句在模块定义中建立可选依赖。如果 模块 A 可选地需要 模块 B,你应在模块 A 的定义中指定requires static B。 -
在开发和编译时间,你不需要做任何不同的事情。你可以像使用常规
requires依赖项一样使用可选依赖项导出的类型。始终如一,带有可选依赖项的模块需要在编译时可用,以便代码可以编译。 -
然而,在运行时,情况就不同了。这次,无论带有可选依赖项的模块是否可用,你都可以执行你的应用程序。如果模块可用,它会被正常选中。但如果它不可用,你会得到一个
NoClassDefFound错误。虽然这不是强制性的,但编写代码来处理这种错误场景是个好主意,以防你可选需要的模块不可用。
示例
考虑在 09-module-patterns/03-optional-dependencies 中的示例代码。pattern.three 是一个可选需要 pattern.three.optlib 的模块。如果可用,它将使用可选库,但如果在运行时不可用,模块也会非常高兴。为了建立这种依赖项的可选性质,pattern.three 使用了 requires static 子句:
module pattern.three {
requires static pattern.three.optlib;
exports pattern.three.external;
}
pattern.three.optlib 有一个简单的库类,它会向控制台打印一条消息。这里没有什么令人惊讶的:
public class LibImpl {
public void publicApi() {
System.out.println("Called API method in LibImpl");
}
}
现在,模块 pattern.three 中的代码可以直接导入和使用可选模块(在这种情况下,LibImpl)导出的类型。但这不是一个好主意。通过指定一个依赖项是可选的,你实际上是在要求平台放弃其可靠的配置保证,并且不要检查和确保模块可用。这打开了这种依赖项在运行时不满足的可能性。因此,现在模块有责任能够处理模块的存在和不存在。我们不必将 NoClassDefFound 错误抛给用户,我们可以聪明地使用可选类型,如果它们可用的话。我们可以使用 Class.forName API 来检查类是否存在。
这是模块 pattern.three 中 Util 类的代码。这是从可选依赖中反射和使用类型的一种方法:
try {
Class<?> clazz = Class.forName("pattern.three.lib.LibImpl");
LibImpl impl = (LibImpl) clazz.getConstructor()
.newInstance(); // Create new instance
impl.publicApi(); // Call the API
} catch (ReflectiveOperationException e) {
System.out.println("Did not find the Impl class module");
}
与前面的示例一样,我们有一个 consumer 模块,它需要 pattern.three 并有一个 Main 类型,该类型调用模块的 API。在执行应用程序之前,这里还有关于可选依赖的另一件重要的事情需要你知道。它们在模块解析过程中不会被选中!因此,我们必须显式地将模块添加到执行过程中。
在第八章中,我们讨论了模块解析过程以及 Java 平台如何通过递归获取模块定义中所需的所有依赖模块的树结构。它不会对可选依赖项做这件事!在模块解析过程中,如果运行时遇到requires static依赖项,它不会解析那个模块及其依赖项。这些可选模块可能已经编译并准备好,与其他模块一起位于模块路径中,但运行时仍然看不到它们。这引发了一个有趣的问题。如果运行时不查找,Java 运行时如何知道这些可选模块存在于模块路径中呢?
解决方案是将模块手动添加到模块解析过程中。记得我们在第八章中传递给jlink的--add-modules选项吗?我们使用那个标志让jlink将模块包含到解析过程中。java命令也有那个标志,可以用来包含模块。因此,为了让运行时看到并使用可选模块,我们需要使用--add-modules标志。
你可以使用javac命令像往常一样编译示例代码。这里没有变化:
$ javac --module-source-path src -p src -d out $(find . -name '*.java')
然而,在运行时,你需要使用--add-modules标志添加所有可选依赖项的模块。在这里,pattern.three.optlib是可选依赖项。因此,以下是执行consumer模块中的Main类的命令以及相应的输出:
$ java --module-path out --add-modules pattern.three.optlib -m
consumer/app.Main
Called API method in LibImpl
尝试从out目录中移除编译好的pattern.three.optlib模块,然后不带-add-modules选项再次运行:
$ java --module-path out -m consumer/app.Main
Did not find the Impl class module
这次,由于必要的类不可用,执行了回退代码。这里的重要区别是代码仍然可以运行。如果依赖项不是可选的,它就不会运行。
--add-modules参数对顺序很敏感,它应该出现在-m参数之前。因此,以下命令将不会工作:
java --module-path out -m consumer/app.Main --add-modules out/pattern.three.optlib # 不起作用
优点:
- 允许你创建即插即用的库。你可以创建一个主库模块,它可选地依赖于其他模块,从而在模块和功能方面提供运行时灵活性,这些模块和功能实际上是执行的一部分。
模式 4 - 使用服务处理可选依赖项
在阅读关于可选模块依赖关系时,你可能会有一个想法——使用服务怎么样?我们在第七章,“介绍服务”,中学习了如何在 Java 模块中使用服务提供一种灵活且松散耦合的方式,使模块能够相互协作。使用服务,你不必使用requires指定可读性关系。提供服务的模块在运行时是可选的,甚至在编译时也是可选的!所以服务难道不是已经比可选依赖更好了吗?
简单的答案是肯定的。服务无疑是解耦模块和移除硬依赖的首选方式。这正是我们将要在这个模式中探讨的。然而,它们确实存在一个问题,并且不会像你想象的那样工作得很好。让我们来看看原因。
这就是它的工作原理:
当使用服务时,你通常通过创建两种类型来实现抽象——接口(即服务)和接口的实现(即服务的提供者)。当然,你不必这样做。Java 类本身也可以是服务类型。但我们现在讨论的也适用于这种情况。
给定两个模块 A 和 B,如果你想使模块 A 可选地依赖于 B,你可以使用之前的模式,并在 A 的模块定义中使用requires static B。然而,如果你想使用服务,你需要指定一个或多个 Java 接口或类作为服务类型。模块 A需要指定它使用这些类型:
module A {
uses <service-type>;
}
而模块 B需要提供服务:
module B {
provides <service-type> with <implementation-type>;
}
现在,B 在技术上来说是可选的。应用程序可以在模块路径中是否存在模块 B 的情况下运行!看起来很简单,不是吗?但有一个陷阱!服务类型应该放在哪个模块中?是模块 A 还是模块 B?它不能放在模块 B 中,因为在这种情况下,模块 A 将需要要求模块 B 来访问 B 中的服务类型,这违背了使 B 可选的整个目的!它可以放在模块 A 中,但现在模块 B 应该依赖于模块 A 来访问服务类型。因此,模块 B 需要 A,而模块 A 导出 包含服务类型的包。
但是等等!我们的原始目标是让模块 A 可选地依赖于 B。现在我们得到的却是模块 B 依赖于 A!这似乎是反过来的,但如果您考虑服务动态,A 仍然在使用 B 提供的实现,而 B 只是依赖于 A 来获取服务类型。这仍然很令人困惑,仅通过查看模块定义并不能明显看出发生了什么。解决这个问题的方法之一是将服务类型移动到一个第三方的模块 C,这个模块同时被 A 和 B 所依赖。现在 A 和 B 都可以访问服务类型,因此它们是完全解耦的。这种方案可能并不总是可行,而且仅仅为了解决这个问题而创建一个单独的模块显得有些尴尬。但是,当它确实可行时,这种使用服务的方法是实现灵活性和 Java 8 及更早版本中一些库和框架所具有的drop-anything-you-need机制的最佳方法之一。
示例
请查看09-module-patterns/04-optional-dependencies-with-services目录下的示例代码。我们有两个模块,pattern.four和pattern.four.optlib。我们希望pattern.four能够使用我们之前看到的模式,可选地依赖于pattern.four.optlib。
模块pattern.four包含一个导出的服务类型LibInterface。它还声明它使用LibInterface的提供者实现,这本质上使得LibInterface成为一个服务类型:
module pattern.four {
exports pattern.four.external;
uses pattern.four.external.LibInterface;
}
模块pattern.four.optlib提供了LibInterface服务类型的实现。它还依赖于pattern.four以首先访问服务类型。这正是我们之前讨论的看似颠倒的关系:
module pattern.four.optlib {
requires pattern.four;
provides pattern.four.external.LibInterface with
pattern.four.lib.LibImpl;
}
在pattern.four.optlib中有一个名为LibImpl的类,它实现了在前面模块定义中声明的LibInterface接口:
public class LibImpl implements LibInterface {
public void publicApi() {
System.out.println("Called API method in Service");
}
}
现在,模块pattern.four对pattern.four.optlib一无所知。它使用ServiceLoader API 来获取任何可用的实例,如果可选模块可用,它将乐意使用它:
public class Util {
public void utilMethod() {
Iterable<LibInterface> libInstances =
ServiceLoader.load(LibInterface.class);
for (LibInterface libInstance : libInstances) {
libInstance.publicApi();
}
}
}
优点:
-
扩展了之前模式解决的
即插即用概念,并增加了新的解耦层次。模块在编译时和运行时都可以是可选的! -
扩展了
requires static的一对一依赖关系,通过服务提供一对多依赖关系。可能有多个模块提供服务,这些服务可以被使用服务的模块可选地获取。
模式 6 - 将模型类作为独立的可共享模块捆绑
许多企业应用程序通常需要处理多层和层。它们通常需要在这些层之间进行通信和共享数据,一个常用的模式是使用模型对象或数据传输对象(DTOs)来交换数据。它们是需要跨多个层和模块共享的代码的例子。
一个好的做法是为模型(或 DTO)类创建单独的模块。然后,任何需要访问这些类型的模块都可以读取这些模块。这可能是一个只包含模型类而没有其他内容的轻量级模块。
模式 7 - 开放模块以进行反思
反射是 Java 编程语言中的一个重要特性,允许在运行时动态地检查和修改类型。这是另一个被 Spring、Hibernate 等框架以及其他框架充分利用的特性。这些框架使用反射来检查你的类以查找注解和接口实现,以推断如何处理你的代码。你可能在自己的代码中使用反射来实现这种动态功能。
反射如何融入我们迄今为止所学的模块化概念?就像我们之前看到的那样,强封装的默认行为保护模块中的类型免受静态访问,同时也为反射访问提供了类似的保护。如果一个 Java 类型在一个导出它的模块中,并且调用反射 API 的类型在读取其他模块的模块中,那么这个类型可以通过反射访问。
这导致了由于反射在 Java 中传统使用方式的问题,尤其是在我们之前提到的许多框架中。例如,Spring 框架期望扫描整个代码库以查找带有特定关键注解的类。多年来,Java 代码库中大量的反射 API 使用都是基于一个隐含的理解,即被反射的类对他们来说是可访问的。一旦你将这些类型移动到模块中,所有封装的类型实际上都被封闭起来,无法用于反射。一个简单的解决方案是暴露一切!所以,每个模块都暴露了所有需要通过反射访问的类型。但这不是一个好主意,因为我们之前讨论的模块 API 概念。一个模块使用 exports 子句暴露的类型是模块 API。仅仅因为一个类型包含 Spring 框架、Hibernate、JPA 或任何其他使用反射的类似框架的注解,就将其暴露出来,这会不利于模块的 API,并且违反了封装的目的。
为了解决这个问题,并且仍然提供使用这些框架进行反射的选项,平台引入了一个称为 开放模块 的概念。这些是类似于我们熟悉的 Java 模块,但有一个主要区别。这些开放模块中的封装类型在运行时可用于反射访问,而无需允许 exports 声明提供的编译时访问。
你如何将一个 Java 模块变成一个 开放模块?非常简单。只需在 module-info.java 中模块定义前添加 open 关键字:
open module <module-name> {
}
这样,模块的内容仍然是封装的(除了在模块定义中导出的任何包之外)。但现在,模块中的所有包都可通过任何读取此模块的模块在运行时使用反射进行访问。
记住,open关键字不会使模块对所有模块都开放以进行反射访问。需要使用反射访问任何此类类型的模块仍然需要使用requires关键字来读取包含该类型的模块。
不仅整个模块,甚至单个包也可以使用opens关键字标记为开放,后跟你希望对反射开放的特定包。当你知道模块中只有某些类需要被反射时,这提供了更细粒度的控制:
module modulename {
opens package.one;
opens package.two to anothermodule;
exports package.three;
}
在前面的例子中,包package.one对所有读取modulename模块的模块都可用于反射。包package.two仅对选择require它的anothermodule模块可用于反射访问。而包package.three既可用于反射访问也可用于编译时访问,因为它被导出了。
示例
在09-module-patterns/06-open-modules的示例代码中,模块pattern.six在其pattern.six.internal包中包含类型Contact.java。让我们假设我们希望这个类在模块内部是内部的,而不是公开的。然而,我们希望能够从另一个模块consumer反射地访问Contact类。
这里是consumer模块中执行反射的代码:
try {
Class clazz = Class.forName("pattern.six.internal.Contact");
Constructor<?> ctor = clazz.getConstructor();
Object object = ctor.newInstance(new Object[] { });
System.out.println("Successfully created object using
reflection");
} catch (ReflectiveOperationException e) {
System.out.println("Did not find the Impl class module");
}
消费者模块首先建立对模块pattern.six的依赖关系:
module consumer {
requires pattern.six;
}
但这还不够!模块pattern.six应该导出类型或声明它是一个开放模块。
下面是pattern.six模块定义的示例:
open module pattern.six {
}
注意,模块的内容实际上并没有被导出,所以模块中的类型在静态访问方面仍然是封装的。然而,由于模块是开放的,类型对于反射是可用的。现在在consumer模块中运行Main应该可以工作。
优点:
-
允许选择性地仅对反射暴露类型。
-
在需要应用程序框架和库使用该方法对类型进行扫描以获取注解和实现的情况下很有用。例如,包含 Spring 或 Hibernate 注解的模块中的包可以声明为开放,以便它们可以通过这样的框架访问。你现在可以这样做,同时仍然保持传统访问的封装。
记住,使用open和opens声明,你实际上是通过允许包被反射访问而放弃了严格的封装。这仍然是一个好选择,至少,当你的目的是仅启用反射访问时,它比导出包要好得多。你的模块消费者的意图是明确的。
模式 8 - 使用工具进行版本控制
如同在第三章中讨论的处理模块间依赖性,Java 模块化中明显缺少了大多数模块系统的一个重要特性——模块版本化和版本管理。如果你在 Java 生态系统中的构建和打包工具(如 Maven 和 Gradle)方面有所处理,你可能已经遇到过这样一个事实:所有这些库工件都与版本号相关联。使用 Maven 或 Gradle 时,当你对另一个工件或库模块(我在这里使用“模块”一词较为宽松)建立依赖关系时,你不仅必须指定其名称和坐标,还必须指定其版本号。
在 Java 模块依赖中,无法指定基于版本的依赖。requires语法,requires <module-name>;仅接受模块名称,而不接受版本。例如,你可以指定你的模块依赖于google.guava模块。但你不能指定它依赖于google.guava的版本1.5.2。Java 平台模块系统规范明确指出,版本化不是模块规范的目标之一。其想法是利用现有的构建工具和容器来解决这个问题,它们已经在 Java 的早期版本中做到了这一点。
如果你对构建工具(如 Maven 或 Gradle)所做的工作不熟悉,它们在引入依赖关系方面的任务可以分为两部分。当然,我在这里简化了,但从高层次来看,这些工具执行以下操作:
-
为每个项目提供一种指定它们所依赖的其他库的方法。
-
在构建过程中引入这些库,并将 JAR 文件添加到类路径中,以便需要它们的项自有必要的库可用。这些 JAR 文件通常根据指定的依赖关系和版本号从中央仓库下载。
由于像 Maven 和 Gradle 这样的工具执行了上述第 2 点,因此它们必须拥有所有详细信息——不仅要知道下载哪个库,还要知道下载哪个版本的库。记住,它需要从包含数千个库且每个库有多个版本的仓库中下载正确的 JAR 文件。Java 模块系统执行第 1 点但不执行第 2 点,但不是为了从某处检索或下载工件的目的。它只是假设模块已经存在!这就是为什么版本在这里不适用。模块路径中你拥有的模块版本就是将使用的版本。
这就是 Maven 与 JPMS 根本不同的地方。构建工具处理构建工件——下载和组装打包的 JAR 文件分发物。Java 模块不是一个构建时工件,而是一个编译时和运行时工件。Maven 关注确保正确的依赖关系被组装。Java 模块系统关注于已组装的二进制文件的编译和运行时完整性。
这允许使用 Maven 或 Gradle 等构建工具下载正确的模块 JAR 文件和依赖项,从而在模块路径中留下完全准备好的模块集,然后模块系统接管并使用它们。
我们将在第十二章使用构建工具和测试 Java 模块中更详细地探讨 Maven 与 Java 9 模块化应用程序的集成。
不要在模块名称中使用版本号。创建多个带有版本号后缀的模块(如 my.module.v1、my.module.v2 等)非常有诱惑力。这不被推荐,因为这并不能提供关于两个不同版本相同模块之间关系和相似性的任何指示,本质上是一种为了使版本控制与 Java 模块一起工作而进行的 hack。一个更好的方法是通过构建系统引入正确的模块版本,正如之前讨论的那样,平台无需处理版本控制。
模式 9 - 设计以适应变化
就像构建任何 API 一样,当你最终计划更改它时,你必须考虑到你的用户。因此,在设计时你必须考虑到可能未来的更改!你模块中的导出包是公共 API,所以你的用户可能访问其中的任何一个。这意味着,改变你模块中导出包中的任何类型都需要谨慎行事,因为它可能会破坏你模块的任何消费者。
当然,这取决于变化本身。如果你正在向导出的包中添加新类型,或者正在向现有的导出类型添加新的成员变量或方法,这些更改仍然是向后兼容的。但如果你想要从导出类型中删除成员变量或更改方法签名,最终会导致使用这些 API 的代码出现错误。
以下是一些有助于最小化模块 API 可能更改的指南:
-
尽可能保持导出的类型尽可能轻量。我们已经看到你可以如何暴露由封装的实现类型支持的接口类型。在暴露的类型中拥有较少的可移动部分,使得它们在未来不太可能发生变化。
-
当你计划进行向后不兼容的更改时,你应该提前通知你的模块消费者。这可能是在你计划删除的方法上使用
@Deprecated注解的形式。如果可能的话,尝试同时提供旧的和新的 API(在旧 API 上有明确的弃用通知),这样你的模块消费者就有足够的时间将他们的代码切换到使用你的新 API。
Java 9 中的 @Deprecated 注解也可以用于模块声明!当你想标记一个完整的模块为弃用时,这非常方便。以下是一个示例:
@Deprecated(since = "9", forRemoval = true)
module mymodule {
}
这标志着从 Java 9 开始,该模块已被标记为弃用,并且它可能在任何未来的版本中被移除。如果任何模块尝试使用你的模块并带有requires,编译器将发出关于弃用的警告。
模式 10 - 防止依赖泄露
在第五章,“使用平台 API”,我们探讨了依赖类型是如何通过 API 泄露的,并且可能并不明显它在这样做。最佳实践是尽可能使你的模块使用尽可能轻量。理想情况下,使用你的模块应该像添加一个模块的requires子句然后直接使用它一样简单!
这里有一些遵循的指南:
-
确保你的模块是自给自足的。你不应该需要依赖另一个模块来使用它。
-
如果你的模块的 API 需要返回属于另一个模块的类型,尽量将它们封装成你自己的模块中暴露的类型。如果你的 API 可能会抛出属于另一个模块的异常,尽量捕获它们并重新抛出由你的模块暴露的自定义异常。
-
如果使用你的模块的 API 需要使用其他模块中的类型,并且无法像之前提到的那样封装,确保那些模块有传递依赖,这样你的模块的消费者会自动获得它们。
如第三条指南所暗示的,有时你可能想启用传递依赖,并允许其他模块的类型成为你模块 API 的一部分。与大多数最佳实践一样,你需要根据具体情况逐一检查,因为没有适用于所有情况的正确答案。
然而,有一个注意事项。一旦你在你的模块中建立了对另一个模块的传递依赖,这些类型很容易悄悄进入你的 API。例如,假设你正在开发模块A,该模块暴露了一组不同的 API。假设使用其中一个 API 需要来自模块B的类型,并且这种情况无法避免。解决方案是对模块B有传递依赖,这样任何使用你的模块的消费者也会得到来自B的类型:

现在,一旦建立了传递依赖,你就可以非常容易地在模块 A 中构建新的 API,这些 API 也需要来自 B 的类型。B 本身就是传递的,所以消费者已经可以访问这些类型。所以,没有必要阻止它或包装来自 B 的这些类型,对吧?嗯,这是一个滑稽的斜坡!你使用的传递模块的类型越多,该模块与你的耦合度就越高。如果你将来选择重构模块 A,可能要移除对 B 的依赖,那么解耦它就会更难。这就是为什么即使这些类型对消费模块是传递可用的,我仍然建议在 API 中包装类型并防止依赖类型的泄漏。设计模块的主要目标应该是为模块建立一个目的和 API,而不是盲目地添加任何 requires 声明,只是为了让事情工作!
模式 11 - 聚合器和外观模块
我们在第六章中讨论了聚合器模块,模块解析、可读性和可访问性。聚合器模块允许我们创建模块,这些模块可以整合一组常用的库,使得消费者只需要求一个聚合器模块,而不是更繁琐的过程去找到正确的模块列表来要求。当你的应用程序中有多个模块需要一组标准的依赖项时,使用聚合器模块是一个很好的模式。这不仅使得在新的模块上建立依赖项的过程变得更容易,还允许你在一个地方更改和更新依赖项列表,并使其反映在你应用程序或组织中的所有其他模块。
下面是一个提供对三个其他模块的传递依赖的示例聚合器模块:

另有一个与之密切相关且我喜欢称之为外观模块的模式。这些是聚合器模块的扩展,因为它们确实通过传递依赖为多个模块提供依赖项,但它们也可能包含处理来自多个模块的类型逻辑。虽然聚合器模块只做传递依赖且不一定包含自己的逻辑,但外观模块可能包含逻辑来执行诸如根据某些标准从模块中选择一个 API、协调和同步多个模块 API 的调用等操作。
代理模块和外观模块都是为了特定的用例而设计的,它们作为底层模块的包装器。由于它们出于这个原因对模块进行了整合,因此可能不会提供最佳的复用机会。但没关系!就像我们之前讨论的那样,最佳的模块是简单且单一用途的模块。然而,代理模块和外观模块在难以使用的极度碎片化模块和易于使用但缺乏灵活性的专用模块之间提供了一个折中方案。在这种情况下,当你试图达到这种平衡时,这是一个非常实用的模式。
示例
在09-module-patterns/09-aggregator-and-facade-modules的示例代码中,模块pattern.nine.facade作为一个聚合器和外观模块,整合了两个独立的模块--module.one和module.two。它对这两个模块都有传递依赖关系,所以任何读取pattern.nine.facade的模块也会自动读取这两个模块:
module pattern.nine.facade {
requires transitive module.one;
requires transitive module.two;
exports pattern.nine.external;
}
模块不仅做到了这一点,它还有一个薄的外观 API。它导出了一个类--FacadeApi,有一个示例方法来说明一个方法如何选择两种实现之一。在这里,方法根据输入的字符串值选择两种实现之一。但你可以很容易地想象出在这样一个模块中编写的提供有关业务规则或逻辑的 API,这些规则或逻辑影响应用程序使用哪些库:
public void facadeMethod(String apiChoice) {
if ("one".equals(apiChoice)) {
apiOne.apiMethod();
}
else if ("two".equals(apiChoice)) {
apiTwo.apiMethod();
}
}
现在,读取pattern.nine.facade的consumer模块有两个选择。它可以直接访问库模块(它之所以可以这样做,是因为传递依赖关系--它传递性地读取了module.one和module.two)。或者,它可以通过外观方法调用 API 来获得有关调用哪个模块的帮助。两者都工作得很好,如下面的代码所示:
public static void main(String[] args) {
FacadeApi facade = new FacadeApi();
ApiTwo apiTwo = new ApiTwo();
facade.facadeMethod("one"); // Calling the API through the facade
apiTwo.apiMethod(); // Calling the other API directly
}

摘要
在本章中,我们探讨了创建模块和识别模块边界的一些指南和最佳实践。在创建新应用程序或迁移现有遗留应用程序到 Java 模块时,提前设计模块及其交互的映射总是一个好主意。我们查看了一些最佳实践,这些实践可以帮助你确定模块应该由什么组成,以及什么会导致你将逻辑分离到单独的模块中。
我们随后查看了一组最佳实践和想法,这些可以在你的代码中使用。之前讨论的许多最佳实践都伴随着简化的代码示例。每个示例都是故意简化,只包含展示正在讨论的模式的相关代码,几乎没有其他内容,这样你就可以轻松地选择任何一个并进一步调整它们或在你的代码中应用它们。
现在我们已经拥有了这些工具包中的模式,让我们来应对 Java 开发者们在迁移到 Java 9 时将面临的一个主要挑战,即现有代码的迁移。多年来,Java 一直在发展,并且积累了大量使用早期 Java 版本构建的、各种复杂性的遗留代码。我们如何着手迁移它们以利用 Java 模块的优势呢?在此之前,它们甚至能在 Java 9 上运行吗?让我们开始回答这些问题,并在下一章中了解为了使代码准备好这样的迁移需要做些什么。
第十章:为 Java 9 准备你的代码
在最后一章,我们探讨了在构建 Java 9 模块化应用程序时可以使用的一些模式和最佳实践。它们是一套极其实用的规则,当构建新应用程序时值得记住。但是,开发者并不总是有机会在绿地项目中工作,在这些项目中他们有自由从头开始思考和构建应用程序架构。如果已经有很多使用 Java 8 或更早版本构建的代码怎么办?我们如何将这些代码迁移到 Java 9?
在本章中,我们将涵盖以下内容:
-
处理遗留代码并使其准备好在 Java 9 中运行
-
在 Java 9 中编译遗留代码并在 Java 9 运行时执行 Java 9 之前的编译代码
-
Java 9 中的类路径行为和未命名的模块
-
处理错误和非标准 API 访问以及使用 jdeps 工具
-
使用覆盖开关来处理棘手的代码和 API
开始 Java 9 迁移
你可能有一些 Java 8(或更早)的代码。你可能想知道你需要做什么才能让它与 Java 9 一起工作。当 Java 9 规范正在通过Java 社区过程(JCP)时,开发社区对此有一些担忧。遗留的 Java 代码在 Java 9 中是否可以按原样工作?如果需要做出更改,它们将消耗多少时间和精力?幸运的是,Java 在维护向后兼容性方面有着出色的记录,即使在引入语言的新模块化特性这样的重大变化中也是如此。然而,由于 Java 9 是对 Java 内部结构最大的重整之一,可能需要进行一些工作。工作的量主要取决于两个因素——你试图执行的迁移性质以及代码本身的编写方式。
我所说的“迁移性质”是什么意思?当处理 Java 9 迁移时,将努力分为几个阶段是有用的。从高层次来看,你可以将现有的 Java 9 之前的代码通过以下两个阶段进行迁移:
-
让你的代码在 Java 9 中编译和执行。
-
对你的代码结构进行重构以使用模块化特性。
第一步涉及使用与之前相同的javac和java命令处理现有的代码库,但使用新的 Java 9 版本的编译器和运行时。在这个阶段,你希望尽可能少地更改代码!第二步涉及重构或重写你的代码以使用模块化特性,包括我们在本书中学到的内容——将代码库分解成模块单元,为每个模块创建module-info.java,然后在这些模块之间建立关系。
这两个步骤对于迁移到 Java 9 是否都是必要的?嗯,第一步是必要的。对于你计划在未来运行和使用的任何应用程序,至少让它与新 Java 9 运行时兼容是值得的。这样,你就可以随时准备应对 Java 8 在未来成为 生命终结 的情况。这种类型的迁移应该相对简单,除了我们将在本章中介绍的一些需要注意的事项。
在你完成这些之后,并且你的应用程序现在可以与 Java 9 编译器和运行时一起工作,你确实有一个选择,可以将你的代码重构为使用你已学到的所有酷炫的新模块化功能。但这可能并不总是有价值的。如果你有计划在未来不修改或增强的代码,并且你只需要 维护 它以运行业务,那么通过重构它来使用 Java 9 模块,你不会得到很多价值。
然而,如果你预计会进行更改并积极地对代码库进行工作,将代码重构为使用模块是有好处的。下一章将处理涉及使用我们现在通过 Java 9 获得的丰富 JPMS 功能的代码迁移。
我们将在本章中介绍 第一步。第二步在 第十一章 中介绍,将您的代码迁移到 Java 9。
介绍示例 Java 8 应用程序
我们将使用一个示例 Java 8 代码库来尝试迁移到 Java 9。它是一个命令行 购物袋 工具。当你运行应用程序时,它会提示你将项目添加到你的购物袋中。一旦你添加了所有项目并且完成,你输入 end。应用程序随后显示你已添加的项目的综合购物清单。应用程序故意很简单,但它为我们提供了一个很好的起点,以便在迁移过程中进行工作。
这里是应用程序运行时的截图:

应用程序由三个不同包中的三个类组成:
ShoppingBag类:它包含一个将项目添加到购物袋的方法,以及一个用于打印袋子内容的格式化方法。该类使用 Apache Commons Collections 库中的Bag数据结构。将这种数据结构想象成类似于Set,但允许重复:
public class ShoppingBag {
public static String END_TOKEN = "end";
private Bag<String> bag = new HashBag<>();
public boolean addToBag(String itemName) {
return (END_TOKEN.equals(itemName)) ||
this.bag.add(itemName);
}
public void prettyPrintBag() {
...
}
}
UserInputUtil类:它包含一个提示用户输入的方法。它还包含一个公开的close方法,用于完成输入流时关闭:
public class UserInputUtil {
Scanner scanner = new Scanner(System.in);
public String getUserInput(String prompt) {
System.out.print(prompt);
return scanner.nextLine();
}
public void close() {
scanner.close();
}
}
App类:将所有内容整合在一起。这个类包含main方法。它使用UserInputUtil来提示用户将项目输入到购物袋中。它将每个项目添加到ShoppingBag实例中,完成后打印出袋子:
public class App {
private static final Logger logger =
Logger.getLogger(App.class.getName());
public static void main(String[] args) {
logger.info("Shopping Bag application: Started");
ShoppingBag shoppingBag = new ShoppingBag();
UserInputUtil userInputUtil = new UserInputUtil();
String itemName;
do {
itemName = userInputUtil.getUserInput("Enter item (
Type '" + ShoppingBag.END_TOKEN + "' when done): ");
shoppingBag.addToBag(itemName);
} while (!ShoppingBag.END_TOKEN.equals(itemName));
userInputUtil.close();
shoppingBag.prettyPrintBag();
logger.info("Shopping Bag application: Completed");
}
}
除了应用程序代码外,还有一个 lib 文件夹,其中包含 Apache Commons Collections 库的 JAR 文件--commons-collections4-4.1.jar。代码依赖于这个库 JAR 文件。在编译和运行代码时,我们需要将这个 JAR 文件添加到类路径中。
我建议查看位于-10-migrating-application/01-legacy-app的包含源代码的位置,并熟悉它。在迁移过程中,我们将使用这个应用程序。
使用 Java 9 编译器和运行时
让我们从第一步开始——使用 Java 9 编译器和运行时编译和运行旧代码库。如果一切按预期进行,那就太好了。如果需要更改,我们希望尽可能少地更改。
首先,请确保您正在使用 Java 9,可以使用以下命令进行确认。如果您使用的是不同版本,您需要切换版本,这已在第二章“创建您的第一个 Java 模块”中介绍过:
$ java --version
从项目文件夹中,为我们的编译类创建一个新的out目录,并运行以下 Java 编译器命令来编译所有的.java文件:
$ mkdir out
$ javac -cp lib/commons-collections4-4.1.jar -d out $(find . -name
'*.java')
在前面的javac命令中,我们使用-cp选项将公共集合 JAR 文件添加到 classpath 中,使用-d选项指定编译类的输出目录,然后使用$(find . -name '*.java')递归地指定以下目录中的所有.java文件。
编译步骤应该顺利通过,没有任何错误。太好了!让我们尝试运行它:
$ java -cp out:lib/commons-collections4-4.1.jar
com.packt.sortstrings.app.App
在前面的java命令中,我们在 classpath 的-cp选项中指定了两个路径——包含编译类的out目录和公共集合 JAR 文件。接下来是包含main方法的类的完全限定类名。
注意,我们仍在使用 classpath 而不是模块路径的概念。Java 9 仍然与 classpath 一起工作,并且与之前 Java 版本相同的-cp选项。关于这一点,稍后会有更多介绍。
运行命令应该会成功,提示符会按预期显示。这就是了!一个 Java 8 应用程序已经使用 Java 9 编译和执行,而且不需要更改任何一行代码!尽管我很想告诉您,所有遗留代码都将像这样轻松地工作,但不幸的是,事实并非如此。有些情况需要更多的努力。然而,好消息是,在大多数情况下,这个过程应该是如此轻松。我们将在下一节中查看您可能会遇到问题的某些情况,以及如何解决这些问题。但首先,鉴于我们现在对 Java 9 的了解,一切工作得如此顺利,这不是很令人惊讶吗?如果您仔细想想,编译和执行都应该失败!为什么?以下是一些原因:
-
我们了解到 Java 9 正在转向模块系统,并且所有东西,无论是应用程序代码还是平台,都应该在一个模块中!我们的 Java 8 代码显然不在预定义的模块中。这在 Java 8 中是可以的,但在 Java 9 中不应该导致错误吗?
-
注意到
App.java正在使用 Java 日志 API。我们在第四章,介绍模块化 JDK中了解到,日志 API 已经被打包到一个名为java.logging的独立平台模块中。并且访问任何不是java.base的模块的所有代码都应该明确要求它。显然,这里的代码并没有这样做,因为这是 Java 8 代码,而且一开始就没有module-info.java模块定义。
这引发了一个问题——为什么在 Java 9 中编译和执行这段代码仍然可以工作?这一切都得益于语言中引入的一些特殊功能,以支持这个过程——在 Java 9 中执行遗留代码。这里为我们工作的特定功能被称为未命名模块。
未命名模块
好吧,我们并没有错。Java 9 中的所有代码都需要在模块中。所有模块都需要有正确的可读性和可访问性关系,以便应用程序能够正常工作。但这可能是个问题!因为,就像每个新的 Java 版本一样,有成千上万的开发者试图使用新的 Java 版本运行他们的遗留 Java 代码库。如果我们期望每个遗留 Java 代码库在 Java 9 中运行之前都封装到模块中,这将给开发者社区带来巨大的努力成本。幸运的是,有一个解决办法。当你使用 Java 9 编译或运行类路径中的遗留无模块 Java 代码时,你不需要手动创建模块包装器。平台会自动创建一个包含你类路径中所有内容的单个模块。这个模块没有名字,因此被称为未命名模块。
到目前为止,我们编译和执行的所有 Java 9 代码都没有使用类路径。以下是到目前为止我们运行 Java 9 应用程序中的模块示意图:

到目前为止,整个运行时都使用 JRE 中的内置平台模块以及来自模块路径的应用程序模块一起工作。这些共同构成了可观察模块的完整集合。那么,如果你在其中有类路径会发生什么呢?在带有类路径选项运行时,画面如下所示:

正是在这里,我们本应违反了所有代码都需要在模块中的规则。幸亏有了自动模块功能,我们并没有这样做。Java 9 平台自动将类路径中的所有类和 JAR 文件封装到一个未命名的模块中:

这正是购物袋应用程序所发生的情况。所有应用程序代码,包括公共集合 jar 文件,都是单个未命名模块的一部分。好的,这解决了其中一个担忧。第二个问题呢?代码是如何访问 Java 日志 API 的?未命名的模块不应该声明requires java.logging吗?但是它怎么能够做到呢?未命名的模块根本就没有module-info.java文件!因为平台无法真正确定类路径代码需要什么,所以未命名的模块自动获得了对所有可观察模块的可读性访问。换句话说,它模仿了 Java 9 之前类路径中代码的“自由 forall”行为——它requires一切,因为这是平台最大化任何遗留代码在 Java 9 中按原样工作的唯一方式。如果你要绘制一个模块图,它将看起来像未命名的模块与可观察模块集中的每个模块都有可读性关系,如下面的图所示:

这个图非常混乱,所以我们不会再绘制这个图。从现在开始,我们将把来自自动模块的所有可读性边缘简化为单个箭头,以保持内容的可读性。
自动模块功能是专门为了迁移目的而构建到平台中的,正如你所见,这也是我们的 Java 8 代码能够在 Java 9 中编译和运行而没有任何错误的原因。总结一下:
-
每次你在 Java 9 中提供
-classpath选项来编译或执行代码时,平台都会创建一个未命名的模块。提供的类路径中的所有类和 jar 文件都被捆绑到这个未命名的模块中。 -
未命名的模块自动读取每个可观察模块。因此,它不仅读取所有 Java 9 平台模块,还读取你在模块路径中的所有应用程序和库模块(如果你在命令中提供了模块路径参数,除了类路径)。
每次你使用 Java 编译器而不提供任何模块路径选项时,你就是在所谓的单模块模式下运行编译器。在这种模式下,就像我们所看到的,我们正在处理单个未命名的模块。代码应该被组织成传统的基于包的目录结构,并且没有模块文件夹。
处理非标准访问
等等!有一个陷阱!未命名的模块读取所有 Java 平台模块,因此我提到它模仿了预 Java 9 平台的“自由放任”行为。但这并不完全正确。在 Java 9 之前,整个平台及其中的所有类都对应用程序代码是可访问的。随着 Java 9 平台模块化,平台模块中有几个内部类型没有被导出,因此它们是封装的。自动读取所有平台模块是一个好事,但这足够吗?并不真正如此,因为它只是允许类路径中的代码访问模块中导出的类型。但如果你遗留的代码使用了现在封装在 Java 9 平台模块中的类型怎么办?仅仅让未命名的模块读取所有平台模块是不够的,因为封装的类型仍然不可用。对于访问已删除类型的代码也存在相同的问题。是的,在 JDK 8 及更早版本中,某些类型在 Java 9 中不再可用。并且任何使用这些类型的遗留代码在 Java 9 中将无法编译和运行。
10-migrating-application/02-non-standard-api 中的代码是一个具有此类内部类型访问的代码示例。App.java 类使用了两种类型:
-
Base64Encoder,在 Java 的先前版本中可用,但现在随着 Java 9 的推出,已被完全移除 -
CalendarUtils,在 Java 9 的java.base模块中作为内部类型封装
即使你正在创建一个新的 Java 9 模块,也没有平台模块可以让你的代码要求访问它。该类型没有从模块中导出,因此实际上是密封的。因此,即使是未命名的模块也无法访问它们。
这里是示例代码。代码本身完全无效,因为它只是尝试使用这两种类型:
package com.packt.app;
import sun.misc.BASE64Encoder;
import sun.util.calendar.CalendarUtils;
public class App {
public static void main(String[] args) {
BASE64Encoder enc = new BASE64Encoder();
CalendarUtils.isGregorianLeapYear(2018);
}
}
切换到 Java 8 编译器并编译代码:
$ export JAVA_HOME=$(/usr/libexec/java_home -v 1.8)
注意,虽然编译过程顺利,但你确实看到了警告!

很长一段时间以来,开发者们都被警告过这种内部类型的用法!对于正在编写这种代码的人来说,这些 API 在 Java 9 中不起作用并不应该令人惊讶。
让我们切换到 Java 9 并编译此代码。在 macOS 或 Linux 上使用以下命令:
$ export JAVA_HOME=$(/usr/libexec/java_home -v 1.9)
在 Windows 上,按照 第二章 中概述的步骤,创建您的第一个 Java 模块。
将 Java 版本切换到 9 后,运行此代码的编译命令。这次,你不会收到警告。你会得到错误,代码无法编译:

注意到有两个不同的错误,每个类型一个:
-
CalendarUtils的错误表明该类型现在已被封装(即未导出)从java.base。 -
BASE64Encoder的错误提到编译器找不到它。该类型已被从 Java 9 中移除。
当在 Java 9 中编译和运行遗留代码时,由于模块化变更,你可能会遇到这两种最有可能的错误。修复这些错误之一需要更改你的代码。你可能需要在新平台上找到一个等效的类或 API 来完成你需要的功能。或者找到一个包含所需 API 的外部库。棘手的是,有问题的代码可能不一定在你的应用程序代码中。它可能在你使用的库或框架中。即使在那种情况下,你的应用程序在 Java 9 中也不会编译或运行,直到你移除依赖项或库被更新。
平台提供了一些帮助,以帮助你识别应用程序及其依赖项中存在的问题。这是一个名为jdeps的工具。
jdeps最初与 Java 8 一起发布,以帮助开发者识别和修复内部 API 访问问题。在 Java 9 中,它变得更加有用和详细。
jdeps 工具
Java 依赖分析工具(jdeps)是一个实用工具,可以静态地检查你的应用程序和库类,以确定是否有任何不再与 Java 9 兼容的 JDK 内部 API 的使用。你可以在编译好的类文件或 JAR 上运行jdeps,并让它列出所有此类引用。对于每个引用,jdeps将突出显示不再可用于你的代码使用的内部类型的使用。如果可用,它甚至会建议替代 API。
命令语法如下:
$ jdeps -jdkinternals <jar-files>
如果你有一堆编译好的类,并想在它们上运行jdeps,你可以甚至提供 classpath 参数:
$ jdeps -jdkinternals -cp <class-paths>
让我们用02-non-standard-api项目来试一试。我们已经用 Java 8 编译器编译了这个项目(尽管有警告),现在类已经存在于 out 目录中。对它们运行jdeps会得到以下输出:

sun.misc.BASE64Encoder被标记为JDK 已移除的内部 API,而sun.util.calendar.CalendarUtils被标记为来自java.base的 JDK 内部 API。在BASE64Encoder的情况下,工具提供了一个有用的建议,即使用自 Java 1.8 版本以来就可用作为替代的 API(java.util.Base64)。
如前所述,jdeps也可以在 JAR 上运行。如果我们运行包含的01-app-migration项目上的工具,我们将得到没有输出。这是一个好事,因为这表明没有使用 JDK 内部 API,JAR 可以用于 Java 9。
$ jdeps -jdkinternals commons-collections4-4.1.jar
jdeps是一个静态代码分析工具,重点在于单词静态。它查看代码以识别非法 API 使用。它不能通过反射识别动态运行时使用。因此,有可能jdeps给你的代码库给出了全部放行的信号,但当你运行它时,你可能会遇到IllegalAccessException,因为代码使用了反射来访问不再可用的内部类型。
总结来说,jdeps -jdkinternals是一个检查预编译的应用程序和库类并验证与 Java 9 任何不兼容性的优秀工具。特别有用的是,该工具在可能的情况下推荐使用替代选项。
覆盖模块行为
jdeps在识别内部 API 访问和提出修复建议方面非常出色。当修复包含此类问题的自己的应用程序代码时,它们非常有用。但是,如果jdeps报告了你正在使用的库或框架中某些代码的问题呢?在这种情况下,你对代码的控制较少。即使框架本身是开源的,库的规模和复杂性可能不会使你能够自己实施修复。这确实为迁移到 Java 9 的应用程序带来了一个非常明显的问题——直到你的所有库都更新到可以在 Java 9 中工作,你的应用程序才会在 Java 9 中运行。很可能大多数库开发者已经注意到了 Java 8 中的大量警告并修复了他们的代码,或者他们很快就会这样做,因为他们的代码在 Java 9 中会中断。但是,如果他们不这样做,这可能意味着你的迁移计划将取决于库的作者。
幸运的是,平台提供了一些覆盖功能来解决这个问题。我们在这里要查看的覆盖功能不仅适用于在 Java 9 中编译的遗留 Java 代码,它们也适用于 Java 9 模块。但是,由于它们主要是为了帮助迁移而设计的,因此它们应该仅在迁移的上下文中使用,其他用途理想情况下应避免。
这些覆盖功能是什么?请记住,Java 9 模块有一个模块定义,它指定了它requires什么和它exports什么。这些在开发时在module-info.java文件中指定的单个模块定义实际上控制了模块在编译和运行时的可访问性关系。然而,结果却是,编译器和运行时都有覆盖这些模块关系的选项,允许你通过指定命令行参数来更改任何给定模块requires或exports的内容。
有三个命令行标志用于javac和java来覆盖特定的模块配置:
add-reads:--add-reads选项允许你指定可能尚未在模块配置中可用的附加可读性关系。语法是:
--add-reads <source-module>=<target-module>
将此选项添加到javac和java命令行中,只为使用该参数的命令执行创建一个新的可读性关系。例如,假设你有模块moduleA和moduleB,你想要moduleA读取moduleB。你可以编辑moduleA中的module-info.java文件并添加行requires moduleB;,或者添加以下参数到编译器和运行时,如下所示的这个截断命令:
$ java ... --add-reads moduleA=moduleB
add-exports:--add-exports选项允许你从模块添加额外的导出包,从而打破或覆盖封装。语法是:
--add-exports <source-module>/<package-name>=<target-module>
- 例如,如果
moduleA需要从moduleB获取pack.internal包,但moduleB没有导出该包,你可以添加以下覆盖,使moduleB为moduleA导出所需的包,如以下截断命令所示:
$ java ... --add-exports moduleB/pack.internal=moduleA
add-opens:--add-opens选项允许你覆盖模块之间的opens关系以允许反射访问。这是一个模拟模块定义中opens关键字配置的覆盖。语法是:
--add-opens <source-module>/<package-name>=<target-module>
- 按照相同的示例,如果
moduleA需要moduleB中pack.internal包的运行时反射访问,你需要使用以下选项运行javac或java命令:
$ java ... --add-opens moduleB/pack.internal=moduleA
注意,覆盖总是有资格的,也就是说,你为特定的目标模块指定它们。例如,你不会使用--add-exports标志将包导出到每个其他模块。你明确指定一个或多个从源模块应用覆盖的目标模块。这是一个好事,因为每个覆盖都是经过深思熟虑的,并且很容易跟踪需要使应用程序正常工作所需的内容。
除了命令行参数之外,还有另一种提供这些覆盖的方法。你可以在 JAR 文件清单中指定它们。假设你有一个需要与 Java 9 平台模块一起工作的可执行 JAR 文件,并且需要进行一些覆盖。为了避免每次都需要作为命令行参数指定这些覆盖,你只需在 JAR 文件中的MANIFEST.MF文件中指定这些作为清单属性。与所有清单属性一样,值应在MANIFEST.MF文件中属性名称之后指定一个空格。清单属性Add-Exports对应于--add-exports参数。属性Add-Opens对应于--add-opens。没有与--add-reads对应的清单属性。
除了这些覆盖之外,还有一个针对类路径中类封装的任何模块化相关封装的“主关闭开关”——--permit-illegal-access标志。与前面的三个选项不同,此选项仅在类路径中的代码上工作。当将此标志传递给javac或java时,它有效地禁用了所有可读性和可访问性限制,从而使类路径中的任何类型都可以访问其他类型。这几乎就像没有 Java 模块化功能,类路径中的所有内容都像在 Java 8 或更早版本中运行一样。正如你所期望的,使用此标志不是一个好主意,尤其是在你正在运行生产代码的情况下。这是为了帮助开发者迁移他们的类路径应用程序,并且完全有可能在未来被删除。将其视为在迁移过程中解决问题的最后手段选项。
现在你已经了解了这些覆盖选项,你该如何将它们应用到解决我们在这个部分开始讨论的问题上呢?那就是在你的 Java 8 代码中使用封装的 API 的库或类,这些类在 Java 9 中不再工作?如果代码不在你的控制之下,你不能修复它以避免使用封装的类型,你可以使用覆盖开关手动添加所需的exports!例如,我们在02-non-standard-api中使用的内部未导出类型CalendarUtils来自java.base。由于我们显然不能更改java.base模块的模块描述符,我们可以做的是将--add-exports选项传递给模块,并让它导出所需的包。
但这里有一个问题。请注意,语法需要源模块名和目标模块名。源模块名当然是java.base。那么目标模块是什么呢?它是未命名的模块,因为需要这个包的类在类路径中。这引发了一个有趣的问题——未命名的模块的名字是什么?未命名的模块没有名字(或者确实不会被称为那样!),但有一个特殊的标记ALL-UNNAMED,你可以将其传递给覆盖参数,让平台知道你正在引用未命名的模块:

这个命令应该可以解决我们代码中CalendarUtils访问的问题。仍然缺少BASE64Encoder类型。没有命令行参数可以修复这个问题。就像我们看到的,这个类型在 Java 9 中根本不存在。我们将不得不用其他东西来替换它。根据jdeps提供的建议,AppFixed.java类有更新的代码,它使用Base64class代替:

这次,你没有遇到错误,编译过程也顺利通过了。你仍然会看到关于使用内部类型的警告。编译器仍然提醒你这不是一个理想的情况,但既然你已经添加了override,它相信你知道你在做什么。
如果代码是在 Java 的早期版本中编译的,你正在 Java 9 中运行它,并且问题仅与访问封装类型有关,你可以将相同的覆盖参数添加到java命令中,并让它运行。然而,如果编译的类引用了不可用的类型,就像我们刚才看到的BASE64Encoder类型,你除了编辑代码并重新编译之外别无选择。
理解影响
现在你已经知道了在 Java 9 中编译或运行遗留代码时可能遇到的问题,以及当这些问题发生时如何解决它们,让我们花些时间来了解这些问题的范围。你对于在遗留的 Java 代码中遇到和修复这些问题应该有多担心?
我们可以将向后不兼容的 API 分为以下几大类:
-
具有替代方案的 API:此链接(也是
jdeps --jdk-internals输出中提供的链接)提供了需要替换的 Java 9 中 API 的完整列表:wiki.openjdk.java.net/display/JDK8/Java+Dependency+Analysis+Tool。 -
封装的 API:当您运行
jdeps时,您可能会看到有关使用没有替代建议的内部 JDK 类型的错误。它们可能是以前在 Java 9 之前可用的类型,但现在被封装在平台模块中。这些可以通过使用正确的命令行覆盖javac和java调用来修复。
另一方面,有一些 API 原本打算移除,但它们仍然可用。虽然意图是封装所有内部 JDK API,但有一些 API 在 Java 开发者社区中被广泛使用,并且没有合适的替代方案,封装它们会给迁移过程带来更多困难。记得我们在 第一章 “介绍 Java 9 模块化” 中讨论的广泛使用的 sun.misc.Unsafe API?这样的 API 在 Java 9 中仍然可以访问。但为了使这些 API 更容易在以后弃用,它们已经被移动到一个名为 jdk.unsupported 的单独模块中,从该模块可以访问这些类型。
在 jdk.unsupported 模块上运行 java -d 会显示目前仍然可访问的 不受支持 API:
$ java -d jdk.unsupported
jdk.unsupported@9
exports com.sun.nio.file
exports sun.misc
exports sun.reflect
requires java.base mandated
opens sun.reflect
opens sun.misc
提醒一句。jdk.unsupported 模块和 API 很可能将在 Java 的下一个版本中被移除,因此不要计划长期依赖这个模块。
推荐策略
让我们总结一下,概述在 Java 9 中编译和运行遗留代码的推荐策略。以下是您理想中应该遵循的步骤:
-
运行
jdeps --jdk-internals以验证您的代码是否有任何内部 API 访问。如果没有错误,只需尝试在 Java 9 中编译和运行您的代码。对于大多数没有访问内部 JDK API 的情况,代码应该可以简单地正常工作。 -
如果存在错误,并且是由您可以更改的应用程序代码引起的,请遵循
jdeps的建议并修复这些错误。 -
如果错误是由您无法控制的库引起的,请检查库作者是否发布了更新,并获取最新版本。许多使用内部 API 的库正在更新以兼容 Java 9,您的库的修复可能已经完成并发布。
-
如果前面的步骤都不奏效,请考虑使用覆盖选项
--add-exports或--add-opens来访问您需要的平台 API。这是一个短期应急措施,直到有问题的代码或库被修复。 -
如果这些方法都不奏效,作为最后的手段,可以使用
--permit-illegal-access关闭开关来关闭所有模块化特性。虽然这不被推荐(你当然不希望在生产环境中部署带有此开关的应用),但如果你被兼容性问题压得喘不过气来,这是一个方便的开始方法。这个开关的一个酷特性是,当你运行任何进行非法访问的代码时,它会打印出一个警告信息。这可以非常有帮助,以便汇总这些信息并计划稍后修复它们。
摘要
本章介绍了代码迁移到 Java 9 的两个阶段中的第一个——在 Java 9 中编译或运行你的 Java 9 之前的代码的过程。我们使用了一个示例 Java 8 项目(没有任何内部 API 访问)来在 Java 9 中编译和执行它。然后我们查看了一个包含几个故意内部 API 访问实例的类,并看到了我们将遇到的错误的样子。我们了解了jdeps工具以及如何使用它来静态扫描你的代码库并识别这些实例。
一旦确定了这些实例,我们介绍了几种解决问题的方法——使用建议的替换 API 或使用命令行标志临时克服问题。我们使用了这两种选项来使之前失败的代码在 Java 9 中编译和执行良好。然后我们查看了一个高级策略,以完成将你的遗留代码在 Java 9 中运行的过程。
如果你正在维护的代码你只是需要维护而不太可能进行扩展,那么你可以在迁移之旅的这里停止。但是,如果你需要积极演化代码,那么超越这一点是个好主意。在下一章中,我们将学习如何通过 Java 9 迁移的第二阶段——将其重构为使用 Java 9 模块化特性。
第十一章:将您的代码迁移到 Java 9
在前一章中,我们探讨了从一个 Java 9 之前的代码库开始,使其在新的 Java 9 平台上编译或运行所需的最小更改。我们还探讨了您可能会遇到的一些遗留代码问题以及如何解决这些问题。
如果您正在对一个预期将进行许多更改或增强的代码库进行工作,那么您不仅想要在 Java 9 上运行它。您想要利用 Java 9 提供的模块化特性。当然,您不应该盲目地重写应用程序以使用模块,仅仅因为您能够这样做!模块化的优势——强大的封装和可靠的配置——在具有大型代码库、清晰的边界和多个团队共同工作的应用程序中最为有用。在本章中,我们将探讨如何使用这些新的模块化特性,并逐步将它们引入到您的 Java 9 之前的代码库中。
本章我们将涵盖以下主题:
-
代码库迁移策略
-
自动模块
-
库迁移
-
多版本 JAR 文件
我们将在前一章中讨论的购物袋示例上继续工作。我们已经将它编译并运行在 Java 9 平台上。现在,我们将向代码中添加模块化特性。
现在,您如何进行类似的事情呢?在小型应用程序的情况下,例如我们正在查看的示例代码,在整个应用程序中进行全面更改是微不足道的——您可以根据代码中不同类型所扮演的角色将小型代码库分割成模块。然后,用正确的模块定义将各个模块包装起来。简单!
不幸的是,大多数现实世界的应用程序都更大、更复杂。因此,它们不能通过“大爆炸”方法进行模块化。您将不得不逐步将其分割成块,将应用程序的部分移动到模块中。在一个部分代码已经模块化而其余部分没有的应用程序中,这会是如何运作的呢?此外,大多数应用程序,尤其是企业 Java 应用程序,都使用某种类型的框架或库来处理应用程序基础设施。在这些情况下,Java 9 迁移意味着什么呢?库是否需要重写以使用模块?在库尚未模块化的情况下,您能否模块化您的应用程序?在我们回答这些问题之前,让我们首先了解迁移目标是什么。我们试图实现什么?
理解迁移目标
假设您已经完成了前一章中的步骤,并且您的遗留代码现在符合或可以在 Java 9 上运行。您已经准备好下一步——将您的代码迁移到使用 Java 9 模块!这看起来会是什么样子?
这是一个非常高级的图示,展示了在 Java 9 平台上运行的典型 Java 9 之前应用程序的不同元素:

你可以将一个典型应用程序分解为三个不同的层。在最顶层是应用程序类和 JAR 文件。典型应用程序结合了应用程序类和 JAR 文件以及任何内部库,例如共享的实用组件。所有这些都是特定于应用程序的。由于应用程序尚未迁移到 Java 9,因此这一层由 classpath 中的类和 JAR 文件组成。
第二层表示应用程序可能使用的任何框架。现在很难找到不使用某种应用程序框架的 Java 应用程序。Spring、Vaadin、JSF 和 Hibernate 等框架非常常用。这些通常捆绑到应用程序中作为.jar文件,无论是手动下载还是通过 Maven 或 Gradle 之类的依赖关系管理工具。库将在 classpath 中还是在模块路径中?这取决于库,以及作者是否将其迁移到 Java 9。如果库已经迁移,你只需将它们添加到模块路径即可!然而,为了本章的目的,让我们假设库尚未迁移,这样你就知道如何处理更复杂的情况。
第三层是所有这一切背后的底层Java 平台。正如我们在本书中看到的,自 Java 9 起,这是一个完全模块化的平台。
由于我们假设应用程序代码或库都不是 Java 9 模块,它们主要在 classpath 中运行,而模块路径完全为空。这正是我们在上一章结束时留下的代码的方式。以下是之前的图片:

目标是创建模块,并将所有内容从classpath移动到模块路径。一旦完成,classpath将变为空,应用程序所需的所有内容都将从模块路径运行。以下是理想的之后图片:

注意,在之后的图片中,我们甚至不再使用classpath了。我们现在需要的所有代码和二进制文件都已转换为模块,并在模块路径中提供。因此,在一个理想的世界里,甚至不需要传递 classpath 参数!此外,请注意,我故意将模块的表示改为随机大小。这是为了强调,JAR 文件和 classpath 中的类与转换后的模块之间可能不存在一对一的映射。你可能会在 Java 9 中将 Java 8 应用程序中的一个 JAR 文件拆分为多个模块,或者将多个 JAR 文件合并为一个模块。
既然我们已经对最终目标有了概念,让我们来看看迁移策略。
开始迁移
让我们通过处理示例购物袋应用程序来逐步进行迁移过程。这是一个简单的应用程序,包含三个类——一个用于读取用户输入,一个提供购物袋功能,还有一个包含 main 方法的类来驱动执行——迭代地接收用户输入,将其添加到购物袋中,然后打印袋中的内容。该应用程序依赖于 commons collections JAR 文件中的 Bag 数据结构。它还调用 Java 日志 API 将开始和结束时间记录到控制台。
购物袋应用程序中的代码被称为单体。也就是说,构成应用程序的所有代码都在一个代码库中。这实际上是一种简化,并不代表一个可能跨越多个项目并具有不同构建工件捆绑在一起的真实世界应用程序。我们将保持简单,首先使用简化的单体代码库运行迁移过程,然后将其扩展到多项目设置。
我们从01-legacy-app文件夹中的代码开始。应用程序代码在src文件夹中,commons collections JAR 在lib文件夹中:

将此应用程序模块化的第一步是创建一个大的模块,它围绕整个应用程序。我们在第十章中运行了这个应用程序,为 Java 9 准备你的代码。平台通过创建一个包含所有我们代码的无名模块来帮助我们,这是一个自动的过程。这次,我们将自己创建一个名为packt.shoppingbag的应用程序模块。
首先,就像之前一样,让我们指定一个模块源文件夹,其中包含所有模块的源代码。你可以创建一个新的文件夹或使用现有的src文件夹。我将选择后者。在src文件夹中,创建一个名为packt.shoppingbag的模块文件夹,并在其中创建一个module-info.java文件:
module packt.shoppingbag {
}
目前这只是一个空的模块描述符。我们稍后会回到这个问题。
现在我们有了模块根,你可以将整个源代码(包括包名文件夹层次结构)移动到模块根文件夹中。11-migrating-application/02-migrating-to-one-module文件夹中的源代码代表了代码库的这种状态:

我们现在拥有的远非一个模块化的 Java 应用程序。然而,从技术上讲,它确实有一个模块。因此,编译和执行此应用程序的方式需要与我们在这本书中迄今为止所做的方式相似。也就是说,使用模块源路径参数来指定包含模块根的源位置,并使用模块路径参数指向编译模块的位置。
让我们尝试编译这个应用程序。我们首先创建一个名为 out 的文件夹来包含编译后的类:
$ mkdir out
这是到目前为止我们使用的 javac 命令:
$ javac --module-source-path src -d out $(find . -name '*.java')
如果你运行这个,你会得到以下错误:
$ javac --module-source-path src -d out $(find . -name '*.java')
./src/packt.shoppingbag/module-info.java:3: error: module not found: commons.collections4
requires commons.collections4;
^
1 error
编译器无法找到 commons collections 依赖项。这很合理!lib文件夹中的 JAR 文件,而我们从未告诉编译器关于它。现在,我们可以将这个 JAR 文件添加到类路径中并再次编译吗?
$ javac --module-source-path src -cp lib/commons-collections4-4.1.jar -d out $(find . -name '*.java')
./src/packt.shoppingbag/module-info.java:3: error: module not found: commons.collections4
requires commons.collections4;
^
1 error
不行,这也不会起作用。为什么?下面是我们现在拥有的应用程序的图片:

我们已经将应用程序代码移动到模块路径中,但库(在我们的例子中是一个单独的 JAR 文件)仍然存在于类路径中。由于它位于类路径中,它成为了自动创建的未命名模块的一部分。我们已经看到未命名模块默认读取所有解析的模块。因此,未命名模块中的任何代码都可以访问模块路径中的类型。这就是我们在第十章,“为 Java 9 准备您的代码”中做的事情:

然而,我们在这里试图做的正好相反。我们希望模块路径中的模块能够访问未命名模块中的类型,这就是问题所在。结果是,没有任何其他模块可以读取未命名模块!

这是一个有意为之的限制。每个模块都需要满足强封装和可靠配置的要求。如果模块要读取类路径,我们基本上就会把可靠的配置抛出窗外!由于类路径没有可靠的配置,平台无法验证模块是否拥有它所需的一切。因此,防止显式 Java 9 模块访问类路径是一件好事™。
这确实会在迁移过程中引起重大问题。将代码从类路径移动到模块路径是在走一条单行道。一旦代码跨越到模块路径,它就无法访问类路径中的任何内容。这对你应用程序的代码来说并不是一个大问题。就像我们看到的,你可以将整个代码库放入一个巨大的命名模块中,就像我们在购物袋应用程序中做的那样。现在,你的所有代码都不在类路径中。太好了!然而,关于库怎么办?几乎每个 Java 应用程序都有第三方库和框架,这些库主要是从互联网上拉取的 JAR 文件,并捆绑到类路径中。由于我们无法控制或维护库代码,我们不能将它们的代码放入模块中并用模块描述符包装它们。因此,直到你的库的作者将它们的代码迁移到 Java 9,你将不得不使用非模块化的库。你的类型如何访问它们?你不得不等到你使用的最后一个库的代码迁移到 Java 9 之后才模块化你的代码吗?
幸运的是,情况并非如此。平台再次通过创建模块的能力来提供帮助。这些模块被称为自动模块。
自动模块
为了避免我们刚才讨论的第三方库依赖问题,Java 平台有一个机制可以自动从 JAR 文件创建模块。你不需要访问库代码或甚至创建模块描述符。你只需要 JAR 文件。这很好,因为对于任何第三方库,你确定会有的就是 JAR 文件!
好吧,将 JAR 文件转换为自动模块并将其放入应用程序中需要什么?答案是--什么也不需要!你只需要将任何 JAR 文件放入模块路径中。平台会自动将其转换为模块。以下是平台对模块路径中遇到的每个 JAR 文件所执行的操作:
-
它自动将 JAR 转换为模块并给它命名
-
它设置了模块定义--模块读取和导出的内容
由于它们现在是有名称的模块(尽管是自动创建的),你的代码可以像依赖和引用任何其他模块一样依赖和引用它们。
让我们详细检查前两个选项。
自动模块命名
给定一个 JAR 文件,平台是如何知道如何命名它的?例如,如果我把我们目前正在使用的 JAR 文件--commons-collections4-4.1.jar--放入模块路径中,从它创建的模块将叫什么名字?
自动模块的命名基于 JAR 文件名,但不包括 .jar 扩展名。例如,如果你的 JAR 文件名为 foo.jar,则自动模块的名称是 foo!但是,等等!关于无效字符怎么办?在 JAR 文件名中包含 - 字符是很常见的,但在模块名称中是不允许的。在这种情况下,- 字符会自动被 . 字符替换。所以,如果 JAR 文件名是 my-lib.jar,则自动模块的名称将是 my.lib。
虽然这种命名方式可行,但可能会很麻烦。这是因为大多数 JAR 文件(尤其是来自 Maven 或 Gradle 构建系统的 JAR 文件)通常在名称中包含版本号。这意味着每次你获取 JAR 文件的新版本时,模块的名称都会改变!为了避免这种情况,并使库模块名称保持一致,自动模块名称会从名称中删除版本号。
总结来说,给定一个 JAR 文件名,自动模块命名执行以下操作:
-
它删除了
.jar文件扩展名 -
它将
-字符替换为.字符 -
它删除了版本字符串
因此,JAR 文件 commons-collections4-4.1.jar 获得了自动模块名称-- commons.collections4。
这里还有一些其他示例:
| JAR 文件名 | 自动模块名称 |
|---|---|
commons-lang-1.2.10.jar |
commons.lang |
spring-core-4.3.10.RELEASE.jar |
spring.core |
guice-4.1.0.jar |
guice |
自动模块定义
自动模块需要和导出什么?答案是——一切!记住,平台会自动创建模块描述符,所以它不知道模块需要什么或它将被用于什么。为了确保一切正常工作,它创建了最不限制的模块定义。
-
它
requires transitive所有已解析的模块 -
它导出所有模块
-
它读取未命名的模块(即类路径中的所有代码)
我希望你们同意,这是你可以为模块创建的最糟糕的模块定义。然而,这是确保 Java 生态系统中的库作为自动模块无缝工作的必要条件。这不是理想的状态。我们希望迁移到一个所有库都模块化的环境中,并且我们使用具有在模块路径中定义良好的requires和exports定义的实际模块 JAR 文件。直到那时,自动模块帮助我们开始迁移。
注意,自动模块requires transitive解决了所有已解析的模块。你读得对!当你依赖于一个自动模块时,你会读取所有内容,无论你是否需要!请非常谨慎地使用你依赖的模块。仅仅因为你可以读取任何模块,并不意味着使用它是可以的。始终牢记模块定义。自动模块只是一个权宜之计。你不应该把从自动模块得到的可读性关系视为理所当然。记住,当自动模块消失时,传递的可读性关系也会随之消失。
使用自动模块进行迁移
带着对自动模块的了解,让我们继续迁移购物袋应用程序。我们需要将 commons collection JAR 从类路径中移除,并制作成自动模块。为此,我们不会移动 JAR 文件,而是只需提供带有 JAR 文件路径(lib文件夹)的--module-path参数,从而在模块路径中创建 JAR 文件。(与类路径不同,你不需要指定文件名。只需文件夹位置即可。)
这是编译器命令:
$ javac --module-source-path src --module-path lib -d out $(find . -
name '*.java')
我们现在将得到以下不同的错误:
./src/packt.shoppingbag/com/packt/shoppingbag/app/App.java:3: error: package java.util.logging is not visible
import java.util.logging.Logger;
^
(package java.util.logging is declared in module java.logging, but module packt.shoppingbag does not read it)
./src/packt.shoppingbag/com/packt/shoppingbag/data/ShoppingBag.java:3: error: package org.apache.commons.collections4 is not visible
import org.apache.commons.collections4.Bag;
^
(package org.apache.commons.collections4 is declared in module commons.collections4, but module packt.shoppingbag does not read it)
./src/packt.shoppingbag/com/packt/shoppingbag/data/ShoppingBag.java:4: error: package org.apache.commons.collections4.bag is not visible
import org.apache.commons.collections4.bag.HashBag;
^
(package org.apache.commons.collections4.bag is declared in module commons.collections4, but module packt.shoppingbag does not read it)
3 errors
这个修复应该更明显一些。编译器抱怨packt.shoppingbag模块没有要求它使用的模块。它使用了日志 API(在模块java.logging中)和 Commons Collections API(来自现在创建的自动模块commons.collections4)。让我们在module-info.java中将它们都添加为依赖项。请注意,我们使用自动模块名称来建立读取关系,就像任何其他 Java 9 模块一样:
module packt.shoppingbag {
requires java.logging;
requires commons.collections4;
}
在这个示例应用程序中,我们只使用了一个 JAR 文件。这远非一个现实场景。大多数现实世界应用程序都有多个 JAR 文件。因此,这一步将涉及将所有必要的 JAR 文件转换为自动模块,通过将它们添加到模块路径,然后在你的模块定义文件中添加正确的requires声明。
再次编译时,应该没有任何错误。为了执行,我们将使用之前使用的带有 --module-path 标志的相同 java 命令,但有一个小的变化。我们需要将 lib 文件夹添加到模块路径中,因为我们再次希望将 commons 集合 JAR 文件视为自动模块。
$ java --module-path out:lib -m packt.shoppingbag/com.packt.shoppingbag.app.App
Aug 02, 2017 2:47:45 PM com.packt.shoppingbag.app.App main
INFO: Shopping Bag application: Started
Enter item (Type 'end' when done):
我们使用分隔符(macOS/Linux 上的 : 和 Windows 上的 ;)来分隔两个模块路径——out,其中包含编译模块,和 lib,其中包含 JAR 文件。一切都应该按预期工作。
自动模块可能会引起的一个潜在问题是你需要注意的。还记得我们在第六章“模块解析、可读性和可访问性”中讨论的拆分包问题吗?在 Java 9 中,单个包不能存在于两个不同的模块中。然而,一个包可以存在于两个不同的 JAR 文件中。现在,当你将两个共享包的 JAR 文件作为自动模块使用时会发生什么?它们将无法工作,因为它们会导致拆分包问题。如果你在自己的任何库中遇到这个问题,很遗憾,你几乎无能为力。你可能不得不将东西移回类路径,或者联系库的开发者让他们修复代码。或者两者都要做!
使用 jdeps 概述模块关系
我们使用 jdeps 来识别内部 JDK API 的使用情况。这个工具的功能远不止于此!在将代码迁移到 Java 9 时,一个非常有用的功能是 -summary 选项。这个选项会遍历你的编译模块,并识别不同模块之间的关系。这确保了你在模块中指定的 requires 关系是正确的,包括自动模块。
在 11-migrating-application/02-migrating-to-one-module 文件夹中运行以下 jdeps 命令:
$ jdeps -cp lib/commons-collections4-4.1.jar -recursive -summary out
commons-collections4-4.1.jar -> java.base
out -> lib/commons-collections4-4.1.jar
out -> java.base
out -> java.logging
-recursive 标志指示 jdeps 递归地遍历子文件夹,并列出其中找到的模块的依赖关系。
注意,你会得到一个非常有用的输出列表,列出哪些模块读取了哪些内容。当你有一堆在 Java 8 或更早版本编译的 JAR 依赖项,并且你试图将它们作为自动模块添加时,这非常有用;你不必猜测需要读取这些自动模块的模块,只需运行这个命令就可以得到一个很好的概述。
将代码重构为更小的模块
现在你已经将代码库放在模块源路径中,下一步就是逐步将其分解为更小的模块。这项工作取决于你的代码库的大小以及你一次想要处理多少。你可以选择保持单个模块不变,只为任何你编写的新代码创建模块。因此,遗留代码不会从模块化概念中受益,但任何新代码都会。然而,强烈建议在此处执行以下两个步骤:
-
为您的库找到模块化版本或等效版本,并将 JAR 文件移出模块路径。
-
将模块分解成更小的模块。
步骤 1 移除自动模块提供的广泛传递依赖项,这样您就可以对依赖项有更精细的控制。这取决于您使用的库以及作者是否已经将它们迁移到 Java 9。一旦库更新到 Java 9,更新的版本仍然可以放置在模块路径中,但这次,由于它们将有一个适当的模块描述符,平台将不需要将它们转换为自动模块。您可能需要检查那些库中 Java 9 模块的新名称是否与您之前使用的自动模块名称不同,如果是,请更新您的模块描述符以使用新的库模块名称。
步骤 2 确保遗留代码也能获得强封装和可靠配置的好处。由于与步骤 1 不同,我们可以控制步骤 2,所以让我们为购物袋应用程序做这件事。
假设我们希望将代码分解成以下三个模块:
-
用户输入模块
-
包模块
-
应用程序模块
我知道这对这个小型应用程序来说有点过度,但是它作为一个例子,说明了迁移的下一步。
11-migrating-application/03-splitting-modules 文件夹包含将代码分解成多个模块后的应用程序状态。注意每个模块的模块描述符缩小了依赖范围,使得代码的哪些部分需要那些外部 API 变得清晰。java.logging 仅由 packt.app 模块需要。commons collection 由 packt.bag 需要。
处理更大的代码库
我们模块化的示例应用程序非常简单,并不代表大多数现实世界应用程序。以下是一些大多数应用程序不同的特性:
-
它们有一个更广泛的代码库,跨越多个项目。这些项目可能位于不同的源位置,并且可能连接到构建系统。主应用程序的构建随后将正确的依赖项收集在一起,以形成最终的应用程序构建。
-
它们有更多的框架依赖项,这些依赖项有更复杂的需求。例如,Spring 或 Hibernate 框架需要访问您的应用程序代码以进行反射。它们可能会扫描您的类以查找注解,并执行各种操作,如依赖注入和对象关系映射。从这个意义上说,不仅您的应用程序代码需要访问库作为自动模块,甚至这样的自动模块也需要访问您的应用程序代码。
考虑到这样一个庞大的 Java 8 代码库,您甚至如何开始迁移?以下是一些您通常会遵循的步骤:
步骤 1:绘制模块边界并创建高级模块图:
在我看来,模块化现有代码首先需要至少有一个大致的高级想法,即你需要哪些模块以及你计划如何拆分代码库。我们查看了一些策略和技巧,以帮助你确定第九章,模块设计模式和策略中的模块边界。根据你代码的复杂性,你可能需要查看整个代码或高级部分,并想出一些模块名称和接口。
一旦你对你的模块有一个大致的想法,你可以创建一个表示这些模块之间依赖关系的模块图。不要过于纠结于细节。这只是一个粗略的草图,当你开始深入细节并进行重构时,你可能倾向于对模块或它们之间的关系进行修改。
步骤 2:模块化主要应用程序:
在一个大型应用程序所包含的所有代码项目中,通常有一个可以归类为主要项目。这可能是一个启动执行的项目,或者是一个构建并部署为应用程序的项目。那将是一个好的开始地方。你可以遵循本章学到的步骤,首先将那个应用程序带到模块路径上。
步骤 3:使用模块覆盖来满足特殊库需求:
如果你使用 Spring 或 Hibernate 等框架,当你将它们用作自动模块时,你肯定会遇到问题。这是因为那些框架通常需要访问你的代码库以反射性地扫描你的类中的注解。我们知道自动模块读取所有解析的模块。所以,技术上它读取了可能包含 Spring 注解的应用程序模块。然而,如果你的模块没有导出包,它仍然无法访问它们。你可以通过几种方式解决这个问题:
-
在你的模块定义中将包含此类注释的包添加
opens声明,以便库能够访问必要的类进行反射 -
使用
--add-opens命令行参数来达到相同的结果
步骤 4:利用自动模块为内部构建工件:
没有理由你不应该为你的应用程序 JAR 使用自动模块。假设你正在迁移一个大型 Maven 应用程序,它依赖于其他内部构建的项目(或你拥有的代码)的多个工件依赖项。这些内部工件可以被添加到模块路径并转换为自动模块。这里需要注意的一点是拆分包问题。由于我们处理的是内部代码,因此 JAR 之间存在包重叠的可能性很大。在这种情况下,你需要重构你的代码以确保 JAR 文件中没有重叠的包。你可以在使用较旧版本的 Java 的同时进行此重构。
步骤 5a:将主要项目分解成更小的模块:
再次强调,按照本章中使用的流程,开始将单体模块分解成更小的部分。在分解过程中,明确较小的模块之间的依赖关系。
步骤 5b:从叶子向上迁移模块:
与步骤 5a并行,你也可以开始迁移除主项目之外的其他项目。由于你已经构建了模块树,模块迁移的顺序变得清晰。你可以通过从模块依赖树的叶子开始,逐步向上迁移,使迁移过程显著简化。理想的迁移候选者是一个没有其他应用程序模块依赖的模块。对 Java 模块的依赖是可以接受的,尽管如此!
例如,假设这是你计划在迁移后实现的代码的目标模块图。该图仅包括你的应用程序模块。任何对平台模块的依赖都被排除在这个图中:

你应该首先选择迁移的模块集是D和E。一旦完成,迁移C,然后是A和B。
迁移库
我们已经探讨了在将应用程序迁移到 Java 模块时需要遵循的步骤和策略。那么库呢?假设你是被许多人使用的开源库的维护者。或者,也许你维护的库被你组织中的多个团队使用。你将如何迁移这样的代码库?这不会要求你遵循我们为迁移应用程序所涵盖的相同步骤吗?嗯,大部分是的。然而,有一些事情你需要对库进行不同的处理。本节将涵盖这些细节。
与库相比,最大的不同可能在于你不再在应用程序的上下文中工作。一个库可以被多个应用程序使用。这些应用程序可能使用多个版本的 Java。你如何创建一个可以适用于所有这些情况的单一库 JAR 文件呢?幸运的是,平台中有些功能可以简化这个过程。
在我们深入研究这些具体问题之前,让我们看看将库代码迁移到使用 Java 9 模块需要什么。以下是一些作为库作者你需要遵循的高级步骤:
-
消除 JDK 内部 API 的使用:这与我们为应用程序所做的工作没有不同。我们需要确保库是一个好的 Java 9 公民。对 JDK 内部或已弃用的 API 的调用是不允许的。重构你的代码以避免调用,或者使用我们的友好工具
jdeps的--jdk-internals选项建议的替代方案。 -
消除任何分割的包:我们已经探讨了分割包如何导致自动模块出现问题。你需要确保你的 JAR 文件不包含可能存在于你组织中的其他 JAR 文件中的包。如果其他团队拥有的库的包与你的冲突,你需要与他们合作,简化包名。
-
为你的核心库模块命名:与任何模块一样,你需要为库起一个名字。当与仅在你组织内部使用的库一起工作时,这并不是什么大问题。然而,当处理开源模块时,这是一个非常重要的步骤。正如我们在第二章“创建你的第一个 Java 模块”中提到的,模块名称可以遵循反向域名约定。你可以选择为仅限内部使用的库使用更短的名字,以简化可读性和沟通,因为在这些情况下名称冲突的可能性较小。
-
开始重构并将你的代码转换为模块:这包括将你的代码移动到模块根目录,添加模块描述符,并定义模块的
requires和exports定义。注意任何你封装的类型。如果有使用这些类型的库消费者,他们将无法再使用它们,除非他们添加--add-exports覆盖。
就像应用程序迁移一样,我强烈建议你在深入代码和移动文件之前,对你的库代码进行一次调查,并制定一个高级模块图,概述模块之间的关系。这将为你节省大量的时间和工作!
- 添加传递依赖或处理依赖泄漏:有可能你的库代码依赖于其他库。它们可能是其他内部库或开源 JAR 文件。这些库可能尚未迁移到 Java 9,我们面临与应用程序依赖相同的问题。在这里,你同样需要为你的库所依赖的 JAR 文件使用自动模块。如果你使用你的 API 需要访问这些库,那么在你的模块定义中添加对这些库的 require transitive 是一个好主意。如果可能的话,将这些类型包装起来,这样使用你的库的代码就不需要知道这个依赖了。
预留库名称
假设你是一个尚未准备好将代码迁移到 Java 9 的库开发者。我们知道那不是问题。其他 Java 9 应用程序仍然可以通过将 JAR 文件放入模块路径中来消费你的库,从而将其自动转换为模块。他们使用从 JAR 文件名自动生成的模块名称,并在他们的模块定义文件中使用它。然而,如果你有一个计划在最终迁移到 Java 9 时使用的非常酷的模块名称,这意味着所有消费者都必须去更新所有指定自动生成名称的模块定义吗?那可能会很麻烦。
Java 9 平台为库作者提供了一个选项,在将库迁移到 Java 9 之前,为他们的库预留一个模块名称。因此,作为库作者,您可以在 JAR 文件中的META-INF/MANIFEST.MF文件中指定您希望 Java 9 模块名称是什么。您可以在 Java 8 编译的 JAR 文件中这样做。一旦这样做并将它捆绑到 JAR 文件中,当它被放入 Java 9 应用程序的模块路径中时,平台就会将其名称作为自动模块名称拾取。它实际上覆盖了从 JAR 文件名中自动命名的模块名称。
以下是您如何在 JAR 文件中指定首选的自动模块名称的方法。在 JAR 文件的根目录下的META_INF文件夹中创建一个名为MANIFEST.MF的文件。添加以下行以指定首选的自动模块名称:
Automatic-Module-Name: <my.preferred.module.name>
完成此操作后,JAR 文件将使用此名称,而不是平台将其转换为自动模块时从 JAR 文件名中获取的名称。并且所有消费者都必须通过使用此首选模块名称来在模块路径中引用您的 JAR 文件。因此,当您开始将模块迁移到 Java 9 时,您可以在模块描述符中使用首选名称,而您的库的消费者不需要更改他们的模块描述符。
使用 jdeps 创建模块描述符
一旦您开始将库 JAR 文件分解成模块,根据您库的大小,您可能有很多工作要做。确定您需要require哪些模块以及需要export哪些模块并不简单。jdeps工具还有另一个技巧。它可以查看您的 JAR 文件,并自动为您生成可使用的模块描述符。
语法如下:
$ jdeps --generate-module-info <output-location> <path-to-jars>
让我们以 commons-collections JAR 文件为例:
$ jdeps --generate-module-info out lib/commons-collections4-4.1.jar
输出应该看起来像这样:
writing to out/commons.collections4/module-info.java
如您所见,jdeps已生成一个具有与我们之前看到的相同自动命名算法的模块根文件夹。在该文件夹内,它创建了一个module-info.java文件,并用通过扫描 JAR 文件中的类所识别的requires和exports声明填充了该文件:
module commons.collections4 {
requires transitive java.xml;
exports org.apache.commons.collections4;
...
}
您可以运行此命令并指向多个 JAR 文件,它将为每个 JAR 文件执行此操作,这也有利于 JAR 文件之间的任何关系。为这些相关模块生成的module-info.java文件也将包括这些关系!
记住,将此功能仅作为定义你的模块定义的起点。显然,平台不能仅通过查看代码就猜测出你的库的完美模块定义。作为库的作者,你的任务是确定它需要什么,它封装或导出什么。这里也存在一个技术限制。jdeps 进行静态代码分析,因此它将无法捕获库可能执行的任何运行时反射访问。如果你的库使用了反射,你需要手动将 exports 或 opens 声明添加到正确的模块中。
为多个 Java 版本构建库
在迁移应用程序时,我们必须处理这样一个场景:依赖库可能并非全部都迁移到 Java 9。在处理库时,你将需要解决相反的问题。使用你的库的应用程序可能并非全部都是 Java 9。你将不得不支持 Java 8(或者在某些情况下甚至更旧的 Java 版本)。作为库的作者,你该如何为所有这些版本创建库分发?在 Java 9 之前,你通常有两个选择:
-
你可以为每个 Java 版本创建单独的 JAR
-
在你的库代码中,你可以使用反射来进行 特性检查。例如,你可以反射地访问在 Java 8 中引入的平台 API。如果它工作正常,你就在 Java 8 中。如果不工作,就降级到 Java 7,依此类推。
这两个选项都很繁琐。Java 9 提供了一个新的替代方案,其特性称为 多版本 JAR。这个概念很简单。你创建一个特殊的 JAR 文件,称为 多版本 JAR,其中包含你针对的所有 Java 版本的类。
这就是这样工作的。多版本 JAR 有一个特殊的结构,其中包含其内部的类:

在多版本 JAR 文件中,你可以找到以下对应于图中编号的内容:
- 有一个根
META-INF文件夹,其中包含一个MANIFEST.MF文件,该文件包含以下行:
Multi-Release: true
这告诉平台这是一个多版本 JAR,因此需要以不同的方式处理
-
JAR 根目录也包含编译后的类的默认版本,就像任何其他 JAR 一样。记住,这个 JAR 针对多个 Java 版本,并且可能包含同一类的多个目标版本。根目录中的类是 默认 的基础版本,可能适用于多个 Java 版本
-
在
META-INF中有一个名为versions的文件夹。为了针对多个运行时,JAR 将类打包到这里的子文件夹中。每个要针对的 Java 版本都有一个文件夹。这样的每个文件夹都包含专门为该发布版本编译的类。因此,如果 JAR 在该版本的 Java 平台上使用,版本文件夹中的类将覆盖multirelease文件夹中的类,并取而代之。如果 JAR 在没有META-INF文件夹的平台版本上使用,或者所需的类不存在于版本文件夹中,则运行时会回退到multirelease文件夹的内容。
注意,类的默认版本位于 JAR 文件的根位置。这就是为什么您也可以使用旧版本的 Java 来使用这个 JAR 文件。对于旧版本的 Java,多版本 JAR 文件看起来就像一个普通的 JAR 文件——平台只查看根位置,而版本文件夹被忽略!
让我们尝试创建一个简单的多版本 JAR。11-migrating-application/04-multirelease-jars 文件夹中包含一个非常简单的库。它被称为 mylib,并且有一个方法可以打印传递给它的列表内容。
我们希望为这个库创建一个针对两个不同 Java 版本的多版本 JAR:
- 库的基本版本针对所有 Java 9 之前的版本。它包含执行
for循环并按如下方式打印列表内容的代码:
public class PrintList {
public void print(List<?> list) {
for (int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}
}
}
- 这个库针对 Java 9 的特定版本有两个更改——它通过
module-info.java声明自己为 Java 9 模块,并使用forEach和函数引用来打印列表内容,如下所示:
public class PrintList {
public void print(List<?> list) {
list.forEach(System.out::println);
}
}
库的两个版本分别位于两个不同的文件夹中。由于将有两个相同类的不同版本,因此这样分离它们是有帮助的。
这是代码的结构:

制作多版本 JAR 的第一步是添加声明它的 MANIFEST.MF 文件。将此文件添加到项目的根目录,如下所示,确保您完全匹配该语句,不要有任何额外的空格:
Multi-Release: true
现在,我们将创建包含编译类的文件夹。我们将创建一个名为 out 的文件夹,并有两个子文件夹——base 用于基本类,9 用于 Java 9 版本,如下所示:
$ mkdir out
$ mkdir out/base
$ mkdir out/9
接下来,我们将通过设置正确的发布版本来将这些类编译到这两个文件夹中。javac 命令的 --release 参数允许您为目标编译的类指定特定的 Java 版本:
$ javac --release 7 -d out/base base/src/packt/mylib/PrintList.java
之前的命令使用目标发布版本 7 编译了 PrintList.java 类,并将编译输出放置在 out/base 目录中。
注意,您不需要在机器上安装多个版本的 Java 来实现这一点。Java 9 有能力自行生成针对不同 Java 版本的类!这类似于 Java 平台早期版本中可用的 -target 标志。
接下来,我们将按照以下方式编译 Java 9 版本:
$ javac --release 9 -d out/9 java9/src/packt.mylib/module-info.java java9/src/packt.mylib/packt/mylib/PrintList.java
这次有两个 Java 文件--PrintList.java 和 module-info.java。编译后的类将放入 out/9 目录。
现在我们有了编译后的类,是时候创建一个多版本 JAR 了。让我们首先创建一个包含基础版本类的 JAR 文件。我们还提供了要包含在 JAR 中的 MANIFEST.MF 文件:
$ jar -cf mylib.jar MANIFEST.MF -C out/base .
-c 选项告诉 jar 工具创建一个新的 JAR,f 选项用于指定 JAR 文件名(这里为 mylib.jar)。-C 选项更改工具正在查找的目录为 out.base,并允许它在那里编译类(如 "." 所指定)。
这将创建 JAR 文件并将基础类添加到其中。接下来,让我们添加 Java 9 类:
$ jar -uf mylib.jar --release 9 -C out/9 .
-u 选项告诉 jar 工具更新 JAR 而不是创建一个新的。这次我们针对的是版本 9,并将编译后的类包含在 out/9 目录中。
您不需要为每个版本添加 JAR 文件中的所有类。尽量将特定版本的类保持到最小。如果基础版本中有可被特定版本复用的通用类,您就无需在此包含它。平台将回退到基础文件夹以查找该特定版本的类。
这里是生成的 JAR 文件的内容。这是我们之前已经看到的结构:

记住,多版本 JAR 功能是在 Java 9 中引入的。因此,您无法在 JAR 中为 Java 8 或更早的版本创建特定版本的替代品。这些平台将不知道从 META-INF/versions 文件夹中读取。它们只会使用 JAR 根目录中的编译类。然而,如果您需要创建仅适用于 Java 9 的类,这是一个很好的功能。由于这些类最终会出现在 META-INF/versions 文件夹中,较旧的平台将忽略它们。一旦发布了 Java 的未来版本,这个功能也可以用于那些版本。例如,您可以有一个针对 Java 10 平台的 META-INF/versions/10 文件夹。
摘要
在本章中,我们探讨了如何将 Java 9 之前的应用程序迁移到使用 Java 9 的模块化功能。您学习了如何规划整体迁移策略,以及这种迁移的理想最终目标是什么。您还了解了一个新功能,该功能允许您处理非模块化的依赖项和库--自动模块。我们探讨了自动模块的命名方式、它们的行为方式以及我们如何在迁移过程中使用它们。
然后,你学习了如何处理库迁移。我们讨论了在迁移库代码时需要考虑的一些因素,以及多版本 JAR 功能,它允许我们创建针对多个 Java 平台版本的单一 JAR 文件。
在下一章和最后一章中,我们将通过探讨 Java 开发者经常处理的两个重要方面来总结全文——构建工具和单元测试。我们将了解它们在模块化 Java 应用程序中的工作原理。
第十二章:使用构建工具和测试 Java 模块
在上一章中,我们探讨了将现有的 Java 代码库迁移到 Java 9 的几种策略和方法。在本章中,我们将通过探讨两个你很可能遇到的重要主题来结束我们的 Java 9 模块化之旅,无论你是迁移遗留代码库还是从头开始创建新的模块化应用程序。它们是构建工具集成和单元测试。
本章我们将涵盖以下内容:
-
将 Java 9 模块与 Maven 构建过程集成
-
与多模块 Java 9 Maven 项目一起工作
-
使用 JUnit 为 Java 9 模块编写单元测试用例
-
处理 Java 9 中的新访问问题和测试挑战
考虑到构建系统和单元测试是现代 Java 开发者工作流程中极其常见且重要的部分,我们在这本书的最后一章同时处理这两个主题可能看起来有些奇怪。这有一个很好的原因。理解和处理这些概念需要理解我们在前几章中讨论的许多主题。现在,你已经探索了诸如第九章中的开放模块、“模块设计模式和策略”以及第十一章中的自动模块“将您的代码迁移到 Java 9”等概念,你现在可以轻松地应对这一章节了!
与 Apache Maven 集成
在 Java 领域,有两个非常常用的构建系统是 Apache Maven 和 Gradle。当你在一个企业 Java 应用程序上工作时,你很可能必须处理这两个选项之一。到目前为止,我们在本书中一直使用命令行来获取编译器和运行时执行。然而,在复杂的项目中,这很少是合理的事情。那么,在 Java 9 模块化应用程序中使用这样的构建系统需要什么?
在撰写本文时,Maven 已经与 Java 9 有了可行的集成,而 Gradle 仍在积极开发中。因此,我们在这本书中只涵盖 Apache Maven 的集成。Java 工具生态系统赶上 Java 9 的模块化变化只是时间问题,因此随着时间的推移,看到更好的集成和使用这些工具与 Java 9 的整体体验不会令人惊讶。
让我们来看看你可以如何创建一个包含并构建 Java 9 模块化应用程序的 Maven 项目。
Maven 快速回顾
本章假设你至少熟悉 Maven 的基本概念,但这里有一个快速回顾。Maven,在其他方面,是一个项目构建工具。它基于约定并提供了一种正式的结构来组织你的代码、命名你的工件以及在其他项目上建立依赖关系。这听起来可能与我们一直在使用的 Java 9 模块化非常相似,但实际上并不相同。与 Java 平台模块系统不同,Maven 关注的是构建(或组装)你的工件,而不是验证编译时间或运行时准确性。
当你创建一个 Maven 工件时,你给它分配坐标:组名、工件名和版本。你在这个名为pom.xml的文件中指定这些信息。此文件还允许你指定对其他 Maven 工件依赖项,以便在构建过程运行时,Maven 可以获取必要的依赖项并将它们提供给 Java 编译器或运行时。
使用 Maven 与 Java 9 模块
当你将 Java 9 模块引入场景时,你可以看到这里有两种并行的模块概念:Maven 关于在pom.xml中定义的工件的概念和 Java 平台关于在module-info.java中定义的模块的概念。然而,当你将这两个合并,并且每个 Maven 项目包含一个 Java 9 模块时,这两个概念出奇地好地协同工作。
考虑以下单个 Maven 项目的文件夹结构。代码位于lib文件夹中。这是一个典型的 Maven 项目。它有一个包含此工件 Maven 坐标的pom.xml描述符。然而,它还有一个位于src/main/java文件夹中的module-info.java文件,将其设置为 Java 9 模块!

采用这种方法,目的是为每个 Java 9 模块创建一个 Maven 工件。这意味着你需要想出两个不同的名称:
-
Maven 工件坐标——包括组名和工件名
-
Java 9 模块的名称
现在,当涉及到在两个这些模块之间建立依赖关系时,你需要在两个地方指定依赖项。比如说,例如,你拥有两个名为A和B的 Maven Java 9 项目。为了指定 A 依赖于 B,你需要执行以下操作:
-
在 A 的 Maven
pom.xml文件中添加一个<dependency>标签,指定 B 的 Maven 坐标 -
在 A 的
module-info.java文件中添加一个requires声明,指定 B 的模块名称
这种方法的优点是 Maven 负责获取必要的工件并将它们放置在模块路径中。然后,当编译器或运行时执行时,Java 平台模块系统就有了一切所需!请注意,如果你遗漏了这两个依赖配置中的任何一个,这就不起作用了。如果你忘记指定 Maven 依赖项,Maven 将不会获取工件并将其放置在模块路径中。如果你忘记在module-info.java中添加 requires 声明,即使 Maven 已经在模块路径中提供了它,你的代码也无法访问依赖项中的类型。
当你处理由多个模块组成的应用程序时,虽然这对一个或两个模块来说效果很好,但这也可能变得难以管理。在这种情况下,我们可以利用 Maven 的多模块项目功能来更好地组织多个 Maven + Java 9 模块。
在一个多模块 Java 9 Maven 项目中工作
让我们看看一个示例 Maven 多模块项目。假设我们想要构建两个 Java 模块:packt.main和packt.lib。packt.lib模块包含一个名为Lib的库类,它有一个名为sampleMethod的方法,而packt.main模块包含一个名为App的类,它有一个主方法调用Lib中的sampleMethod。因此,packt.main必须读取packt.lib,如下所示:

你已经了解到你应该有一个 Maven 项目对应于每个 Java 模块。然而,为了简化开发并利用 Maven 中多模块项目的概念,我们可以创建一个父根 Maven 工件。现在,我们应用程序的模块都可以是 Maven 子项目,如下所示:

代码位于12-build-tools-and-testing/01-maven-integration文件夹中。在根目录中有一个根 Maven 模块。这个模块充当父模块。这只是一个 Maven 容器,用于简化构建过程。我们实际上不需要为这个创建相应的 Java 模块。在根文件夹中是两个子 Maven 项目main和lib。
这里,它的根目录下的pom.xml(为了简洁而截断):
<project ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.packt</groupId>
<artifactId>root</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<name>root</name>
<modules>
<module>main</module>
<module>lib</module>
</modules>
...
</project>
XML 中的 packaging 节点指定了 pom 值,表示这是一个父 pom。它有两个模块声明,表示它是两个 Maven 子模块的父模块。在这里使用术语模块时不要混淆。我们在这里谈论的是 Maven 模块,而不是 Java 9 模块。
在每个子模块中,主模块和 lib 模块,就像我们之前看到的那样。它们是标准的 Maven 项目,但在src/main/java位置有一个module-info.java文件,使它们成为 Java 9 模块。
下面的截图显示了完整的文件夹结构:

由于主项目使用 lib 项目中的一个类型,因此配置了 Maven 和 Java 依赖项。
这里是主项目的pom.xml文件,指定了依赖项:
<dependency>
<groupId>com.packt</groupId>
<artifactId>lib</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
下面是这个module-info.java文件:
module packt.main {
requires packt.lib;
}
构建多模块项目
在我们构建之前,请确保你的路径中安装了最新的 Maven 版本。运行以下命令应该会给出你机器上安装的 Maven 版本:
$ mvn -v
Apache Maven 3.5.0 (ff8f5e7444045639af65f6095c62210b5713f426;
2017-04-03T12:39:06-07:00)
如果你没有看到这个输出,你需要从 maven.apache.org 下载 Apache Maven,并将下载的 bin 文件夹添加到你的操作系统的 PATH 变量中。
让我们尝试构建这个项目。在根项目的 pom.xml 中有包含项,使其准备好在 Java 9 上构建。以下是用于设置 Java 版本为 9 的 Maven 编译器插件:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.6.2</version>
<configuration>
<release>9</release>
</configuration>
</plugin>
使用这个方法,你应该能够运行 Maven 的构建命令,并让 Java 9 编译器编译我们的类。切换到 12-build-tools-and-testing/01-maven-integration/root 目录,并运行以下命令:
$ mvn clean install
下面的输出,为了可读性进行了截断,表明所有模块都已编译:
[INFO] Reactor Summary:
[INFO]
[INFO] root ........................................ SUCCESS [ 0.379 s]
[INFO] lib ......................................... SUCCESS [ 3.646 s]
[INFO] main ........................................ SUCCESS [ 0.195 s]
[INFO] -----------------------------------------------------------------
[INFO] BUILD SUCCESS
执行多模块项目
为了将具有 main 方法的类作为 Maven 生命周期执行,我们使用 exec-maven-plugin。这也得益于根项目 pom.xml 文件中的配置。以下是指定此配置的列表:
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>1.6.0</version>
<executions>
<execution>
<goals>
<goal>exec</goal>
</goals>
</execution>
</executions>
<configuration>
<executable>${JAVA_HOME}/bin/java</executable>
<arguments>
<argument>--module-path</argument>
<modulepath/>
<argument>--module</argument>
<argument>packt.main/com.packt.App</argument>
</arguments>
</configuration>
</plugin>
如同 Maven 配置的典型情况,这看起来有些冗长。然而,对我们来说,有趣的是配置部分。我们正在配置 java 命令,因此你在这里将可执行路径从 $JAVA_HOME 映射过来。我们还在传递我们现在应该非常熟悉的两个参数--表示编译模块位置的 --module-path 参数,以及表示包含主方法的模块和类的 --module 参数。
注意,对于 --module-path 参数,我们并没有手动指定路径。这是因为 Maven 在为我们编译模块,所以我们希望 Maven 本身提供给我们它放置编译类的路径。这是通过特殊的 <modulepath /> 标签完成的。我们将在下一节中更详细地讨论 Maven 中的模块路径。
切换到 12-build-tools-and-testing/01-maven-integration/root/main 目录,并运行以下命令来调用 exec 插件:
$ mvn exec:exec
下面是截断后的输出:
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] Building main 1.0-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[INFO]
[INFO] --- exec-maven-plugin:1.6.0:exec (default-cli) @ main ---
Library method called!
...
Library method called! 这一行是 main 方法调用库方法并将消息打印到控制台的输出。
理解 exec 插件的模块路径
虽然使用 Maven 这种方式有几个优点,但一个显著的优势是,在编译和构建步骤中管理目录变得非常容易。当我们手动运行javac时,我们总是必须手动指定所有编译后的类将放入的输出目录。当我们运行java时,我们必须确保模块路径包含类的输出位置,以及任何依赖的模块和库。Maven 为我们承担了这项工作。多亏了我们添加到exec-maven-plugin模块路径参数中的<modulepath/>行,Maven 自动为我们构建模块路径。以下是 Maven 添加到模块路径中的内容:
-
它自动包括项目的构建位置。我们在
main项目上运行了插件。Maven 确保main编译后的类在模块路径中可用。 -
它自动确保依赖项也在模块路径中。在
main项目的pom.xml中,我们指定了一个对lib的依赖。Maven 承认这个依赖,并自动将编译后的lib模块包含到模块路径中! -
它自动包括那些不是 Java 9 模块的依赖项!假设你的
pom.xml文件指定了一个依赖于尚未迁移到 Java 9 的第三方库。Maven 会自动将这些 jar 文件添加到模块路径中。猜猜当你将一个 Java 9 之前的 JAR 文件添加到模块路径中会发生什么?它们变成了自动模块!你的模块可以使用requires语法来依赖它们,就像任何 Java 9 模块一样。因此,当处理依赖项时,无论是 Java 9 还是更早的版本,你的工作流程都变得极其简单和一致。
使用 Java 9 进行单元测试模块
当在 Java 9 中进行测试时,可读性和可访问性约束带来了新的和有趣的问题。让我们回顾一下我们一直使用的 Java 单元测试代码的方式。以下是两种常见的做法:
-
单元测试代码通常位于一个单独的源文件夹中,该文件夹被添加到类路径中。这是为了将测试代码与实际的应用程序代码分开,同时也便于在构建部署应用程序时排除测试文件夹。
-
单元测试类通常与被测试的类共享相同的包。这是为了确保测试类可以访问被测试类的包私有成员,即使它们位于完全不同的位置。
当类在类路径中时,这两个设计决策效果很好,因为我们知道类路径中类的物理位置并不重要。然而,所有这些在 Java 9 中都在改变!以下是这样做的方法:
-
在 Java 9 中,测试代码可能会因为强封装而面临访问限制。您正在测试的 Java 9 类位于一个模块中。因此,要从测试类中访问模块中的所有类型,唯一的方法是将测试类也放在同一个模块中!这并不是一个好的选择,因为当您构建和分发 Java 模块时,整个内容都会随之而来。另一个选择是将测试类放在模块外部,并且只测试那些导出的类。
-
如果您将测试放在单独的文件夹和单独的模块中,您的测试类就不能与被测试类共享相同的包。这会导致拆分包问题,因为相同的包存在于应用程序模块和测试模块中。因此,您无法访问和测试包私有成员。
考虑到这些挑战,一种绕过它们的方法如下:
-
为每个需要测试的模块创建一个单独的测试模块。
-
编写测试用例以测试导出模块接口。
-
如果您需要为模块未导出的任何内部类型编写测试,请在测试执行期间使用
--add-exports覆盖。是的,--add-exports对于应用程序代码来说不是一个好主意,但它是一个合理的测试解决方案。
测试 Java 9 模块
让我们通过测试示例地址簿查看器应用程序中的packt.sortutil来检查这是如何工作的。代码位于12-build-tools-and-testing/02-testing位置。src文件夹包含packt.sortutil模块--即测试的模块。
要进行测试,我们可以创建一个新的测试模块:packt.sortutil.test。遵循的一个好惯例是将测试模块命名为要测试的模块名称后跟.test。以下是packt.sortutil.test的模块定义:
module packt.sortutil.test {
requires packt.sortutil;
}
通过声明对模块的依赖,您可以访问其导出类型并通过代码进行测试。以下是测试模块中的一个示例类,它验证输出是否准确:
package packt.util.test;
public class SortUtilTestMain {
public static void main(String[] args) {
SortUtil sortUtil = new SortUtil();
List out = sortUtil.sortList(Arrays.asList("b", "a", "c"));
assert out.size() == 3;
assert "a".equals(out.get(0));
assert "b".equals(out.get(1));
assert "c".equals(out.get(2));
}
}
使用断言启用(-ea参数)编译和运行代码告诉我们测试已经通过:
$ javac -d out --module-source-path src --module
packt.sortutil,packt.sortutil.test
$ java -ea --module-path out:lib --module
packt.sortutil.test/packt.util.test.SortUtilTestMain
您不应该看到任何输出,这表明所有断言都已成功通过。
与 JUnit 集成
虽然使用带有 main 方法的类进行单元测试可以完成任务,但我们还可以做得更好。您通常使用 JUnit 这样的框架在 Java 中编写测试。JUnit 是一个完整的测试框架,它提供了方便的生命周期钩子和注释,您可以使用它们轻松地编写测试。让我们看看如何将我们的测试模块转换为使用 JUnit。
这里是步骤:
-
获取 JUnit JAR 文件。您可以从 JUnit 网站 (
junit.org/junit4/) 下载,或者从 Maven Central 下载。它还依赖于 hamcrest core JAR 文件,所以也要下载它。将 JAR 文件放在lib文件夹中。我们打算将此位置添加到模块路径中。下载的 JAR 文件位于lib文件夹的12-build-tools-and-testing/02-testing/src/packt.sortutil.test。 -
在您的测试代码中使用 JUnit 注解。以下是将
SortUtilTest编写为 JUnit 测试的新代码:
public class SortUtilTest {
private SortUtil sortUtil;
@Before public void setUp() {
sortUtil = new SortUtil();
}
@Test
public void testReturnsSameSize() {
List out = sortUtil.sortList(Arrays.asList("b", "a", "c"));
SortUtil sortUtil = new SortUtil();
assert out.size() == 3;
}
@Test
public void sortsList() {
List out = sortUtil.sortList(Arrays.asList("b", "a", "c"));
assert "a".equals(out.get(0));
assert "b".equals(out.get(1));
assert "c".equals(out.get(2));
}
}
-
指定测试模块依赖于 JUnit 库。由于 JUnit JAR 将被添加到类路径中,它将被视为一个自动模块。因此,为了建立依赖关系,您需要从 JAR 文件名中找出自动模块的名称。下载的 JAR 文件名为
junit-4.12.jar。去掉.jar扩展名和版本号,我们得到自动模块--name - junit。 -
将测试模块声明为
open。JUnit 的工作方式是通过扫描您类上的注解来确定要做什么。因此,它需要访问您测试模块中的测试类。您可以选择导出必要的包或将它们声明为 open。我更喜欢后者,因为我们只需要启用对 JUnit 的反射访问。
这是 packt.sortutil.test 模块的更新模块定义:
open module packt.sortutil.test {
requires packt.sortutil;
requires junit;
}
让我们编译并运行测试以查看行为:
$ javac -d out --module-source-path src --module-path lib --module
packt.sortutil,packt.sortutil.test
这次唯一的更改是将 lib 目录添加为模块路径。这使得 Java 平台将 JUnit JAR 视为一个自动模块,这正是我们所需要的。这应该会成功,没有任何错误。
如果我们现在运行它会发生什么?我们正在运行 JUnit 测试运行器类,所以我们需要在核心 JUnit 运行器类 JUnitCore(在自动模块 junit 中)中将它指定为 --module 参数的值给 Java。接下来是正在测试的类的完全限定名--SortUtilTest。以下是命令的样子:
$ java --module-path out:lib --module junit/org.junit.runner.JUnitCore
packt.util.test.SortUtilTest
这会工作吗?不会!以下是您应该看到的错误:
JUnit version 4.12.E
Time: 0.001
There was 1 failure:
1) initializationError(org.junit.runner.JUnitCommandLineParseResult)
java.lang.IllegalArgumentException: Could not find class [packt.util.test.SortUtilTest]
结果发现 Java 无法找到 SortUtilTest 类。为什么?编译后的模块位于我们传递给 --module-path 选项的 out 目录中!它没有看到这个类是有原因的。
回想一下第八章中关于模块解析的讨论,理解链接和使用 jlink。模块解析是从起点——你在--module参数中指定的模块——开始的依赖模块的遍历。由于这里的起点是 JUnit 自动模块,模块解析过程永远不会解析应用程序或测试模块。这是因为 JUnit 自动模块不会读取我们的模块!要解决这个问题并让运行时看到我们的模块,可以使用--add-modules选项。使用此选项传递我们的测试模块应该会导致执行成功完成:
$ java --module-path out:lib --add-modules packt.sortutil.test --module junit/org.junit.runner.JUnitCore packt.util.test.SortUtilTest
JUnit version 4.12
..
Time: 0.005
OK (2 tests)
注意,我们不需要将packt.sortutil模块添加到--add-modules选项中。只需测试模块就足够了。这是因为测试模块通过 requires 声明明确依赖于packt.sortutil,因此模块解析过程现在会自动获取它!
总结
有了这些,我们就一起结束了对 Java 9 模块化的探索。你现在对 Java 9 模块化有了很好的理解,更重要的是,你了解了如何在代码中使用这个特性和相关概念。这无疑是 Java 语言的一个令人兴奋的新增功能,作为开发者,我们既有能力也有责任明智且有效地使用这些功能。
虽然这本书的内容到此结束,但我希望你能感到兴奋,并且已经准备好继续你的旅程,深入了解并构建令人惊叹的 Java 模块化应用程序。
摘要
在本章中,我们讨论了 Java 编程的两个重要方面,这在大多数现实世界的 Java 应用程序中都发挥着重要作用——构建系统和测试。我们探讨了如何使用 Maven 来构建我们的项目,并将 Maven 的多模块项目概念与 Java 9 模块化应用程序对齐。我们检查了这样的应用程序看起来像什么,并学习了如何通过 Maven 的生命周期过程编译和执行应用程序。然后我们学习了如何将测试集成到 Java 模块化应用程序中。我们探讨了由于 Java 模块化引入到语言中的一些约束导致的测试挑战,以及如何绕过它们。然后我们创建了一个 JUnit 测试用例,并利用 JUnit 框架执行模块测试用例。


或通过菜单文件 | 新建项目,你将看到一个带有 
浙公网安备 33010602011771号