把 Java 类路径留在后视镜里:用模块路径完成一次可控迁移

2026-09-19 20 预计阅读时间: 1 分钟
来源: netflixtechblog.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

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

根据输出,可以按以下顺序推进:

  1. 稳定模块名:优先采用反向域名,例如 com.example.orders,不要依赖文件名偶然推导出的名称。
  2. 先模块化边界清楚的库:工具库、领域库和依赖较少的叶子组件通常比大型入口应用更容易处理。
  3. 缩小导出面:只导出真正属于公共 API 的包,不要为了通过编译而使用无差别导出。
  4. 保留过渡期:无法立即改造的普通 JAR 可以先放到 module path 上作为自动模块,但应把这种状态视为迁移措施,而不是最终设计。
  5. 在 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。
  • 测试通过反射访问未开放成员。
  • 插件系统在运行时动态扫描并加载实现类。
  • 资源文件仍假设只有一个全局类加载器或固定目录结构。

服务提供者机制可通过 usesprovides ... with ... 表达,但迁移插件架构前应单独验证发现、加载和生命周期逻辑,不要只确认编译成功。

不必为了“纯模块化”一次性重写项目

classpath 仍适合短脚本、旧系统和没有封装需求的小工具。迁移是否值得,取决于项目规模、生命周期以及依赖复杂度。更稳妥的落地检查表是:

  • 模块名是否稳定,并且不会随 JAR 文件名变化?
  • requires 是否只包含实际直接依赖?
  • exports 是否对应有意维护的公共 API?
  • 反射访问是否使用最小范围的 opens
  • 是否清理了 split package 和内部 JDK API?
  • CI 是否同时覆盖编译、测试、打包和 module path 启动?
  • 是否为暂时保留的自动模块建立了后续处理清单?

真正应该留在后视镜里的,不只是 -cp 参数,而是“所有代码默认彼此可见”的设计习惯。先让一个边界清晰的组件成为模块,再逐步扩大范围,通常比全仓库同时切换更容易控制风险。


相关推荐