Java21手册(七):模块系统
Java平台模块系统(Java Platform Module System)是Java 9版本引入的一项重大变更,目的是帮助开发人员更加高效地构建、维护和重用软件系统。具体来说,模块系统的核心目标有两个:
- 提供可靠的依赖配置,用模块的方案来替代混乱的classpath机制,使程序组件能够声明彼此之间的显式依赖关系。
- 提供强封装机制,允许java组件声明哪些public类和方法可被外部访问。
Java的模块是在包上面一层更高级别的聚合。每个模块都会明确地声明对其他模块的依赖,以及向其他模块提供的API,这种显示的声明为系统提供了更好的封装和安全性。模块系统彻底重构了JDK的组织结构,去掉了以rt.jar等jar包组成的结构,改用模块来进行组织。对比Java 8和Java 20的包路径,可以清晰看到模块系统改造前后的差别:
以Java20为例,使用java --list-modules可以看到目前约有70个模块,由java和jdk开头分为了两类模块:
- 标准模块,其规范受JCP管理,即The Java Platform, Standard Edition (Java SE) API,名称以字符串"java."开头。
- 非标准模块,仅属于JDK,但并不在Java SE平台的所有版本中都可用的API,名称以字符串"jdk."开头。
模块之间的依赖由模块图来描述,标准的Java SE模块以橙色表示,非SE模块以蓝色表示,模块之间的强依赖关系以深色箭头连接,隐式依赖关系以浅色箭头连接。以模块 jdk.jshell 为例,它依赖Java SE模块 java.compiler 和 java.prefs,以及非SE模块 jdk.jdi,这三个模块依赖 java.base 模块:
在所有模块中,java.base 是唯一一个完全独立的模块,不依赖任何其他模块,其他所有模块和用户自定义模块都会默认依赖java.base 模块。java.se 是一个聚合模块,它汇集了组成Java SE平台的所有模块,不添加其他内容,依赖 java.se 模块的系统会包含所有Java SE平台的API。java.smartcardio 是一个特殊的模块,它虽然是标准模块,但并不包含在Java SE中,因此在模块图中是蓝色的。Java21的全部模块可以查看文档:download.java.net/java/early_…
7.2 定义模块
模块的内容
模块 = 模块唯一命名 + 包 + 类 + 数据 + 资源文件 + 模块描述文件,一个模块包含的内容如下图所示:
模块由jar作为载体,按照约定在模块内的源文件根目录添加模块描述符文件 module-info.java,该文件所在包以及子包就组成了一个模块。
模块描述符文件 module-info.java 定义了模块的如下信息:
-
模块的命名
-
依赖的其他模块
-
模块的封装信息(类的访问、反射的权限)
-
服务提供者和服务消费者(用于SPI)
模块描述符
创建模块
通过module定义模块的名字,模块的命名不可重复,一般推荐使用反转域名。可添加open修饰表示整个模块都可以用反射的方式访问,默认是不开启的,例子如下:
module com.cz.mod1{ }
open module com.cz.mod1{ }
定义依赖
- 通过requires定义依赖的模块,使其可以访问其他模块下的包,为了方便使用,用户定义的模块都会默认requires java.base
- 添加static关键字表示模块在编译期是必须的而在运行期是可选的,例如我们引入lombok仅在编译期间执行即可
- 添加transitive关键字表示开启传递依赖,例如A模块依赖B模块,B模块依赖C模块,则A可以直接访问C模块,但是官方并不推荐使用这种隐式传递的方式
例子如下:
requires com.cz.mod2;
requires static com.cz.mod2;
requires transitive com.cz.mod2;
定义公开接口
通过exports定义对外开放的包,可通过exprots...to... 明确指定允许访问的其他模块,例子如下:
exports com.cz.mod1
exports com.cz.mod1 to com.cz.mod2
定义访问权限
通过opens定义可被反射访问的包,和exports的区别是,exports是在编译期和运行期都允许被访问,而opens是专门用来描述在运行期间可以被访问的指令。
同样的opens...to... 也可指定允许访问的其他模块,需要注意的是如果module已经被open修饰,则不需要再使用opens了,因为整个模块都已经允许被反射访问了,例子如下:
opens com.cz.mod1
opens com.cz.mod1 to com.cz.mod2,com.cz.mod3;
定义SPI
通过 uses 和 provider...with 定义SPI,在模块化系统中SPI的接口和实现的配置全部移动到了module-info.java中(老的定义方式还可以使用),例子如下:
第一步,创建三个模块:app模块、接口定义模块、接口实现模块
api层的module-info.java,使用exports对外暴露api接口,同时使用uses声明PayService为SPI:
module demo.spi.service.api {
exports org.example.spi.service.api;
uses org.example.spi.service.api.PayService;
}
provider层的module-info.java,使用requires依赖spi所在模块,使用provides...声明使用哪个SPI,使用with...表示要公开的SPI的具体实现:
module demo.spi.service.provider {
requires demo.spi.service.api;
provides org.example.spi.service.api.PayService with
org.example.spi.service.provider.WechatPayService,
org.example.spi.service.provider.AliPayService;
}
app层的module-info.java,应用层直接依赖spi接口层模块:
module demo.spi.app {
requires demo.spi.service.api;
}
第二步,PayService实现:定义接口方法pay,并定义getInstances方法通过ServiceLoader获取SPI的实现:
public interface PayService {
void pay();
static List<PayService> getInstances() {
ServiceLoader<PayService> services = ServiceLoader.load(PayService.class);
List<PayService> list = new ArrayList<>();
services.iterator().forEachRemaining(list::add);
return list;
}
}
第三步:应用层执行
public class Main {
public static void main(String[] args) {
List<PayService> instances = PayService.getInstances();
instances.forEach(PayService::pay);
}
}
执行结果:
wechat pay
ali pay
编译和运行模块
三个重要命令:
- -d 参数指定编译文件的输出目录
- -p 指定模块路径module-path
- -m 指定运行模块主函数
完整的示例:使用前面的spi作为示例项目进行编译和运行,由于demo.spi.app、demo.spi.service.api、demo.spi.service.provider三个模块存在着依赖关系(后面简称app、api、provider),app依赖api,provider依赖api,所以模块编译有先后顺序。
1、编译api模块
使用-d命令编译
javac -d mods/demo.spi.service.api demo-spi-service-api/src/main/java/module-info.java demo-spi-service-api/src/main/java/org/example/spi/service/api/PayService.java
2、编译provider模块
使用-p指定依赖的模块,再使用-d命名编译
javac -p mods -d mods/demo.spi.service.provider demo-spi-service-provider/src/main/java/module-info.java demo-spi-service-provider/src/main/java/org/example/spi/service/provider/*
3、编译app模块
使用-p指定依赖的模块,再使用-d命名编译
javac -p mods -d mods/demo.spi.app demo-spi-app/src/main/java/module-info.java demo-spi-app/src/main/java/org/example/spi/app/Main.java
至此模块已编译打包完成,生成的mod如下图所示:
4、运行模块
使用-p命令指定依赖的模块,使用-m指定运行模块的主函数
java -p mods -m demo.spi.app/org.example.spi.app.Main
执行效果图如下:
7.3 非模块系统兼容
到目前为止,我们已经通过例子,了解了如何从头开始定义模块,以及如何与其他模块一起使用。然而作为Java 8系统的开发者,我们现有的项目以及我们使用的各种JAR包依赖,大多数并不是通过模块系统构建的。这一节我们来简单介绍一下如何兼容非模块系统构建项目的运行。
模块的分类
在使用Java9后续版本的项目中,任何代码都会包含在模块下,具体来说模块分为三类:标准模块、自动模块(Automatic modules)和未命名模块(The unnamed module)。
标准模块 就是使用标准模块化方式开发的模块,并且在运行时放在了程序module-path下面。
自动模块 是由放在module-path下未模块化的jar文件自动转化而来,它有以下特性:
- 自动模块根据manifest或jar的名称来决定模块名
- 自动模块会 exports 和 opens 模块内所有的包
- 自动模块 requires 所有模块(也可以读取未命名模块)
未命名模块是由所有在class-path下的jar文件自动转化而来,它有以下特性:
-
未命名模块会 exports 所有包
-
未命名模块 requires 所有模块
-
因为没有名字,未命名模块无法被requires使用
由于未命名模块默认可访问所有其他可读模块的导出类型,因此我们在Java 8上编译和运行的程序,在Java 9及后续版本中依然可以正常编译和运行,只要使用的是标准非过时的Java SE API。如果一个包在一个命名模块和未命名模块中都有定义,那么未命名模块中的包将被忽略。
自底向上迁移模块
由于未命名模块没办法被命名模块 requires,对现有模块的迁移需要以自底向上的方法来完成,以官网的实例为例,com-foo-app.jar、com-foo-bar.jar、org-baz-qux.jar是在Java 8环境下开发和构建的包,在Java 9下的模块依赖如下:
这三个包中,com-foo-app.jar 依赖 com-foo-bar.jar,com-foo-bar.jar 依赖 org-baz-qux.jar,那么这三个jar包迁移模块化的顺序应该为 org-baz-qux.jar -> com-foo-bar.jar -> com-foo-app.jar,迁移后的模块依赖图为:
如果 org-baz-qux.jar 是一个外部二方库,或者你推动其他人把这个包改为标准模块的成本又过高,那么你可以把 org-baz-qux.jar 放在module-path下,让它成为自动模块,由于自动模块也是命名模块,所以可以被标准模块依赖。然而依赖自动模块进行迁移改造,我的观点是不要轻易尝试,原因有二:
-
将jar包放在module-path下这个操作本身,跟我们常用的maven、gradle等现有包管理系统的机制理念并不相容,程序在远程部署时很难对特定的包路径进行特殊设置;
-
更关键的是一旦依赖了自动模块,相当于我们的模块依赖的是文件名而不是模块名,通常我们使用maven工具来打包时,文件名和模块名是完全不同的,依赖了错误的名字意味着未来将会有非常大的成本去修改这个错误,因为模块依赖是嵌套的。
因此现有代码向模块迁移,最好是在确认当前的包所有的依赖都已经模块化,至少使用maven等工具时确认依赖包的MANIFEST.MF设置了Automatic-Module-Name(maven会自动按照设置的模块名,将该模块添加到模块路径中,并解析其依赖关系),然后再进行模块化改造,否则还是保持当前作为未命名模块的状态来使用。
打破模块封装
JDK的模块化,除了重新组织了代码结构外,还对外隐藏了很多内部类和非公共方法,这意味着一些在Java8中可以使用的库,在模块系统中会因为访问权限不足而导致服务启动失败。例如guice库中定义的反射方法类$ReflectUtils,通过反射的方式调用java.base模块中的java.lang.ClassLoader::defineClass方法:
AccessController.doPrivileged(new PrivilegedAction() {
public Object run() {
try {
Class loader = Class.forName("java.lang.ClassLoader");
$ReflectUtils.DEFINE_CLASS = loader.getDeclaredMethod("defineClass", String.class, byte[].class, Integer.TYPE, Integer.TYPE, ProtectionDomain.class);
$ReflectUtils.DEFINE_CLASS.setAccessible(true);
return null;
} catch (ClassNotFoundException var2) {
throw new com.google.inject.internal.cglib.core..CodeGenerationException(var2);
} catch (NoSuchMethodException var3) {
throw new com.google.inject.internal.cglib.core..CodeGenerationException(var3);
}
}
});
由于java.lang.ClassLoader::defineClass并不是public类型,guice-4.1.0库没有权限访问,使用到这个方法的服务就会报这样的异常:
Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int,java.security.ProtectionDomain) throws java.lang.ClassFormatError accessible: module java.base does not "opens java.lang" to unnamed module @183ec003
而如上文所述,假如你暂时还不能将现有的系统和组件全部升级到与模块系统兼容的版本,那么你就需要通过打破模块封装来快速实现兼容。打破模块封装有两种方式,在启动参数中添加 --add-exports 或 --add-opens,这两种参数都可以重复使用,使用形式也相同,都是
--add-opens <source-module>/<package>=<target-module>(,<target-module>)*
其中source-module和target-module是模块名称,package 是包的名称。
--add-exports 的效果是从源模块向目标模块添加指定包的限定导出。这本质上是模块声明中 exports 子句的命令行形式,或者是 Module::addExports 方法的无限制调用。因此,如果目标模块读取源模块(通过模块声明的 requires 子句、Module::addReads 方法的调用或 --add-reads 选项的实例),则目标模块中的代码将能够访问源模块中指定包的公共类型。例如,如果模块 jmx.wbtest 包含对 java.management 模块的未导出 com.sun.jmx.remote.internal 包的白盒测试,则可以通过以下选项授予所需的访问权限:
--add-exports java.management/com.sun.jmx.remote.internal=jmx.wbtest
作为特殊情况,如果 是 ALL-UNNAMED,则源包将被导出给所有未命名模块,无论它们是否最初存在或稍后创建。因此,可以通过以下选项将对 java.management 模块的 sun.management 包的访问权限授予class-path上的所有代码:
--add-exports java.management/sun.management=ALL-UNNAMED
--add-opens 的语义也类似,但作用只在程序运行时,而非像exports那样覆盖编译期和运行时,但opens可以让目标模块中的代码能够使用核心反射 API 访问源模块中的所有类型,包括公共和非公共类型。因此要想让上面那个guice代码正常运行,需要添加启动参数:
--add-opens java.base/java.lang=ALL-UNNAMED
如果你打算在自己的公司升级Java 8及之前版本的项目,打破模块封装可能是你不得不使用的方式,这也是我所在的团队目前选择的方式。对于业务繁多、依赖库复杂的公司项目,推动组件升级是一个需要慎之又慎的过程,对现有代码尽可能小的改动无疑是最优选择。然而打破封装确实也违背了Java的封装意图,并且我们用到的非公开API,在未来的版本中也存在变更或移除的风险,长久来看,我们还是要向标准的模块系统构建项目去规划。