模块系统的说明
该文章是关于JSR 376: The Java Platform Module System中提议的Jigsaw项目原型的非正式版概览。另一篇文章描述了JDK相关工具以及API的一些增强特性,不过这不在JSR的范围之内了。
就像JSR376中描述的一样,模块化系统的目标是提供:
- 可靠的配置,用程序组件的方式来替代脆弱的、易出错的classpath机制,并且可以显示的声明对其他组件的依赖。
- 强封装,允许组件声明哪些类型可以对其它组件开放,哪些不可以。
这些特性将会为应用开发者,库开发者,以及Java SE平台的实现人员带来直接跟间接的好处,因为它们将使得系统具有更好的拓展性,更高的完整性,以及更高的性能。
目录
1. 定义模块
2. 使用模块
3. 兼容性&迁移
4. 服务
5. 高级话题
总结
致谢
这是该文档的第二版,相比最初版,该版本介绍了兼容性与迁移,修改了映射可读性,重新编排文字,已改善叙述流程,并且分了两级以便于导航阅读。
目前的设计中依旧有很多问题,他们的解决方案将会在该文档的未来版本中给予说明。
1 定义模块
为了能够在即对开发者友好,又能支持当前工具链的前提下,提供可靠的配置以及强封装性,我们把模块看作一种全新的Java基础程序组件。一个模块就是一个由代码跟数据组成的有名称的且自描述的集合。代码被组织为一个包含类型,如Java类跟接口的包的集合,而数据则包括资源或其他形式的静态信息。
1.1 模块声明
一个模块的自描述性很好的体现在了它的模块声明
上,一种Java编程语言中定义的新结构。一个可能的最简单的模块定义差不多只需要指定模块的名字即可:
module com.foo.bar { }
可以定义一个或多个requires
语句来指定该模块在编译期以及运行时所依赖的其他模块的名字:
module com.foo.bar {
requires org.baz.qux;
}
最后,exports
语句用来声明指定的包中的所有的,并且只有,public
类型对其他模块可见:
module com.foo.bar {
requires org.baz.qux;
exports com.foo.bar.alpha;
exports com.foo.bar.beta;
}
如果一个模块的声明中,不包含exports
语句,则表明该模块不会对其他模块公开任何类型。
按照约定,源码的模块定义被放在一个名为module-info.java
的文件中,该文件位于源码层次结构的根目录下。com.foo.bar模块的源文件如下:
module-info.java
com/foo/bar/alpha/AlphaFactory.java
com/foo/bar/alpha/Alpha.java
...
按照约定,模块定义被编译到module-info.class
文件中,同样会被放到类文件的输出目录中去。
模块名称,就像包名称,必须不能有冲突。就像我们一直推荐的包取名的模式一样,我们推荐使用翻转域名的模式来给模块取名字。因此,通常模块的名字是导出包的名字的前缀,不过这种关系并不是强制性的。
一个模块的定义不包含版本字符串,也不包含它所依赖的其他模块的版本号,这是有意为之的:解决版本选择问题并不是模块系统的一个目标,我们更倾向于将它留给构建工具或者容器来解决。
由于一些原因,模块声明是Java编程语言的一部分,而不是一门自成体系的语言或符号,其中最重要的就是模块信息在编译期和运行时必须都可用,以达到跨阶段的高保真度,例如,确保模块系统在编译期跟运行时能以相同的方式工作,相应的,这就可以避免一些错误,或者至少在编译期能更早的报告错误,然后诊断并修复。
将源文件中的模块声明与模块中的其他源文件一起转换为Java虚拟机所需的类文件是建立保真度的一种常规手段。这种方式对于开发人员相当熟悉,并且对于IDE或者构建工具的支持也不困难。尤其是IDE,可以根据组件的项目描述中已经提供的信息来合成requires语句,从而为现有组件提供初始模块声明的建议。
1.2 模块工件
现有工具可以创建,操作和使用JAR文件,因此为了方便采用和迁移,我们定义了模块化的JAR文件。一个模块化的JAR文件就像普通的JAR文件一样,除了在根目录下还包含一个module-info.class
文件。例如,上述com.foo.bar
模块的jar文件中的内容可能像下面这样:
META-INF/
META-INF/MANIFEST.MF
module-info.class
com/foo/bar/alpha/AlphaFactory.class
com/foo/bar/alpha/Alpha.class
...
模块化JAR文件可以作为一个模块,在这种情况下,它的module-info.class
文件被用来包含模块的声明。或者,可以把它放到普通的类路径中,这种情况下,它的module-info.class
文件将被忽略。模块化JAR文件允许库的维护者既可以以工件的形式传输,来作为Java SE9 或者更高版本中的模块来使用,也可以作为适用于所有版本中类路径下的常规JAR文件。我们期望Java SE9的实现中包含一个增强的jar工具,可以很容易的创建模块化的JAR文件。
为了模块化Java SE平台的参考实现JDK,我们将引入一种新的工件格式,以适应本机代码,配置文件以及其他各种天生并不适配JAR文件的数据。这种格式利用了在源文件中定义模块并编译到类文件中的另一个优势,即类文件跟其他任何特定的工件格式无关。这种新格式,暂时被叫做“JMOD”,它的标准名称至今是个悬而未决的问题。
1.3 模块描述符
将模块声明编译到类文件中还有一个最终的优势,就是类文件已经具备一种精确定义且可拓展的格式。因此在一个更广泛的意义上,我们可以将module-info.class
作为模块描述符,其中包括源码级别的模块声明的编译格式,以及声明被初始编译后插入到类文件属性中的各种其他的信息。
例如,IDE或者构建期的打包工具可以将包含像模块版本,标题,描述以及许可证等文档信息插入到属性中。可以通过模块系统的反射工具在编译期以及运行时来读取这些信息用来生成文档,诊断以及调试等。下游工具也可以使用它来构建特定于操作系统的程序包工件。将会有一系列特定的属性被标准化,但是,既然Java的类文件格式是可拓展的,那么其他的工具或者框架在必要的时候同样可以定义自己的属性。非标准的属性对模块系统本身不会造成影响。
1.4 平台模块
Java SE 9 平台规范将使用模块系统把平台划分为一系列模块。Java SE 9平台的实现可能包含所有的平台模块,也可能只包含其中的一部分。
任何情况下,模块系统中唯一明确知道的模块是被叫做java.base
的基模块。基模块定义并导出了所有的平台核心包,同样包括它自身:
module java.base {
exports java.io;
exports java.lang;
exports java.lang.annotation;
exports java.lang.invoke;
exports java.lang.module;
exports java.lang.ref;
exports java.lang.reflect;
exports java.math;
exports java.net;
...
}
基模块将会一直存在,任何其他模块都将隐式的依赖于基模块,然而基模块不会依赖于其他任何模块。
其他的平台模块将使用java.
前缀,并且可能包含像用来连接数据库的java.sql
模块,用来处理XML的java.xml
模块,以及日志处理的java.logging
模块。Java SE 9平台规范中没有定义的模块,而在JDK中定义的模块,习惯上将会使用jdk.
前缀。
2 使用模块
个人模块可以在模块工件中定义,也可以内置于编译期或者运行时环境中。为了在任意阶段都可以使用他们,模块系统需要定位它们,然后确定相互依赖关系,以便提供可靠的配置以及强封装性。
2.1 模块路径
为了定位定义在工件中的模块,模块系统需要搜索主机中定义的模块路径
。模块路径是一个序列,其中每个元素要么是模块工件,要么是包含模块工件的目录。模块系统会按顺序搜索模块路径中的元素去寻找第一个满足条件的模块。
模块路径跟类路径有着本质上的区别,并且更加健壮强大。类路径天生的脆性是由于它只是定位所有工件中单个类型的手段,而不能区分工件本身的不同。这对于提前判断工件是否缺失尤其重要。它也允许不同的工件在相同的包中定义类型,即便这些工件是同一个程序组件的不同版本甚至是完全不同的组件。
相反,模块路径是用来定位整个模块而不是单个类型的手段。如果模块系统无法在模块路径中找到某个特定的工件依赖,或者在同一个目录下遇到两个相同名称的工件,那么编译器或者虚拟机将会报告该错误并且退出。
编译期或者运行时内置的模块,以及模块路径中定义的工件统称为可观察到的模块
。
2.2 解析
假设我们有一个应用程序使用上面的com.foo.bar模块和平台的java.sql模块。包含应用程序核心的模块声明如下:
module com.foo.app {
requires com.foo.bar;
requires java.sql;
}
对于该初始应用模块,模块系统通过定位其它的可观察到的模块来解析requires
语句中的依赖,然后再解析这些可观察到的模块的依赖,直到所有模块的所有依赖都得到解析。这种传递闭包计算的结果是形成一个模块图
,对于每一个依赖其他模块的模块都包含一个从第一个模块到第二个模块的有向边。
为了构建com.foo.app模块的模块图,模块系统会检查java.sql模块的声明:
module java.sql {
requires java.logging;
requires java.xml;
exports java.sql;
exports javax.sql;
exports javax.transaction.xa;
}
同样会检查com.foo.bar模块的声明,以及org.baz.qux,java.logging,跟 java.xml模块。简单起见,最后三个并未包含提到,是因为他们并不包含对其他模块的依赖。
基于所有这些模块声明,com.foo.app模块的模块图应该包含以下节点跟有向边:
该图中深蓝色的线表示requires
语句中表明的显示的依赖关系,而浅蓝色的线表示每个模块对基模块的隐式的依赖。
2.3 可读性
当模块图中的一个模块直接依赖其他模块时,那么第一个模块中的代码就可以引用第二个模块中的类型。因此我们说第一个模块可读取
第二个模块,或者等价的说,第二个模块对第一个模块是可读的
。因此,在上面的图中,com.foo.app模块可以读取com.foo.bar以及java.sql模块,但是无法读取org.baz.qux,java.xml,以及java.logging模块。java.logging模块对于java.sql模块是可读的,但对其他模块不可读。(根据定义,任何模块对自己是可读的)
模块图中定义的可读性关系是可靠配置的基础:模块系统确保对每一个其他模块依赖的精确匹配,保证模块图是无回路的,每一个模块最多读取一个给定包的模块,以及定义相同包名的模块互不影响。
可靠的配置不仅更可靠,而且更快。当一个模块中的代码引用某个包中的类型时,我们可以确定该包被定义在了该模块或者该模块可读的其他模块中。因此当搜索某个具体的类型时就没有必要搜索多个模块,更不用糟糕到的去搜索整个类路径。
2.4 可访问性
模块图中定义的可读性关系以及模块声明中的exports
语句是强封装性的基础。Java编译器跟虚拟机认为一个模块的某个包中的公共类型对其他模块中的代码可访问
的条件是第一个模块对第二个模块是可读的,并且第一个模块导出了那个包。比如,两个类型S跟T定义在两个不同的模块中,并且T是公共的,那么S可以访问T的条件是:
- S所在模块可读去T所在模块,并且
- T所在模块导出了T所在的包。
就像私有方法跟私有属性不可被其它类访问一样,一个类型是无法透过不可访问的模块边界被引用的。任何尝试对它访问都会得到一个编译器报告的错误,或者虚拟机抛出的IllegalAccessError
,或者反射运行时API抛出的IllegalAccessException
。因此即使当一个类型被定义为公共的,但是假如它所在的包没有在模块声明中被导出,那么它也只能被本模块内的代码访问。
如果透过模块边界,一个方法或者属性的外围类是可以访问的,并且该成员本身的声明也是允许访问的,那么它也可以透过模块边界被访问。
来看一下上面的模块图中的强封装性是如何工作的,我们给每个模块贴上它所导出的包的标签:
模块com.foo.app模块中的代码可以访问com.foo.bar.alpah包中的公共类型,因为com.foo.app依赖于它,因此可读取com.foo.bar模块,并且com.foo.bar模块导出了com.foo.bar.alpah包。 如com.foo.bar包含一个内部包com.foo.bar.internal,那么com.foo.app中的代码不能访问该包中的任何类型,因为com.foo.bar没有导出它。com.foo.app中的代码不能访问org.baz.qux包中的类型,因为coom.foo.app不依赖它,因此不可读去该模块。
2.5 隐式可读性
如果一个模块可以读取另一个模块,某些情况,逻辑上也应该可以读取其他模块。
例如,平台的java.sql模块依赖java.logging跟java.xml模块,不仅因为它的代码实现中使用了这些模块中的类型,而且还因为它定义的类型的方法签名引用了这些模块中的类型。 java.sql.Driver接口中声明了下面这个公共方法:
public Logger getParentLogger();
其中Logger是java.logging模块中导出的java.util.logging包中声明的类型。
假设,com.foo.app模块中的代码为了获取logger并且记录日志而调取了这个方法:
String url = ...;
Properties props = ...;
Driver d = DriverManager.getDriver(url);
Connection c = d.connect(url, props);
d.getParentLogger().info("Connection acquired");
如果com.foo.app模块的声明像上面提到的那样,那么这段代码将不能工作:getParentLogger方法返回一个Logger,它是一个在java.logging模块中声明的类型,它对com.foo.app模块是不可读的,因此上面对Logger类中的info方法的调用在编译期跟运行时都将失败,因为该类不可访问,所以该方法同样不可访问。
该问题的一个解决方案是所有依赖java.sql模块并且包含使用getParentLogger方法返回的Logger对象的代码的模块作者记得声明一个对java.logging模块的依赖。当然这种方式是不可靠的,因为它打破了最少意外的原则:如果一个模块依赖第二个模块,那么很自然的希望每个需要使用第一个模块的类型,即使是在第二个模块中定义的类型,都将对于仅仅依赖第一个模块的模块是直接可访问的。
因此我们拓展模块的声明以便一个模块可以将它所依赖的其他模块的可读性授予依赖它的任何模块。这种隐式的可读性
通过在requires
语句中包含一个public修饰符来表达。java.sql模块的声明实际上是这样:
module java.sql {
requires public java.logging;
requires public java.xml;
exports java.sql;
exports javax.sql;
exports javax.transaction.xa;
}
public修饰符意味着任何依赖java.sql的模块不仅可读取java.sql模块,而且也可以读取java.logging跟java.xml模块。因此com.foo.app模块的模块图将包含两条用绿色边连接到java.sql模块的深蓝色的边,因为它们是因该模块而隐式可读的:
现在com.foo.app模块可以包含访问java.logging及java.xml模块导出包中所有公共类型的代码了,即使它的声明中没有提到这两个模块。
通常,如果一个模块导出一个包,该包包含一个签名引用第二个模块的类型,那么第一个模块的声明应该包含一个对第二个模块的requires public
依赖。这样可以确保其它依赖第一个模块的模块自动的对第二个模块具有可读性,以及可以访问该模块导出包中的所有公共类型。
3 兼容性&迁移
到目前为止,我们已经看到了如何从头开始定义模块,将它们打包成模块工件,并且把他们与其他平台内置的模块或者定义在其他工件中的模块一起使用。
当然,大部分的Java代码是在模块系统引入前就写好的,并且还必须像现在这样不需任何改变依旧能正常运行。因此,即使平台本身是由模块组成,模块系统也仍然可以编译运行由类路径中的Jar文件组成的应用。并且也可以将先用的应用以一种灵活渐进的方式迁移到模块化中来。
3.1 未命名模块
如果有个需求是在任意已知的模块中加载一个没有定义包的类型,那么模块系统会尝试从类路径中加载它。如果加载成功,那么会被认为是一个特殊的被称为未命名模块
的成员,以便确保每个类型关联到某个模块上。未命名模块就像是高级层面上的现有的未命名包的概念。当然,以后我们就把那些有名称的模块称作命名的模块
。
未命名的模块可以读取其他任意模块。因此从类路径中加载的任意类型中的代码都将可以访问任意其他可读模块的导出类型,这些可读模块默认包括命名模块,内置的平台模块。因此,在Java SE 8上编译和运行的现有类路径应用程序将在Java SE 9上以完全相同的方式进行编译和运行,只要它只使用了标准的,不被废弃的Java SE API即可。
未命名模块会导出它的所有包。就像我们将在下面看到的,这会使得迁移更加灵活。然而,这不意味着命名模块中的代码可以访问未命名模块中的类型。事实上,命名模块甚至不能声明对未命名模块的依赖。这个限制是有意为之的。因为允许命名模块依赖类路径中的任意内容是不可能做到可靠的配置的。
如果一个包被定义在了命名模块跟未命名模块中,那么未命名模块中的包会被忽略。即使面对类路径的混乱这依旧保持可靠的配置,即确保每一个模块依旧最多只会读取一个定义特定包的模块。如果在我们上面的例子中,一个类路径下的JAR文件包含com/foo/bar/alpha/AlphaFactory.class类,那么该文件将永远不会被加载,因为com.foo.bar.alpha包是com.foo.bar模块的导出包。
3.2 由下而上的迁移
把从类路径中加载的类型当作未命名模块的成员允许我们以增量,自下而上的方式将现有应用程序的组件从JAR文件迁移到模块。
假设,上面提到的应用最初是在Java SE 8下构建的,作为放在类路径下的一组类似命名的JAR文件。如果我们按原样在Java SE 9中运行它们,那么这些JAR文件中的所有类型都会被定义到未命名模块中。该模块可以读取所有其他模块,包括所有内置的平台模块,简单起见,假设只讨论前面提到的java.sql,java.xml,java.logging,以及java.base模块,因为我们得到如下模块图:
我们可以直接将org-bar-qux.jar转化为命名模块,因为我们知道它不会引用其它两个JAR文件中的任何类型,所以,作为一个命名模块,它不会引用任何被留在未命名模块中的类型。(我们碰巧从最初的例子中知道这点,但是如果我们还不知道,那么我们可以使用jdeps)我们写一个org.baz.qux的模块声明,并把它添加到模块的源码中,然后编译它,并将结果打包成一个模块JAR文件。如果我们把这个JAR文件放到模块路径中,并且把其它的留在类路径中,我们会得到如下增强的模块图:
com-foo-bar.jar和com-foo-app.jar中的代码可以继续工作,因为未命名模块可以读取任意命名模块,包括新的org.baz.qux模块。
我们可以用相似的方式处理com-foo-bar.jar以及com-foo-app.jar,最终重新绘制前面显示的模块图:
我们知道对原始的JAR文件中的类做了什么,当然可以一步就将三个应用模块化。然而,如果org-baz-qux.jar是由一个完全不同的团队或者组织单独维护的,那么它可以在其他两个组件之前被模块化,同样com-foo-bar.jar可以在com-foo-app.jar之前被模块化。
3.3 自动模块
自下而上的迁移是直截了当的,当并不可能总是如此。即使org-baz-qux.jar的维护者还没有将它转化为合适的模块-或者永远不会将它模块化-我们依旧想模块化我们自己的com-foo-app.jar和com-foo-bar.jar组件。
我们已经知道com-foo-bar.jar中的代码引用org-baz-qux.jar中的类型。如果我们把com-foo-bar.jar转换为命名模块com.foo.bar,但是把org-baz-qux.jar留在类路径中,那么会导致代码不可用:org-baz-qux.jar中的类型会被定义到未命名模块中,而com.boo.bar是命名模块,是无法依赖于未命名模块的。
因此我们必须以某种方式让org-baz-qux.jar以命名模块的方式运行,以便com.foo.bar可以依赖它。我们可以fork一个org.baz.qux源码的分支然后我们自己将其模块化,但是维护者不愿将它合并到上游仓库中,那么就不得不一直维护这个分支。
相反,我们可以将org-baz-qux.jar作为一个自动模块
,原封不动的将其放到模块路径中而不是类路径下。这样将会定义一个可观察的模块,它的名字将由JAR文件衍生而来org.baz.qux
,以便非自动模块可以用常规的方式依赖它:
自动模块是一个隐式定义的命名模块,因为它没有模块声明。相比之下,一个普通的命名模块会有模块声明来显示的定义;我们以后把这类模块看作显示模块。
没有好办法可以提前告知自动模块可能依赖哪些其他模块。因此在一个模块图被确定之后,自动模块可以读取任意其他的命名模块,无论自动还是显示:
(这些新的可读性边确实在模块图中造成了回路,使得它有些更加难懂了,但是我们把这看作是更加灵活迁移的可容忍的结果。)
类似的,没有好的办法去判断一个自动模块中的包会被其他模块或者仍在类路径中的类使用。因此,自动模块中的每个包都会被导出,即使实际上它只被内部使用:
最后,没有好的办法判断自动模块中是否有导出包中包含某些类型,它的方法签名中引用了其他自动模块中的类型。例如,我们首先模块化com.foo.app。并且将com.foo.bar和org.baz.qux都当作自动模块,那么我们将会得到下面模块图:
不读取相关的JAR文件中的所有类文件,是不能知道com.foo.bar中的公共类型是否声明了一个返回类型是org.baz.qux中的公共方法。因此,自动模块被授予对其它所有自动模块的隐式可读性:
现在,com.foo.app中的代码可以访问org.baz.qux中的类型,尽管我们知道实际上并不是这么做的。
自动模块提供了一个类路径的混乱与显示模块的严格的中间方案。就像上面看到的,他们允许一个由JAR文件组成的现有应用可以以自上而下,或者结合自上而下与自下而上的方式迁移到模块化中来。一般来讲,我们从一组任意类路径下的JAR文件组件开始,使用jdeps工具来分析他们的相互依赖,将那些我们可以控制源码的组件转换为显示模块,并且与剩余的JAR文件一起放到模块路径下。那些不能控制源码的JAR文件组建会被当作自动模块直到有一天他们也被转换为显示模块。
3.4 类路径桥接
许多现存的JAR文件可以被用作自动模块,但是有些却不能。如果类路径下有多于两个JAR文件有相同的包,那么最多只有一个可以被用作自动模块,因为模块系统要确保每个命名模块最多读取一个定义了特定包的模块,以及定义相同包的命名模块不会互相干扰。在这种情况下,通常只需要一个JAR文件。如果其他重复或者近似重复的,不小心放到了类路径下,那么可以将一个用作自动模块并且丢弃其他的。然而如果类路径上的多个JAR文件有意地包含相同包中的类型,那么他们必须被保留在类路径上。
为了能够在多个JAR文件无法被用作自动模块时依旧可以迁移,我们可以用自动模块作为显示模块中的代码与类路径中的代码的桥梁:除了读取所有的命名模块,自动模块还会读取未命名模块。例如,如果我们应用的原始类路径包含了org-baz-fiz.jar跟org-baz-fuz.jar文件,那么我们得到下图:
就像前面提到的,未命名模块会导出它的所有包,因此自动模块中的代码可以访问类路径中加载的所有公共类型。
使用类路径中某类型的自动模块必须不能将这些类型暴露给依赖它的显示模块,因为显示模块不能声明对未命名模块的依赖。例如,如果显示模块com.foo.app中的代码引用了com.foo.bar中的公共类型,并且该类型的方法签名引用了依旧在类路径JAR文件中的类型,那么com.foo.app中的代码将无法访问这些类型,因为com.foo.app不能依赖未命名模块。可以临时将com.foo.app当作自动模块来补救这一点,以便它的代码可以访问类路径中的类,直到类路径中的相关JAR文件可以被当作自动模块或者转换为显示模块。
4 服务
通过服务接口与服务提供者实现程序组件间的松耦合是大型软件系统的强大工具。Java一直通过java.util.ServiceLoader来支持服务,它在运行时通过搜索类路径来定位服务提供者。对于模块中定义的服务提供者,我们必须考虑如何在诸多可观察模块中定位这些模块来解决它们之间的依赖,并且使得那些使用相应服务的代码可用。
例如,假设我们的com.foo.app模块使用MySQL数据库,并且MySQL 的JDBC驱动是在如下可观察模块中提供的:
module com.mysql.jdbc {
requires java.sql;
requires org.slf4j;
exports com.mysql.jdbc;
}
其中org.slf4j是驱动中使用的日志库,com.mysql.jdbc是包含java.sql.Driver服务接口实现的包。(没有必要导出驱动包,这里只是为了清晰)
为了使java.sql模块可以使用该驱动,ServiceLoader类必须可以通过反射实例化该驱动类;为了实现这一点,模块系统必须将驱动模块添加到模块图中并解决它的依赖,因此:
为了完成这点,模块系统必须可以通过之前解析的模块来识别任何服务的使用,然后从诸多可观察模块中定位并解析提供者。
模块系统可以通过扫描模块工件中的类文件来调用ServiceLoader::load 方法来识别服务的使用,但是这样即慢又不可靠。一个模块使用特定的服务是该模块定义的基本面,因此为了效率和清晰度,我们在模块定义中使用use
语句表达这一点:
module java.sql {
requires public java.logging;
requires public java.xml;
exports java.sql;
exports javax.sql;
exports javax.transaction.xa;
uses java.sql.Driver;
}
就像ServiceLoader类目前所做的一样,模块系统可以通过扫描模块工件的META-INF/services资源条目来识别服务提供者。一个模块提供一个特定服务的实现同样的重要,因此我们在模块声明中使用provides
语句来表达这一点:
module com.mysql.jdbc {
requires java.sql;
requires org.slf4j;
exports com.mysql.jdbc;
provides java.sql.Driver with com.mysql.jdbc.Driver;
}
现在,通过简单的阅读这些模块的声明可以看到其中一个使用了另一个提供的服务。
在模块声明中声明模块提供与模块使用关系不仅仅是提供效率跟清晰度。 这两种服务声明可以在编译期被解析来确保服务接口(如java.sql.Driver)可以被提供者及服务使用者访问到。服务提供者声明可以被进一步解析来确保提供者(如com.mysql.jdbc.Driver)确实实现了声明的服务接口。最后,可以使用预先编译和链接工具来解析服务使用声明以确保可观察的提供者在运行之前被正确的编译和链接。
出于迁移的目的,如果定义自动模块的JAR文件包含META-INF/services资源条目,那么每个条目都会被假设在该模块下声明了相应的provides语句。自动模块可以使用任意的可用服务。
5 高级话题
本文档的剩余部分涉及到的高级话题,虽然重要,不过大部分开发人员可能并不感兴趣。
5.1 映射
为了使模块图在运行时通过反射可用,我们在java.lang.reflect包中定义了Module类,并在java.lang.module包中新定义了一些相关类型。Module类的实例代表运行时的单个模块。每个类型都在某个模块中,因此每个Class对象都有一个相关联的Module对象,可以通过Class::getModule方法返回。
模块对象的基本操作如下:
package java.lang.reflect;
public final class Module {
public String getName();
public ModuleDescriptor getDescriptor();
public ClassLoader getClassLoader();
public boolean canRead(Module target);
public boolean isExported(String packageName);
}
其中ModuleDescriptor是java.lang.module包中的类,它的实例表示模块描述符;getClassLoder方法返回模块的类加载器;canRead方法判断该模块是否可以读取目标模块;以及isExported方法判断指定的包是否被模块导出。
java.lang.reflect包不是平台唯一的反射工具。编译期javax.lang.model包中会有类似的添加,以支持注解处理器以及文档工具。
5.2 映射可读性
框架是一种在运行时使用反射来加载,检查,实例化其他类的工具。Java SE平台自身的框架例子包括服务加载器,资源束,动态代理,以及序列化,当然还有很多流行的外部框架库,像是数据库持久,依赖注入和测试。
运行时发现的类,框架必须能够访问它的某个构造器才能实例化它。然而事情并不总是这样。
例如,平台的streaming XML parser,通过javax.xml.steam.XMLInputFactory系统属性来加载并实例化XMLInputFactor服务的实现,如果定义了,则优先于通过ServiceLoader类来发现发现提供者。忽略异常处理和安全检查,代码大致像这样:
String providerName
= System.getProperty("javax.xml.stream.XMLInputFactory");
if (providerName != null) {
Class providerClass = Class.forName(providerName, false,
Thread.getContextClassLoader());
Object ob = providerClass.newInstance();
return (XMLInputFactory)ob;
}
// Otherwise use ServiceLoader
...
在模块化环境下,只要包包含对context类加载器可知的提供者类,那么对Class:forName的调用就可以正常工作。然而,通过反射newInstance方法对提供者类构造器的调用就没那么幸运了。提供者可能是从类路径中加载的,这种情况下它位于未命名模块内,或者一些明明模块内,但是不管哪种情况,框架自身是在java.xml模块中的。该模块仅仅依赖于基模块,因此其他模块中的提供者对框架来说是不可访问到的。
为了使提供者类对框架是可访问的,我们需要让提供者模块对框架模块是可读的。我们可以允许所有框架显示的将必要的可读性边在运行时动态的添加到模块图中来,就像本文档之前的版本描述的一样,但是经验告诉我们,这种方式太麻烦,并且碍于迁移。
因此,取而代之,我们简单的修订了反射API,基于这样一种假设:任何反射某些类型的代码都在能够读取这些类型所在模块的模块中。这使得上面的例子以及跟此例相同的代码可用,并且不需要做任何改动。这种方式并不会削弱强封装性:如果公共类型想要被其他模块访问,无论是从编译的代码中,还是通过反射,它都必须在模块的导出包中。
5.3 类加载器
每个类型都在某个模块中,并且运行时每个模块都有一个类加载器,但是一个类加载只加载一个模块吗?事实上,模块系统在模块与类加载器之间存在少量限制。一个类加载器可以一个或多个模块中的类型,只要模块间不互相干扰,并且模块中的所有类型必须是由同一个加载器加载的。
这种灵活性对兼容性极其重要,因为这允许我们保留平台现存的内置类加载器的层次结构。bootstrap和extension类加载器依旧存在,并且用来加载平台模块中的类型。application类加载器也存在,并且用来加载模块路径中的类型。
这种灵活性还可以使现有的应用程序更容易地模块化,这些应用程序已经构建了自定义类加载器的复杂层次结构或甚至图,因为可以升级加载器用来加载模块中的类型,而不必改变其委托模式。
5.4 未命名模块
我们前面学到如果一个类型不是定在在命名的,可观察的模块中,那么它就是未命名模块中的成员,但是这个未命名模块所关联的类加载器是哪个呢?
事实上,每个类加载器都有唯一的未命名模块,可以通过ClassLoader::getUnnamedModule新方法获得。如果一个类加载器加载了一个未命名模块中的类型,那么这个类型会被认在该类加载器的未命名模块中,例如,该类型的Class对象的getModule方法会返回它的类加载器的未命名模块。因此,被简称为“未命名模块”的模块其实是application类加载器的未命名模块,它从类路径加载那些没有定义在已知模块中的类型。
5.5 层
模块系统不会强制规定模块与类加载器之前的关系,但是为了加载特定的类型,必须能够以某种方式找到一个合适的加载器。因此,模块图在运行时的实例化会产生一个层
,它将图中每个模块映射到唯一的负责加载该模块中类型的类加载器。就像前面讨论的,boot
层是JVM在启动时通过解析应用的初始模块,而不是可观察模块来创建的。
大部分的应用,以及现存的所有应用将不会用到除了boot层以外的其它层。然而复杂应用可以通过插件或者容器架构使用多层,像应用服务器,IDE,以及测试工具。这些应用可以使用动态的类加载以及模块系统反射API,加载和运行托管的应用,这些应用包含一个或多个模块。然而,还需要额外的两个灵活性:
- 某个托管应用可能需要某个已经存在的模块的不同版本。例如,一个Java EE的web应用,可能需要JAX-WS栈在java.xml.ws模块中的版本,而不是运行时环境中内置的版本。
- 某个托管应用需要的服务提供者可能不是已经发现的提供者。托管系统甚至会嵌入自己倾向的提供者。例如,一个web应用可能包含Woodstox streaming XML parse版本的一个副本,这种情况下,ServiceLoader类应该返回这个提供者,而不是其它的。
容器应用可以在现存层的上面为托管应用创建一个新层,通过解析应用的初始模块而不是一个可观察模块的不同空间。这个空间可以包含可平台可升级模块或者其他模块的备用版本,非平台模块存在于更低的层中;解析器会为这些备用模块设置优先级。这样一个空间也可以包含不同的服务提供者而不是哪些已经在低层被发现的;ServiceLoader类会在从低层返回提供者之前加载并且返回相应的提供者。
层可以叠加:一个新层可以构建在boot层上,并且其它层也可以构建在它之上。正常的解析过程的结果是某一层上的模块可以读取位于该层以及低于该层中的模块。因此某一层的模块图可以按引用包含比它低的每一层的模块图。
5.6 有限制的导出
有时候有必要重新安排某些类型,使其对于一些模块是可访问的,而对其他的所有模块是不可访问的。
例如,标准JDK实现java.sql跟java.xml模块中的代码使用了定义在内部包sun.reflect中的类型,该包位于java.base模块中。为了使这些代码可以访问sun.reflect包中的类型,我们可以简单在java.base模块中导出该包:
module java.base {
...
exports sun.reflect;
}
然而这使得sun.reflect包中的所有类型对于其它任意模块都是可以访问的了,因为所有模块都可以读取java.base模块,这不是我们想要的结果,因为该包中的一些类定义了授权,安全敏感的方法。
因为我们拓展模块定义来允许包被导出给一个或多个特定名称的模块,而不会导出给除此之外的其他模块。java.base模块的声明事实上只把sun.reflect包导出给了特定的几个JDK模块:
module java.base {
...
exports sun.reflect to
java.corba,
java.logging,
java.sql,
java.sql.rowset,
jdk.scripting.nashorn;
}
这种有限制的导出
可以在模块图中以另一种类型的边呈现,这里是金色的,从包到它所导出的指定的模块:
下面精炼了上面陈述的可访问性规则:两个类型S和T被定义在不同的模块中,并且T是公共的,那么S中的代码可以访问T的条件是:
- S的模块可以读取T的模块,并且
- T的模块导出T所在的包,到S所在的模块,或者所有模块。
同样我们拓展反射Module类添加一个方法来判断是否某个包被导出给了特定的模块,而不是所有模块:
public final class Module {
...
public boolean isExported(String packageName, Module target);
}
有限制的导出不经意的就会使得内部类型对一些不想对其公开的模块变成可访问的,因此它们必须被小心使用。例如,一些恶意用户可以将模块命名为java.corba来访问sun.reflect包。为了避免这种情况,我们可以在构建期分析一些相关的模块并且在模块描述符中,记录那些被允许依赖它的模块以及使用它的有限制的导出的模块的内容哈希值。解析的时候,对于名字在有限制的导出列表中的模块,我们要验证它的哈希值是否与记录的该名字的模块的哈希值一致。只要以这种方式将模块的声明与使用绑在一起,有限制的导出在不受信的环境下使用也是安全的。
6 总结
这篇文章中描述的模块系统有很多方面,但是大部分开发者只需要使用它们中常规的基础。我们期望大部分开发者在未来几年,能对模块声明,模块化JAR文件,模块路径,可读性,可访问性,未命名模块,自动模块以及模块化服务等这些基本概念都有适当的理解。相反,对于像反射可读性,层,以及有限制的导出等这些高级的特性的要求就相对少些。
7 致谢
Alan Bateman, Alex Buckley, Mandy Chung, Jonathan Gibbons, Chris Hegarty, Karen Kinnear, and Paul Sandoz都对这篇文章做出了很多贡献。