Java平台模块系统(JPMS)随Java 9引入,代表了该平台最重大的变革之一。对于运行遗留单体应用的组织而言,JPMS通过显式的模块边界和强封装,提供了一条通往更好可维护性、安全性和可扩展性的道路。然而,迁移需要周密的规划和策略。本文为成功将遗留Java应用迁移至模块化架构提供了实用指南。

理解JPMS解决的问题
遗留Java单体应用通常存在紧耦合(组件深度交织,导致更改风险高)和弱封装(所有内容全局可访问,增加错误)的问题。其他挑战还包括构建速度慢(微小更改需要完全重建)以及有限的可扩展性(难以独立部署部分功能)。
JPMS通过引入显式的模块边界、强封装和更好的依赖管理来解决这些问题。该模块系统允许您分组包,并严格控制哪些属于公共API,模块会显式声明其依赖项,而不是仅仅依赖类路径。
什么是模块?
模块用于分组包,并严格控制哪些包属于公共API。与Jar文件不同,模块显式声明它们依赖哪些模块,以及它们导出哪些包。模块声明存在于源层次结构根目录下名为 module-info.java 的文件中。
以下是一个基本的模块声明:
module com.company.data {
requires java.sql;
requires java.logging;
exports com.company.data.api;
// 内部包不会被导出
}
导出包的公共成员将被依赖模块访问,而私有成员则无法访问(即使通过反射,具体取决于Java版本设置)。
迁移准备
在着手模块化之前,您需要了解应用程序的结构和依赖关系。
步骤1:分析依赖关系
JDeps(Java依赖分析工具)是一个命令行工具,用于处理Java字节码并分析类之间静态声明的依赖关系。该工具对于迁移规划至关重要。
在您的应用程序上运行 jdeps 以获取依赖关系概览:
jdeps -s myapp.jar
这将提供一个摘要,显示您的应用程序依赖哪些JDK模块。要进行更详细的分析:
jdeps --class-path 'libs/*' -summary -recursive myapp.jar
步骤2:识别JDK内部API使用情况
最关键的迁移挑战之一是识别对JDK内部API的依赖,这些API在Java 9+中不再可访问。使用 jdeps 查找这些有问题的依赖:
jdeps -jdkinternals myapp.jar
如果您使用了内部API,jdeps 可能会建议替换方案以帮助您更新代码。例如,如果您的代码使用了 sun.misc.BASE64Encoder,jdeps 将建议改用 java.util.Base64。
步骤3:创建依赖关系图
理解应用程序的一个有效方法是使用分层图,以分层方式表示依赖关系。这有助于识别哪些组件可以迁移以及迁移顺序。
您可以生成DOT文件用于可视化:
jdeps --dot-output . myapp.jar
然后使用像Graphviz这样的工具或像 webgraphviz.com 这样的在线服务来可视化生成的 .dot 文件。
步骤4:识别循环依赖
JPMS禁止模块之间存在循环依赖。解决此问题的常用方法是创建一个包含共享代码的中间模块,从而打破循环。
例如,如果模块A依赖于B,而模块B又依赖于A,您需要将共享功能提取到一个新的模块C中,让A和B都依赖于C。
迁移策略:自顶向下 vs 自底向上
JPMS支持两种主要的迁移策略,每种策略适用于不同的场景。
自底向上迁移
在此策略中,最初所有JAR文件都位于类路径上。然后,通过一个逐步的过程,所有JAR都可以逐个迁移到模块路径上。
过程:
- 选择最低层级的模块:从对您的其他JAR没有依赖关系的模块开始。
- 添加 module-info.java:创建一个模块描述符,定义其依赖项和导出项。
- 移动到模块路径:此JAR成为命名模块。
- 向上重复:继续处理下一层级的模块。
一个三层应用程序(Utils → Services → Application)的示例进展:
# 步骤1:模块化Utils(无内部依赖)
jdeps --generate-module-info . utils.jar
# 步骤2:模块化Services(依赖于Utils)
jdeps --generate-module-info . services.jar
# 步骤3:模块化Application(依赖于两者)
jdeps --generate-module-info . application.jar
自底向上迁移允许底层模块强制执行强封装,而上层模块在过渡期间仍可以访问模块化和非模块化的依赖项。
自顶向下迁移
在此策略中,最初所有JAR文件都位于模块路径上,因此所有未迁移的项目都被视为自动模块。然后,您选择依赖层次结构中尚未迁移的最高层级的项目。
过程:
- 将所有JAR放在模块路径上:非模块化JAR成为自动模块。
- 从顶部开始:首先模块化您的主应用程序模块。
- 添加 module-info.java:使用自动模块名称定义依赖项。
- 向下工作:逐渐用适当的命名模块替换自动模块。
自顶向下迁移鼓励在更高层级进行仔细的API设计,并允许您的主应用程序代码立即从模块化中受益。
理解模块类型
在迁移过程中,您将处理三种类型的模块:
命名模块(显式模块)
显式模块遵循其模块声明(module-info.java)中定义的依赖项和API规则。这些是具有完整模块描述符的完全模块化JAR。
自动模块
自动模块是位于模块路径上的普通JAR(无模块描述符)。模块名称源自JAR文件名或 MANIFEST.MF 文件中的 Automatic-Module-Name 条目。
自动模块在迁移期间提供了一个桥梁:
- 它们可以读取所有其他模块
- 它们导出其所有包
- 其他命名模块可以依赖它们
未命名模块
类路径上的所有内容都成为未命名模块的一部分。未命名模块可以读取所有其他模块,但不能被命名模块所需。这提供了向后兼容性。
使用 jdeps 生成模块描述符
jdeps 命令可以为指定的JAR文件生成 module-info.java 文件:
# 生成模块描述符
jdeps --generate-module-info output-dir myapp.jar dependency.jar
# 生成开放模块(用于反射密集型代码)
jdeps --generate-open-module output-dir myapp.jar
生成的 module-info.java 文件提供了一个起点,但您应该审查并完善它们,以正确封装内部包。
处理常见的迁移挑战
拆分包
一个包不能存在于多个模块中。如果您有拆分包,您需要进行重构或合并,以避免同一个包分布在多个模块中。
反射密集型框架
如果框架使用反射(例如 Spring, Hibernate),请声明 opens 以允许访问:
module com.company.data {
requires java.sql;
exports com.company.data.api;
opens com.company.data.entities to org.hibernate.core;
}
opens 指令允许对包进行反射访问,而无需将其导出到公共API。
第三方库
某些第三方库可能尚未模块化;在这些情况下,请使用自动模块或模块修补。随着生态系统的成熟,越来越多的库正在添加模块支持。
对于没有模块的库,请确保它们的 MANIFEST.MF 中有一个 Automatic-Module-Name,或者向上游贡献一个。
更新构建工具
Maven 配置
使用 maven-compiler-plugin 并配置 --module-path 和 --patch-module 选项:
<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-library 插件:
plugins {
id 'java-library'
}
java {
modularity.inferModulePath = true
}
实践示例:模块化一个三层应用程序
让我们逐步完成一个典型的分层应用程序(包含数据层、服务层和API层)的模块化过程。
步骤1:数据层模块
// 数据层的 module-info.java
module com.company.app.data {
requires java.sql;
requires java.logging;
exports com.company.app.data.api;
// 内部包不导出
}
编译和打包:
javac -d mods/com.company.app.data src/com/company/app/data/**/*.java
jar --create --file mods/com.company.app.data.jar -C mods/com.company.app.data .
步骤2:服务层模块
// 服务层的 module-info.java
module com.company.app.service {
requires com.company.app.data;
requires java.logging;
exports com.company.app.service.api;
}
使用模块路径编译:
javac --module-path mods -d mods/com.company.app.service \
src/com/company/app/service/**/*.java
步骤3:应用程序模块
// 应用程序的 module-info.java
module com.company.app {
requires com.company.app.service;
requires com.company.app.data;
requires java.logging;
}
运行模块化应用程序:
java --module-path mods -m com.company.app/com.company.app.Main
测试您的模块化应用程序
运行完整的单元测试和集成测试,以验证模块边界。测试确保:
- 所有必需的模块都已正确声明
- 导出的包对于依赖模块是足够的
- 没有发生非法访问尝试
- 基于反射的框架能通过
opens指令正常工作
用于兼容性的命令行选项
在迁移期间,您可能需要临时的变通方案。Java提供了几个命令行选项:
--add-exports:将一个模块的包导出给另一个模块--add-opens:为一个包开放深度反射权限--add-reads:使一个模块读取另一个模块--patch-module:覆盖或扩充模块的内容
这些选项应作为临时解决方案,同时您需要适当地重构代码。
迁移后的好处
成功迁移后,您将获得:强封装(模块显式控制API)、更好的依赖管理(具有清晰的模块边界)、更高的安全性(通过减少攻击面)以及更快的启动时间(通过优化的模块图)。
模块化的JDK本身展示了这些好处——您可以使用 jlink 创建仅包含应用程序所需模块的自定义运行时映像,从而显著减小部署大小。
最佳实践
成功迁移到JPMS需要遵循成熟的模式并避免常见的陷阱。以下最佳实践源自现实世界的迁移经验,代表了已成功模块化大型Java应用程序的团队的集体智慧。遵循这些指南将帮助您避免代价高昂的错误,并确保顺利过渡到模块化架构。
| 最佳实践 | 描述 | 重要性 |
|---|---|---|
| 从 Java 9+ 兼容性开始 | 在尝试模块化之前,确保您的应用程序可以在 Java 9+ 上运行 | 将运行时问题与模块相关问题分开,使调试更容易 |
| 增量迁移 | 不要试图一次性模块化所有内容;选择自底向上或自顶向下策略并系统地进行 | 降低风险,允许从早期模块中学习,并保持应用程序稳定性 |
| 策略性使用自动模块 | 将自动模块视为迁移期间的临时桥梁,而不是最终目标 | 自动模块缺乏适当的封装,应替换为显式模块 |
| 审查生成的描述符 | 始终审查和完善由 jdeps 生成的 module-info.java 文件 |
生成的描述符是起点,可能会过度导出或错过重要的封装机会 |
| 积极封装 | 仅导出真正属于公共API的包;隐藏内部包 | 强封装防止紧耦合,使重构更安全 |
| 文档化模块依赖关系 | 维护关于模块边界、职责和依赖关系的清晰文档 | 帮助团队理解模块图并在开发过程中做出明智决策 |
| 彻底测试 | 在每次模块迁移后运行全面的单元测试和集成测试 | 模块化改变了可见性和访问模式,可能会破坏基于反射的代码 |
| 监控非法访问 | 在测试期间使用 --illegal-access=warn 或 --illegal-access=deny |
在内部API导致生产问题之前识别依赖它们的代码 |
| 规划循环依赖 | 通过将共享功能提取到单独的模块中,及早识别并解决循环依赖 | JPMS禁止模块间的循环依赖,因此必须对它们进行重构 |
| 一致的模块版本管理 | 在模块生态系统中使用一致的版本控制方案 | 简化依赖管理并使故障排除更容易 |
结论
将遗留Java单体应用迁移到使用JPMS的模块化Java是一项重大但有益的工作。通过周密的规划、重构和测试,您可以获得更好的可维护性、性能和安全性等好处。
关键是为您的应用程序选择正确的策略——对于依赖关系较少的库和组件使用自底向上,对于希望在应用程序级别立即受益的应用使用自顶向下。广泛使用 jdeps 来了解您当前的状态并指导您的迁移决策。
虽然模块化可以说是Java有史以来最令人望而生畏的特性之一,并且适当的模块在Java世界中仍然很少见,但对于大规模应用程序而言,强封装、显式依赖关系和更高的安全性所带来的好处使得迁移工作是值得的。
【注】本文译自:Java Platform Module System: Migration Strategies for Legacy Applications

