写点什么

从头学 Java17-Modules 模块

作者:烧霞
  • 2023-07-03
    广西
  • 本文字数:30274 字

    阅读完需:约 99 分钟

模块 Modules

了解 module 系统如何塑造 JDK,如何使用,使项目更易于维护。

烧哥注

从头讲 JDK17 的文章比较少,英文为主,老外虽能讲清原理,但写的比较绕,所以决定翻译一下,也有个别细节完善。

原文关注点主要在 java 生态,以及类库的维护者如何过渡到 module,对新用户也同样适用。


module 简介

了解 module 系统基础知识,如何创建和构建 module,如何提高可维护性和封装性。


Java API 的作用范围分为 methods、classes、packages 和 modules(最高)。 module 包含许多基本信息:


  • 名字

  • 对其他 module 的依赖关系

  • 开放的 API(其他都是 module 内部的,无法访问)

  • 使用和提供的 service


不仅 Java API ,也可以是你自己项目,提供这些信息都能生成 module。 项目部署为 module,可以提高可靠性和可维护性,防止内部 API 的意外使用,并且可以更轻松地创建运行映像,里面仅包含必需的 JDK 代码。甚至可以把应用程序打包为独立映像。


在讨论这些优点之前,我们将探讨如何定义 module 及其属性,如何将其转换为可交付 JAR,以及 module 系统如何处理它们。 为了简化讨论,我们将假设所有内容(JDK 中的代码、库、框架、应用程序)都是一个 module。

module 声明

module 声明是每个 module 的核心,一个module-info.java,定义 module 所有属性。 下面是 java.sql 的 module 声明,定义了 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;}
复制代码


包含了 module 的名称(java.sql),对其他 module(java.loggingjava.transaction.xajava.xml)的依赖关系,开放的 API 包(java.sqljavax.sql),以及它使用的 service(java.sql.Driver)。 这里还有个transitive,可以先跳过。 一般来说,module 声明具有以下基本形式:


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;}
复制代码


可以为自己项目创建 module 声明,module-info.java通常放在源码根目录,比如src/main/java。 对于库,lib module 声明可能如下:


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;}
复制代码


对于应用程序,app module 可能是这样:


module com.example.app {    requires com.example.lib;
opens com.example.app.entities;
provides com.example.lib.Service with com.example.app.MyService;}
复制代码


让我们快速浏览一下细节。 本节重点为 module 声明的内容:


  • module 名称

  • 依赖

  • 导出的包

  • 使用和提供的 service

module 名称

module 名称的要求和准则跟包一样:


  • 合法字符包括 A-Z, a-z, 0-9, _,和 $,用 .分隔

  • 按照惯例,都用小写,$一般不用

  • 应全局唯一


一个 JAR 的 module 信息一般项目文档里都会描述,也可以查看module-info.class,使用jar --describe-module --file $FILE


关于 module 的名称唯一,建议类似包名,可以采用翻转域名。

依赖关系

requires指令。 看看上面三个 module 中的那些:


// from java.sql, but without `transitive`requires java.logging;requires java.transaction.xa;requires java.xml;
// from com.example.librequires java.sql;requires com.sample.other;
// from com.example.apprequires com.example.lib;
复制代码


我们可以看到,app module com.example.app 依赖于 lib module com.example.lib,而 lib module 接着依赖了不相关的 com.sample.other 和平台 module java.sql。 虽然不懂com.sample.other,但我们知道java.sql依赖于java.loggingjava.transaction.xajava.xml。 继续的话并没有其他依赖。 (确切地说,没有显式依赖)


后面章节可以了解到可选依赖和隐式依赖。


外部依赖列表跟构建配置(如 Maven)列出的依赖项非常相似,不过并不多余,因为 module 名称不包含 Maven 获取 JAR 所需的版本或任何其他信息(如 group ID 和 artifact ID),而 Maven 列出了这些信息,却不需要 module 的名称。属于两种不同角度。

导出和 open Packages

默认,所有类型(甚至是public)只能在 module 内部访问。 外部要想访问,需要exportsopens包含该类型的包。 要点是:


  • exports包的 public 类型和成员在编译和运行时可用

  • opens包的所有类型和成员都可以在运行时通过反射访问


以下是三个示例 module 中的指令:


// from module java.sqlexports java.sql;exports javax.sql;
// from com.example.libexports com.example.lib;exports com.example.lib.db;
// from com.example.appopens com.example.app.entities;
复制代码


这表明 java.sql 导出了同名的包,以及javax.sql - 该 module 当然包含更多的包,但它们不是其 API 的一部分,与我们无关。 lib module 导出两个包供其他 module 使用 - 同样,所有其他(潜在)包都被安全地锁定。 app module 不导出任何包,这种情况并不少见,因为启动应用程序的 module 很少是其他 module 的依赖项,没有人调用它。 不过,com.example.app.entities确实可以进行反射 - 从名称来看可能是因为它包含其他 module 通过反射与之交互的实体(想想 JPA)。


根据经验,exports 的包要尽可能少 - 就像保持字段 private,需要时才让方法 package 可见或 public 可见。让类默认 package 可见,在另一个包需要时才 public 可见。 这减少了随处可见的代码量,降低了复杂性。

使用和提供 Services

可以将 service API 的使用者与其实现分离,从而在启动应用程序之前更容易的替换它。 如果 module 使用某种类型(接口或类)作为 service,需要uses指令,后跟完全限定的类型名称。 提供 service 的 module 也要表名自己的类型(通常通过实现或扩展)。


lib 和 app 示例 module 显示了两个方面:


// in com.example.libuses com.example.lib.Service;
// in module com.example.appprovides com.example.lib.Service with com.example.app.MyService;
复制代码


lib module 使用Service ,它自己的类型之一,作为 service,而具体实现由 app module 使用MyService提供(依赖于 lib module) 。在运行时,lib module 将使用ServiceLoader的 API ServiceLoader.load(Service.class)来访问所有实现/扩展类。 这意味着 lib module 的具体执行,是在 app module 中定义,但并不依赖它 - 这有利于解除依赖关系,使 module 更加专注自己的业务。

构建和启动 modules

module 声明module-info.java也是一个源文件,因此在 JVM 中运行之前需要几个步骤。 幸运的是,这些步骤与普通源代码所执行的步骤完全相同,大多数构建工具和 IDE 都非常了解这些步骤,足以适应它的存在。 很可能,您无需任何手动操作即可构建和启动模块化项目。 当然,了解细节还是有价值的。


这里,我们将站在更高的抽象层面,讨论一些在构建和运行模块化代码中发挥重要作用的概念:


  • 模块化 JARs

  • module path

  • module 解析和 module 图

  • base module

模块化 JARs

module-info.java文件(又名 module 声明)被编译为module-info.class(称为 module 描述符),放入 JAR 的根目录。 包含 module 描述符的 JAR 称为模块化 JAR,可以用作 module ,而普通的没有描述符的 JARs 则是纯 JARs。 如果 module JAR 放置在 module path 上(见下文),它在运行时会成为 module,不过也可以在 class path 上,成为 unamed module 的一部分,就像 class path 上的普通 JAR 。

module path

module path 是一个与 class path 平行的新概念: 包含制品(JAR 或字节码文件夹)和制品目录。 module 系统在程序运行中找不到所需 module 时,用它来查找,通常是应用、库和框架 module。 它将 module path 上的所有制品,甚至是普通 JARs,转换成自动 module,实现渐进模块化。 javacjava以及其他与 module 相关的命令都能理解和正确处理 module path。


**旁注:**JAR 是否模块化并不能决定它是否被视为 module! class path 上的 JAR 都被视为 unamed module,module path 上的 JAR 都转换为 module。 这意味着项目的负责人可以决定哪些依赖项最终成为单独的 module(与依赖项的维护者相反)。

module 解析和 module 图

要启动模块化应用程序,使用java命令,并提供 module path 和所谓的初始 module(包含 main 方法的 module):


# modules are in `app-jars` | initial module is `com.example.app`java --module-path app-jars --module com.example.app
复制代码


这将启动一个称为 module 解析的过程: 从初始 module 开始,module 系统将在 module path 中搜索。 如果找到,将检查requires指令以查看它需要哪些 module,然后重复该过程。 如果找不到 module,抛出错误,让你知道缺少依赖项。 您可以通过添加--show-module-resolution来观察此过程。


此过程的产出是 module 图。 节点是 module,根据每个requires指令在两个 module 之间生成一个可读边,表示向量方向。


想象一个普通的 Java 程序,例如 Web 应用程序的后端,我们可以画出它的 module 图: 在顶部,我们将找到初始 module,再往下找到其他应用程序 module 以及它们使用的框架和库。 然后是它们的依赖关系,可能是 JDK module,最底部是 java.base

base module

有一个 module 支配了这一切:java.base,即所谓的 base module。 它包含像ClassClassLoader的类,像java.langjava.util的包,以及整个 module 系统。 没有它,JVM 上的程序将无法运行,因此它获得了特殊状态:


  • module 系统特别了解它

  • module 声明中不需要requires java.base - 对 base module 的依赖是免费的


因此,前面讨论的各种 module 的依赖关系,并不完全完整。 它们都隐式依赖于 base module - 它们必须这样做。 上一节说 module 系统从 module 解析开始时,也不是 100% 正确的。 首先发生的是,module 系统解析了 base module 并自行引导。

module 系统优势

那么,为项目创建 module 声明那么麻烦,能得到什么呢? 以下是三个最突出的好处:


  • 强封装

  • 可靠的配置

  • 可扩展的平台

强封装

如果没有 module,每个 public 类或成员都可以自由地被其他类使用——无法控制某些内容只在 JAR 中可见而不越界。 甚至非 public 可见性也不是真正的威慑力,因为总能用反射来访问私有 API。归根于 JAR 本身并没有界限,它们只是类加载器从中加载类的容器。


module 是不同的,它们确实具有编译和运行时能够识别的界限。 只有以下情况才能使用 module 中的类型:


  • 该类型是 public 的(和以前一样)

  • exports这个包

  • 调用的 module 声明了requires此 module


这意味着 module 的创建者可以更好地控制哪些类型将构成 public API。 不再是所有,现在是导出包中的所有 public 类型


这对于 JDK API 本身显然至关重要,其开发人员不必再恳求我们不要使用类似 sun.*com.sun.* 的包。 JDK 也不必再依赖安全管理器的手动方法来防止访问安全敏感的类型和方法,从而消除了一整大类潜在的安全隐患。定义好清晰的外部交互,并强制说明哪些 API 是 public 的和(大概的)稳定的,库和框架也能从中受益。


应用程序项目可以确保不会意外使用依赖项中那些可能在新版本更改的内部 API。 较大的项目可以进一步受益于创建具有强界限的多个 module。 这样,实现功能的开发人员可以清楚地与同事沟通,哪些添加的代码用于哪个部分,而哪些只是内部脚手架 - 不再有 API 的意外使用。

可靠的配置

在 module 解析期间,module 系统会检查是否存在所有必需的依赖项(直接依赖项和传递依赖项),并在缺少某些依赖项时报告错误。 但它不仅仅是检查存在。


不能有歧义,即没有两个制品可以声称它们是同一个 module。 在存在同一 module 的两个版本时,这尤其有趣。 由于 module 系统没有版本的概念(除了将它们记录为字符串之外),因此它将其视为重复 module。 因此,遇到这种情况它会报错。


module 之间不得有静态依赖循环。 在运行时,module 相互访问是可能的,甚至是必要的(想想使用 Spring 注释和 Spring 反射的代码),但这些不能是编译依赖项。


包应具有唯一的源,因此没有两个 module 可以包含同一包中的类型。 如果他们这样做,这被称为拆分包,module 系统将拒绝编译或启动此类配置。


当然,这种验证不是绝对严谨,问题可能会隐藏很久才使运行中的应用程序崩溃。 例如,如果错误版本的 module 的确有,则应用程序将启动(所有必需的 module 都存在),但稍后会崩溃,例如,当缺少类或方法时。 不过,它确实会尽早检测到许多常见问题,从而降低了启动的应用程序由于依赖关系问题而在运行时失败的可能性。

可扩展的平台

通过将 JDK 拆分为从 XML 处理到 JDBC API 的所有 module,最终可以手工制作一个仅包含您需要的 JDK 功能的运行映像 runtime image,并将其与您的应用程序一起发布。 如果您的项目是完全模块化的,则可以更进一步,将 module 打包到该映像中,使其成为一个独立的应用程序映像 application image,其中包含它所需的一切,从代码到依赖项,再到 JDK API 和 JVM。用户不需要 JDK 也能运行。

用反射访问 open module 和 open packages

使用 open packages 和 open module,以允许反射访问封装包。


module 系统的强大封装也作用于反射,反射已经失去了闯入内部 API 的“超能力”。 当然,反射是 Java 生态系统的重要组成部分,因此 module 系统具有支持反射的特定指令。 它允许 open packages,这使它们在编译时无法访问,但允许在运行时进行深度反射,并 open 了整个 module。

为什么导出的包不能用于反射

使类型在 module 外部可访问的主要机制是使用 module 声明中的exports导出包含它们的包。 这不能用于反射,原因有两个:


  1. 导出包会使其成为模块 public API 的一部分。 这邀请其他 module 使用它包含的类型,并表示一定程度的稳定性。 这通常不适合处理 HTTP 请求或与数据库交互的类。

  2. 一个更技术性的问题是,即使在导出的包中,也只能访问 public 类型和成员。 但是依赖于反射的框架通常会访问非 public 类型、构造、访问器或字段,这样会失败。


open packages(和 module)专门设计用于解决这两点。

open packages 进行反射

opens指令添加到 module 声明:


module com.example.app {    opens com.example.entities;}
复制代码


在编译时,包被完全封装,就好像指令不存在一样。 这意味着 module 外部调用com.example.entities将无法编译。


另一方面,在运行时,包的类型可用于反射,包括 public 或非 public 成员(像通常对非 public 成员一样AccessibleObject.setAccessible()。


正如您可能知道的那样,opens是专门为反射设计的,其行为与exports不同:


  • 允许访问所有成员,不会影响您有关可见性的决定

  • 防止针对 open 的包中的代码进行编译,并且只允许在运行时访问

  • 跟基于反射的框架进行沟通


如有必要,package 可以同时 open 和 export。

open module

如果你有一个大 module,其中包含许多需要暴露在反射下的包,你可能会发现单独 open 每个包很烦人。 虽然没有像opens com.example.* 这样的通配符,但存在接近它的东西。 通过在 module 声明中将open放在module前面,将创建一个 open module


open module com.example.entities {    // ...}
复制代码


要注意这里是**open**,不是opens


open module 会 open 它包含的所有包,就好像每个包都在指令中单独使用一样。 因此,手动 open 更多包是没有意义的,再添加opens指令会导致编译错误。

可选的依赖项requires static

用于可选依赖项 - 以这种方式requires的模块在编译时可访问,但运行时可以不存在。


module 系统的依赖关系默认为强依赖,如果被requires(可访问),需要在编译和运行时都存在。 但可选依赖不是, requires static在编译时要求存在,但运行时容忍不存在来解决此问题。

可选依赖项requires static

当一个 module 需要另一个 module 的类型进行编译,但不想在运行时依赖它时,它可以使用requires static。 如果 module A requires static module B,则 module 系统在编译和运行时的行为不同:


  • 在编译时,B 必须存在(否则会出现错误),并且 B 可由 A 读取。 (这是依赖项的常见行为。

  • 在运行时,B 可能不存在,这既不会导致错误也不会导致警告。 如果存在,则 A 可读取。


JDK 中,没有依赖是可选的,所以我们必须提出自己的依赖。


让我们想象一个应用程序,它能很好地解决了其业务案例,但存在额外专有库的情况下可以做得更好。 比如应用 com.example.app* 和库 com.sample.solver。如果代码这样声明:


module com.example.app {    requires com.sample.solver;//wrong}
复制代码


但是正如前面所说,这意味着如果 com.sample.solver 不存在,module 系统将在运行时抛出错误 - 显然依赖项不是可选的。 让我们改用:requires static


module com.example.app {    requires static com.sample.solver;}
复制代码


对于 com.example.app 的编译,com.sample.solver 是必需的,并且必须存在。 但运行时,可以忽略,这会导致我们接下来要回答的两个问题:


  • 在什么情况下会出现可选依赖项?

  • 我们如何针对可选依赖项进行编码?

可选依赖项的解析

module 解析是从 root module 开始,通过解析requires指令构建 module 图的过程。 解析 module 时,必须在运行时或 module path 中找到它所需的所有 module,如果是,则将它们添加到 module 图中;否则会发生错误。 (请注意,在解析期间未进入 module 图的 module 在以后的编译或执行期间也不可用。 在编译时,module 解析处理可选依赖项,就像常规依赖项一样。 但是,在运行时,它们大多被忽略。


当 module 系统遇到requires static指令时,它不会尝试执行它,这意味着它甚至不会检查是否可以找到引用的 module。 因此,即使 module 存在于 module path 上(或就此而言在 JDK 中),也不会添加到 module 图中。 只有用--add-modules显式添加时,它才会进入图。 这种情况下,module 系统将根据可选依赖添加边。


换句话说,除非它以其他方式进入 module 图,否则将忽略可选依赖项,在这种情况下,生成的 module 图与非可选依赖项相同。

针对可选依赖项进行编码

针对可选依赖项编写代码时需要多考虑一下。 一般来说,当当前正在执行的代码引用某个类型时,Java 运行时会检查该类型是否已加载。 如果没有,它会告诉类加载器这样做,如果失败,结果是NoClassDefFoundError ,这通常会使应用程序崩溃或至少在正在执行的逻辑块中失败。


这是著名的 JAR hell,module 系统希望在启动应用程序时检查声明的依赖项来克服这一点。 但是,requires static将退出该检查,这意味着我们最终可能会得到一个NoClassDefFoundError

检查 module 是否存在

为了避免这种情况,我们可以查询 module 系统是否存在 module:


public class ModuleUtils {
public static boolean isModulePresent(Object caller, String moduleName) { return caller.getClass() .getModule() .getLayer() .findModule(moduleName) .isPresent(); }
}
复制代码


调用方需要将自身传递给方法。

已建立的依赖项

但是,可能并不总是需要显式检查 module 的存在。 想象一个库 com.example.lib,它可以帮助使用各种现有 API,其中包括 java.sql 中的 JDBC API。 然后,可以假定不使用 JDBC 的代码自然也不使用库的那个部分。 换句话说,我们可以假设库的 JDBC 部分只给已经使用 JDBC 的代码调用,这意味着 java.sql 肯定已经是 module 图的一部分。


一般来说,如果使用了可选依赖项,而调用它的代码也已经知道,则可以假定它的存在,不需要检查。

隐式读取requires transitive

用于表示隐式可读,一个模块的依赖项传递给了依赖它的另一个模块,允许读取而不需要显式声明。


module 系统对访问其他 module 中的代码有严格规定,其中之一是访问 module 必须能读取被访问的 module。 建立读取关系的最常见方法是让一个 modulerequires另一个 module。 如果一个 module 使用来自另一个 module 的类型,那使用第一个 module 的每个 module 也只能被迫requires第二个 module。那得多麻烦。 其实可以让第一个 module 声明requires transitive于第二个 module,这意味着第二个 module 对于读取第一个 module 的任何 module 都能读取。 这有点困惑,但你会在几分钟内理解它。

隐式读取

常见情况下,module 的依赖项只在内部使用,外界对此无需感知。 以java.prefs为例,modulerequiresjava.xml: 它需要 XML 解析功能,但它自己的 API 既不接受也不返回 java.xml 包中的类型。


但还有另一种情况,依赖项并不完全是内部的,而是存在于 module 之间的界限上。 这种情况下,一个 module 依赖于另一个 module,并在其自己的 public API 中引用了另一个 module 中的类型。 一个很好的例子是java.sql。 它也使用 java.xml 但与 java.prefs 不同的是,不仅在内部 - public 类java.sql.SQLXML映射了 SQL XML 类型,因此使用了来自 java.xml 的 API。 类似地,java.sqlDriver有一个方法 getParentLogger() 返回一个Logger ,这是来自 java.logging module 的类型。


这种情况下,想要调用 module 的代码(例如 java.sql)可能必须使用依赖 module(例如 java.xmljava.logging)中的类型。 但是,如果它不能读取依赖的 module,则无法执行此操作。 这样的话,为了使 module 完全可用,客户端也必须显式依赖第二个 module。 识别和手动解决此类隐藏的依赖项将是一项繁琐且容易出错的任务。


这就是隐式读取的用武之地。 它扩展了 module 声明,以便一个 module 可以向依赖于它的任何 module 授予它所依赖的 module 的读取权限。 这种隐式的读取是通过在 require 子句中添加transitive来表示的。


这就是为什么 java.sql 的 module 声明如下所示:


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 的 module(requires)也将自动读取 java.logging*,java.transaction.xa java.xml

何时使用隐式读取

module 系统对何时使用隐式读取有个明确建议:


通常,如果一个 module 导出的包,里面有个类型的签名引用了第二个 module 的包,则第一个 module 应该声明requires transitive。 这将确保依赖第一个 module 的其他 module 能够自动读取第二个 module。


但要一直这么嵌套下去吗? 回顾 java*.sql*的例子,使用它的 module 是否必须requiresjava.logging? 从技术上讲,不需要,而且似乎多余。


要回答这个问题,我们必须看看那个模块究竟如何使用 java.logging。 它可能只需要读取它,然后调用Driver.getParentLogger() ,例如更改 logger 的日志级别,仅此而已。 在这种情况下,您与 java.logging 的交互发生在它与 java.sql Driver交互附近。 就是上面所说的两个模块之间的边界。


另一种,您的模块实际上可能会在代码中使用日志记录。 然后,来自 java.logging 的类型出现在许多独立于Driver的地方,不再局限于您的模块和 java.sql 的边界。


建议:如果第一个 module 声明requires transitive第二个 module 的包,但调用的 module 只在边界使用第二个包的类型,那不需要做什么。否则,即使不是严格需要,也应该显式声明依赖。 这样能清晰阐明系统结构,并为未来的重构提供保证。

限定的exportsopens

将导出或打开的包的可访问性限制为特定模块。


module 系统允许 export/open packages,使其可供外部代码访问,每个读取它的 module 都可以访问这些包中的类型。 这意味着这个包,我们要么强封装,让别人都不能,要么让所有人都能随时访问它。 为了处理第三种情况,module 系统为exportsopens 提供了限定变体,仅授予特定 module 访问。

限定的 export/open packages

exports指令可以后跟to $MODULES限定,其中 $MODULES是目标 module 名称的逗号分隔列表。 opens指令也是同样。


JDK 本身中有很多限定导出的例子,但我们来看 java.xml,它定义了用于 XML 处理的 Java API(JAXP)。 它的六个内部包,前缀为com.sun.org.apache.xml.internalcom.sun.org.apache.xpath.internal,只为 java*.xml.crypto*(XML 加密的 API)使用,因此只导出给它:


module java.xml {    // lots of regular exports
exports com.sun.org.apache.xml.internal.dtm to java.xml.crypto; exports com.sun.org.apache.xml.internal.utils to java.xml.crypto; exports com.sun.org.apache.xpath.internal to java.xml.crypto; exports com.sun.org.apache.xpath.internal.compiler to java.xml.crypto; exports com.sun.org.apache.xpath.internal.functions to java.xml.crypto; exports com.sun.org.apache.xpath.internal.objects to java.xml.crypto; exports com.sun.org.apache.xpath.internal.res to java.xml.crypto;
// lots of services usages}
复制代码


关于编译的两个小说明:


  • 如果声明了限定 export/open ,但编译时找不到目标 module,编译器会警告,但不是错误,因为目标 module 并不是必需的。

  • 不允许一个包同时存在于在exportsexports to ,或者 opensopens to 中,会导致编译报错。


有两个细节:


  • 目标 module 可以依赖于所属 module(实际上 java.xml.crypto 依赖于 java.xml),从而创建一个循环。 考虑到这一点,只能使用隐式读取,实际上也必须如此

  • 每当新 module 需要访问限定导出的包时,都需要更改那个 module,以便它提供对这个新 module 的访问权限。 虽然让那个 module 控制谁可以访问是限定导出的意义所在,但可能很麻烦。

何时使用限定 export

如前所述,限定导出的使用场景是控制哪些 module 可以访问相关包。 多久适用一次? 一般来说,每当一组 module 想要在不 public 情况下共享功能时。


在 Java 9 之前, 某个工具类要想跨包可用,它必须是 public 的,这意味着所有其他代码都可以访问它。 现在强封装能解决这个问题,允许在 module 外部无法访问这个 public 类。


同样,我们想要隐藏一个包(以前是一个类),但一旦它想跨 module(包)可用,它就必须被导出(public),来被所有其他 module(所有其他类)访问。 限定 export 则开始发挥作用。 它们允许 module 之间共享包,而不会普遍可用。 对于由多个 module 组成的库和框架这非常有用,能够在外人无法使用的情况下共享代码。 对于想要限制对特定 API 依赖关系的大型应用程序,它也将派上用场。


限定的导出可以看作是将强封装从保护 module 中的类型提升到保护 module 集。

何时使用限定 open

限定 open 的目标 module 通常是框架,使用场景要小得多。


限定 open 的一个缺点是,在规范和实现分开的情况下(例如,JPA 和 Hibernate),您可能必须 open 实体包到实现而不是 API(例如,Hibernate module 而不是 JPA module)。 某些项目的编码规范不允许这样。


如果项目代码使用大量反射, 声明限定 open 需要指明每个包,会有很大工作量,这种完全应该避免。

module 与 service 解耦

用 ServiceLoader API 将服务的使用者和提供者分离,在声明中使用 usesprovides


在 Java 中,通常将 API 建模为接口(有时是抽象类),然后根据情况选择最佳实现。 理想情况下,API 的使用者与实现完全分离,这意味着它们之间没有直接依赖关系。 Java 的 service 加载器 API 允许将这种方法应用于 JAR(模块化或非模块化)。作为 module 系统的重要概念,module 声明中可以使用usesprovides

Java module 系统中的 service

举例说明问题

让我们从一个示例开始,该示例在三个 module 中使用这三种类型:


  • com.example.app 的类Main

  • com.example.api 中的接口Service

  • com.example.impl 的实现类Implementation


Main 想使用Service但需要创建Implementation才能得到实例:


public class Main {
public static void main(String[] args) { Service service = new Implementation(); use(service); }
private static void use(Service service) { // ... }
}
复制代码


这将导致以下 module 声明:


module com.example.api {    exports com.example.api;}
module com.example.impl { requires com.example.api; exports com.example.impl;}
module com.example.app { // dependency on the API: ✅ requires com.example.api; // dependency on the implementation: ⚠️ requires com.example.impl;}
复制代码


如您所见,按说使用接口应该将使用者和 API 提供者分离。挑战在于,在某些时候必须实例化特定的实现。 如果这是作为常规构造函数调用发生的(如Main ),则会创建对实现 module 的依赖关系。 这就是 service 解决的问题。

service 定位器模式作为解决方案

Java 通过实现service定位器模式来解决此问题,其中类 ServiceLoader 充当中央注册表。 这是它的工作原理。


service 是一种可访问的类型(不必是接口;抽象甚至具体的类也可以),一个 module 想要使用它,另一个 module 需提供以下实例:


  • 使用 service 的 module 必须在其 module 声明中使用uses $SERVICE其中$SERVICE 是 service 类型的完全限定名称。

  • 提供 service 的 module 必须使用provides $SERVICE with $PROVIDER,其中$SERVICE与上面指令中的类型相同,$PROVIDER可以是以下类型:

  • 扩展或实现$SERVICE的具体类,具有 public 无参构造

  • 任意类型,具有public static provide()方法,返回$SERVICE的扩展实现


在运行时,依赖 module 调用ServiceLoader.load($SERVICE.class) 来获取 service 的所有实现。 然后,module 系统将返回一个ServiceLoader<$SERVICE>,您可以通过各种方式使用它来访问 service 实现。 ServiceLoader 的 Javadoc 详细介绍了所有与 service 相关的内容。

解决方案示例

以下是我们之前研究的三个类和 module 如何使用 service。 我们从 module 声明开始:


module com.example.api {    exports com.example.api;}
module com.example.impl { requires com.example.api;
provides com.example.api.Service with com.example.impl.Implementation;}
module com.example.app { requires com.example.api;
uses com.example.api.Service;//✅}
复制代码


这里,com.example.app 不再需要 com.example.impl。 而是使用Service,并且 com.example.impl 提供了Implementation. 此外,com.example.impl 不再导出包。 service 加载器不要求 service 的实现在 module 外部可访问,如果该包中其他类也不需要外部访问,可以完全不导出它。 这是 service 的额外好处,因为它可以减少 module 的 API 。


以下是Main如何调用Service的实现:


public class Main {
public static void main(String[] args) { Service service = ServiceLoader .load(Service.class) .findFirst() .orElseThrow(); use(service); }
private static void use(Service service) { // ... }
}
复制代码

一些 JDK service

JDK 本身也使用 service。 例如,包含 JDBC API 的 java.sql module 用java.sql.Driver作 service:


module java.sql {    // requires...    // exports...    uses java.sql.Driver;}
复制代码


这也表明 service 可以是自己的类型。


JDK 中 service 的另一个示例性用法是java.lang.System.LoggerFinder 。 这个 API 允许用户将 JDK 的日志消息(而不是运行时的!)通过管道传输到他们选择的日志框架中(例如,Log4J 或 Logback)。 简单地说,JDK 不是写入标准输出,而是使用 LoggerFinder 创建Logger实例,来记录所有消息。 由于它用LoggerFinder作 service,日志框架可以提供它的实现。


module com.example.logger {    // `LoggerFinder` is the service interface    provides java.lang.System.LoggerFinder        with com.example.logger.ExLoggerFinder;}
public class ExLoggerFinder implements System.LoggerFinder {
// `ExLoggerFinder` must have a parameterless constructor
@Override public Logger getLogger(String name, Module module) { // `ExLogger` must implement `Logger` return new ExLogger(name, module); }
}
复制代码

module 解析期间的 service

如果您曾经使用--show-module-resolution启动过一个简单的模块化应用程序,并观察 module 系统到底在做什么,您可能会对解析的平台 module 数量感到惊讶。 对于一个足够简单的应用程序,唯一的平台 module 应该是 java.base,也许还有一两个,那么为什么还有这么多其他 module 呢? 答案就是 service。


请记住,只有 module 解析期间进入图的 module 在运行时才可用。 为了确保 service 的所有提供者都存在,解析过程会考虑usesprovides指令。 因此,除了跟踪依赖关系之外,一旦它解析了使用 service 的 module,它还会将所有提供该 service 的 module 也添加到图中。 此过程称为 service 绑定

class path 上的代码 - unamed module

class path 上的所有 JAR,无论是否模块化,都将成为 unamed module 的一部分。这使得“一切皆为模块”,让 class path 可以保持之前的混乱。


module 系统希望所有内容都是 module,可以统一规则,但同时,创建 module 并不是强制性的(考虑到兼容性)。 unamed module 包含了 class path 上所有类,当然也有一点点特殊规则。


这意味着,如果您从 class path 启动代码,则 unamed module 将发挥作用。 除非你的应用程序相当小,否则它可能需要渐进模块化,那需要掺杂 JARs,modules,class path 和 module path。 首先,需要了解 module 系统的“class path 模式”如何工作。

unamed module

unamed module 包含所有“非模块化类”,包括


  • 在编译时,没有 module 描述符的类

  • 在编译和运行时,从 class path 加载的所有类


所有 module 都有三个中心属性,对于 unamed module 也是如此:


  • 名称:unamed module 没有(也算吧?),这意味着没有其他 module 可以在其声明中提及它(例如requires它)

  • 依赖关系:unamed module 能读取进入 module 图的所有其他 module

  • 导出:unamed module 对其所有包都 export/open


META-INF/services里提供的 service 也可供ServiceLoader使用。


相反,之前的都称为命名 module。unamed module 的概念让有序的 module 图变得完整。

class path 的混乱

unamed module 的主要目标是捕获 class path 内容并使其在 module 系统中工作。 由于 class path 上的 JAR 之间从来没有任何界限,试图区分开它们并没有意义,整个 class path 只需要一个 unamed module。 在里面,就像在 class path 上一样,所有 public 类都可以相互访问,并且包可以跨 JAR 拆分。


unamed module 的独特角色及其对兼容性的关注赋予了它一些特殊属性。 一个能间接地访问 Java 9 到 16 中的强封装 API。 另一个,许多应用于命名 module 的检查都将跳过它。 因此,如果它和命名 module 之间有拆分包,一是不会发现,二是 class path 的部分会不可用。 (这意味着,如果命名 module 中也存在相同的包,则可能会因 class path 上缺少类而出错)。


一个有点违反直觉且容易出错的细节是 unamed module 的确切构成。 似乎很明显,模块化 JAR 成为 module,因此普通 JAR 进入 unamed module,对吧? 但事实并非如此,unamed module 负责 class path 上的所有 JAR,无论是否模块化。 因此,模块化 JAR 不一定会作为 module 加载! 因此,如果一个库开始提供模块化的 JAR,它的用户绝不会被迫当 module 来用。 相反,他们可以将它们留在 class path 上,其中的代码被捆绑到 unamed module 中。 这使得生态系统几乎可以彼此独立地进行模块化。


若要尝试此操作,可以将以下两行代码放入打包为模块化 JAR 的类中:


String moduleName = this.getClass().getModule().getName();System.out.println("Module name: " + moduleName);
复制代码


从 class path 启动时,输出为Module name: null ,指示该类最终位于 unamed module 中。 从 module path 启动时,您将获得预期的Module name: $MODULE ,其中$MODULE是您为 module 指定的名称。

unamed module 的解析

unamed module 与 module 图的其余部分是什么关系,它可以读取哪些其他 module? 如前所述,module 解析通过从 root module(特别是初始 module)开始,然后迭代添加所有直接和传递依赖项来构建 module 图。 那么代码编译过程具体怎样的?如果应用程序的main方法位于 unamed module 中,就像从 class path 启动应用程序时一样,这将如何工作? 毕竟,普通 JAR 没声明任何依赖关系。


好,如果初始 module 是 unamed module,则 module 解析将从一组预定义的 root module 开始。根据经验,这些是在运行时能找到的 module,但实际规则更详细一些:


  • 成为 root 的 java.* module 的精确集合取决于 java.se module(即表示整个 Java SE API 的 module;它存在于完整的 JRE 映像中,但在使用jlink 创建的自定义运行映像中可能不存在):

  • 如果 java.se 存在,它就成为 root 。

  • 如果不是,则每个非限定导出的 java.* module 都将成为 root。

  • 除了 java.* module,运行中非孵化,而且至少非限定导出一个包的所有其他 module 都将成为 root module。 这对于 jdk.* module 尤其重要。

  • --add-modules 列出的始终是 root module。


请注意,使用 unamed module 作为初始 module 时,root module 集始终是运行映像 module 的子集。 除非使用 显式--add-modules添加,否则将永远不会解析 module path 上存在的 module。 如果手动添加的 module path 已经准确包含所需的 module,则可能需要--add-modules ALL-MODULE-PATH来添加所有 module。

信任 unamed module

module 系统的主要目标之一是可靠的配置: module 必须表达其依赖关系,module 系统必须能够保证它们的存在。 对于带有 module 描述符的显式 module,我们讨论了这个问题,但是如果我们尝试将可靠配置扩展到 class path 会发生什么?

一个头脑风暴

想象一下,module 可能依赖于 class path 内容,也许在它们的描述符中有一些类似requires class-path的东西。 module 系统可以为这种依赖性提供哪些保证? 事实证明,几乎没有。 只要 class path 上至少有一个类,module 系统就必须假定依赖关系已满足。 那不会很有帮助。 更糟糕的是,它会严重破坏可靠的配置,因为您最终可能会依赖requires class-path . 但这几乎不包含任何信息 - class path 上到底需要什么?


进一步假设,假设两个 module com.example.framework 和 com.example.library 依赖于相同的第三个 module,比如 SLF4J。 一个依赖于尚未模块化的 SLF4J,因此requires class-path,另一个依赖已经模块化的 SLF4J,因此requires org.slf4j。 现在,依赖 com.example.framework 和 com.example.library 的人会将 SLF4J JAR 放置在哪条路径上? 无论选择哪种,module 系统都只能满足一个。


仔细考虑这一点可以得出结论,如果您想要可靠的 module,那么依赖任意 class path 内容不是一个好主意。 由于这个确切的原因,不要用requires class-path

因此,unamed

所以说,包含 class path 内容的 module 不应该被其他 module 依赖。 因为 module 系统中需要名称来引用,而它是 unamed,听起来很合理。


总之,要使 module 显式依赖某个制品,该制品必须位于 module path 上。 这很可能意味着您将普通 JAR 放置在 module path 上,会将它们转换为自动 module - 我们接下来将探讨这一概念。

使用自动 modules 实现渐进模块化

module path 上的普通 JAR 成为自动模块,它们可以充当从模块化 JAR 到 class path 的桥梁。


module 系统要求在 module path(或运行时)中找到 module 的所有依赖项。 如果只允许模块化 JAR ,那么项目的所有依赖项都必须是 module,大型项目则必须全部模块化。 为了避免繁杂工作量,module 系统允许 module path 上的普通 JAR 变成自动 module。 当然也有一点点特殊规则:自动 module 可以读取 unamed module,这允许它们充当从 module path 到 class path 的桥梁。

自动 module

对于 module path 上没有 module 描述符的每个 JAR,module 系统都会创建一个自动 module。 与任何其他 module 一样,它具有三个中心属性:


  • 名称:可以在 JAR 的清单中使用标头Automatic-Module-Name定义名称;如果缺少,将从文件名自动生成

  • 依赖关系:自动 module 能读取进入图的所有其他 module,包括 unamed module

  • 导出:自动 module 对其所有包都 export/open


META-INF/services提供的 service 将提供给 ServiceLoader


自动 module 是正规的命名 module,这意味着:


  • 可以在其他 module 的声明中通过名称引用它们,例如需要它们。

  • 即使在 Java 9 到 16 上,它们也没有受到 JDKmodule 强封装的例外的影响。

  • 它们要像拆分包一样进行可靠性检查。


若要尝试自动 module,可以将以下两行代码放入打包为纯 JAR 的类中:


String moduleName = this.getClass().getModule().getName();System.out.println("Module name: " + moduleName);
复制代码


从 class path 启动时,输出为Module name: null ,指示该类最终位于 unamed module 中。 从 module path 启动时,您将获得预期的Module name: $JAR ,其中 $JAR是 JAR 文件的名称。 如果添加Automatic-Module-Name标头到清单,则在从 module path 启动 JAR 时将显示该名称。

自动 module 名称 - 细节小,影响大

将普通 JAR 转换为 module 的要点是能够在 module 声明中requires它们。 但缺少 module 描述符,名称从何而来?

首先是清单条目,然后是文件名

确定纯 JAR module 名称的一种方法依赖于其清单,该清单是 JAR 文件夹META-INF中的MANIFEST.MF文件。 如果 module path 上的 JAR 不包含描述符,则 module 系统将遵循两步过程来确定自动 module 的名称:


  1. 清单中查找标头Automatic-Module-Name。 如果找到它,它将使用相应的值作为 module 的名称。

  2. 如果清单中不存在标头,module 系统会从文件名推断 module 名称。


从文件名推断 module 名称的确切规则有点复杂,但细节并不重要 - 这是要点:


  • JAR 文件名通常以版本字符串结尾(如-2.0.5 )。 这些可以被识别但会忽略。

  • 除了字母和数字之外的每个字符都变成一个点。


此过程可能会导致不幸的结果,即生成的 module 名称无效。 一个例子是字节码操作工具ByteBuddy: 它在 Maven Central 中以 byte-buddy-$VERSION.jar的形式发布,这会导致自动 module 名称byte.buddy(在它定义专有名称之前)。 不幸的是,这是非法的,因为byte是一个 Java 关键字。

找出名字

jar --describe-module --file $FILE


  • 提取清单并手动查看。jar --file $JAR --extract META-INF/MANIFEST.MF

  • 在 Linux 上,将清单打印到终端,从而节省 open 文件的时间。unzip -p $JAR META-INF/MANIFEST.MF

  • 重命名文件并再次运行。jar --describe-module

何时设置Automatic-Module-Name

如果您维护的是 public 发布的项目,这意味着其制品可通过 Maven Central 或其他 public 存储库获得,应仔细考虑何时在清单中设置Automatic-Module-Name。 如前所述,它使您的项目用作自动 module 更加可靠,同时也承诺,将来显式 module 将替代当前 JAR。 你基本上是在说:“这就是 module 的样子,我只是还没有开始发布它们”。


定义自动 module 名称会邀请用户开始信任项目制品作为 module,这一事实有以下几个重要含义:


  • 未来 module 的名称必须与您现在声明的名称完全相同。 (否则,可靠的配置会因为缺少 module 而撕咬您的用户)

  • 制品结构必须一致,因此无法将支持的类或包从一个 JAR 再移动到另一个。 (即使没有 module,这也是不推荐的做法)

  • 该项目在 Java 9 及更高版本上运行得相当好。 如果需要命令行选项或其他解决方法,都有很好的文档。

自动 module 的 module 解析

自动 module 是从普通 JAR 创建的,因此没有明确的依赖声明,这就引出了一个问题,它们在解析过程中的行为方式。 JAR 倾向于相互依赖,如果 module 系统只解析显式requires的自动 module(或者用 --add-modules 添加的)。 想象一下,对于一个具有数百个依赖项的大型项目,要将所有都放置在 module path 上,这样很恐怖。


为了防止这种过分和脆弱的手动操作,module 系统一旦遇到第一个显式requires的自动 module,就会载入所有自动 module。 换句话说,您要么将所有普通 JAR 都作为自动 module,要么一个都没。 另一方面,自动 module 之间都是隐式读取,这意味着读取任何一个自动 module 的 module 都会读取所有自动 module。


一旦在 module path 上放置了一个普通 JAR,它的所有直接依赖也必须在 module path 上,然后是下级依赖,依此类推,直到所有传递依赖都被视为 module,显式或自动的。


但是,将普通 JAR 转换为自动 module 可能不起作用,因为不一定通过检查(例如搜索拆分包)。 因此,能作为普通 JAR 保留在 class path 上并将它们加载为 unamed module 也是个办法。 事实上,module 系统允许自动 module 读取 unamed module,这意味着它们的依赖项可以位于 class path module path 上。


当我们来看平台 module,我们看到自动 module 无法声明依赖关系。 因此,module 图可能包含依赖也可能不包含,如果没有,自动 module 可能在运行时失败,因缺少类而异常。 解决这个问题的唯一方法是让项目的维护者公开说明他们需要哪些 module,以便他们的用户可以确保所需的 module 存在。 用户可以通过显式requires它们或使用 --add-modules

信任自动 module

自动 module 的唯一目的是能够依赖普通的 JAR,因此可以显式创建 module,而不必等到所有依赖项都模块化。


但根据其设置,不同的项目可能会对相同的 JAR 使用不同的名称。 大多数项目使用 Maven 支持的本地存储库,其中 JAR 文件以${artifactID}-$VERSION命名,module 系统可能会从中推断 $*{artifactID}*作为自动 module 的名称。 这是有问题的,因为制品 ID 通常不遵循反向域命约定,这意味着一旦项目模块化,module 名称可能会变。


总之,同一个 JAR 可能会在不同的项目(取决于它们的设置)和不同的时间(模块化之前和之后)获得不同的 module 名称。 这有可能给下游造成严重破坏,需要不惜一切代价避免!


看起来,好像让纯 JAR 的文件名跟 module 名称一致就行。 但不是这么简单 - 使用此方法对于应用程序以及开发人员可以完全控制 module 描述符的场景是可以。 但不要将具有此类依赖项的 module 发布到 public 存储库。那样的话,module 可能隐式依赖于用户无法控制的细节,这可能导致额外的工作甚至无法解决的冲突。


因此,您永远不应该发布(到可 public 访问的存储库)这种 module,这种依赖某个没有Automatic-Module-Name的纯 JAR 的 module。 有Automatic-Module-Name的自动 module 才可以稳定依赖。 是的,这可能意味着您必须等待依赖项添加了该条目,你的库或框架的模块化版本才能发布。

在命令行上构建 module

了解如何使用 javac、jar 和 java 命令手动编译、打包和启动模块化应用程序 - 即使构建工具完成了大部分繁重的工作,也很高兴知道。


创建 module 时,可能会使用构建工具。 但也要了解“正确的”应该是什么样子,以及如何配置javacjarjava 、 以及如何编译、打包和运行应用程序。 这将使您更好地了解 module 系统,帮助调试问题。

基本构建

给定一个包含几个源文件、一个 module 声明和一些依赖项的项目,您可以通过这种方式以最简单的方式编译、打包和运行它:


# compile sources files, including module-info.java$ javac    --module-path $DEPS    -d $CLASS_FOLDER    $SOURCES# package class files, including module-info.class$ jar --create    --file $JAR    $CLASSES# run by specifying a module by name$ java    --module-path $JAR:$DEPS    --module $MODULE_NAME/$MAIN_CLASS
复制代码


里面有一堆占位符:


  • $DEPS是依赖项的列表。通常是由 :(Unix) 或 ;(Windows) 分隔的 JAR 文件路径,或者文件夹(没有/*)。

  • $CLASS_FOLDER*.class保存的路径。

  • $SOURCES是源文件列表,必须包含 *.javamodule-info.java

  • $JAR是将创建的 JAR 文件的路径。

  • $CLASSES是在编译的*.class文件列表,必须包含module-info.class

  • $MODULE_NAME/$MAIN_CLASS是初始 module 名称,后跟包含main方法的类。


对于具有通用src/main/java结构的简单“Hello World”样式项目,只有一个源文件,deps为依赖项的文件夹,并使用 Maven 的target文件夹,如下所示:


$ javac    --module-path deps    -d target/classes    src/main/java/module-info.java    src/main/java/com/example/Main.java$ jar --create    --file target/hello-modules.jar    target/classes/module-info.class    target/classes/com/example/Main.class$ java    --module-path target/hello-modules.jar:deps    --module com.example/com.example.Main
复制代码

定义主类

jar--main-class $MAIN_CLASS选项指定包含main方法的类,它允许您启动 module 时无需指定主类:


$ jar --create    --file target/hello-modules.jar    --main-class com.example.Main    target/classes/module-info.class    target/classes/com/example/Main.class$ java    --module-path target/hello-modules.jar:deps    --module com.example
复制代码


请注意,可以覆盖该类并启动另一个类,只需像以前一样命名它:


# create a JAR with `Main` and `Side`,# making `Main` the main class$ jar --create    --file target/hello-modules.jar    --main-class com.example.Main    target/classes/module-info.class    target/classes/com/example/Main.class    target/classes/com/example/Side.class# override the main class and launch `Side`$ java    --module-path target/hello-modules.jar:deps    --module com.example/com.example.Side
复制代码

绕过强封装

module 系统对访问内部 API 有严格限制: 如果未 export/open packages,访问将被拒绝。 但是包不能只由 module 的作者 export/open - 还有命令行标志--add-exports--add-opens,允许用户执行此操作。


例如,请参阅尝试创建内部类实例的代码:sun.util.BuddhistCalendar


BuddhistCalendar calendar = new BuddhistCalendar();
复制代码


要编译和运行它,我们需要使用 :--add-exports


javac    --add-exports java.base/sun.util=com.example.internal    module-info.java Internal.java# package with `jar`java    --add-exports java.base/sun.util=com.example.internal    --module-path com.example.internal.jar    --module com.example.internal
复制代码


如果访问是反射性的...


Class.forName("sun.util.BuddhistCalendar").getConstructor().newInstance();
复制代码


...编译无需进一步配置即可工作,但我们需要在运行代码时添加:--add-opens


java    --add-opens java.base/sun.util=com.example.internal    --module-path com.example.internal.jar    --module com.example.internal
复制代码

扩展 module 图

从一组初始的 root module 开始,module 系统计算它们的所有依赖关系并构建一个图,其中 module 是节点,它们的读取关系是有向边。 此 module 图可以使用--add-modules--add-reads 进行扩展,它们分别添加 module(及其依赖项)和读取边。


例如,让我们假设一个项目对 java.sql 具有可选的依赖关系,但该 module 不是必需的。 这意味着如果没有一点帮助,它就不会添加到 module 图中:


# launch without java.sql$ java    --module-path example.jar:deps    --module com.example/com.example.Main
# launch with java.sql$ java --module-path example.jar:deps --add-modules java.sql --module com.example/com.example.Main
复制代码


可选依赖项的另一种方法是根本不列出依赖项,而只添加--add-modules--add-reads(这很少用,通常不推荐 - 只是一个示例):


$ java    --module-path example.jar:deps    --add-modules java.sql    --add-reads com.example=java.sql    --module com.example/com.example.Main
复制代码

强封装(JDK 内部)

强封装是模块系统的基石。它避免(意外)使用内部 API,主要是java.*包中的非 public 类型/成员以及sun.*com.sun.*的大部分 。


几乎所有依赖项(无论是框架、库、JDK API 还是您自己的(子)项目)都有一个 public 的、受支持的和稳定的 API ,以及所需的内部代码。 强封装说的是避免(意外)使用内部 API ,以使项目更加健壮和可维护。 我们将探讨为什么需要这样做,内部 API 的构成究竟是什么(特别是对于 JDK),以及强封装在实践中是如何工作的。

什么是强封装?

在许多方面,OpenJDK 项目与任何其他软件项目相似,一个常见的是重构。 代码被更改、移动、删除等,来保持项目干净可维护。 当然,并非所有代码: 像 publicAPI,是跟 Java 用户的约定,非常要求保持稳定。


如您所见,public API 和内部代码之间的区别对于维护兼容性至关重要,对 JDK 开发人员和您来说也是如此。 您需要确保您的项目(代码、依赖项)不依赖于经常在次要更新中更改的内部结构,从而导致令人惊讶和不必要的作业。 更糟糕的是,此类依赖项可能会妨碍系统正常工作。 还有,您可能想用内部 API 提供些特殊功能,为您的项目带来点竞争力。


总之,这意味着需要一种机制,默认必须锁定内部 API,但特定情况下又允许解锁某部分。 强封装就是这种机制。


由于只有 export/open 的包中的类型才能在 module 外部访问,其他所有都视为内部,也就无法访问。 首先,JDK 本身就是这样的,自 Java 9 开始,JDK 就拆分成了 module。

什么是内部 API?

那么哪些 JDK API 是内部的呢? 要回答这个问题,我们先看三个命名空间:


第一: 当然,java.*这些包构成了 public API,但只是 public 类的 public 成员。 其他可见性的都是内部的,并且由 module 系统强封装。


然后是sun.*. 几乎所有这样的软件包都是内部的,但有两个例外:sun.miscsun.reflect,由 module jdk.unsupported 导出和 open 。因为它们提供了对许多项目至关重要的功能,并且在 JDK 内部或外部没有可行的替代方案(sun.misc.Unsafe最突出)。 不过,也只是小例外: 一般来说,sun.*包应该被视为内部的。


最后是com.sun.* ,这比较复杂。 整个命名空间是特定于 JDK 的,这意味着它不是 Java 标准 API 的一部分,并且某些 JDK 可能不包含它。 其中大约 90% 是非 export 包,是内部的。 剩下的 10% 是由 jdk.* module 导出的包,支持在 JDK 外部使用。 这是标准化 API 进化同时考虑兼容性造成的。这里有个列表,jdk8 里的内部包与导出包,在 jdk17 里并不存在。


综上所述,使用java.*、避免sun.*、小心com.sun.*

强封装实验

为了试验强封装,让我们创建一个使用来自 public API 的类的简单类:


public class Internal {
public static void main(String[] args) { System.out.println(java.util.List.class.getSimpleName()); }
}
复制代码


由于它是一个单一的类,你可以直接运行它,而无需显式编译:


java Internal.java
复制代码


这应该成功运行并打印“List”。


接下来,让我们混合其中一个出于兼容性原因可访问的异常:


// add to `main` methodSystem.out.println(sun.misc.Unsafe.class.getSimpleName());
复制代码


您仍然可以立即运行它,打印“List”和“Unsafe”。


现在让我们使用一个无法访问的内部类:


// add to `main` methodSystem.out.println(sun.util.BuddhistCalendar.class.getSimpleName());
复制代码


如果您尝试像以前一样运行它,则会出现编译错误(java命令在内存中编译):


Internal.java:8: error: package sun.util is not visible                System.out.println(sun.util.PreHashedMap.class.getSimpleName());                                      ^  (package sun.util is declared in module java.base, which does not export it)1 errorerror: compilation failed
复制代码


错误消息非常清楚: sun.util包属于 module java.base,因为它不导出它,所以它被认为是内部的,因此无法访问。


我们可以在编译期间避免使用类型,而是使用反射:


Class.forName("sun.util.BuddhistCalendar").getConstructor().newInstance();
复制代码


执行会导致运行时出现异常:


Exception in thread "main" java.lang.IllegalAccessException:    class Internal cannot access class sun.util.BuddhistCalendar (in module java.base)    because module java.base does not export sun.util to unnamed module @1f021e6c        at java.base/jdk.internal.reflect.Reflection.newIllegalAccessException(Reflection.java:392)        at java.base/java.lang.reflect.AccessibleObject.checkAccess(AccessibleObject.java:674)        at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:489)        at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480)        at org.codefx.lab.internal.Internal.main(Internal.java:9)
复制代码

实践中的强封装

如果您确定需要访问内部 API,有两个命令行标志可以让您绕过:


  • --add-exports使导出包中的 public 类型和成员在编译或运行时可访问

  • --add-opens使 open 包中的所有类型及成员在运行时可反射


在编译期间应用--add-exports时,必须在运行应用程序时再次应用它,当然--add-opens只有在运行时才有意义。 这意味着无论任何代码(您的或您的依赖项)只要想访问 JDK 内部时,都需要在启动时配置它。 这使应用程序的所有者完全透明地了解这些问题,允许他们评估情况并更改代码/依赖项,或主观接受使用内部 API 带来的可维护性危害。


强封装对所有有名 module 都有效。 这包括完全模块化的整个 JDK,但也可能包括您的代码和依赖项,也可能作为 module path 上的模块化 JAR 出现。 在这种情况下,到目前为止所说的一切都适用于这些 module:


  • 在编译和运行时,只有导出包中的 public 类型和成员才能在 module 外部访问

  • open 的包中的所有类型和成员都可以在运行时在 module 外部访问

  • 其他类型和成员在编译期间和运行时无法访问

  • 可以使用--add-exports(对于静态依赖项)和--add-opens(对于反射访问)创建例外


这意味着您可以将强封装的优势扩展到 JDK API 之外,以涵盖您的代码和依赖。

强封装的演变

强封装是 Java 9 中引入的 module 系统的基石,但出于兼容性原因,class path 中的代码仍然可能访问内部 JDK API。 这是用--illegal-access来管理的,该选项在 JDK 9 到 15 中具有默认值permit。 JDK 16 更改为deny,17 中已完全停用。


从 JDK 17 开始,仅允许--add-exports--add-opens访问内部 API。

绕过强封装--add-exports--add-opens

通过在编译或运行时导出包,或在运行时打开包进行反射,来授予对内部 API 的访问,无论是 JDK 的一部分还是依赖项。


module 系统对访问内部 API 非常严格: 如果未 export/open packages,访问将被拒绝。 但是包不能只由 module 的作者 export/open - 还有--add-exports--add-opens,允许 module 的用户执行此操作。


这样,应用程序可以访问到依赖项或 JDK API 的内部。 由于这需要在更多的功能或性能(大概),与更少的可维护性或破坏平台完整性之间进行权衡,因此不应轻易做出此决定。 而且由于它最终不仅涉及开发人员,还涉及应用程序的用户,因此必须在启动时添加这些命令行标志,让用户需要知道自己正在权衡。

导出包时--add-exports

该选项--add-exports $MODULE/$PACKAGE=$READING_MODULE ,可用于 javajavac命令,将$MODULE$PACKAGE导出到 $READING_MODULE。 这样,$READING_MODULE 中的代码就可以访问$PACKAGE中的所有 public 类型和成员,但其他 module 不能。 将 $READING_MODULE 设置为ALL-UNNAMED 时,class path 中的所有代码都可以访问该包。$MODULE只对 module 项目有效。


后面的空格可以替换为等号,这有助于某些工具配置(例如 Maven): 。--add-exports``=``--add-exports=.../...=...

编译时

例如,请参阅尝试创建内部类实例的代码:sun.util.BuddhistCalendar


BuddhistCalendar calendar = new BuddhistCalendar();
复制代码


如果我们这样编译它,我们会得到以下错误,如果没有导入:


error: package sun.util is not visible  (package sun.util is declared in module java.base, which does not export it)
复制代码


--add-exports可以解决此问题。 如果上面的代码是在没有 module 声明的情况下编译的,我们需要 export packages 到:ALL-UNNAMED


javac    --add-exports java.base/sun.util=ALL-UNNAMED    Internal.java
复制代码


如果它在名为 com.example.internal 的 module 中,我们可以更精确,从而最大限度地减少内部的暴露:


javac    --add-exports java.base/sun.util=com.example.internal    module-info.java Internal.java
复制代码

在运行时

启动代码(在 JDK 17 及更高版本上)时,我们收到运行时错误:


java.lang.IllegalAccessError:    class Internal (in unnamed module @0x758e9812)    cannot access class sun.util.BuddhistCalendar (in module java.base)    because module java.base does not export sun.util to unnamed module @0x758e9812
复制代码


为了解决这个问题,我们需要在启动时重复--add-exports。 对于 class path 中的代码:


java    --add-exports java.base/sun.util=ALL-UNNAMED    --class-path com.example.internal.jar    com.example.internal.Internal
复制代码


如果它位于名为 com.example.internal 的 module 中(定义了一个主类),我们可以再次更精确:


java    --add-exports java.base/sun.util=com.example.internal    --module-path com.example.internal.jar    --module com.example.internal
复制代码

open packages 时--add-opens

命令行选项--add-opens $MODULE/$PACKAGE=$REFLECTING_MODULEopen $MODULE$PACKAGE$REFLECTING_MODULE。 因此,$REFLECTING_MODULE 中的代码可以反射性地访问$PACKAGE所有类型和成员,public 和非 public 成员。 将 $REFLECTING_MODULE设置为ALL-UNNAMED 时,class path 中的所有代码都可以反射方式访问该包。 $MODULE只对 module 项目有效。


--add-opens后面的空格可以用=代替,这有助于某些工具配置:--add-opens=.../...=...


由于--add-opens绑定到反射,一个纯粹的运行时概念,它只对java命令有意义。 但是,鉴于许多命令行选项可以跨多个工具工作,因此报告和解释选项何时不起作用是很有帮助的,因此javac不会拒绝该选项,而是发出警告“--add-open 在编译时不起作用”。

在运行时

例如,尝试使用反射创建内部类sun.util.BuddhistCalendar实例的类Internal


Class.forName("sun.util.BuddhistCalendar").getConstructor().newInstance();
复制代码


由于代码不针对内部类BuddhistCalendar进行编译,编译无需额外的命令行标志即可工作。 但在 JDK 17 及更高版本上,运行时会出现异常:


Exception in thread "main" java.lang.IllegalAccessException:    class Internal cannot access class sun.util.BuddhistCalendar (in module java.base)    because module java.base does not export sun.util to unnamed module @1f021e6c        at java.base/jdk.internal.reflect.Reflection.newIllegalAccessException(Reflection.java:392)        at java.base/java.lang.reflect.AccessibleObject.checkAccess(AccessibleObject.java:674)        at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:489)        at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480)
复制代码


--add-opens可以解决此问题。 如果上面的代码在 class path 上的 JAR 中,我们需要 open packagessun.utilALL-UNNAMED


java    --add-opens java.base/sun.util=ALL-UNNAMED    --class-path com.example.internal.jar    com.example.internal.Internal
复制代码


还记得吗,没有必要 opensun.miscsun.reflect包,因为它们是由 jdk.unsupport 导出的。


如果它位于名为 com.example.internal 的 module(定义一个主类)中,我们可以更精确,从而最大限度地减少内部的暴露:


java    --add-opens java.base/sun.util=com.example.internal    --module-path com.example.internal.jar    --module com.example.internal
复制代码

扩展 module 图--add-modules--add-reads

手动添加模块(节点)和可读关系(边)来扩展模块系统生成的 module 图。


从一组初始的 root module 开始,module 系统计算它们的所有依赖关系并构建一个图,其中 module 是节点,它们的读取关系是有向边。 此 module 图可以使用--add-modules--add-reads进行扩展,它们分别添加 module(及其依赖项)和读取边。 前者有一些场景,后者非常小众,但无论哪种方式,了解它们都很好。

添加 root module--add-modules

--add-modules $MODULESjavacjlinkjava上可用,并且接受以逗号分隔的 module 列表,将它们添加到 root module 集中。 (root module 构成 module 解析开始的初始 module 集。 这允许您将 module(及其依赖项)添加到 module 图中,否则不起作用。


--add-modules具有三个特殊值:


  • ALL-DEFAULT是从 class path 启动时的 root module 集。 当应用程序是托管其他应用程序的容器时,这很有用,它们的依赖对容器本身并不必要。

  • ALL-SYSTEM将所有系统 module(下一章会看到)添加到 root 集,测试工具有时需要这样做。 此选项将导致许多 module 被解析;一般来说,首选ALL-DEFAULT

  • ALL-MODULE-PATH将 module path 上找到的所有 module 添加到 root 集。 这是供构建工具使用的(Maven 等),这些工具已经确定需要 module path 上的所有 module。 这也是将自动 module 添加到 root 集的便捷方法。


前两个仅在运行时工作,很少用到,本文不讨论。 最后一个可能有用:有了它,module path 上的所有 module 都成为 root module,因此它们都进入了 module 图。


--add-modules后面的空格可以用=代替,这有助于某些工具配置:--add-modules=...

添加 module 的场景

一个场景是添加可选的依赖项,这些依赖项不是必需的,因此不会进入 module 图。 例如,让我们假设一个项目对 java.sql 具有可选的依赖关系,但该 module 不是必需的:


# launch without java.sql$ java    --module-path example.jar:deps    --module com.example/com.example.Main
# launch with java.sql$ java --module-path example.jar:deps --add-modules java.sql --module com.example/com.example.Main
复制代码


另一种方法是在使用 jlink 创建运行映像时定义 root module 集。


添加 module 时,可能需要让其他 module 读取它们,所以接下来让我们这样做。

添加读取边--add-reads

编译器和运行时--add-reads $MODULE=$TARGETS$MODULE的读取边添加到逗号分隔列表$TARGETS中的所有 module。 这允许$MODULE访问这些 module 导出的包中的所有 public 类型,即使$MODULE没有requires它们。 如果$TARGETS设置为ALL-UNNAMED$MODULE甚至可以读取 unamed module。


--add-reads后面的空格可以用=代替,这有助于某些工具配置。--add-reads=.../...

添加读取的示例

让我们回到前面的例子,其中代码使用 java.sql,但不想总是依赖它。 可选依赖项的另一种方法是根本不列出依赖项,而只添加 --add-modules--add-reads(这很少有用,通常不推荐 - 只是一个示例):


# this only shows launch, but compilation# would also need the two options$ java    --module-path example.jar:deps    --add-modules java.sql    --add-reads com.example=java.sql    --module com.example/com.example.Main
复制代码

使用 JLink 创建运行时和应用程序映像

了解如何创建自定义运行映像或自包含的应用程序映像。


使用jlink,您可以选择许多模块、平台模块以及组成应用程序的模块,并将它们链接到运行映像中。 这样的运行映像的作用类似于您可以下载的 JDK,但仅包含您选择的模块及其运行所需的依赖项。 如果包含的是您的项目,则结果是独立的应用程序,意味着它不依赖于目标系统上的 JDK。 在链接阶段,jlink可以进一步优化映像大小并提高 VM 性能,尤其是启动时间。


虽然不太重要,但区分运行映像(JDK 的子集)和应用程序映像(也包含特定于项目的模块)很有帮助,我们按此顺序进行。


注意:jlink “只是”链接字节码 - 它不会将其编译为机器代码,因此这不是提前编译。

创建运行映像

要创建映像,jlink需要两条信息:


  • 从哪些模块开始 --add-modules

  • 在哪个文件夹中创建映像 --output


给定这些命令行选项, jlink解析模块,从 --add-modules列出的模块开始。 但它有一些特点:


  • 默认情况下,service 不包含 - 我们将在下面进一步看到如何处理

  • 可选依赖项不解析 - 需要手动添加

  • 不允许使用自动模块 - 我们将在进入应用程序映像时讨论这个问题


除非遇到任何问题,例如缺少或重复的模块,否则解析的模块(root module 加上传递依赖项)最终会出现在运行映像中。

最小的运行时

让我们来看看。 最简单的运行映像仅包含基本模块:


# create the image$ jlink    --add-modules java.base    --output jdk-base# use the image's java launcher to list all contained modules$ jdk-base/bin/java --list-modules> java.base
复制代码

创建应用程序映像

可以使用类似的方法来创建包含整个应用程序的映像,这意味着包含应用程序 module(应用本身及其依赖项)和支持它们所需的平台 module。 要创建此类映像,您需要:


  • --module-path告知jlink在何处可以找到应用模块

  • 根据需要与应用程序的主模块和其他模块一起使用--add-modules,例如 service(见下文)或可选依赖


映像包含的平台和应用程序模块一起称为系统 module。 请注意,jlink只在显式模块上运行,因此依赖于自动模块的应用程序无法链接到映像中。

可选 module path

例如,假设可以在mods文件夹中找到应用程序的模块,并且其主模块称为 com.example.app。 然后以下命令在app-image文件夹中创建一个映像:


# create the image$ jlink    --module-path mods    --add-modules com.example.main    --output app-image
# list contained modules$ app-image/bin/java --list-modules> com.example.app# other app modules> java.base# other java/jdk modules
复制代码


由于映像包含整个应用程序,因此启动它时无需使用 module path:


$ app-image/bin/java --module com.example.app/com.example.app.Main
复制代码


虽然您不必使用 module path,但也可以用。 这种情况下,系统模块将始终在 module path 上隐藏同名模块。 因此,不能使用 module path 替换系统模块,但可以添加其他模块。 比如 service 的实现。这允许它随应用程序一起发布映像,同时也允许用户轻松地在本地扩展它。

生成本机启动器

应用程序模块可以包含一个自定义启动器,它是映像bin文件夹中的可执行脚本(基于 Unix 的操作系统上的 shell,Windows 上的批处理),该脚本预配置为使用具体模块和主类启动 JVM。 要创建启动器,请使用--launcher $NAME=$MODULE/$MAIN-CLASS


  • $NAME是您为可执行文件选择的文件名

  • $MODULE是要用来启动的模块的名称

  • $MAIN-CLASS是模块主类


后两个是你通常会放在java --module后面的。 就像那里一样,如果模块定义了一个主类,你可以省略/$MAIN-CLASS


扩展上面的示例,这是如何创建一个名为app 的启动器:


# create the image$ jlink    --module-path mods    --add-modules com.example.main    --launcher app=com.example.app/com.example.app.Main    --output app-image
# launch$ app-image/bin/app
复制代码


不过,使用启动器确实有一个缺点: 您尝试应用于启动 JVM 的所有选项都将被解释为您将它们放在--module后面,使它们成为程序参数。 这意味着,在使用启动器时,您不能临时配置java命令,例如添加我们之前讨论的其他服务。 一种方法是编辑脚本并将此类选项放在JLINK_VM_OPTIONS环境变量中。 另一种方法是回退到java命令本身,该命令在映像中仍然可用。

包含服务

若要启用创建小型且有意组装的运行映像,jlink默认情况下,在创建映像时不执行任何 service 绑定。 相反,必须通过--add-modules列出服务提供程序模块来手动包含这些模块。 要了解哪些模块提供特定服务,请使用--suggest-providers $SERVICE ,该选项列出了运行时或 module path 上提供 $SERVICE实现 的所有模块。 作为添加单个服务的替代方法,--bind-services可用于包含提供由另一个解析模块使用的服务的所有模块。


让我们以 ISO-8859-1、UTF-8 或 UTF-16 等字符集为例。 基础模块知道您每天需要的模块,但是有一个特定的平台模块包含其他一些模块:jdk.charsets。 基本模块和 jdk.charset 通过服务分离 - 以下是其模块声明的相关部分:


module java.base {    uses java.nio.charset.spi.CharsetProvider;}
module jdk.charsets { provides java.nio.charset.spi.CharsetProvider with sun.nio.cs.ext.ExtendedCharsets}
复制代码


当模块系统在常规启动期间解析模块时,服务绑定将载入 jdk.charsets,因此从标准 JDK 启动时,其字符集始终可用。 但是,使用jlink 创建运行映像时,默认情况下不会发生这种情况,因此此类映像将不包含字符集模块。 如果您确定需要它们,则只需--add-modules将模块包含在映像中:


$ jlink    --add-modules java.base,jdk.charsets    --output jdk-charsets$ jdk-charsets/bin/java --list-modules> java.base> jdk.charsets
复制代码

跨操作系统生成映像

虽然应用程序和库 JAR 包含的字节码独立于任何操作系统 (OS),但它需要特定于操作系统的 Java 虚拟机来执行它们 - 这就是您下载专门针对 Linux、macOS 或 Windows 的 JDK 的原因(例如)。 由于这是jlink提取平台模块的位置,因此它创建的运行时和应用程序映像始终绑定到具体的操作系统。 幸运的是,它不一定是您正在运行jlink的那个。


如果下载并解压缩其他操作系统的 JDK,则可以在从系统的 JDK 运行jlink版本时将其jmods文件夹放在 module path 上。 然后,链接器将确定要为该操作系统创建映像,从而能在那上面工作。 因此,给定应用程序支持的所有操作系统的 JDK,您可以在同一台计算机上为每个操作系统生成运行时或应用程序映像。 为了使它正常工作,建议仅引用与jlink二进制文件完全相同的 JDK 版本的模块,例如,jlink版本为 16.0.2,请确保它从 JDK 16.0.2 加载平台模块。


让我们回到之前创建的应用程序映像,并假设它是在 Linux 生成服务器上构建的。 然后,这是为 Windows 创建应用程序映像的方法:


# download JDK for Windows and unpack into `jdk-win`
# create the image with the jlink binary from the system's JDK# (in this example, Linux)$ jlink --module-path jdk-win/jmods:mods --add-modules com.example.main --output app-image
复制代码


要验证此映像是否特定于 Windows,请检查app-image/bin ,其中包含 java.exe

优化映像

了解如何生成映像后,可以对其优化。 大多数优化会减小映像大小,有些会稍微缩短启动时间。 查看 jlink 参考,了解您可以使用的选项的完整列表。 无论您应用什么选项,都不要忘记彻底测试生成的映像,并在实际中衡量改进。

烧哥总结

(验证中,代码库持续更新)




requires static 不进入 module 图,运行时不一定可用,除非--add-modules


requires transitive 使用传递,简化了上层 module 声明,如果有脱离这个 API 的越级调用,最好显式requires


export ... to 的目标 module,编译时可以不存在


provides ... with的具体实现,运行时必须存在


--add-reads ...=ALL-UNNAMED 尽量别用,打破了 module 设计初衷,所有被依赖的都应该在 module path 上


自动 module 让大部分现有 jar 成为 module 提供了便利,只需要注意名称


自动 module 只需要声明requires一个,其他所有都会全部加载,但可能缺少依赖项,因为没法声明


自动 module 之间都是隐式读取,不需要显式声明依赖


自动 module 的依赖项可以在 class path 上,可以是 unamed module


unamed module 中的包,会被命名 module 中的包覆盖


unamed module 中的包做初始 module 的话,module path 上都不会生效,除非--add-modules


独立应用程序映像,运行java可以附加-m,但无法覆盖映像里面的包


jlik 默认不包含 service 的实现,可以打包时用--add-modules添加

发布于: 刚刚阅读数: 4
用户头像

烧霞

关注

还未添加个人签名 2020-08-26 加入

一步一步 架构师之路

评论

发布
暂无评论
从头学Java17-Modules模块_modules_烧霞_InfoQ写作社区