烧霞

一步一步 架构师之路

从头学Java17-Modules模块

模块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.lib
requires java.sql;
requires com.sample.other;

// from com.example.app
requires 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.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 - 该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.lib
uses com.example.lib.Service;

// in module com.example.app
provides 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.loggingjava.transaction.xajava.xml

何时使用隐式读取

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

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

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

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

另一种,您的模块实际上可能会在代码中使用日志记录。 然后,来自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 pathmodule 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` method
System.out.println(sun.misc.Unsafe.class.getSimpleName());

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

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

// add to `main` method
System.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 error
error: 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 参考,了解您可以使用的选项的完整列表。 无论您应用什么选项,都不要忘记彻底测试生成的映像,并在实际中衡量改进。

烧哥总结

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

正常module 自动module unamed module
描述 模块化jar放在module path 普通jar放在module path 所有jar放在class path(不论是否module)
module name 正常 根据MANIFEST/自动生成 null
可读性 不能读取unamed module 能读取所有module 能读取所有module
--add-reads this=ALL-UNNAMED 我能读谁 this能读取所有module
--add-exports this=ALL-UNNAMED 谁能读我 unamed module能读取this
--add-opens this=xxx 谁能反射我 xxx能反射访问我
jlink 正常 依赖它的module无法打包
依赖它的module是否可访问 public成员 protected/package成员 private成员
默认 私有 私有 私有
export 编译时、运行时可访问 私有 私有
open 运行时可反射 运行时可反射 运行时可反射
unamed module 全部export/open
自动module 全部export/open

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添加

posted on 2023-07-01 17:21  烧霞  阅读(514)  评论(0编辑  收藏  举报

导航