本文目录导读:

从混乱到清晰:JPMS(Java平台模块系统)实战案例深度解析——如何用模块化重构你的遗留系统
目录导读
- 引言:模块化的“救赎”还是“灾难”?
- JPMS核心概念速览(不想看理论的直接跳到案例)
- 实战案例一:电商微服务中的“依赖地狱”破解
- 1 痛点分析:Classpath的“三宗罪”
- 2 模块化设计:从
module-info.java开始的边界重构 - 3 关键代码与迁移策略(隐藏内部API的妙用)
- 实战案例二:JDK内置库的强封装——
jdk.unsupported的启示 - 高频问题问答(FAQ)与避坑指南
- 何时该用JPMS,何时该“绕道走”?
引言:模块化的“救赎”还是“灾难”?
在Java的世界里,Classpath曾经是自由的代名词,但也是混乱的温床,你是否有过这样的经历:一个大型项目部署时,因为jar包版本冲突,或者由于某个类被意外地重复加载,导致NoSuchMethodError在深夜两点准时拜访你?
JPMS(Java Platform Module System,即Project Jigsaw)自JDK 9引入后,宣称要解决这些问题,但很多团队尝试后反馈:“模块化太难了,迁移成本太高。” 事实真的如此吗?本文将通过两个真实的JPMS案例,展示它如何将“隐式依赖”变成“显式契约”,并告诉你如何在不伤筋动骨的情况下完成改造。
JPMS核心概念速览
在进入案例前,我们需要统一认知,JPMS的核心不是“隔离”,而是“声明”,通过module-info.java文件,你明确声明:
requires:我需要谁(依赖)。exports:我开放谁(对外API)。opens:我允许谁通过反射访问我(对框架特别重要)。
实战案例一:电商微服务中的“依赖地狱”破解
1 痛点分析:Classpath的“三宗罪”
假设你有一个电商系统,包含order-service、user-service和common-util三个模块(这里指Maven模块,非JPMS模块),在传统Classpath模式下,存在以下问题:
- 隐式传递:
order-service依赖了common-util,而common-util又悄悄依赖了httpclient,如果order-service直接使用httpclient的类,编译器不会报错,但当common-util升级并移除httpclient时,编译期正常,运行期直接崩溃。 - 强封装失效:
common-util中有一个internal包,本意是仅供内部使用,但任何Classpath上的代码都能直接访问它。 - 反射攻击:框架(如Spring)通过反射访问私有属性,导致无法轻易进行强封装。
2 模块化设计:从module-info.java开始的边界重构
我们在改造时,并没有把所有包都模块化,而是先建立“模块化基座”,步骤如下:
-
第一步:拆分
common-util将原common-util拆分成两个JAR:common.util(对外API:com.example.common.util)common.internal(内部实现:com.example.common.internal)
-
第二步:编写
module-info.java对于
common.util模块,其module-info.java如下:
module common.util {
exports com.example.common.util; // 只导出工具接口
requires java.base; // 隐含依赖
}
对于order-service模块,关键点在于隐藏内部API:
module order.service {
requires common.util; // 显式声明依赖
// 不添加 requires common.internal; 故意不依赖内部模块
exports com.example.order.api;
}
关键操作:我们通过--patch-module或者Maven插件,将common.internal在运行时以add-reads方式提供给common.util内部使用,但禁止order.service直接读取。
3 关键代码与迁移策略(隐藏内部API的妙用)
痛点解决:
- 痛点1解决:现在
order-service的IDE中,你无法导入com.example.common.internal包下的任何类,因为common.util模块没exports它,编译期就能发现“不合法依赖”,而非运行期。 - 痛点2解决:
internal包被common.util模块封装后,即使你在Classpath上强行加回了common.internal.jar,由于没有exports,在模块化环境下JVM会抛出IllegalAccessError。
迁移技巧(实用):
不要追求“一刀切”,在module-info.java中,你可以使用requires static来声明编译期依赖(如Lombok),使用uses和provides来实现SPI(服务提供者接口),这让Spring等框架得以正常工作。
实战案例二:JDK内置库的强封装——jdk.unsupported的启示
这是JDK自身提供的完美案例,在JDK 8及以前,sun.misc.Unsafe是一个“神一样的存在”,所有魔法(CAS操作、内存分配)都靠它,但这也是Oracle的痛——任何人只要import sun.misc.Unsafe;就能破坏JVM稳定性。
在JPMS中,JDK定义了一个名为jdk.unsupported的模块,它导出了sun.misc和sun.reflect,但明确标注这些包是“不支持且危险的”。
这个案例告诉我们什么?
- 模块化不是禁止,而是管理风险,即使是最底层的JDK,也通过模块声明,告诉开发者:“你可以用,但后果自负,且未来版本可能随时删除。”
- 对于我们的业务代码,如果你有类似的“危险API”(如全局静态缓存),应当单独放在一个模块(例如
com.yourcompany.hotspot)中,并加上详细注释,通过exports控制访问权限。
高频问题问答(FAQ)与避坑指南
Q1:我的项目还在用Spring Boot 2.x,需要迁移到JPMS吗?
- A:不需要完全迁移,Spring Boot 2.x采用“命名模块”与“自动模块”并存机制,你可以将项目保留在Classpath模式,但构建出模块化JAR(带
module-info.class),这样其他模块化项目能引用它,而它自己依然依赖Classpath,这是一个折中方案。
Q2:为什么一用JPMS,反射就报InaccessibleObjectException?
- A:根源在于封装,解决方案有三种:
- 在模块声明中添加
opens com.example.entity to spring.core;(定制化开放)。 - 使用
--add-opens命令行参数(开发阶段必备)。 - (推荐)使用
org.reflections这类库时,确保其作为自动模块(JAR在Classpath)工作,而非模块路径。
- 在模块声明中添加
Q3:迁移后,JAR包体积变小了,但启动报错Module com.a not found?
- A:这是典型的服务加载器(ServiceLoader) 问题,你需要检查
META-INF/services文件,确保使用了provides关键字声明实现类,而非仅放在模块内。
避坑指南:
- 不要使用
requires transitive泛滥,除非你确定你的API向外暴露了依赖类型。 - 分包时避免循环依赖,JPMS默认禁止模块之间的循环依赖(编译期直接报错),这倒逼你重构架构。
何时该用JPMS,何时该“绕道走”?
-
强烈建议使用JPMS的场景:
- 你正在开发公共基础库(如日志、ORM框架),且希望用户只能访问API,不能碰内部实现。
- 你有插件化架构需求(如IDEA、Eclipse),需要严格的版本隔离。
-
建议“绕道走”的场景:
- 中型单体应用,团队人数少,且Classpath冲突极少。
- 大量依赖旧版
JAXB、CORBA等JEE模块(JDK 11后已移除)。
最后决策:JPMS是一把手术刀,它很锋利,但割错了地方会大出血,本文的案例表明,最高的价值在于“编译期强制约束”和“隐藏内部实现”,如果你能从这两个角度审视你的系统,那么JPMS必能为你带来长期的架构稳定。
互动问答延伸(精选用户反馈)
读者“代码骑士”问:在案例一中,你拆分了
common-util,但如果不想拆包,只想隐藏包内的某个类,该如何操作?答:JPMS的粒度是“包”,不是“类”,如果你不想拆包,只能通过代码层面设计——将该类改为
package-private(默认权限),或者使用接口隔离,若必须隐藏,建议还是拆包,这是最正统的解法。
(文章完)