Java平台模块系统- 整合及JDK工具
Maven + JPMS的相关整合可以参考这一个(看着有点像jmod + jlink): jmod-example
使用Idea可以很方便的进行整合,但是当下针对Spring Boot项目,还需要更多的资料。
主体感觉是还有很长的路要走。
在完成项目模块的源代码之后,我们需要编译和运行这些模块。大部分时候,我们都是在IDE上进行开发和测试,可以把编译和运行的工作交给IDE来完成,当下IDE已经能帮我们完成较多的迁移工作。不过我们仍然可以用javac
和java
来分别编译和运行代码。了解这些JDK工具的细节,可以帮助我们更好的了解模块的生命周期的细节。这里先使用JDK内置的工具来完成某些任务。在迁移到Java 9模块系统时,可能会遇到各种相关问题。了解这些工具,可以帮助更好的分析和解决问题。
我们接下来讨论JDK中的几个内置工具。有些工具在Java 9中增加了与模块相关的参数,而有的工具是Java 9中新增的。这些工具支持一些通用的命令行参数,与模块系统的一些通用概念相对应。这些工具可以在不同的阶段中使用。
- 编译时:使用
javac
命令来把Java源代码编译成字节代码。 - 链接时:这是Java 9中引入的新的可选阶段。使用
jlink
命令来组装和优化一组模块及其传递依赖,并创建自定义的运行时镜像。 - 运行时:通过
java
命令来启动JVM并执行字节代码。
下面首先介绍几个相关的概念。
模块路径
模块系统使用模块路径来查找在不同的模块工件(module artifact)中定义的模块。模块路径本身是一个路径的序列,其中的路径可以是模块定义的路径,也可以是包含模块定义的目录的路径。模块定义可以是模块工件,也可以是包含了模块内容的目录。模块路径中包含的路径会按照在序列中的顺序被依次搜索,直到找到第一个定义了某个特定模块的工件。模块系统使用模块路径来解析依赖关系。如果模块系统无法找到定义某个模块的工件,或者在一个目录中找到了两个定义了名称相同的模块的工件,模块系统会报错然后退出。模块路径使用操作系统上路径分隔符来进行分隔:Windows上是分号(;),macOS和Linux上是冒号(:)。
在不同的阶段可以使用不同类型的模块路径,如下表所示。正如表中所说明的那样,模块路径的不同命令行选项可以应用在多个阶段中。每个模块路径的顺序,定义了当多个模块路径存在时的查找顺序。比如,在使用javac
的编译时刻,表中的4个模块路径都可以适用。模块系统会首先检查--module-source-path
指定的模块路径,其次--upgrade-module-path
,接着是--system
,最后是--module-path
或-p
。
所有Java内置模块和模块路径中定义的模块的集合,被称为可见模块的集合(observable modules)。可见模块的概念在模块解析的过程中非常重要。如果需要解析的模块不出现在可见模块的集合中,模块系统会报错并退出。
模块版本
尽管在模块声明中没有与版本号相关的配置,我们仍然可以记录一个模块的版本信息。推荐的做法是使用语义化版本号(semver)来记录模块版本。Maven和Gradle这些构建工具,应该已经会自动记录版本号。因此,除非是直接使用javac
或jar
命令,我们不需要担心版本号的问题。需要注意的是,模块系统在查找模块时会忽略版本信息。如果一个模块路径中包含了具有相同名称,但是版本号不同的模块,模块系统仍然认为这是一个错误。在解析模块时,模块系统只考虑模块名称。
模块版本可以通过参数--module-version
来指定。
主模块
主模块可以通过参数--module
或-m
来指定。在运行时,主模块包含了需要运行的入口Java类。如果入口类已经被记录在模块的描述文件中,那么只需要指定模块名称就足够了。否则的话,需要通过<module>/<mainclass>
的方式来指定模块名称和入口类名称。
在编译时,--module
或-m
用来指定要编译的模块。
根模块
可见模块的集合中包含了所有可能被解析的模块。然后不是所有的可见模块都在运行时是必须的。模块系统从根模块的集合中开始解析过程,通过传递依赖关系不断地把新的模块加入进来,得到最终的模块依赖图。有可能并不需要解析所有的可见模块,但是只有可见模块是可以被解析的。
模块系统使用一些规则来选择默认的根模块。当编译或运行未命名模块中的代码时,即Java 9之前的代码时,未命名模块对应的根模块包含JDK系统模块和应用模块。如果存在模块java.se
,那么它是唯一的JDK系统模块。否则的话,所有无条件导出了至少一个包的java.*
模块,都是系统模块。所有无条件导出了至少一个包的非java.*
模块,都是根模块。
当编译或运行Java 9代码时,默认的根模块集合取决于阶段。
- 在编译时,是需要编译的模块集合
- 在链接时,是空的。
- 在运行时,是应用的主模块。
我们可以通过参数--add-modules
来添加额外的模块到跟模块集合中。该参数的值是一个逗号分隔的模块名称列表。除了模块列表之外,该参数还可以接受3个特殊值。
ALL-DEFAULT
:添加未命名模块对应的根模块集合,参见上面所述的定义。ALL-SYSTEM
:添加所有的系统模块。ALL-MODULE-PATH
:添加模块路径中搜索到的全部可见模块。
限制可见模块
我们可以通过参数--limit-modules
来限制可见模块。在应用了此参数之后,可见模块的集合被限制为一个新的模块集合的传递闭包。这个新的模块集合中包含--limit-modules
指定的模块、主模块和通过--add-modules
添加的模块。--limit-modules
参数的值也是一个逗号分隔的模块名称列表。
升级模块路径
命令行参数--upgrade-module-path
用来指定升级模块路径。该路径包含可以用来升级环境内置模块的模块。升级模块路径替代了Java已有的扩展机制。
一个系统模块是否可以被升级,已经在module-info.java
里面清楚的说明。比如,模块java.xml.bind
和java.xml.ws
都是可以升级的。
提高可读性和打破封装
模块系统的首要目的是为了封装。然后在有些时候,我们必须要打破封装来处理遗留代码或是运行测试。我们可以下面几个命令行参数来打破封装。
--add-reads module=target-module(,target-module)*
:更新源模块来读取目标模块。目标模块可以是ALL-UNNAMED
来读取所有未命名模块。--add-exports module/package=target-module(,target-module)*
:更新源模块来导出包到目标模块。这会添加一个从源模块来目标模块的受限导出。目标模块可以是ALL-UNNAMED
来导出到所有未命名模块。--add-opens module/package=target-module(,target-module)*
:更新源模块来开放包到目标模块。这回添加一个从源模块到目标模块的受限开放。--patch-module module=file(;file)*
:使用JAR文件或目录中的类和资源文件来覆盖或增加一个模块的内容。在需要临时修改一个模块的内容以方便测试时,--patch-module
非常实用。
在介绍了基本的概念之后,我们下面来介绍JDK的工具。
javac
javac
命令支持下列与模块相关的参数。这些参数的含义已经在之前的小节中进行了介绍。
--module
或-m
--module-path
或-p
--module-source-path
--upgrade-module-path
--system
--module-version
--add-modules
--limit-modules
--add-exports
--add-reads
--patch-module
我们通过在介绍传递依赖一节中使用的模块来说明javac
的用法。我们在一个目录中保存了所有这些模块的源代码。每个模块有其自己的子目录。我们可以使用如下命令来编译单个模块。模块C没有任何其他依赖,可以被直接编译。
$ javac -d ~/Downloads/modules_output/C C/**/*.java
为了编译模块B,我们使用-p
来指定编译好的模块C,因为模块B依赖模块C。当模块依赖第三方库时,也需要使用-p
来指定路径。
$ javac -d ~/Downloads/modules_output/B -p ~/Downloads/modules_output B/**/*.java
我们有全部模块的源代码,可以直接编译全部模块。
$ javac -d ~/Downloads/modules_output --module-source-path . **/*.java
我们也可以使用-m
来编译单个模块。当使用-m
时,需要使用--module-source-path
来指定模块的源代码路径。
$ javac -d ~/Downloads/modules_output --module-source-path . -m B
jlink
运行Java程序需要在本机上安装JRE或JDK。如前所述,在Java 9之前,并没有简单有效的方式来对JRE或JDK的内容进行定制。即便是一个最简单的“Hello World”程序也需要完整大小的JRE来运行。JDK的模块化使得创建自定义的Java运行时镜像变得可能。自定义的镜像只包含应用程序运行所需的模块,从而可以极大地降低镜像的大小。
我们使用jlink工具来创建自定义镜像。假设我们有一个模块demo.simple
,其中的Java类test.Main
用来输出“Hello World”。这是一个最简单的Java应用程序。我们使用jar
工具创建了该模块的JAR文件demo.simple-1.0.0.jar
,并记录了入口Java类。下面的代码给出了使用jlink创建自定义镜像的示例。路径<module_dir>
包含了模块demo.simple
的工件。路径<JDK_PATH>/jmods
则包含了JDK模块。
$ jlink -p <module_dir>:<JDK_PATH>/jmods\
--add-modules demo.simple \
--output <output_dir> \
--launcher simple=demo.simple
jlink
工具会在指定的输出目录中创建自定义镜像。我们可以运行镜像目录下的子目录bin
中的可执行文件simple
来运行该应用程序。在macOS上,创建出来的自定义镜像的大小只有36.5MB,远远低于完整的JRE的大小。下图中给出了自定义镜像中的内容。
jlink
工具也支持很多不同的命令行参数,如下表所示。
不过jlink
工具有一个很大的局限性。那就是jlink
不支持自动模块,这也意味着没有升级到模块的第三方库无法使用jlink
来链接。举例来说,当尝试使用jlink
来创建示例应用的自定义镜像时,会看到如下错误。
Error: module-info.class not found for slf4j.api module
从错误消息中我们可以得知,jlink
需要模块slf4j.api
中包含module-info.class
文件。但是SLF4J还没有升级为Java 9模块,因此无法包括在镜像中。
java
java命令支持下列与模块相关的新参数。
--module-path
或-p
--upgrade-module-path
--add-modules
--limit-modules
--list-modules
:列出所有的可见模块。--describe-module
或-d
:描述模块。--validate-modules
:校验所有模块。可以用来发现冲突和错误。--show-module-resolution
:在JVM启动时输出模块解析结果。--add-exports
--add-reads
--add-opens
--patch-module
对于示例应用来说,在使用Maven的assembly插件把所有的第三方库和模块拷贝到一个目录之后,我们可以用下面的命令来运行应用程序。
$ java -p <path> -m io.vividcode.store.runtime/io.vividcode.store.runtime.Main
如果我们在模块工件中记录了主Java类,可以直接使用java -p <path> -m io.vividcode.store.runtime
来运行应用。
选择--list-modules
在调试与模块解析相关的问题时非常有用,因为它可以列出来所有的可见模块。比如,我们可以使用java --list-modules
来列出来所有的JDK系统模块。当我们使用-p
来添加模块路径时,输出中会包含在模块路径中查找到的模块。选项--show-module-resolution
在解决模块解析问题时也非常实用。
jdeps
jdeps工具用来分析模块的依赖关系。下面的命令输出模块io.vividcode.store.runtime
的描述信息,以及分析出来的模块依赖关系和压缩了传递依赖之后的依赖关系图。
$ jdeps --module-path <path> \
--check io.vividcode.store.runtime
上述jdeps
命令的输出结果如下所示。
io.vividcode.store.runtime (file:///<path>/runtime-1.0.0-SNAPSHOT.jar)
[Module descriptor]
requires io.vividcode.store.filestore;
requires io.vividcode.store.product.persistence;
requires mandated java.base (@9-ea);
requires slf4j.simple;
[Suggested module descriptor for io.vividcode.store.runtime]
requires io.vividcode.store.common;
requires io.vividcode.store.product;
requires io.vividcode.store.product.persistence;
requires mandated java.base;
[Transitive reduced graph for io.vividcode.store.runtime]
requires io.vividcode.store.product.persistence;
requires mandated java.base;
使用--check
参数要求事先知道模块名称。如果只有一个JAR文件,可以使用参数--list-deps
或--list-reduced-deps
。
$ jdeps --module-path <path> \
--list-deps <path>/runtime-1.0.0-SNAPSHOT.jar
上述命令的输出结果如下所示。
io.vividcode.store.common
io.vividcode.store.product
io.vividcode.store.product.persistence
java.base
参数--list-deps
和--list-reduced-deps
的区别在于,--list-reduced-deps
参数的结果中不包含模块依赖关系图中的隐式读取边。
$ jdeps --module-path <path> \
--list-reduced-deps <path>/runtime-1.0.0-SNAPSHOT.jar
上述命令的输出如下所示。
io.vividcode.store.product.persistence
jdeps
工具的另外一个功能是生成graphviz的DOT文件,可以通过图的方式来可视化模块依赖关系。下面的命令生成模块io.vividcode.store.runtime
的依赖关系DOT文件。
$ jdeps --module-path <module_path> \
--dot-output <dot_output_path> \
-m io.vividcode.store.runtime
在输出目录中包含了一个对应于全部模块的DOT文件summary.dot
,以及一个对应于模块的文件io.vividcode.store.runtime.dot
。接着我们可以把DOT文件转化成PNG文件来查看。需要首先安装graphviz。
$ dot -Tpng <dot_output_path>/summary.dot \
-o <dot_output_path>/summary.png
我们可以使用jdeps
来处理多个JAR文件来生成整个项目的模块依赖关系图。生成的summary.dot
文件中包含了全部模块。对于示例应用,我们使用Maven来把所有的模块工件和第三方库复制到不同的目录。可以使用Maven的dependency插件来完成。我们接着可以使用如下命令来生成DOT文件。<third_party-libs-path>
是第三方库所在的路径,<modules_path>
是所有模块工件的路径。
$jdeps --module-path <third_party-libs-path> \
--dot-output <dot_output_path> \
<modules_path>/*.jar
接着我们把生成的summary.dot
文件转换成PNG文件,如下图所示。
jdeps
可以用来从JAR文件中生成模块声明文件module-info.java
。选项--generate-module-info
和--generate-open-module
可以分别用来生成正常模块和开放模块的声明。比如,我们可以用下面的命令来生成JAR文件jackson-core-2.8.7.jar
的module-info.java
文件。
生成的module-info.java
文件如下所示。该声明文件只是简单地导出了全部包和添加了服务提供者。生成的module-info.java
文件可以作为把遗留JAR文件迁移到Java 9的基础。
module jackson.core {
exports com.fasterxml.jackson.core;
exports com.fasterxml.jackson.core.base;
exports com.fasterxml.jackson.core.filter;
exports com.fasterxml.jackson.core.format;
exports com.fasterxml.jackson.core.io;
exports com.fasterxml.jackson.core.json;
exports com.fasterxml.jackson.core.sym;
exports com.fasterxml.jackson.core.type;
exports com.fasterxml.jackson.core.util;
provides com.fasterxml.jackson.core.JsonFactory with
com.fasterxml.jackson.core.JsonFactory;
}