Java 的 classpath 简单直接,却把依赖可见性、包冲突和运行时缺失等问题推迟到了应用启动之后。随着项目扩大,一串越来越长的 JAR 路径很难回答几个基本问题:某个组件依赖谁、哪些包允许外部访问,以及启动时是否带齐了依赖。
如果准备减少对 classpath 的依赖,可以把 Java Platform Module System(JPMS)的 module path 当作迁移目标。重点不是立刻把所有 JAR 改造成模块,而是逐步建立明确的依赖边界,并让错误尽量在编译或启动阶段暴露。
classpath 缺少的不是路径,而是边界
在传统 classpath 中,应用通常这样启动:
java -cp 'lib/*:app.jar' com.example.app.Main
这个命令告诉 JVM 去哪里找类,但没有描述这些 JAR 之间的关系。只要类名能够被找到,代码就可能访问原本只想内部使用的包。两个 JAR 如果包含同名包或类,实际加载结果还会受到路径顺序影响。
模块路径增加了三类显式信息:
- 模块身份:每个组件拥有稳定的模块名。
- 依赖关系:通过
requires声明直接依赖。 - 可见边界:只有
exports的包才能被其他模块正常使用。
这并不意味着模块化会自动消除版本冲突。JPMS 主要解决可读性、封装和可靠配置问题,并不是 Maven 或 Gradle 依赖解析器的替代品。构建工具仍然负责下载依赖、选择版本和生成制品。
从两个小模块开始
在缺少更多项目上下文时,可以先用下面这个最小工程验证编译、导出和运行流程。示例需要 JDK 11 或更高版本,JDK 17 或 21 更适合作为当前项目的基线。
复制并执行以下命令:
rm -rf jpms-demo
mkdir -p jpms-demo/src/com.example.greeter/com/example/greeter
mkdir -p jpms-demo/src/com.example.app/com/example/app
cd jpms-demo
cat > src/com.example.greeter/module-info.java <<'EOF'
module com.example.greeter {
exports com.example.greeter;
}
EOF
cat > src/com.example.greeter/com/example/greeter/Greeter.java <<'EOF'
package com.example.greeter;
public final class Greeter {
private Greeter() {}
public static String message(String name) {
return "Hello, " + name + "!";
}
}
EOF
cat > src/com.example.app/module-info.java <<'EOF'
module com.example.app {
requires com.example.greeter;
}
EOF
cat > src/com.example.app/com/example/app/Main.java <<'EOF'
package com.example.app;
import com.example.greeter.Greeter;
public final class Main {
public static void main(String[] args) {
String name = args.length == 0 ? "modules" : args[0];
System.out.println(Greeter.message(name));
}
}
EOF
javac \
-d out \
--module-source-path src \
-m com.example.greeter,com.example.app
java \
--module-path out \
-m com.example.app/com.example.app.Main Java
预期输出为:
Hello, Java!
这个例子中,com.example.app 只有声明 requires com.example.greeter 后才能读取问候模块;问候模块也只有通过 exports com.example.greeter 才会公开对应包。没有导出的实现包仍可供模块内部使用,但不会自然成为外部 API。
如果删除 exports com.example.greeter; 后重新编译,编译器会直接阻止应用导入该包。这正是模块路径的价值之一:边界错误不必等到生产环境中某条代码路径被触发才出现。
迁移现有应用时,先盘点再改造
真实项目通常同时包含自研模块、第三方模块化 JAR 和传统 JAR。不要先批量编写 module-info.java,而应先观察已有制品。
可以使用 JDK 自带工具检查 JAR:
# 查看一个 JAR 是否包含显式模块描述符,或会得到怎样的自动模块名
jar --describe-module --file build/libs/my-library.jar
# 分析应用实际引用了哪些 JDK 模块和外部依赖
jdeps --print-module-deps --ignore-missing-deps build/libs/my-app.jar
# 递归查看依赖关系,定位仍需处理的组件
jdeps --recursive --summary build/libs/my-app.jar
根据输出,可以按以下顺序推进:
- 稳定模块名:优先采用反向域名,例如
com.example.orders,不要依赖文件名偶然推导出的名称。 - 先模块化边界清楚的库:工具库、领域库和依赖较少的叶子组件通常比大型入口应用更容易处理。
- 缩小导出面:只导出真正属于公共 API 的包,不要为了通过编译而使用无差别导出。
- 保留过渡期:无法立即改造的普通 JAR 可以先放到 module path 上作为自动模块,但应把这种状态视为迁移措施,而不是最终设计。
- 在 CI 中验证:使用与生产环境相同的 JDK,通过 module path 编译并启动至少一个烟雾测试。
自动模块可以读取其他模块,也会导出自身包含的包,因此兼容性较强,但封装较弱。如果模块名是根据 JAR 文件名推导出来的,升级依赖或更换文件名可能导致名称变化。引入第三方库前,可以用 jar --describe-module 确认其模块名;库作者也可以提供显式 module-info.class 或稳定的自动模块名元数据。
反射框架是最常见的摩擦点
序列化、依赖注入、ORM 和测试框架经常通过反射访问字段或构造函数。exports 只负责普通代码访问,并不等于允许深度反射。确实需要时,可以对指定模块开放包:
module com.example.orders {
requires com.fasterxml.jackson.databind;
exports com.example.orders.api;
opens com.example.orders.model to com.fasterxml.jackson.databind;
}
这里的关键是使用定向 opens,只向需要反射的框架开放模型包。直接写 open module 会打开整个模块,虽然迁移更省事,却会削弱封装收益。
还要特别检查以下情况:
- 同一个包分散在多个 JAR 中,也就是 split package。
- 代码依赖 JDK 内部 API。
- 测试通过反射访问未开放成员。
- 插件系统在运行时动态扫描并加载实现类。
- 资源文件仍假设只有一个全局类加载器或固定目录结构。
服务提供者机制可通过 uses 和 provides ... with ... 表达,但迁移插件架构前应单独验证发现、加载和生命周期逻辑,不要只确认编译成功。
不必为了“纯模块化”一次性重写项目
classpath 仍适合短脚本、旧系统和没有封装需求的小工具。迁移是否值得,取决于项目规模、生命周期以及依赖复杂度。更稳妥的落地检查表是:
- 模块名是否稳定,并且不会随 JAR 文件名变化?
requires是否只包含实际直接依赖?exports是否对应有意维护的公共 API?- 反射访问是否使用最小范围的
opens? - 是否清理了 split package 和内部 JDK API?
- CI 是否同时覆盖编译、测试、打包和 module path 启动?
- 是否为暂时保留的自动模块建立了后续处理清单?
真正应该留在后视镜里的,不只是 -cp 参数,而是“所有代码默认彼此可见”的设计习惯。先让一个边界清晰的组件成为模块,再逐步扩大范围,通常比全仓库同时切换更容易控制风险。