起因:架构评审会上的一个提议
七月底的架构评审,有人提议"我们新服务要不要上 JPMS 模块化"。理由是能强封装、能裁剪 JRE 让镜像变小。当时我们正在做 JDK 11 迁移(见上一篇),正好顺手调研了一下。
我花了三天做了个 POC,结论是:服务端业务系统别上,工具类库可以考虑。过程和理由记一下。
先写了个 module-info 试水
POC 用的是我负责的订单服务的一个子集,拆成三个模块:com.xxx.order.api、com.xxx.order.service、com.xxx.order.infra。
// order-api/src/main/java/module-info.java
module com.xxx.order.api {
exports com.xxx.order.api.dto;
exports com.xxx.order.api.facade;
}
// order-service/src/main/java/module-info.java
module com.xxx.order.service {
requires com.xxx.order.api;
requires java.sql;
requires transitive com.fasterxml.jackson.annotation;
exports com.xxx.order.service.impl;
uses com.xxx.order.api.spi.DiscountPolicy; // SPI
}
语法本身不难,就那么几个关键字:
| 指令 | 作用 |
|---|---|
requires | 声明依赖的模块 |
requires transitive | 依赖传递,别人 requires 我时也能读到 |
exports | 导出包,编译期 + 运行期可读(反射只能读 public 成员) |
exports ... to | 只导出给指定模块 |
opens | 开放包,允许运行期反射(含 setAccessible) |
uses / provides ... with | 模块化的 SPI,替代 META-INF/services |
第一个体会:exports 和 opens 的区别是所有困惑的源头。exports 只保证"能 import、能正常调用 public 成员";一旦框架要 setAccessible(true) 去读写私有字段,就必须 opens。而我们的服务里 Spring、MyBatis、Jackson 全都在干这件事。
第一个坑:自动模块的名字是猜出来的
现实是,绝大多数三方库根本没有 module-info.class。JPMS 对这种情况的处理叫"自动模块":把普通 jar 放到模块路径上,系统按文件名给它起一个模块名。
jackson-databind-2.12.3.jar → jackson.databind
mysql-connector-java-8.0.25.jar → mysql.connector.java
mybatis-3.5.7.jar → mybatis
spring-core-5.3.8.jar → spring.core
规则是去掉版本号、把非字母数字换成点。问题是:这个名字不受任何契约保护。库作者哪天改个 Automatic-Module-Name 的 MANIFEST 头,你的 requires 就编译不过了。而且像 commons-lang3-3.12.0.jar 会被推成 commons.lang3,而 commons-lang-2.6.jar 变成 commons.lang,两个版本共存时非常混乱。
那时候(2021 年 7 月)Spring Framework 5.3 只在 jar 里加了 Automatic-Module-Name,Spring Boot 2.5 完全没有模块化。Hibernate、MyBatis、Netty 也都没有真正的 module-info。也就是说我这个 POC 里 90% 的依赖都走自动模块。
第二个坑:反射全部被拦
加上 module-info 编译通过后,一启动:
Caused by: java.lang.reflect.InaccessibleObjectException:
Unable to make field private java.lang.Long com.xxx.order.api.dto.OrderDTO.userId
accessible: module com.xxx.order.api does not "opens com.xxx.order.api.dto"
to module com.fasterxml.jackson.databind
Jackson 反序列化要设私有字段,被模块系统拦住了。同理 MyBatis 映射 ResultMap、Spring 注入 @Autowired 字段(虽然是 public 方法注入,但 CGLIB 生成的子类在别的包里)都会遇到。
解决办法就是到处 opens:
module com.xxx.order.api {
exports com.xxx.order.api.dto;
exports com.xxx.order.api.facade;
// 为了 Jackson 和 MyBatis 能反射
opens com.xxx.order.api.dto;
opens com.xxx.order.api.entity;
}
或者更省事的 open module com.xxx.order.api { ... },把整个模块全开放。
写到这儿我就开始怀疑了:既然每个包都要 opens,那"强封装"这个最大的卖点还剩什么?我们不过是把编译期检查换成了运行期的一堆 opens 声明。
如果实在不想改代码,也可以用命令行糊过去:
--add-opens com.xxx.order.api/com.xxx.order.api.dto=ALL-UNNAMED
--illegal-access=permit # JDK 16 起默认值变成 deny,17 上基本失效
这跟不模块化有什么区别?
第三个坑:拆分包
编译报了这个错:
error: module com.xxx.order.infra reads package com.xxx.order.api.dto from both
com.xxx.order.api and com.xxx.order.api.v2
JPMS 要求一个包只能属于一个模块。历史上大量库都存在拆分包问题(最典型的是 javax.annotation 同时在 JDK 和 jsr305 里,org.slf4j 的多个实现)。我们自己的代码里也有一个:com.xxx.order.api.dto 在 api 和 api-v2 两个 jar 里都有,是历史遗留的兼容层。要模块化就得先重构掉,这又是额外工作量。
真正拿到的收益:jlink
POC 里唯一让我眼前一亮的是 jlink。把应用模块 + 依赖的 JDK 模块打成定制运行时:
$ jlink --module-path $JAVA_HOME/jmods:target/modules \
--add-modules com.xxx.order.service \
--launcher order=com.xxx.order.service/com.xxx.order.OrderApplication \
--output target/order-runtime \
--strip-debug --no-man-pages --no-header-files \
--compress=2
$ du -sh target/order-runtime
87M target/order-runtime
对比原来的基础镜像:
| 方案 | 镜像大小 | 启动耗时 | 峰值 RSS |
|---|---|---|---|
| openjdk:11-jre-slim + 应用 jar | 243 MB | 24.1 s | 1.42 GB |
| openjdk:11-jre-slim + jlink 运行时 | 112 MB | 22.8 s | 1.38 GB |
| distroless/java11 + jlink 运行时 | 98 MB | 22.5 s | 1.37 GB |
镜像小了 60%,这个收益是实打实的——我们 CI 的镜像推送和网络拉取都变快了,从 47 秒降到 21 秒。但启动时间只快了 1.6 秒(约 6%),因为瓶颈在 Spring 容器初始化,不在类加载。
关键是:jlink 不需要你写 module-info。只要应用能在模块路径上跑起来就行,纯 classpath 应用可以用 jdeps 分析出依赖的 JDK 模块,再手工指定:
$ jdeps --print-module-deps --ignore-missing-deps target/order.jar
java.base,java.compiler,java.instrument,java.logging,java.management,
java.naming,java.security.jgss,java.sql,java.xml,jdk.crypto.ec,
jdk.httpserver,jdk.unsupported
$ jlink --module-path $JAVA_HOME/jmods \
--add-modules java.base,java.sql,java.logging,java.xml,\
java.management,java.naming,jdk.unsupported,jdk.crypto.ec \
--output target/mini-jre --compress=2
这条路能拿到 80% 的镜像收益,成本只有一天,还不用碰任何业务代码。我们最后就是这么干的。
我的结论
对服务端业务系统,JPMS 的账算不过来:
- 生态不支持。2021 年 Spring Boot 2.5、Hibernate、MyBatis 全都没有真正的
module-info,你写的所有requires都指向自动模块,名字不受契约保护。 - 反射是硬伤。IoC、ORM、JSON 全靠反射,最后必然退化为满屏
opens或者--add-opens,封装性收益归零。 - 和构建工具、IDE 的兼容还有毛刺。我 POC 的时候 Lombok 1.18.20 在模块路径下注解处理报错,最后是靠把所有三方 jar 放 classpath、只把自己的代码放 module-path(即混合模式)绕过去的。MapStruct 也有类似问题。
- 拆分包要先重构,历史包袱重的项目根本动不了。
什么情况下值得用:
- 写给别人用的库或者 SDK,有明确的公开 API 边界,且依赖少。JDK 自己的模块化改造就是这个场景。
- 命令行工具、桌面应用,需要
jlink打包成单个可执行文件分发。 - 新项目且技术栈干净,没有重反射框架。
写在后面
现在回头看,《JDK 9 模块化系统 JPMS 到底要不要用》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。