Java 模块简介
Java API 组织成方法、类、包,以及最高级别的模块。模块附带了一些基本信息
- 名称
- 对其他模块的依赖项列表
- 公共 API(其他所有内容都是模块内部的,不可访问)
- 它使用和提供的服务列表
不仅 Java API 附带这些信息,您还可以选择为自己的项目创建模块。通过将您的项目部署为模块,您可以提高可靠性和可维护性,防止意外使用内部 API,并且可以更轻松地创建仅包含您需要的 JDK 代码的运行时映像(可以选择将您自己的应用程序包含在映像中以使其成为独立的)。
在我们开始这些好处之前,我们将探讨如何定义模块及其属性,如何将其转换为可交付的 JAR,以及模块系统如何处理它们。为了简化操作,我们将假设所有内容(JDK 中的代码、库、框架、应用程序)都是模块 - 为什么这不是必需的 以及 如何逐步创建模块 在专门的文章中介绍。
模块声明
每个模块的核心是模块声明,它是一个名为module-info.java
的文件,定义了模块的所有属性。例如,以下是 java.sql
的声明,它是定义 JDBC API 的平台模块
module java.sql {
requires transitive java.logging;
requires transitive java.transaction.xa;
requires transitive java.xml;
exports java.sql;
exports javax.sql;
uses java.sql.Driver;
}
它定义了模块的名称(java.sql)、它对其他模块的依赖项(java.logging
、java.transaction.xa
、java.xml
)、构成其公共 API 的包(java.sql
和 javax.sql
),以及它使用的服务(java.sql.Driver
)。此模块已经采用更精细的形式来定义依赖项,方法是添加关键字transitive
,但当然它并没有使用模块系统的所有功能。一般来说,模块声明具有以下基本形式
module $NAME {
// for each dependency:
requires $MODULE;
// for each API package:
exports $PACKAGE
// for each package intended for reflection:
opens $PACKAGE;
// for each used service:
uses $TYPE;
// for each provided service:
provides $TYPE with $CLASS;
}
(整个模块可以是open
,requires
、exports
和 opens
指令可以进一步细化,但这些是以后要讨论的主题。)
您可以为您的项目创建模块声明。它们推荐的位置 - 所有工具最容易获取它们的位置 - 是项目源代码根目录,即包含您的包目录的目录,通常是src/main/java
。对于库,模块声明可能如下所示
module com.example.lib {
requires java.sql;
requires com.sample.other;
exports com.example.lib;
exports com.example.lib.db;
uses com.example.lib.Service;
}
对于应用程序,它可能类似于以下内容
module com.example.app {
requires com.example.lib;
opens com.example.app.entities;
provides com.example.lib.Service
with com.example.app.MyService;
}
让我们快速回顾一下细节。本节重点介绍模块声明中需要包含的内容
- 模块名称
- 依赖项
- 导出的包
- 使用和提供的服务
其影响将在后面的部分中讨论。
模块名称
模块名称具有与包名称相同的要求和准则
- 合法字母包括
A
-Z
、a
-z
、0
-9
、_
和$
,用.
分隔 - 按照惯例,模块名称全部小写,
$
仅用于机械生成的代码 - 名称应全局唯一
在前面的示例中,JDK 模块的声明以module java.sql
开头,它定义了名为java.sql 的模块。这两个自定义模块分别命名为com.example.lib
和 com.example.app
。
给定一个 JAR,可以从项目的文档中推断出相应的模块名称,查看 JAR 中的module-info.class
文件(稍后详细介绍),借助 IDE,或者通过对 JAR 文件运行jar --describe-module --file $FILE
。
关于模块名称的唯一性,建议与包相同:选择与项目关联的 URL 并将其反转以生成模块名称的第一部分,然后从那里进行细化。(这意味着这两个示例模块与域 example.com 相关联。)如果您将此过程应用于模块名称和包名称,则前者通常是后者的前缀,因为模块比包更通用。这绝不是必需的,但它表明名称选择得很好。
要求依赖项
requires
指令按模块名称列出所有直接依赖项。看看上面三个模块中的那些
// from java.sql, but without `transitive`
requires java.logging;
requires java.transaction.xa;
requires java.xml;
// from com.example.lib
requires java.sql;
requires com.sample.other;
// from com.example.app
requires com.example.lib;
我们可以看到,应用程序模块com.example.app 依赖于库com.example.lib,而库com.example.lib 又需要无关的模块com.sample.other 和平台模块java.sql。我们没有com.sample.other 的声明,但我们知道java.sql 依赖于java.logging、java.transaction.xa 和java.xml。如果我们查找它们,我们会发现它们没有进一步的依赖项。(或者更确切地说,没有显式依赖项 - 请查看下面有关基本模块的部分以了解更多详细信息。)
还可以处理 可选依赖项(使用requires static
)和 “转发”依赖项,这些依赖项是模块 API 的一部分(使用requires transitive
),但这将在单独的文章中介绍。
外部依赖项列表很可能与构建配置中列出的依赖项非常相似。这通常会导致一个问题,即这是否冗余,是否应该自动生成。它不是冗余的,因为模块名称不包含构建工具获取 JAR 所需的任何版本或其他信息(如组 ID 和工件 ID),而构建配置列出了这些信息,但没有列出模块的名称。由于给定 JAR 可以推断出模块名称,因此可以生成module-info.java
的这一部分。不过,目前尚不清楚这是否值得付出努力,尤其是考虑到平台模块依赖项、static
和 transitive
修饰符的复杂性,以及 IDE 已经在需要时建议添加requires
指令(类似于包导入)这一事实,这使得更新模块声明变得非常简单。
导出和打开包
默认情况下,所有类型,即使是public
类型,也只在模块内部可访问。要让模块外部的代码访问类型,包含该类型的包需要导出或打开。这是通过使用exports
和 opens
指令来实现的,这些指令包括模块包含的包的名称。导出确切的效果将在下面关于强封装的部分中讨论,打开将在 关于反射的文章 中讨论,但要点是
- 导出的包中的公共类型和成员在编译时和运行时可用
- 开放包中的所有类型和成员可以在运行时通过反射访问
以下是三个示例模块中的exports
和 opens
指令
// from module java.sql
exports java.sql;
exports javax.sql;
// from com.example.lib
exports com.example.lib;
exports com.example.lib.db;
// from com.example.app
opens com.example.app.entities;
这表明java.sql 导出了与它同名的包以及javax.sql
- 该模块当然包含更多包,但它们不是其 API 的一部分,与我们无关。库模块导出两个包供其他模块使用 - 同样,所有其他(潜在的)包都被安全地锁定了。应用程序模块没有导出任何包,这并不罕见,因为启动应用程序的模块很少是其他模块的依赖项,因此没有人调用它。不过,它确实为反射打开了com.example.app.entities
- 从名称判断,可能是因为它包含其他模块希望通过反射与之交互的实体(想想 JPA)。
exports
和 opens
指令还有 限定变体,允许您仅向特定模块导出/打开包。
作为经验法则,尽量减少导出的包数量 - 就像保持字段私有、仅在需要时使方法包可见或公共,以及默认情况下使类包可见,仅在另一个包中需要时才将其设为公共。这减少了在其他地方可见的代码量,从而降低了复杂性。
使用和提供服务
服务是它们自己的主题 - 现在,只需说您可以使用它们将 API 的用户与其实现分离,从而更容易在启动应用程序时替换它。如果模块使用类型(接口或类)作为服务,则需要在模块声明中使用uses
指令说明这一点,该指令包括完全限定的类型名称。提供服务的模块在其模块声明中表达它们自己的哪些类型执行此操作(通常是通过实现或扩展它)。
库和应用程序示例模块显示了这两方面
// in com.example.lib
uses com.example.lib.Service;
// in module com.example.app
provides com.example.lib.Service
with com.example.app.MyService;
lib 模块使用Service
(它自己的类型之一)作为服务,而应用程序模块(依赖于 lib 模块)使用MyService
提供它。在运行时,lib 模块将通过使用ServiceLoader
API 调用ServiceLoader.load(Service.class)
来访问实现/扩展服务类型的所有类。这意味着库模块执行了应用程序模块中定义的行为,即使它不依赖于它 - 这对于解开依赖项并使模块专注于其关注点非常有用。
构建和启动模块
模块声明module-info.java
是一个源代码文件,与其他文件一样,因此在它在 JVM 中运行之前需要执行几个步骤。幸运的是,这些步骤与您的源代码所采取的步骤完全相同,大多数构建工具和 IDE 都足够了解这一点,可以适应它的存在。您很可能不需要手动执行任何操作来构建和启动模块化代码库。当然,了解这些细节很有价值,因此 专门的文章 将带您从源代码到仅使用命令行工具运行的 JVM。
在这里,我们将停留在更高层次的抽象上,而是讨论一些在构建和运行模块化代码中起重要作用的概念
- 模块化 JAR
- 模块路径
- 模块解析和模块图
- 基本模块
模块化 JAR
module-info.java
文件(也称为模块声明)被编译为module-info.class
(称为模块描述符),然后可以将其放置在 JAR 的根目录中,或者如果它是 多版本 JAR,则放置在特定于版本的目录中。包含模块描述符的 JAR 称为模块化 JAR,可以作为模块使用 - 不包含描述符的 JAR 称为普通 JAR。如果将模块 JAR 放置在模块路径上(见下文),它将在运行时成为模块,但它仍然可以在类路径上使用,在那里它与类路径上的普通 JAR 一样成为 未命名模块 的一部分。
模块路径
模块路径是一个新的概念,与类路径类似:它是一个包含工件(JAR 或字节码文件夹)和目录的工件列表。模块系统使用它来定位运行时中找不到的必需模块,因此通常包括所有应用程序、库和框架模块。它将模块路径上的所有工件都转换为模块,即使是普通的 JAR,也会被转换为自动模块,这使得增量模块化成为可能。javac
和 java
以及其他与模块相关的命令都理解并处理模块路径。
旁注:本节和上一节共同揭示了模块系统的一个可能令人惊讶的行为:JAR 是否是模块化的并不决定它是否被视为模块!类路径上的所有 JAR 都被视为单个几乎不是模块的模块,模块路径上的所有 JAR 都被转换为模块。这意味着项目负责人可以决定哪些依赖项最终成为独立的模块,哪些不成为独立的模块(而不是依赖项的维护者)。
模块解析和模块图
要启动一个模块化应用程序,请使用模块路径和一个所谓的初始模块运行 java
命令 - 包含 main
方法的模块
# modules are in `app-jars` | initial module is `com.example.app`
java --module-path app-jars --module com.example.app
这将启动一个名为模块解析的过程:从初始模块的名称开始,模块系统将在模块路径中搜索它。如果找到它,它将检查它的 requires
指令以查看它需要哪些模块,然后对它们重复此过程。如果它找不到一个模块,它将立即抛出一个错误,让你知道缺少一个依赖项。你可以通过添加命令行选项 --show-module-resolution
来观察此过程。
此过程的结果是模块图。它的节点是模块,它的边更复杂一些:每个 requires
指令都会在两个模块之间产生一条边,称为可读性边,其中请求模块读取请求的模块。还有其他创建边的方法,但现在我们不必担心,因为它不会改变任何基本的东西。
如果我们想象一个普通的 Java 程序,例如一个 Web 应用程序的后端,我们可以想象它的模块图:在顶部我们将找到初始模块,再往下是其他应用程序模块以及它们使用的框架和库。然后是它们的依赖项,在某个时刻,JDK 模块将以java.base位于底部 - 继续阅读以了解有关它的详细信息。
基础模块
有一个模块来统治它们:java.base,即所谓的基础模块。它包含 Class
和 ClassLoader
等类,java.lang
和 java.util
等包,以及整个模块系统。没有它,JVM 上的程序就无法运行,因此它获得了特殊状态
- 模块系统专门了解它
- 无需在模块声明中放置
requires java.base
- 对基础模块的依赖关系是免费提供的
因此,当上一节讨论了各个模块的依赖关系时,这并不完全完整。它们也都隐式地依赖于基础模块 - 因为它们必须这样做。当上一节说模块系统从模块解析开始时,这也不是 100% 正确的。首先发生的事情是,在一个深刻的鸡和蛋的难题中,模块系统解析基础模块并引导自身。
模块系统优势
那么,为你的项目创建模块声明会给你带来什么好处呢?以下是三个最突出的优势
- 强大的封装
- 可靠的配置
- 可扩展的平台
强大的封装
没有模块,每个公共类或成员都可以被任何其他类自由使用 - 无法使某些内容在 JAR 内可见,但不能超出其边界。即使是非公共可见性也不是真正的威慑,因为总有反射可以用来侵入私有 API。原因是 JAR 没有边界,它们只是类加载器从其加载类的容器。
模块不同,它们确实有一个编译器和运行时识别的边界。来自模块的类型只有在以下情况下才能使用
- 该类型是公共的(如前所述)
- 该包已导出
- 使用该类型的模块读取包含该类型的模块
这意味着模块的创建者对构成公共 API 的类型有了更多的控制。它不再是所有公共类型,而是导出包中的所有公共类型,这最终允许我们锁定包含不应在子项目之外使用的公共类型的功能。
这对于 JDK API 本身来说显然至关重要,其开发人员不再需要恳求我们不要使用 sun.*
或 com.sun.*
等包(有关这些内部 API 发生了什么以及为什么你仍然可以使用 sun.misc.Unsafe
的更多信息,请参阅有关 JDK 内部强封装的这篇文章)。JDK 也无需再依赖安全管理器的手动方法来阻止访问安全敏感类型和方法,从而消除了整类潜在的安全风险。库和框架也可以从清晰地传达和强制执行哪些 API 应该公开且(可能)稳定,以及哪些是内部 API 中受益。
无论大小,应用程序代码库都可以确保不会意外使用其依赖项的内部 API,这些 API 可能会在任何补丁版本中更改。更大的代码库可以进一步从创建具有强大边界的多个模块中受益。这样,实现某个功能的开发人员可以清楚地向他们的同事传达哪些部分的添加代码用于应用程序的其他部分,哪些只是内部脚手架 - 不再意外使用“从未打算用于该用例”的 API。
尽管如此,如果你绝对必须使用 JDK 或其他模块的内部 API,你仍然可以使用这两个命令行标志,假设你控制着应用程序的启动命令。
可靠的配置
在模块解析期间,模块系统会检查所有必需的依赖项(直接和传递)是否存在,并在缺少某些内容时报告错误。但它不仅仅是检查存在性。
必须没有歧义,即没有两个工件可以声称它们是同一个模块。这在两个相同模块的版本存在的情况下尤其有趣。因为模块系统没有版本的概念(除了将它们记录为字符串之外),它将此视为重复模块。因此,如果遇到这种情况,它会报告错误。
模块之间不能有静态依赖循环。在运行时,模块之间相互访问是可能的,甚至必要的(想想使用 Spring 注解和 Spring 反射该代码的代码),但这些不能是编译依赖项(Spring 显然不是针对它反射的代码进行编译的)。
包应该具有唯一的来源,因此没有两个模块可以包含相同包中的类型。如果它们这样做,这被称为拆分包,模块系统将拒绝编译或启动此类配置。
当然,这种验证并不严密,问题可能隐藏很长时间,直到运行的应用程序崩溃。例如,如果错误版本的模块最终出现在正确的位置,应用程序将启动(所有必需的模块都存在),但稍后会崩溃,例如,当某个类或方法丢失时。但是,它确实在早期检测到许多常见问题,从而降低了已启动的应用程序因依赖项问题而在运行时失败的可能性。
可扩展的平台
随着 JDK 被拆分为从 XML 处理到 JDBC API 的各个模块,现在终于可以手工制作一个运行时映像,其中只包含你需要的 JDK 功能,并将它与你的应用程序一起发布。如果你的代码库完全模块化,你可以更进一步,将你的模块包含在该映像中,使其成为一个自包含的应用程序映像,它包含它需要的一切,从你的代码到依赖项,再到 JDK API 和 JVM。这篇文章解释了如何做到这一点。
进一步阅读
你现在对模块系统的运作和优势有了基本的了解,并准备探索更多主题以加深你的知识。以下文章不必按顺序阅读 - 每篇文章都在开头提到了你应该在阅读之前阅读的其他文章。
更复杂的依赖项和 API 管理
- 使用开放模块和开放包进行反射访问
- 使用
requires static
的可选依赖项 - 使用
requires transitive
的隐式可读性 - 限定的
exports
和opens
- 使用服务解耦模块
从 JAR 到模块再到映像
更深入的模块系统知识
更多学习
上次更新: 2021 年 9 月 14 日