Administrator
发布于 2021-07-30 / 6454 阅读
94

JDK 9 模块化系统 JPMS 到底要不要用

起因:架构评审会上的一个提议

七月底的架构评审,有人提议"我们新服务要不要上 JPMS 模块化"。理由是能强封装、能裁剪 JRE 让镜像变小。当时我们正在做 JDK 11 迁移(见上一篇),正好顺手调研了一下。

我花了三天做了个 POC,结论是:服务端业务系统别上,工具类库可以考虑。过程和理由记一下。

先写了个 module-info 试水

POC 用的是我负责的订单服务的一个子集,拆成三个模块:com.xxx.order.apicom.xxx.order.servicecom.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

第一个体会:exportsopens 的区别是所有困惑的源头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 + 应用 jar243 MB24.1 s1.42 GB
openjdk:11-jre-slim + jlink 运行时112 MB22.8 s1.38 GB
distroless/java11 + jlink 运行时98 MB22.5 s1.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 到底要不要用》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。

参考