升级到Java模块系统
具2022年初New Relic发布的《Java生态系统状况报告》显示现有超48%的应用程序在生产中使用Java 11(2020年为11.11%),而Java 8则占46.45%。Java 17的排名还不是很高,但它在发布后的几个月里,已经超过了Java 6、Java 10和Java 16版本的占比。升级到新版本的Java已经是大势所趋,为此本文着重描述如何从Java8升级到Java模块系统(Java9+)。
为何要升级到模块系统
时代背景
如摘要所述,Java8及以下版本已经逐渐失去大半“Java江山”。
当然这个理由未必能让你感兴趣做出升级,请继续往下看。
现如今的问题
类路径地狱
Java运行时使用类路径(classpath)来查找类,这可以说是使用Java的常识,请看如下代码:
1 |
|
这段代码很简单,用来输出打印运行当前Java程序的类路径,即使是一个简单的应用,类路径也会冗长复杂。
1 |
|
所有类按照-classpath
参数定义的顺序排列成一个平面列表。当JVM加载一个类时,按照顺序读取类路径,找到所需的类,然后结束搜索就并加载类。如果在类路径中没有找到所需的类又会得到一个运行时异常,由于类会延迟加载,因此在用户看来应用程序会在使用中莫名其妙的突然出错。而当类路径上有重复类时,则会出现更为隐蔽的问题,一般来说,当类路径包含两个具有相同(完全限定名称)的类时,即使它们不相关,也只有一个会被成功加载。
封装依赖
Java提供的访问修饰符(比如private
、protected
或public
等),可以实现类的封装。例如将一个类置为protected
,那么就可以防止其他类访问该类,除非这些类与该类位于相同的包中。但这样做会产生一个问题:如果想从当前库的另一个包访问该类,同时仍然防止其他类使用该类,那么应该怎么做呢?事实是无法做到。当然,可以让类公开,但公开意味着对所有库公开,也就意味着没有封装。我们没有办法隐藏这样的实现包。
例如,Guava
中的com.google.common.base.internal.Finalizer
,它并不是官方API的一部分,而是一个公共类,其他Guava
包可以使用Finalizer
,这也意味着无法阻止任意使用者使用诸如Finalizer
之类的类。
对于Java平台的内部类来说也存在同样的情况。诸如sun.misc
之类的包经常被应用程序代码所访问,虽然相关文档严重警告它们是不受支持的API,不应该使用。例如:sun.misc.BASE64Encoder
之类的工具类仍然在应用程序代码中被使用。缺乏封装性迫使这些类被认为是“半公共”API。
模块化的优势
Java一直是构建大型应用程序的主流语言之一,且Java生态系统中有许多库,在此背景下构建的系统往往超出我们理解和有效开发范围,为什么这么说呢?我们无法理清各部分代码块之间的关系,在使用繁多的归档文件(Java Archive, JAR)时更加突出,模块化能很好的管理和减少这种复杂性,而从Java9开始引入了模块系统,且Java本身也已经被模块化。
什么是模块化
模块化(modularization)是指将系统分解成独立且相互关联的模块的行为。
模块(module)是包含代码的可识别组件,使用元数据来描述模块及与其他模块的关系。
在理想情况下,模块从编译时到运行时都是可识别的,关系是清晰明确的。一个应用程序由多个模块协作组成。
强封装
模块能对其他模块隐藏其部分代码。这样就可以清晰的区分公开代码和被视为内部实现细节的隐藏代码,从而防止模块之间发生意外或不必要的耦合,即其他模块无法使用该模块被封装的内容。
其实封装依赖在Java模块系统出现之前就早有诟病,库的开发者即使明确将实现代码放在xx.internal
之类意味不公开的内部实现包下,但谁会在意呢,只要有类可以用,就有人会使用它,导致库的更新对使用者是破坏性的。
公共接口
模块要一起工作,并不是所有的东西都会封装。其中未封装的代码应该是模块的公共API部分。由于其他模块可以访问这部分公共代码,须谨慎对其进行管理。对公开代码进行更改则可能会导致依赖于它的其他模块无法正常使用,因此,模块对其他模块的公开部分应该定义良好且稳定。
显式依赖
模块如果需要使用其他模块,则模块之间构成依赖关系,这些依赖关系必须是模块定义的一部分。显式依赖可以构建出一个模块图:节点表示模块,而边缘表示模块之间的依赖关系。
通过模块图可以清楚的了解应用程序和运行应用所需各个模块间的关系。它为模块的可靠配置提供了基础。
Java代码中虽然使用了显式的import
语句,但从严格意义上讲,这些依赖关系是编译时结构,一旦代码被打包到JAR
文件,就无法确定哪些文件包含运行所需的类。
模块化带来的好处
明确的依赖
在编译或运行代码之前,模块系统会检查模块是否满足所有依赖关系,从而导致更少的运行时错误。
强封装
模块明确向其他模块的公开内容,阻止对未公开的内部实现细节的意外依赖。
安全
在JVM的最深层次上执行强封装,减少Java运行时的攻击面,同时无法获得对敏感内部类的反射访问。
可扩展
显式定义的模块边界能够让开发团队并行工作,创建可维护的代码库。
优化
由于模块系统清楚的知道模块之间的关系,包括Java平台模块,因此在JVM启动期间不需要考虑其他未使用的代码。同时,也可以为此创建模块分发的最小配置(例如应用镜像)。在模块出现之前,没有可用的显式依赖信息,一个类可以引用类路径中任何其他类。
迁移到模块系统
根据系统业务及可用资源充分评估是否合适进行模块化,我们的最终目的是模块化,但现实并非如此,为此针对不同系统可选用不同的升级方案。
以下为模块化所需的三个步骤,但同时也是模块化之路的三种不同方案即三种目标。
升级Java版本
向后兼容性一直是Java的主要目标。当不需要使用模块系统时,为什么还要迁移到Java9+ 呢?升级将带来新的API、工具和性能改进(从Java8升级到Java17)。
切换Java
一般的,如果应用程序和依赖只使用了JDK认可的API,那么直接切换到新版本Java上编译运行是不会出现问题的。但这是理想情况,现在的Java本身已经实现了模块化,无论应用程序是否模块化。
应用程序可以依旧使用类路径(-classpath
),而所有类路径上的代码都会被当做未命名模块(UnNamed Module)。 而Java模块使用模块路径(--module-path
),由于Java平台本身已经模块化,java/javac
命令会默认使用JDK模块路径。
我们在Javajmods
目录可以清楚的看到平台的模块文件:
1 |
|
深度反射问题
当使用模块时,默认情况下JDK不允许访问封装的包以及深度反射(指使用反射技术访问类的非公共代码)其他模块(包括平台模块)中的代码。在模块化系统之前,平台内部类的滥用已经成为许多安全问题的源头,并且阻碍了API的发展。而一些常用的库确实是这么做的,例如javassist
库试图调用java.lang.Class
上的defineClass
方法,那么此时会引起如下错误:
1 |
|
如上描述,当前未命名模块不能访问平台模块java.base
的protected defineClass
方法,因为平台模块java.base
未将java.lang
包对此未命名模块开放。
此处引入了一个概念,即开放包(open package)。同类的概念还有导出包(export package)、开放模块(open module)等。
导出包
导出包指模块声明自身的公共API所在的包,指示了模块对外公开的内容,而未被导出的其他包即为封装包,其他模块可以直接访问该模块的导出部分(公共部分)。
开放包和开放模块
许多库都希望可以进行深度反射。但由于强封装性,即使导出了实现类所在的包,也会禁止其对非公共部分进行深度反射。
因此,开放包可以向其他模块公开包中封装的资源,允许对指定包内的代码进行深度反射,同理,开放模块则将开放范围延展到整个模块,开放整个模块看似有点不妥,但当不能确定在运行时库或框架使用什么类型时,这种做法是很方便的。
Java9+添加了两个反射对象的新方法:canAccess
和trySetAccessible
,考虑到深度反射并不总是被允许,使用这些方法可以避免调用setAccessible
时抛出异常。
我们可以仅开放允许深度反射的包,而不导出它。这样对于框架来说,开放包是可以自由访问的,但从开发人员的角度看,这部分未导出的开放包仍被强封装。
回到上面我们遇到的错误,未命名模块无法通过深度反射访问平台模块非公共的部分,这里需要开放平台模块java.base
的java.lang
包,通常在模块描述文件中使用opens xxx
子句来开放一个包,但很显然我们无法修改平台模块。
此时可以通过命令行对那些无法控制的模块描述做出补充:
1 |
|
其中,java.base/java.lang
是授权访问的模块/包
格式,最后一个参数是获取访问权限的模块。因为代码仍然在类路径中,所以使用了ALL-UNNAMED
,它表示类路径(未命名模块)。现在,这个包是开放的,所以深度反射不再是非法的。同理,我们可以使用--add-exports
来强制导出包,但这些都只是一个解决方法。
封装访问
JDK包含许多私有的内部API,这些API应该仅被JDK所使用。从早期开始,这一规定就已被清楚地记载下来。比如sun.*
和jdk.internal.*
包。而当你的程序或程序依赖的库任然在使用这些类型时,将无法通过编译。
例如早期Java程序使用sun.security.x509
包中的类型,切换到更高版本的Java时则会遇到如下的编译错误:
1 |
|
如果这部分代码可控,那么涉及访问封装类型时,应该对其进行修改,用公共类型替代。而当所使用的库使用了封装类型且我们自己无法修复这个问题,同上也可以使用命令行标志在编译时打破封装。
此处可使用--add-exports <module>/<package>=<targetmodule>
这样的语法导出封装的包,但这只是一种零时解决办法。
命令行过长
一些操作系统限制了可执行命令行的长度。当在迁移期间需要添加许多标志时,可能会触发这些限制。此时的替代做法是使用一个文件将所有的命令行参数提供给java/javac
1 |
|
参数文件必须包含所有必要的命令行标志。文件中的每一行都包含一个选项。例如,arguments.txt
可以包含以下内容:
1 |
|
移除的类型
随着Java的升级,其中一些内部类会被移除(依赖内部实现类是不规范的)。例如在Java 9中删除的内部类是sun.misc.BASE64Encoder
,而在这之前的Java 8
中引入了其公开替代实现java.util.Base64
。
但我们并总是清楚这些替代实现,而JDK附带了一个工具jdeps
来帮助我们。jdeps
可以找到被删除或封装的JDK类型,并建议替换,其工作基于类文件(.class文件)而非源码文件,例如对在Java 8环境下编译且使用了sun.misc.BASE64Decoder
的类进行解析:
1 |
|
缺失模块
JDK中Java SE和Java EE之间的重叠一直令人困惑。Java EE应用程序服务器通常提供了API的自定义实现。简单地讲,是通过将替代实现放在类路径上,覆盖默认的JDK版本来完成的。而在Java模块系统不允许多个模块提供相同的包。如果在类路径中找到重复的包(在未命名的模块中),则会将其忽略。在任何情况下,若Java SE和应用程序服务器都提供java.xml.bind
包,则不会实现预期的行为,为了避免出现该问题,在基于类路径的场景中,默认情况下不会解析这些模块。
例如:
1 |
|
上述代码在Java 8可以正常使用,但在新版本Java进行编译就会出现问题:
1 |
|
为了解决这个问题,则需要添加--add-modules java.xml.bind
选项到命令行java
或javac
的调用中以添加解析模块java.xml.bind
。当然这些模块会随着版本升级逐渐被删除,你可以通过将这些技术的Jar文件添加到类路径来避免。
移动到模块路径
模块化
终于,我们可以开始着手当前迁移项目(此处的项目表示一个单独的代码模块)的模块化改造了,这部分内容简要概括一下:
模块描述
我们只需要在项目根包(/
)下创建一个名为module-info.java
的模块描述文件即可将当前项目转换为模块,模块描述内容很简单,例如:
1 |
|
解释一下上面所用的描述符:
- (1) 指定了当前模块的唯一命名
格式为[open] module <Module Name>
- (2)
requires
子句指定了模块显式依赖
只有指定为显式依赖,才可以在代码中直接使用依赖模块的导出内容,格式为requires <Module Name>
- (3)
exports
子句指定模块导出的公共包
即对外公开的API,非导出部分都将被封装,格式为exports <Package Name> [to <Module Name>]
- (4)
opens
子句指定模块的开放包
即允许深度反射的部分,格式为opens <Package Name> [to <Module Name>]
,例如:一般考虑到对ROM框架的支持会开放数据实体包 - (5)
uses
子句表明了模块可以使用实现了指定接口的服务
例如上所描述的java.sql.Driver
,该子句用于指定服务依赖关系(可选依赖) - (5)
provides
子句表明该模块可以对外提供实现了指定接口的服务
例如上所描述的提供实现了me.example.api.DemoService
的服务
在上面的示例中提到了一个新概念:服务依赖。
我们先考虑一个常见的场景:模块A对外导出了公共API DemoService
,同时会有一个封装的默认实现DefaultDemoServiceImpl
,但实现是被封装的,为此需要在公开部分添加一个能获取该默认实现的方法,例如使用一个工厂,很显然模块A的公共API多了一个用于获取默认实现的工厂API,且我们很难再对这个公共工厂做出修改,API不应与其实现紧密耦合。
我们现在可以通过Java模块系统中的服务机制进行解耦。使用服务,可以真正地共享公共接口,并将实现代码强封装到未导出的包中。而是否使用模块系统中的服务是完全可选的,因此服务依赖也就成为可选依赖。
早在模块系统出现之前,Java中就已经存在一个类似的设计———— ServiceLoader
,也就是所谓的SPI(Service Provider Interface)。如下所示,我们使用模块系统中的服务获取java.sql.Driver
实现:
1 |
|
由于我们在模块描述中已经表明了可选依赖uses java.sql.Driver
,所以,一旦在模块路径发现有对应服务的提供者,就会创建于本模块的服务依赖,例如在模块路径中添加了java.sql.Driver
服务的提供模块如Mysql、Oracle等(前提是这些模块在描述provides
子句中声明了其对外提供服务),我们就可以通过ServiceLoader
获取(延迟加载)这些实现,同时还能识别这些实现(通过检查ServiceLoader.Provider
对象)。基于篇幅,这部分简单介绍就到此为止。
自动模块
由于我们的项目已经模块化,成为一个命名模块,此时项目就无法再访问类路径上的内容了(未命名模块)。为此需要将项目直接依赖的库从类路径移到模块路径。
尽管一些库本身并未模块化,其仍然可以作为模块在模块路径中使用,而这些库在模块路径上时会被转化为自动模块(Automatic Module)。截至目前,我们知道了Java模块系统提供了三种类型的模块:未命名模块、自动模块和命名模块。让我们区分一下这些模块类型的特点:
类型 | 生成规则 | 命名 | 依赖关系 | 公开导出 | 开放封装 | 拆分包 | 特殊 |
---|---|---|---|---|---|---|---|
未命名模块 | 在类路径上 | ALL-UNNAMED | 读取所有其他模块 | 所有包 | 所有包 | 允许 | 不允许被命名模块读取 |
自动模块 | 不包含module-info.class 模块描述,且在模块路径上 |
META-INF/MANIFEST.MF中指定或文件名转换 | 读取所有其他自动模块和未命名模块 | 所有包 | 所有包 | 不允许 | 依赖将被传递 |
命名模块 | 包含module-info.class 模块描述,且在模块路径上 |
模块描述指定 | 模块描述指定(requires) | 模块描述指定(exports) | 模块描述指定(opens) | 不允许 | 不允许读取未命名模块 |
拆分包:指不同模块或库拥有相同限定名的包,就好像一个包的内容被拆分到了不同的模块或库中。在Java引入模块系统前(Java9+)是允许拆分包存在的。
自动模块的名称可以通过META-INF/MANIFEST.MF
的Automatic-Module-Name
节点属性指定,这是库作者对模块化最低程度的支持。而当未指定自动模块名称时,自动模块的名称则由库文件名决定:
使用点(.)替换文件名中的非字母数字字符([^A-Za-z0-9]),剔除重复的点(.),例如:jackson-databind-1.0.0.jar
将成为自动模块jackson.databind
。
解析模块图是根据一组给定的根模块计算出来的,在自动模块的情况下,模块解析会产生混乱。当模块未指定自动模块显式依赖时,自动模块和其传递依赖(所有其他自动模块)就不会被解析,同时,使用--add-modules
手动添加依赖项非常耗时。而当应用程序requires
一个自动模块时,所有自动模块都会被自动解析,这样一来,就可能导致未使用的自动模块也被解析(占用不必要的资源),所以请保持模块路径尽可能“干净”,仅将和应用有直接显式依赖关系的自动模块放在模块路径上。
但在众多的库中,我们很难清楚其模块的引用关系,此时可以使用jdeps
工具,在上面我们已经使用过该工具了。现在我们使用该工具进行库的依赖分析,对于如下应用:
1 |
|
使用命令分析类路径编译的应用:
1 |
|
根据上面的输出已经可以得出结论,为了将代码迁移到模块,需要使jackson-databind
成为自动模块。同时jackson-databind
依赖于jackson-core
和jackson-annotations
,所以这些库可以停留在类路径或移动到模块路径。如果想知道为什么存在依赖关系,可以省略上述命令中的-summary
参数打印更多的细节,以及准确显示哪些包需要哪些其他包,如果信息还不够详细,可以添加参数-verbose:class
以打印类级别依赖关系。
未命名模块本身只能通过自动模块读取,迁移过程并非一蹴而就,往往会存在部分类路径和模块路径混合使用的情况。
问题修复
反射加载
迁移到模块时应该特别注意那些使用反射加载的代码。常见的例子比如加载JDBC驱动可能会使用如下代码:
1 |
|
此时我们的模块还没有指定具体的显式依赖描述,运行代码时将会抛出一个异常:java.lang.ClassNotFoundException: org.hsqldb.jdbcDriver
,因为hsqldb
自动模块未被加载。当然我们可以在我们的模块描述中添加自动模块的显式依赖,但这样做很糟糕,我们不应该依赖具体的实现,为了使应用能正常启动,我们可以在启动命令中临时添加参数--add-modules hsqldb
。
现在驱动可以加载了,但又会引起一个异常,由于我们加载的驱动依赖于java.sql.Driver
(java.sql
模块),而我们的模块描述中并未对其进行依赖声明,所以我们修改模块的描述,添加requires java.sql;
子句。 此时如果删除启动命令中的--add-modules hsqldb
参数,应用仍能正常启动,这是为什么?
我们显式依赖了模块java.sql
,而该模块定义了java.sql.Driver
服务接口,同时uses
该服务,而hsqldb
的库提供了一个这样的服务实现,它通过旧的SPI方式(META-INF/services中使用文件注册服务)提供了服务实现,所以基于服务绑定,hsqldb
的自动模块会从模块路径上被解析。
拆分包
拆分包意味着两个模块包含相同的包,Java模块系统不允许拆分包。因此,从类路径到模块路径可能会遇到拆分包的问题。
拆分包始终是不正常的,而当使用解析可传递依赖项的构建工具(如Maven等)时,很容易出现同一个库的多个版本,当Java模块系统检测到一个包存在于模块路径上的多个模块中时,就会拒绝启动。如果在迁移时遇到了拆分包问题,无论如何都是无法绕过的。即使从用户角度来看基于类路径的应用程序可以正确工作,你也最终需要处理这些问题。
Idea启动问题
截至文档发布,我使用的Idea版本为2021.2.1
,其存在一些模块化资源打包的Bug,例如:
Idea将resources
资源编译输出到build/resources/main
目录下,而模块目录为build/classes/java/main
,此时就会出现资源找不到的问题,可以通过添加VM Options
的--patch-module
参数选项,如:--patch-module me.example.test=example/build/resources/main
将资源目录通过patch
的方式追加到指定模块。
模块化改造
虽然项目已经通过添加模块描述文件实现了模块化,但并不是一个优秀的模块,我们还需要对项目的包进行调整,仅导出我们认为需要导出的包,同时使用服务依赖进行对具体实现的解耦,如果有提供API的实现,则需要描述服务提供provides
。
同时,相应的自动模块应该使用其模块化的版本替代。
而资源也是封装在模块中的,推荐的做法是使用服务提供实现各模块间资源共享。
文献参考:《Java 9 Modularity》