本文来自作者 成富 在 GitChat 上分享「Java 9 平台模块系统初探」,「阅读原文」查看交流实录
「文末高能」
编辑 | 嘉仔
提到模块,大部分开发人员应该都不陌生。模块化一直是软件工程领域推荐的实践。通过把一个项目划分成若干个相互依赖的模块,可以开发出所谓高内聚和低耦合的系统。 模块化也有利于项目团队的分工合作,以及代码复用。
模块化的开发实践,早在Java 9之前就已经出现,而且得到了广泛的应用。比如,我们通常会把一个项目划分成多个子项目。子项目之间通过依赖关系组织在一起。
目前已有的构建工具,包括 Apache Maven 和 Gradle,都已经支持这样的开发模式很久了。
在 Apache Maven 中,我们可以在一个项目中创建多个不同的模块;在 Gradle 中,我们可以在一个项目中创建不同的子项目。
不论是 Maven 中的模块还是 Gradle 中的子项目,其实都是广义上的模块。
所有这些模块,在构建时,都会被打包成一个对应的JAR文件。所以这些模块在运行时也是相对独立的。
既然模块化的实践早就存在了,那为什么 Java 9 要花这么大的力气把模块系统做到 Java 平台中呢?
实际上,添加模块系统的 Project Jigsaw 之前是希望在 Java 8 中添加的,只是由于影响太大难以按时完成,才被延迟到了 Java 9 中。Java 9也因为模块系统被延迟了好几次。
我们可以从几个方面来谈谈模块系统的必要性。
从JDK和JRE的角度来说,Java应用在运行时需要JRE的支持。在Java 9之前,JRE的安装是没有办法定制的。
安装的时候只能选择安装完整的 JRE。JRE 中包含的类库和工具多种多样,可以满足所有应用的不同需求。
然而对每个具体的应用来说,JRE中包含的内容大部分都是多余的。比如,服务器端的应用基本上不会用到桌面应用所需的 Swing 库。
随着Java版本的不断升级,其中所包含的内容只会越来越多,JRE所占的空间也会越来越大。这无形之中增加了对存储和带宽的要求。
Java 9 中的模块系统也包括把JDK模块化。JDK 9一共由94个模块组成。通过新增的 jlink 工具可以创建出每个应用所独有的 Java 运行时镜像。
在这样的镜像中,只需要包含应用真正依赖的 JDK 模块即可。这可以极大的减少应用所需的 Java 运行时环境的大小。对于简单的应用来说,所需的运行时镜像大小也就只有几十兆。
从应用的角度来说,Java 9 之前的应用在运行时都需要依赖 CLASSPATH。把应用本身的 JAR 包和第三方库的 JAR 包都放在CLASSPATH上,在运行时由JVM来查找并加载所需的 Java类。
对于一个复杂的应用来说,多个第三方库的传递依赖之间可能互相冲突,产生所谓的 JAR HELL 问题。依赖关系是通过类型之间的引用关系来隐式的表达的。JAR HELL 产生的根源在于 CLASSPATH 是单一的线性空间。
模块系统的引入,增加了一个新的维度,也就是模块。依赖关系通过模块来显式的声明。这为应用所使用的第三方库之间的依赖关系,添加了更多的确定性。
很多开发人员可能听过说OSGi。作为Java平台上模块化的一种实现方式,OSGi通过复杂的类加载器机制来实现不同模块,以及同一模块的不同版本之间的相互隔离。
OSGi功能强大,但是其复杂性也是很高的。作为Java平台上的原生实现,Java平台模块系统更有吸引力。
当然了,从功能上来说,模块系统所能做的事情也相对有限。比如,模块虽然可以记录版本号,但是版本号在模块解析时是被忽略的。
模块系统为开发模块化应用提供了一个Java平台的原生解决方案。对于大部分应用来说,模块系统所提供的功能应该足够了。
什么是模块?
那么, 到底什么是模块呢?根据Oracle的Java平台集团的首席架构师Mark Reinhold的论述:
模块是一个命名的、自我描述的代码和数据的集合。模块的代码被组织成多个包,每个包中包含Java类和接口;模块的数据则包括资源文件和其他静态信息。
从上述的定义可以看出来,Java 9 中的模块与现在我们经常使用的 Maven 中的模块或 Gradle 中的子项目并没有太大的区别。它们可以很容易的转换成 Java 9 模块。
每个模块都需要有自己的名字,称为命名模块。名称是模块的唯一标识符,也是进行解析时的唯一查找条件。
推荐的做法是采用与Java包相同的命名规则,也就是倒转域名的格式,如com.mycompany.mymodule
。
模块与一般子项目的区别在于,模块的源代码的根目录下包含module-info.java
文件来作为模块的描述符。
该文件会被编译成module-info.class
文件出现在打包好的模块工件中,一般是模块的JAR文件中。module-info.class
文件的出现与否,就是Java 9中模块的基本特征。
在module-info.java
文件中,我们可以用新的关键词module
来声明一个模块,如下所示。下面给出了一个模块com.mycompany.mymodule
的最基本的模块声明。
module com.mycompany.mymodule {
}
下面我们介绍模块声明文件中的重要组成部分。
模块依赖和包导出
模块之前存在着依赖关系。每个模块可以通过 requires 来声明其对其他模块的依赖关系。依赖一个模块并不意味着就自动获得了访问该模块中包含的Java类型的许可。
一个模块可以声明其中包含的哪些包(package)是可供其他模块访问的。只有被导出的包才能被其他模块所访问。
而在默认的情况下,是没有任何包被导出的。我们通过在模块声明文件中的exports
来导出包。
导出的包中包含的public
和protected
类型,以及这些类型中包含的public
和protected
成员是可以被依赖它们所在模块的其他模块来访问的。
需要注意的是,当导出一个包时,只有该包中的类型会被导出,子包中的类型不会被导出。
如果声明导出的包为com.mycompany.mymodule
,则类似com.mycompany.mymodule.A
和com.mycompany.mymodule.B
这样的类型会被导出;
而类似com.mycompany.mymodule.impl.C
或com.mycompany.mymodule.test.demo.D
这样的类型则不会。如果需要导出子包,必须使用exports
来对每个子包进行显式声明。
如果一个模块中的类型不能被其他模块所访问,那么该类型等同于该模块中的私有类型或类型中的私有成员。试图在源代码中使用这些类型或成员会产生编译错误。
在运行时,则会由JVM抛出java.lang.IllegalAccessError
错误;如果试图通过Java的反射API来访问,则会抛出java.lang.IllegalAccessException
异常。
下面是一个使用了requires
和exports
的模块声明文件。模块com.mycompany.moduleA
导出了包com.mycompany.moduleA
,同时依赖模块com.mycompany.moduleB
。
module com.mycompany.moduleA {
exports com.mycompany.moduleA;
requires com.mycompany.moduleB;
}
受限导出
在导出一个包时,默认情况下是对所有声明依赖了该包所在模块的全部其他模块可见。在某些情况下,我们会希望限制某些包对于其他模块的可见性。
举例来说,一个包可能在最早的设计中是对所有模块都公开的,但是该包在后来的版本更新中被新的包所替代,因此被声明为废弃的(deprecated)。
这个被废弃的包应该只能被遗留代码所使用。在新的版本中,包含该包的模块应该只是把该包导出给还在使用遗留代码的模块。
这样可以确保遗留代码不会被错误的继续使用。通过在exports声明后添加to语句,可以指定允许访问该包的模块名称。
比如在下面的JDK模块java.rmi
的模块声明中可以看到,包sun.rmi.registry
只对jdk.management.agent
导出。
module java.rmi {
requires java.logging;
exports java.rmi.activation;
exports com.sun.rmi.rmid to java.base;
exports sun.rmi.server to jdk.management.agent,
jdk.jconsole, java.management.rmi;
exports javax.rmi.ssl;
exports java.rmi.dgc;
exports sun.rmi.transport to jdk.management.agent,
jdk.jconsole, java.management.rmi;
exports java.rmi.server;
exports sun.rmi.registry to jdk.management.agent;
exports java.rmi.registry;
exports java.rmi;
uses java.rmi.server.RMIClassLoaderSpi;
}
传递依赖
当模块A依赖模块B时,模块A可以访问模块B中导出的public
和protected
类型。我们把这种关系称为模块A 读取(read)模块B。
同理,如果模块B读取模块C,模块B也可以访问模块C导出的public
和protected
类型。也就是说,模块B可以在其包含的代码中,使用模块C中的类型来作为方法的参数或是返回类型。
模块C的声明如下:
module C {
exports ctest;
}
模块B的声明如下:
module B {
requires C;
exports btest;
}
模块A的声明如下:
module A {
requires B;
}
假设模块A、B和C中分别定义了类MyA
、MyB
和MyC
。其中类MyA
的定义如下所示。其中MyB
来自模块B,其中的方法getC()
返回的是模块C中的类MyC
。
package atest;import btest.MyB;public class MyA { public static void main(String[] args) { new MyB().getC().sayHi();
}
}
如果模块A的声明如上述所示,会发现MyA
无法通过编译。这是因为模块A在其module-info.java
文件中没有声明对模块C的依赖关系,因此模块A并没有读取模块C。模块的读取关系默认并不是传递的。
为了解决这个问题,可以在requires
中添加新的描述符transitive
来声明一个依赖关系是传递的。
一个模块中声明为可传递的依赖模块,可以被依赖该模块的其他模块来读取。这种读取关系称为隐式可读性(implicit readability)。
对于上面的例子来说,只需要把模块B对模块C的依关系声明为可传递即可。这样模块B的可传递依赖模块C,就可以被依赖模块B的模块A所读取,从而模块A的代码可以被成功编译。
module B {
requires transitive C;
exports btest;
}
静态依赖
静态依赖是一种特殊的依赖关系,通过requires static
来进行声明。静态依赖所声明的模块在编译时是必须的,但是在运行时是可选的。
module demo {
requires static A;
}
静态依赖对于框架和第三方库来说比较实用。假设我们需要开发一个可以和不同数据库交互的库。这个库所在的模块可以使用静态依赖来声明对所支持的数据库JDBC驱动的依赖关系。
在编译时,库中的代码可以访问这些驱动中的类型;在运行时,用户只需要添加所需要使用的驱动即可。如果不使用静态依赖,用户必须要添加所有支持的驱动才能完成模块的解析。
模块系统在解析时,如果遇到无法找到的模块,会报错并退出。而要求用户添加所有支持的驱动模块也是不现实的。这就是静态依赖实用的地方。
服务
Java平台有自己的服务接口和服务提供者机制。通过类java.util.ServiceLoader
来完成服务提供者的查找。
服务机制主要用在JDK本身以及第三方框架和库中。服务机制的一个典型应用是JDBC驱动。每个JDBC驱动都需要提供服务接口java.sql.Driver
的实现。
Java 9之前,ServiceLoader
通过扫描CLASSPATH来查找特定服务接口的实现类。在Java 9中, 模块成了代码的组织单元。模块声明文件中提供了与服务使用者和提供者相关的声明。
假设存在一个服务接口com.mycompany.mymodule.Demo
,作为服务的提供者,可以用如下的方式来声明。其含义是该模块提供了服务接口com.mycompany.mymodule.Demo
的实现类com.mycompany.mymodule.d.DemoImpl
。
module com.mycompany.mymodule.D {
provides com.mycompany.mymodule.Demo with com.mycompany.mymodule.d.DemoImpl;
}
当模块需要使用一个服务接口时,可以添加如下所示的声明。
module com.mycompany.mymodule.E {
uses com.mycompany.mymodule.Demo;
}
接着就可以使用ServiceLoader
来查找服务接口的提供者了。
ServiceLoader.load(Demo.class)
开放模块和包
在模块声明文件中,可以在module
之前添加open
描述符来把该模块声明为开放的。一个开放的模块在编译时只允许其他模块访问其通过exports
声明来显式导出的包。
而在运行时,模块中的所有包都是被导出的,包括那些没有通过exports
声明的包。同样的,也可以通过Java反射API来访问所有包中的所有Java类型。
所有Java类型中包括私有类及类型中的私有成员。如果使用Java反射API来绕开Java语言的访问检查机制,如AccessibleObject
类的setAccessible()
方法,就可以访问开放模块中的私有类型和成员。
open module E {
exports etest;
}
对于每个具体的包,也可以使用opens来把它声明为开放的。开放的包可以通过Java反射API来访问。就如同开放模块一样,使用反射API可以访问开放包中的所有类型及其所有成员。开放包的声明也支持通过to语句来指定可访问的模块名称。
module F {
opens ftest1;
opens ftest2 to G;
}
开放模块和包的目的主要是为了解决已有代码的兼容性问题,尤其是在使用Java反射API时。在升级已有代码到Java 9模块系统时,如果遇到了与反射API相关的问题,可以把需要被反射API访问的模块或包声明为开放的。
未命名模块
如果模块系统需要加载一个来自不在任何模块所声明导出的包中的Java类型时,它会尝试从 CLASSPATH 中加载。如果该Java类型被成功加载,那么该类型被视为是一个特殊模块的成员。
该特殊的模块称为未命名模块(unnamed module)。未命名模块的特殊性在于它读取所有其他的模块,并且导出所有内部包含的包。
如果一个类型是从 CLASSPATH 中加载的,那么作为未命名模块中的成员,它可以访问所有其他模块中所导出的包,也包括 Java 平台内部的模块。
正因为这样,Java 9 之前编写的应用,可以不经过任何改动就运行在Java 9之上。虽然未命名模块导出了所有内部的包,但是其它命名模块中的代码并不能访问未命名模块中的类型。
我们也没办法通过requires
来声明对未命名模块的依赖关系。这样的限制是必须的,否则我们就失去了引入模块系统的所有好处,又重新回到了依靠 CLASSPATH 的老路上去了。
未命名模块的主要目的是保持向后兼容性。如果一个包同时在某个命名模块和未命名模块中出现,那么在未命名模块中的包会被忽略。所以在 CLASSPATH 中的包不会干扰在命名模块中的代码。
每个类加载器都有自己的未命名模块。由该类加载器加载的类型,如果来自 CLASSPATH,那么会作为其未命名模块的成员。可以通过ClassLoader
类的getUnnamedModule()
来获取到其对应的未命名模块。
自动模块
由于Java 9是向后兼容的,对于已有的应用来说,不一定要升级到使用模块。不过还是建议升级来利用模块系统的好处。
推荐的升级方式是采用自底向下的做法,也就是说从依赖关系树中的叶子节点开始做起。举例来说,如果一个应用有3个子模块或子项目,A、B和C。它们之间的依赖关系是A -> B -> C。
当升级该应用到Java 9时,推荐的做法是从C开始,然后再依次是B和A。这样的话,当C被升级为模块之后,A和B都还在未命名模块中,可以继续访问模块C中的类型。
这是因为我们之前提到的,未命名模块可以读取所有的其它命名模块。然后我们升级B为一个命名模块,并声明其依赖模块C。最后再把A升级为模块,就完成了该项目的升级。
然而这种自底向上的升级方法并不总是可行的。有些库可能是第三方所维护的,我们没有办法控制这些库升级到模块的时间。我们仍然希望可以升级那些依赖这些第三方库的代码。
我们没有办法直接把应用本身的代码升级为模块,而把第三方库放在CLASSPATH中。这样的话,这些第三方库会出现在未命名模块中。而我们之前已经说过,命名模块是不能访问未命名模块中的Java类型的。
为了解决这个问题,Java模块系统中有另外一个自动模块(automatic module)的概念。我们只需要把这些第三方库放在模块路径中,它们会被转换成自动模块。
与其他显式创建的命名模块不同的是,自动模块是从普通的JAR文件中自动创建出来的。这些JAR文件中并没有包含模块声明文件module-info.class
。
自动模块的名称来自于JAR文件的清单文件MANIFEST.MF
中的属性Automatic-Module-Name
,或从JAR文件的名称中自动推断出来的。其他模块可以使用该名称来声明对该自动模块的依赖。
推荐的做法是使用清单文件的属性Automatic-Module-Name
,比依赖JAR文件名称的做法要更加可靠。
自动模块的特殊性体现在下面几个方面:
自动模块读取所有其它的命名模块。
自动模块导出所包含的全部包。
自动模块读取未命名模块。
自动模块对其他自动模块是传递可读的。
自动模块是 CLASSPATH 和命名模块之间的桥梁。最终的目的当然是把Java 9 之前的那些子模块、子项目和库都升级到Java 9的命名模块。
但是在升级过程中,我们可以把这些子模块、子项目和库的JAR文件加入到模块路径中作为自动模块来使用。
在下面的代码中,模块D中的类dtest.MyD
使用了Google Guava库中的com.google.common.collect.Lists
。
package dtest;
import com.google.common.collect.Lists;
public class MyD {
public static void main(String[] args) {
System.out.println(Lists.newArrayList("Hello", "World"));
}
}
Gauva 还没有升级为 Java 9 的模块。在模块D的描述文件中,我们可以使用requires guava
来声明对 Guava 的依赖关系。guava
是 Guava 库对应的自动模块的名称,从JAR文件名推断而来。
module D {
requires guava;
}
模块解析
下面我们简单介绍一下Java应用运行时的模块解析过程,以及模块相关的Java API。在启动 JVM 运行应用时,模块解析的过程从应用的主模块开始,并根据模块依赖关系来递归地解析其他模块。
模块解析的结果是一个由Configuration
类表示的可读性图。可读性图是一个有向图,其中顶点表示的是已解析模块(ResolvedModule
),而边表示的是模块的读取关系。
接下来的任务是从可读性图中创建出模块层(ModuleLayer
)。创建的方式是为图中的每一个已解析的模块指定一个加载其中类型的类加载器。
JVM中至少包含一个非空的模块层,即启动模块层(boot layer),在JVM启动时自动创建。大部分的应用只使用启动模块层。在一些复杂的场景中,可以创建多个模块层来满足不同的需求。
一个模块层可以有多个父模块层。模块层中的模块可以读取其父模块层中的其他模块。模块层中包含的是运行时模块,由Module
来表示。对于Configuration
中的每个ResolvedModule
都会有一个对应的Module
对象。
由于类型都在模块中,通过Class
类的新方法getModule()
可以获取其所在模块的Module
对象。
总结
我们只是简单的讨论Java 9模块系统中的一些基本概念。这些概念是开发新的模块化应用的基础。
模块系统所包含的内容还有很多没有讨论到。比如对已有的JDK工具,如javac
、java
和jar
的改动,以及新增的工具jlink
。这部分内容可以参考Java 9的官方文档。
近期热文
《机器人的「语料」,如何获取?》
《一页纸,梳理你的商业模式 ,奇妙的「精益画布」》
《轻松几招你也可以架构高性能网站》
《突破技术发展瓶颈、成功转型的重要因素》
《沉迷前端,无法自拔的人,如何规划职业生涯?》
Spring Boot
微服务架构
必修课
「阅读原文」看交流实录,你想知道的都在这里