自动模块:连接传统Java与模块化Java的桥梁

内容目录

当Java 9在2017年发布时,它带来了Java历史上最具雄心的变革之一:Java平台模块系统(JPMS),亲切地称为"Project Jigsaw"。经历了二十年的类路径混沌之后,Java终于拥有了一个真正的模块系统。但存在一个问题——一个巨大的问题。那数十亿行现有的Java代码怎么办?Maven中央仓库中成千上万个从未听说过模块的库又该如何处理?

自动模块应运而生:这一巧妙的折衷方案使得JPMS迁移变得可行,而非灾难性的。它们是连接昨日类路径世界与明日模块化未来的桥梁。可以将它们视为两国边境上的外交翻译——不属于任何一方的公民,但对双方之间的沟通至关重要。

让我们深入探讨自动模块的工作原理、它们为何重要,以及如何在您的迁移之旅中有效地使用它们。

迁移挑战:Java为何需要桥梁

想象一下,您正在管理一个大型企业应用程序。您使用了Spring、Hibernate、Apache Commons、Google Guava以及数十个其他依赖项。现在是2017年,您希望迁移到Java 9的模块系统。但有一个问题:您的所有依赖项都还没有被模块化。

如果没有某种兼容性层,对话将是这样的:

您:"我想为我的应用程序使用模块。"
JPMS:"太好了!您的所有依赖项都有模块描述符吗?"
您:"没有,它们只是普通的JAR文件。"
JPMS:"那么您不能在模块化应用程序中使用它们。"
您:"所以我不能迁移?"
JPMS:"正确。"
您:"……"

这将迫使进行自底向上的迁移:依赖树底部的每个库都需要先进行模块化,然后其上的任何东西才能迁移。整个生态系统完成迁移将需要数年——甚至数十年——的时间。Java社区实际上将被行动最缓慢的依赖项所挟持。

JDK提供了工具来帮助开发人员将现有代码迁移到JPMS。应用程序代码仍然可以依赖Java 9之前的库,这些jar文件被视为特殊的"自动"模块,从而更容易逐步迁移到Java 9。

什么是自动模块?

自动模块是位于模块路径上的普通JAR(没有模块描述符)。模块路径上的所有东西,无论它是普通jar还是带有module-info的jar,都会成为一个命名模块。

其神奇之处在于:自动模块是没有module-info.java的JAR文件,它们仅仅通过被放置在模块路径(而非类路径)上,就能表现得像模块一样。

让我们分解一下当您将常规JAR文件放在模块路径上时会发生什么:

1. 它获得一个模块名称

自动模块的模块名称来源于用于包含该构件的JAR文件,如果其主清单条目中包含Automatic-Module-Name属性。否则,模块名称来源于JAR文件的名称。

示例转换:

  • commons-io-2.11.0.jar → 模块名称:commons.io
  • jackson-databind-2.14.2.jar → 模块名称:jackson.databind
  • my-awesome-library.jar → 模块名称:my.awesome.library

命名算法会剥离版本号,将连字符替换为点,并移除无效字符。但这种基于文件名的命名是危险的——稍后会详细说明。

2. 它导出所有内容

由于自动模块中没有为驻留的包定义显式的导出/开放声明,自动模块中的每个包都被视为已导出,即使它实际上可能仅用于内部用途。

您精心设计的内部专用包?现在全部公开了。这破坏了封装性,但对于兼容性是必要的。传统代码通常依赖于访问"内部"包,而自动模块保留了这种行为。

3. 它依赖所有模块

无法预先确切知道一个自动模块可能依赖哪些其他模块。自动模块在解析过程中会受到特殊处理,以便它们能够读取配置中的所有其他模块。

一个自动模块隐式地依赖模块路径上的所有其他模块,加上类路径上的所有内容。这与模块化设计背道而驰,但同样,这是为了兼容性。传统的JAR没有明确声明依赖关系,因此JPMS假定它们需要一切。

4. 它桥接类路径

这是真正巧妙的部分:自动模块允许模块路径依赖于类路径,而这通常是不允许的。

常规模块无法看到类路径上的任何东西("未命名模块")。但自动模块可以。这使得模块化代码可以使用自动模块,而自动模块又可以反过来使用旧的类路径JAR。这是一个连接所有三个世界的三向握手。

实践示例:迁移Spring应用程序

让我们演练一个真实的迁移场景。您有一个Spring Boot应用程序,包含以下依赖项:

my-app (您的代码)
├── spring-boot-starter-web
├── spring-data-jpa
├── hibernate-core
├── postgresql-driver
└── apache-commons-lang

这些库都还没有被模块化。以下是您的迁移路径:

步骤1:创建您的模块描述符

// module-info.java
module com.mycompany.myapp {
    requires spring.boot;  // 将成为自动模块
    requires spring.data.jpa;  // 将成为自动模块
    requires hibernate.core;  // 将成为自动模块
    requires postgresql;  // 将成为自动模块
    requires org.apache.commons.lang3;  // 将成为自动模块

    // 您的导出
    exports com.mycompany.myapp.api;
}

步骤2:将依赖项放置在模块路径上

配置您的构建工具,将JAR放在模块路径上而不是类路径上:

Maven:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.11.0</version>
    <configuration>
        <release>17</release>
    </configuration>
</plugin>

Gradle:

java {
    modularity.inferModulePath = true
}

步骤3:运行您的应用程序

java --module-path mods --module com.mycompany.myapp/com.mycompany.myapp.Main

您的显式模块(com.mycompany.myapp)现在依赖于自动模块(Spring、Hibernate等),这些自动模块可以访问它们需要的任何东西。迁移完成了——至少对于您的应用程序代码而言。

Automatic-Module-Name:您的稳定锚点

这里事情变得棘手了。当一个模块依赖于一个基于文件名的自动模块,并且该模块又被其他模块依赖时,整个堆栈就被链接起来了。堆栈中的所有东西都必须从v1一起升级到v2。

这就是Stephen Colebourne(Joda-Time的创建者)所说的"模块地狱"。

基于文件名的命名问题
想象以下场景:

  • 您依赖 guava-30.0-jre.jar → 自动模块名称:guava.30.0.jre
  • 您发布了您的库 v1.0,其中包含 requires guava.30.0.jre;
  • Guava 发布 v31.0 → JAR 名称变为 guava-31.0-jre.jar
  • 模块名称变为 guava.31.0.jre
  • 您的 v1.0 发布版本现在损坏了,因为它需要一个不再存在的模块名

针对此问题提出的主要缓解措施是,jar文件可以在MANIFEST.MF中有一个名为"Automatic-Module-Name"的新条目。当JPMS检查一个自动模块时,如果MANIFEST.MF条目存在,则使用该值作为模块名,而不是文件名。

解决方案:显式模块名称
库维护者应该将以下内容添加到他们的JAR清单中:

Maven:

<plugin>
    <artifactId>maven-jar-plugin</artifactId>
    <configuration>
        <archive>
            <manifestEntries>
                <Automatic-Module-Name>com.google.common</Automatic-Module-Name>
            </manifestEntries>
        </archive>
    </configuration>
</plugin>

Gradle:

tasks.jar {
    manifest {
        attributes["Automatic-Module-Name"] = "com.google.common"
    }
}

现在,无论JAR文件名是什么,模块名称始终是com.google.common。模块名应全局唯一,并由点分隔的Java标识符组成。通常它应该是一个反向域名,类似于Java包名中常见的格式。

这使您可以:

  • 将 Guava 从 30.0 升级到 31.0 而不会破坏依赖模块
  • 选择一个与您的主包名匹配的名称
  • 为您未来的显式模块化保留您的模块名

真实世界示例:RxJava

RxJava通过指定一个稳定的自动模块名Automatic-Module-Name: io.reactivex解决了这个问题,同时仍然以JDK8为目标。

这非常高明:RxJava保持了Java 8兼容性(没有module-info.java),但通过声明其未来的模块名为JPMS做好了准备。RxJava的用户可以自信地编写requires io.reactivex;,知道这个名称在RxJava最终成为显式模块时不会改变。

迁移策略:自上而下 vs. 自下而上

模块系统设计为同时支持"自下而上"和"自上而下"的迁移。自下而上迁移意味着您先迁移您的小型工具库,最后迁移您的主要应用程序。自上而下迁移意味着您先迁移您的应用程序,之后再迁移工具库。

自上而下迁移(推荐用于应用程序)

这是应用程序开发人员的实用方法:

  1. 最初将所有东西保留在类路径上(未命名模块)
  2. 将应用程序代码移动到模块路径,添加module-info.java
  3. 应用程序将所有依赖项作为自动模块读取
  4. 随着时间推移逐步迁移内部库
  5. 等待第三方库自然地成为显式模块

优点:

  • 您控制应用程序的模块化时间表
  • 无需等待依赖项
  • 增量进展可见且有价值

缺点:

  • 暂时依赖自动模块
  • 必须小心处理重度使用反射的框架

自下而上迁移(适用于库的理想方式)

在此策略中,最初属于应用程序的所有JAR文件都将位于其模块路径上,因此所有未迁移的项目都被视为自动模块。选择依赖层次结构中尚未迁移到模块的较高级别项目,将module-info.java文件添加到该项目中,将其从自动模块转换为命名模块。

优点:

  • 自下而上构建适当的模块化架构
  • 每一层在依赖层迁移之前都已完全模块化

缺点:

  • 需要缓慢的社区范围协调
  • 受限于行动最慢的依赖项

黑暗面:陷阱与限制

自动模块并不完美。过度依赖它们可能导致脆弱的系统、意外的依赖关系和维护问题。

1. 被破坏的封装

所有东西都被导出。您小心隐藏的内部API?全部暴露了。这可能会在您本不打算支持的实现细节上创建依赖关系。

// 在自动模块 "my.library" 中
package my.library.internal;  // 您本意是将其作为 INTERNAL

public class SecretImplementation {
    // 但因为它是自动模块,所以现在成了 PUBLIC API
    public void doSomethingSecret() { }
}

// 在您的应用程序模块中
import my.library.internal.SecretImplementation;  // 这可以工作!

SecretImplementation.doSomethingSecret();  // 糟糕

当该库最终成为显式模块并停止导出my.library.internal时,您的代码就会中断。

2. 隐式依赖所有模块

每个自动模块都依赖每个其他模块。这会创建隐藏的传递依赖。

// 您的模块
requires my.library;  // 它是自动模块

// 您现在隐式地可以访问:
// - my.library 直接依赖的所有东西
// - 那些依赖项所依赖的所有东西
// - 模块路径上的所有其他东西

// 这是伪装下的"依赖地狱"

3. 模块名称不稳定性

如果清单中没有Automatic-Module-Name,升级依赖项可能会破坏您的构建:

# 构建成功
requires commons.io.2.11.0;

# 升级依赖项
# 构建失败,因为模块名称改变了!
requires commons.io.2.12.0;  # 现在需要这个

4. 拆分包噩梦

拆分包可能是一个挑战:重构或合并以避免同一包出现在多个模块中。

JPMS禁止两个模块拥有同一个包。如果您有:

  • my.library.utils 在 JAR A 中(自动模块 my.library.a
  • my.library.utils 在 JAR B 中(自动模块 my.library.b

模块系统拒绝加载两者。这在基于类路径的系统中很常见,但在JPMS中是不允许的。

最佳实践:明智地使用自动模块

仅将自动模块用作过渡性辅助工具,逐步用显式模块化JAR替换它们,使用MANIFEST.MF中的Automatic-Module-Name定义稳定的名称,在CI/CD流水线中记录使用情况,并跟踪传递依赖关系。

对于库维护者

1. 立即添加 Automatic-Module-Name

即使您还没有准备好进行完全模块化:

tasks.jar {
    manifest {
        attributes["Automatic-Module-Name"] = "com.mycompany.mylib"
    }
}

这几乎没有成本,但能为用户提供巨大价值。

2. 仔细选择名称

模块名称应与JAR文件的根包同名。例如,如果一个JAR文件包含com.google.utilities.i18ncom.google.utilities.strings,那么com.google.utilities是模块名的好选择。

遵循反向DNS命名:com.google.guava 而不是 guava

3. 规划您最终的显式模块

您现在选择的Automatic-Module-Name将来就是您的module-info.java中的名称。请明智选择。

对于应用程序开发人员

1. 跟踪自动模块使用情况

维护一个哪些依赖项是自动模块的列表:

// module-info.java
module com.myapp {
    // 自动模块(待替换)
    requires spring.boot;  // TODO: 当Spring完全模块化时替换
    requires hibernate.core;  // TODO: 当Hibernate完全模块化时替换

    // 显式模块
    requires java.sql;
    requires java.logging;
}

2. 使用 jdeps 分析依赖关系

jdeps --module-path mods --check myapp.jar

# 显示:
# - 哪些模块是自动模块
# - 拆分包
# - 对JDK内部API的依赖

3. 设定迁移里程碑

  • 2025年Q1:应用程序代码模块化
  • 2025年Q2:内部库模块化
  • 2025年Q3:在可用时用显式模块替换自动模块

4. 小心处理反射

像Spring和Hibernate这样的框架大量使用反射。您可能需要:

java --module-path mods \
     --add-opens com.myapp/com.myapp.entities=hibernate.core \
     --module com.myapp/com.myapp.Main

--add-opens标志允许Hibernate反射性地访问您的实体类,即使它们位于一个模块中。

生态系统现状(2025年)

JPMS发布五年后,人们对JPMS的看法褒贬不一。好处是显而易见的——安全性、优化、强封装。然而,即使五年后,仍有太多的库生态系统落后。JPMS是一个只有当大家都配合时,其好处才能彰显的系统。

截至2025年,主要库已取得进展:

完全模块化:

  • 大多数JDK模块(java.base, java.sql 等)
  • Jackson (jackson-databind, jackson-core)
  • SLF4J 和 Logback
  • JUnit 5

使用 Automatic-Module-Name

  • Spring Framework(各种模块)
  • Hibernate ORM
  • Apache Commons 库
  • Google Guava

仍有问题:

  • 一些传统的JDBC驱动
  • 较旧的Apache项目
  • 停留在Java 8的利基库

好消息是:大多数积极维护的库现在至少定义了Automatic-Module-Name,使得自上而下的迁移成为可能。

何时不应使用模块

并非每个项目都需要JPMS。在以下情况下可以跳过模块:

  • 您正在构建没有分发顾虑的内部应用程序
  • 您的依赖项对其支持不佳
  • 迁移成本超过收益
  • 您因兼容性原因而停留在Java 8

在Java 9中,您仍然可以在运行应用程序时使用Java VM的-classpath参数。在类路径上,您可以像在Java 9之前那样包含所有您较旧的Java类。

Java 9+ 并不强制您使用模块。类路径仍然有效。自动模块的存在是为了让您能够桥接到模块化,而不是因为您必须迁移。

定论:训练轮还是永久装置?

自动模块就像学骑自行车时的训练轮。它们帮助您过渡,但永久保留它们会阻碍您充分享受模块化系统的效率和安全性。

这个比喻非常贴切。自动模块被设计为临时桥梁,而非终点。它们牺牲了适当模块化的许多好处:

  • 无封装(所有内容都导出)
  • 无显式依赖(依赖所有模块)
  • 潜在的名称不稳定性(基于文件名的命名)

但它们对于迁移绝对至关重要。没有它们,JPMS将无法被采用——没有成千上万个开源项目的协调,就不可能实现迁移。

前进之路

对于大多数项目来说,现实的时间线如下:

  • 2017-2020年: 引入自动模块,早期采用者进行实验
  • 2020-2023年: 主要库添加 Automatic-Module-Name
  • 2023-2025年: 逐步转换为显式模块
  • 2025-2030年: 大多数活跃项目完全模块化
  • 2030年+: 自动模块罕见,仅用于遗留依赖项

我们现在正处于中间阶段。如果您今天开始迁移:

  • 您的应用程序: 可以立即成为显式模块
  • 您的库: 至少应具有 Automatic-Module-Name
  • 您的依赖项: 将混合自动模块和显式模块
  • 您的未来: 逐步用显式模块替换自动模块

代码示例:完整的迁移工作流

让我们看一个完整的前后迁移示例:

之前(基于类路径)

// src/com/myapp/Main.java
package com.myapp;

import com.google.common.collect.ImmutableList;
import org.apache.commons.lang3.StringUtils;

public class Main {
    public static void main(String[] args) {
        var list = ImmutableList.of("Hello", "World");
        System.out.println(StringUtils.join(list, " "));
    }
}
# 编译和运行
javac -cp libs/guava-31.0.jar:libs/commons-lang3-3.12.jar \
      src/com/myapp/Main.java

java -cp .:libs/guava-31.0.jar:libs/commons-lang3-3.12.jar \
     com.myapp.Main

之后(使用自动模块的基于模块)

// src/module-info.java
module com.myapp {
    // 自动模块(这些JAR中没有module-info)
    requires com.google.common;  // 具有 Automatic-Module-Name 的 Guava
    requires org.apache.commons.lang3;  // 具有 Automatic-Module-Name 的 Commons Lang
}

// src/com/myapp/Main.java
package com.myapp;

import com.google.common.collect.ImmutableList;
import org.apache.commons.lang3.StringUtils;

public class Main {
    public static void main(String[] args) {
        var list = ImmutableList.of("Hello", "World");
        System.out.println(StringUtils.join(list, " "));
    }
}
# 使用模块编译和运行
javac --module-path libs \
      -d mods/com.myapp \
      src/module-info.java src/com/myapp/Main.java

java --module-path mods:libs \
     --module com.myapp/com.myapp.Main

代码是相同的;只有打包方式改变了。但现在您拥有了:

  • 依赖项的显式声明
  • 模块边界强制执行
  • 通往完全模块化的路径
  • 使用 jlink 创建自定义运行时映像的能力

jlink 的额外好处:显著减小体积
在迁移之前,使用launch4j创建的等效运行时映像重达175.1 MB。使用JPMS和jlink,映像仅为30.3 MB,减少了5.8倍(83%)!

一旦您完全模块化(没有剩余的自动模块),您就可以使用jlink创建仅包含您所需模块的自定义JRE映像:

jlink --module-path $JAVA_HOME/jmods:mods:libs \
      --add-modules com.myapp \
      --output myapp-runtime \
      --launcher myapp=com.myapp/com.myapp.Main

# 结果:myapp-runtime/ 是一个自包含的 JRE + 您的应用程序
# 可以比捆绑完整 JDK 小 70-80%

这只有在您的所有依赖项都是显式模块时才有效。自动模块不能被包含在jlink映像中——这是它们是训练轮而非最终目标的又一个原因。

我们在本文中学到了什么

关键要点:
自动模块在Java 9中作为一种兼容性机制被引入,以解决巨大的迁移问题。当JPMS于2017年出现时,Java生态系统已经包含了数十亿行代码和成千上万个从未考虑模块化设计的库。一次性转换所有内容是不现实的,因此自动模块提供了一座桥梁,允许传统JAR参与模块系统,而无需立即进行更改。通过将一个普通JAR放在模块路径上,Java将其视为自动模块,从其文件名派生其名称,导出其所有包,并隐式依赖所有其他模块。这种设计牺牲了模块化的保证——例如封装和显式依赖——以保持兼容性并允许逐步迁移。

然而,这种基于文件名的命名系统引入了严重的不稳定性。一个简单的版本升级就可能改变JAR的文件名,从而改变其模块名,破坏依赖它的应用程序。这导致了"模块地狱"的风险,即由于不一致或冲突的名称导致模块解析失败。推荐的解决方案是提供一个Automatic-Module-Name清单条目,它为模块建立了一个稳定的、面向未来的名称,而无需完整的module-info.java。因此,库维护者有责任至少声明此清单属性,因为它几乎没有成本,但能为用户提供巨大价值,并为未来的显式模块化保留模块名。

自动模块在所谓的三向桥梁中也扮演着关键角色。它们连接了显式模块、自动模块和类路径上的未命名模块,使得模块化和非模块化代码能够共存。这使得自上而下迁移——应用程序开发人员先模块化自己的代码——成为大多数团队最实用的策略,而自下而上迁移则需要整个生态系统进行协调一致的模块化。因此,应用程序开发人员可以将自动模块视为临时依赖项,跟踪它们在依赖图中的存在位置,并随着上游库完全模块化而逐步替换它们。

尽管自动模块很有用,但它们也有显著的缺点。它们导出所有包,这消除了封装性;它们隐式依赖所有其他模块,这隐藏了真实的依赖关系。它们还可能触发在类路径上合法但在模块系统中被禁止的拆分包冲突。像Spring和Hibernate这样重度使用反射的框架增加了更多的复杂性,通常需要--add-opens或类似的标志来访问内部API,尤其是在混合使用显式模块和自动模块时。

截至2025年,生态系统已经取得了实质性进展,主要库要么完全模块化,要么至少声明了Automatic-Module-Name。尽管如此,许多中层和长尾库仍然是非模块化的,因此迁移尚未完成。这减缓了诸如jlink等好处的采用,jlink可以创建显著更小、更高效的自定义运行时映像——但前提是整个依赖图都由显式模块组成。因为自动模块阻碍了这种能力,它们的功能就像训练轮:有意设计的临时辅助工具,帮助开发人员朝着完全模块化迈进,但并非旨在永久使用。

至关重要的是,JPMS仍然是可选的。类路径继续有效,许多项目有充分理由选择根本不采用模块。对于那些确实旨在模块化的项目,当前阶段(2023-2025年)是一个依赖项混合、过渡性工具以及整个生态系统稳步但渐进式进步的时期。最终,自动模块代表了一个核心权衡:它们放弃了真正模块化的严格性、安全性和优化机会,以换取兼容性、增量迁移和生态系统稳定性。它们对于JPMS的采用至关重要——但它们也体现了最终必须解决的技术债务。

底线: 自动模块将JPMS从一场迁移灾难转变为可行的渐进式过渡。它们并不完美——它们破坏了封装并创建了隐藏的依赖关系——但它们绝对必要。请将它们战略性地用作通往完全模块化的垫脚石,而非永久解决方案。对于库维护者,请立即添加Automatic-Module-Name。对于应用程序开发人员,请立即模块化您的代码,并随着生态系统的成熟替换自动依赖项。


【注】本文译自:Automatic Modules: Bridging Legacy and Modular Java

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注