Java 9 打破模块的封装
本文于1798天之前发表,文中内容可能已经过时。
主要介绍以下内容:
- 什么是打破模块的封装
- 如何使用命令行选项将依赖项(添加需要)添加到模块
- 如何使用
--add-exports
命令行选项导出模块的未导出包,并使用可执行JAR的MANIFEST.MF文件 - 如何使用
--add-opens
命令行选项并使用可执行JAR的MANIFEST.MF文件打开模块的非开放包 - 如何使用
--add-read
s命令行选项增加模块的可读性
一. 什么是打破模块的封装
JDK 9的主要目标之一是将类型和资源封装在模块中,并仅导出其他模块要访问其公共类型的软件包。 有时,可能需要打破模块指定的封装,以启用白盒测试或使用不受支持的JDK内部API或类库。 这可以通过在编译时和运行时使用非标准命令行选项来实现。 具有这些选项的另一个原因是向后兼容性。 并不是所有现有的应用程序将完全迁移到JDK 9并将被模块化。 如果这些应用程序需要使用以前是公开的但已经封装在JDK 9中的库提供的JDK API或API,则这些应用程序有一种方法可以继续工作。 其中一些选项具有可以添加到可执行JAR的MANIFEST.MF文件中的对应属性,以避免使用命令行选项。
Tips
使用Module API也可以使用每个命令行选项来打破模块的封装。
虽然可能听起来像这些选项与JDK 9之前的操作相同,但是在访问JDK内部API时没有任何限制。 如果模块中的软件包未导出或打开,则表示模块的设计人员无意在模块外部使用这些软件包。 这样的包可能会被修改或甚至从模块中删除,无需任何通知。 如果仍然使用这些软件包通过使用命令行选项导出或打开它们,可能会面临破坏应用程序的风险!
二. 命令行选项
模块声明中的三个模块语句(statement)允许模块封装其类型和资源,并让其他模块使用来自第一个模块的封装类型和资源。 这些语句是exports
, opens
, 和requires
。 每个模块语句都有一个命令行选项。 对于exports
和opens
语句,可以在JAR的manifest文件中使用相应的属性。 下表列出了这些语句及其相应的命令行选项和清单属性。 在以下部分详细描述这些选项。
Module Statement | Command-Line Option | Manifest Attribute |
---|---|---|
exports | –add-exports | Add-Exports |
opens | –add-opens | Add-Opens |
requires | –add-reads | 无属性可用 |
Tips
您可以在相同的命令行中多次使用--add-exports
,--add-opens
和--add-reads
命令行选项。
1. --add-exports
选项
模块声明中的exports
语句将模块中的包导出到所有或其他模块,因此这些模块可以使用该包中的公共API。 如果程序包未由模块导出,则可以使用-add-exports
的命令行选项导出程序包。 其语法如下:
1 | --add-exports <source-module>/<package>=<target-module-list> |
这里,<source-module>
是将<package>
导出到<target-module-list>
的模块,它是以逗号分隔的目标模块名称列表。 相当于向<source-module>
的声明添加一个限定的exports
语句:
1 | module <source-module> { |
Tips
如果目标模块列表是特殊值ALL-UNNAMED
,对于--add-exports
选项,模块的包将导出到所有未命名的模块。--add-exports
选项可用于javac和java命令。
以下选项将java.base模块中的sun.util.logging包导出到com.jdojo.test和com.jdojo.prime模块:
1 | --add-exports java.base/sun.util.logging=com.jdojo.test,com.jdojo.prime |
以下选项将java.base模块中的sun.util.logging包导出到所有未命名的模块:
1 | --add-exports java.base/sun.util.logging=ALL-UNNAMED |
2. --add-opens
选项
模块声明中的opens
语句使模块里面的包对其他模块开放,因此这些模块可以在运行期使用深层反射访问该程序包中的所有成员类型。 如果一个模块的包未打开,可以使用--add-opens
命令行选项打开它。 其语法如下:
1 | --add-opens <source-module>/<package>=<target-module-list> |
这里,<source-module>
是打开<package>
到<target-module-list>
的模块,它是以逗号分隔的目标模块名称列表。 相当于向<source-module>
的声明添加一个限定的opens
语句:
1 | module <source-module> { |
Tips
如果目标模块列表是特殊值ALL-UNNAMED
,对于--add-opened
选项,模块的软件包对所有未命名的模块开放。--add-opened
选项可用于java命令。 在编译时使用javac命令使用此选项会生成警告,但没有影响。
以下选项将java.base模块中的sun.util.logging包对com.jdojo.test和com.jdojo.prime模块开放:
1 | --add-opens java.base/sun.util.logging=com.jdojo.test,com.jdojo.prime |
以下选项将java.base模块中的sun.util.logging包对所有未命名的模块开放:
1 | --add-opens java.base/sun.util.logging=ALL-UNNAMED |
3.--add-reads
选项
--add-reads
选项不是关于打破封装。 相反,它是关于增加模块的可读性。 在测试和调试过程中,即使第一个模块不依赖于第二个模块,模块有时也需要读取另一个模块。 模块声明中的requires
语句用于声明当前模块对另一个模块的依赖关系。 可以使用--add-reads
命令行选项将可读性边缘从模块添加到另一个模块。 这对于将第一个模块添加requires
语句具有相同的效果。 其语法如下:
1 | --add-reads <source-module>=<target-module-list> |
<source-module>
是其定义被更新以读取<target-module-list>
中指定的模块列表的模块,该目标模块名称是以逗号分隔的列表。 相当于将目标模块列表中每个模块的源模块添加一个requires
语句:
1 | module <source-module> { |
Tips
如果目标模块列表是特殊值ALL-UNNAMED
,则对于--add-reads
选项,源模块读 有未命名的模块。 这是命名模块可以读取未命名模块的唯一方法。 没有可以在命名模块声明中使用的等效模块语句来读取未命名的模块。 此选项在编译时和运行时可用。
以下选项为com.jdojo.common模块添加了一个读取边界,使其读取jdk.accessibility模块:
1 | --add-reads com.jdojo.common=jdk.accessibility |
4. --permit-illegal-access
选项
前面提到的三个命令行选项,用于添加exports
,opens
和reads
仅用于向后兼容。 但是,当需要“非法”访问(反射访问模块中类型不可访问的成员)到几个模块时,使用这些选项是乏味的。 对于这种情况,java命令可以使用--permit-illegal-access
选项。 顾名思义,它允许通过使用深层反射的任何未命名模块(类路径中的代码)的代码非法访问任何命名模块中的类型的成员。 其语法如下:
1 | java --permit-illegal-access <other-options-and-arguments> |
--permit-illegal-access
选项不允许将命名模块中的代码非法访问其他命名模块中的类型的成员。 在这种情况下,可以将此选项与--add-exports
,--add-opens
和--add-reads
选项组合使用。
Tips
--permit-illegal-access
选项在JDK 9中可用,并将在JDK 10中删除。使用此选项会在标准错误流上打印警告。 一个警告打印一个消息,规定此选项将在将来的版本中被删除。 其他警告报告了授予非法访问的代码的详细信息,授予非法访问的代码以及授予访问权限的选项。
在下一节中介绍一个使用所有这些选项的示例,这些选项允许打破模块封装。
三. 一个示例
我们来看一下打破封装的例子。 我使用一个简单的例子。 它的目的是展示可用于打破封装的所有概念和命令行选项。
使用之前创建com.jdojo.intro模块作为第一个模块。 它在com.jdojo.intro包中包含一个Welcome
类。 该模块不导出包,所以Welcome
类被封装,不能在模块外部访问。 在这个例子中,从另一个模块com.jdojo.intruder调用Welcome
类的main()
方法。其声明如下所示。
1 | // module-info.java |
下面显示了此模块中TestNonExported
类的代码。
1 | // TestNonExported.java |
TestNonExported
类只包含一行代码。 它调用Welcome
类的静态方法main()
传递一个空的String
数组。 如果该类被编译并运行,则在运行Welcome
类时打印与第3章中相同的消息:
1 | Welcome to the Module System. |
编译com.jdojo.intruder模块的代码:
1 | C:\Java9Revealed>javac --module-path com.jdojo.intro\dist |
如果收到如下错误:
1 | com.jdojo.intruder\src\com\jdojo\intruder\TestNonExported.java:4: error: package com.jdojo.intro is not visible |
该命令使用--module-path
选项将com.jdojo.intro模块包含在模块路径上。 编译时错误指向导入com.jdojo.intro.Welcome类的import
语句。 它声明包com.jdojo.intro对于com.jdojo.intruder模块是不可见的。 也就是说,com.jdojo.intro模块不导出包含Welcome类的com.jdojo.intro包。 要解决此错误,需要使用--add-exports
命令行选项将com.jdojo.intro模块的com.jdojo.intro包导出到com.jdojo.intruder模块中:
1 | C:\Java9Revealed>javac --module-path com.jdojo.intro\dist |
但是仍然报错:
1 | warning: [options] module name in --add-exports option not found: com.jdojo.intro |
这一次,你会得到警告和错误。 错误与以前相同。 该警告消息指出编译器找不到com.jdojo.intro模块。 因为这个模块没有依赖关系,所以即使在模块路径中也没有解决这个模块。 要解决警告,需要使用–add-modules
选项将com.jdojo.intro模块添加到默认的根模块中:
1 | C:\Java9Revealed>javac --module-path com.jdojo.intro\dist |
即使com.jdojo.intruder模块未读取com.jdojo.intro模块,此javac命令仍然成功。 这似乎是一个错误。 如果它不是一个bug,那么没有找到支持这种行为的文档。 稍后,将看到java命令将不适用于相同的模块。 如果此命令出错,并显示一条消息,表示TestNonExported
类无法访问Welcome
类,请添加以下选项来修复它:
1 | --add-reads com.jdojo.intruder=com.jdojo.intro |
尝试使用以下命令重新运行TestNonExported
类,该命令包括模块路径上的com.jdojo.intruder模块:
1 | C:\Java9Revealed>java --module-path com.jdojo.intro\dist;com.jdojo.intruder\build\classes |
但是会报出以下错误信息:
1 | Exception in thread "main" java.lang.IllegalAccessError: class com.jdojo.intruder.TestNonExported (in module com.jdojo.intruder) cannot access class com.jdojo.intro.Welcome (in module com.jdojo.intro) because module com.jdojo.intruder does not read module com.jdojo.intro |
错误信息已经很清晰。 它声明com.jdojo.intruder模块必须读取com.jdojo.intro模块,以便前者使用后者的Welcome
类。 可以使用--add-reads
选项来修复错误,该选项将在com.jdojo.intruder模块中添加一个读取边界(等同于requires
语句)以读取com.jdojo.intro模块。 以下命令执行此操作:
1 | C:\Java9Revealed>java --module-path com.jdojo.intro\dist;com.jdojo.intruder\build\classes |
输出结果为:
1 | Welcome to the Module System. |
这一次,你会收到所期望的输出。 下图显示了运行此命令时创建的模块图。
com.jdojo.intruder和com.jdojo.intro模块都是根模块。 com.jdojo.intruder模块被添加到默认的根模块中,因为正在运行的主类在此模块中。 com.jdojo.intro模块通过--add-modules
选项添加到默认的根模块集中。 通过--add-reads
选项从com.jdojo.intruder模块将一个读取边界添加到com.jdojo.intro模块。 模块图中,使用虚线显示了从前者到后者的读取,以便在构建模块图之后作为--add-reads
选项的结果添加它。 使用此命令使用-Xdiag:resolver
选项来查看模块的解决方法。
来看看另一个例子,它将展示如何使用--add-opens
命令行选项打开一个包到另一个模块。 在第4章中,有一个com.jdojo.address模块,其中包含com.jdojo.address包中的Address
类。 该模块导出com.jdojo.address包。 该类包含一个名为line1
的私有字段, 有一个public getLine1()
方法返回line1
字段的值。
如下代码所示,TestNonOpen
类尝试加载Address
类,创建类的实例,并访问其公共和私有成员。 TestNonOpen
类是com.jdojo.intruder模块的成员。 在main()
方法的throws
子句中添加了一些异常,以保持逻辑简单。 在实际的程序中,在try-catch
块中处理它们。
1 | // TestNonOpen.java |
使用以下命令编译TestNonOpen
类:
1 | C:Java9revealed> javac -d com.jdojo.intruder\build\classes |
TestNonOpen
类编译正常。 请注意,它使用深层反射访问Address
类,编译器不知道此类不允许读取Address
类及其私有字段。 现在尝试运行TestNonOpen
类:
1 | C:Java9revealed> java --module-path com.jdojo.address\dist;com.jdojo.intruder\build\classes |
会出现以下错误信息:
1 | Using method reference, Line 1: 1111 Main Blvd. |
使用--add-modules
选项将com.jdojo.address模块添加到默认的根模块中。 即使com.jdojo.intruder模块没有读取com.jdojo.address模块,也可以实例化Address
类。 有两个原因:
- com.jdojo.address模块导出包含
Address
类的com.jdojo.address包。 因此,其他模块可访问Address
类,只要其他模块读取com.jdojo.address模块即可。 - Java 反射 API假定所有反射操作都是可读性的。 该规则假设com.jdojo.intruder模块读取com.jdojo.address模块,即使在其模块声明中,com.jdojo.intruder模块未读取com.jdojo.address模块。 如果要在编译时使用com.jdojo.address包中的类型,例如,声明
Address
类类型的变量,则com.jdojo.intruder模块必须在它声明或命令行中读取com.jdojo.address模块。
输出显示TestNonOpen
类能够调用Address
类的public getLine1()
方法。 但是,当它尝试访问私有line1
字段时,抛出异常。 回想一下,如果模块导出了类型,其他模块可以使用反射来访问该类型的公共成员。 对于其他模块访问类型的私有成员,包含该类型的包必须是打开的。 com.jdojo.address包未打开。 因此,com.jdojo.intruder模块无法访问Address
类的私有line1
字段。 为此,可以使用--add-opens
选项将com.jdojo.address包打开到com.jdojo.intruder模块中:
1 | C:Java9revealed> java --module-path com.jdojo.address\dist;com.jdojo.intruder\build\classes |
输出结果为:
1 | Using method reference, Line1: 1111 Main Blvd. |
现在是时候使用--permit-illegal-access
选项了。 我们试试从类路径运行TestNonOpen
类,如下所示:
1 | C:\Java9Revealed>java --module-path com.jdojo.address\dist |
输出结果为:
1 | Using method reference, Line1: 1111 Main Blvd. |
从输出可以看出,由于它位于类路径上,加载到未命名模块中的TestNonOpen
类能够在com.jdojo.address模块中读取导出的类型及其公共方法。 但是,它无法访问私有实例变量。 可以使用--permit-illegal-access
选项修复此问题,如下所示:
1 | C:\Java9Revealed>java --module-path com.jdojo.address\dist |
输出结果为:
1 | WARNING: --permit-illegal-access will be removed in the next major release |
请注意,由于--permit-illegal-access
选项的警告和TestNonOpen
类的消息的都会混合在输出中。
四. 使用JAR的Manifest属性
可执行的JAR是一个JAR文件,可用于使用如下所示的-jar
选项直接运行Java应用程序:
1 | java -jar myapp.jar |
这里,myapp.jar被称为可执行JAR。其MANIFEST.MF文件中的可执行JAR包含一个名为Main-Class的属性,其值是java命应运行的主类的完全限定名。回想一下,还有其他种类的JAR,如模块化JAR和多版本JAR。 JAR基于哪种JAR无关紧要;可执行JAR仅在使用-jar
选项用于启动应用程序的方式的上下文中定义。
考虑现有应用程序作为可执行JAR。假设应用程序使用深层反射来访问JDK内部API。它在JDK 8中工作正常。希望在JDK 9上运行可执行文件JAR。JDK 9中的JDK内部API已封装。现在,必须使用--add-exports
和-add-opens
命令行选项协同-jar
选项来运行相同的可执行文件JAR。在JDK 9中使用新的命令行选项提供了一个解决方案。然而,对于可执行JAR的最终用户来说,这是不方便的。要使用JDK 9,他们需要知道所需要使用的新的命令行选项。为了缓解这种迁移,JDK 9中添加了可执行JAR的MANIFEST.MF文件的两个新属性:
1 | Add-Exports |
这些属性将添加到manifest文件的主要部分。 它们是--add-exports
和--add-opened
两个命令行选项的对应。 使用这些属性有一个区别。 它们导出和打开模块的包给所有的未命名模块。 因此,可以指定源模块列表,它们的包不必将目标模块指定为这些属性的值。 换句话说,在manifest文件中,可以导出或打开包给所有的未命名模块,也可以不打开所选模块。 这些属性的值是以分隔开的模块名/包名称对的空格分隔的列表。 这是一个例子:
1 | Add-Exports: m1/p1 m2/p2 m3/p3 m1/p1 |
该条目将将模块m1中的软件包p1,模块m2中的软件包p2,模块m3中的软件包p3导出到所有未命名的模块。 解析manifest文件的规则是宽松的,并允许重复。 请注意该值中的重复条目m1/p1
。 在运行时,这些包将被导出到所有未命名的模块。
来看一个例子。 这个例子很简单,java.lang.Long
类包含一个名为serialVersionUID
私有静态字段,声明如下:
1 | private static final long serialVersionUID = 4290774380558885855L; |
下面包含使用深层反射访问Long.serialVersionUID
字段的TestManifestAttributes
类的代码。 该类在com.jdojo.intruder模块中。 现有应用程序不使用模块,它们将使用JDK版本8或更低版本开发。 但是,对于这个例子,它没有任何区别。
1 | // TestManifestAttributes.java |
TestManifestAttributes
类编译没有任何错误。 我们把它打包成一个可执行的JAR。 如下显示了在JDK 9之前可执行的JAR中的MANIFEST.MF文件的内容。MANIFEST.MF文件保持在JAR文件根目录下的META-INF目录中。
1 | Manifest-Version: 1.0 |
以下命令将创建名为com.jdojo.intruder.jar的可执行文件JAR:可执行文件JAR将被放置在com.jdojo.intruder\dist目录中。 或者,可以从NetBeans IDE中清理并构建com.jdojo.intruder项目,以创建此JAR。
1 | C:\Java9Revealed>jar --create --file com.jdojo.intruder\dist\com.jdojo.intruder.jar |
现在运行可执行文件JAR:
1 | C:\Java9Revealed>java -jar com.jdojo.intruder\dist\com.jdojo.intruder.jar |
会出现以下错误信息:
1 | Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make field private static final long java.lang.Long.serialVersionUID accessible: module java.base does not "opens java.lang" to unnamed module @224aed64 |
运行时错误表明应用程序无法访问私有静态serialVersionUID
,因为java.base模块中的java.lang包未打开。我们先试试--add-opens
这个选项:
1 | C:\Java9Revealed>java --add-opens java.base/java.lang=ALL-UNNAMED |
输出信息如下:
1 | Long.serialVersionUID=4290774380558885855 |
此命令工作正常,并验证命令行选项是这种情况下的解决方案。 我们使用MANIFEST.MF文件中的Add-Opens
属性来修复此错误,如下所示。
1 | Manifest-Version: 1.0 |
使用相同的命令重新创建可执行文件JAR并运行它:
1 | C:\Java9Revealed>java -jar com.jdojo.intruder\dist\com.jdojo.intruder.jar |
输出结果为:
1 | Long.serialVersionUID=4290774380558885855 |
应用程序运行正常。 如果JAR不用作可执行JAR,我们来验证是否忽略Add-Opens属性。 怎么验证这个? 通过将可执行JAR放置在类路径或模块路径上来运行应用程序,并且期望运行时发生错误。 请注意,能够在模块路径上运行此应用程序,因为正在使用JDK 9并在JAR中包含模块描述。 对于较旧的应用程序,只有一个选项 —— 从类路径运行它。 以下命令从类路径运行应用程序:
1 | C:\Java9Revealed>java --class-path com.jdojo.intruder\dist\com.jdojo.intruder.jar com.jdojo.intruder.TestManifestAttributes |
会出现以下错误:
1 | Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make field private static final long java.lang.Long.serialVersionUID accessible: module java.base does not "opens java.lang" to unnamed module @17ed40e0 |
如果要使用类路径运行此应用程序,如何解决此错误? 使用--add-open
命令行选项来修复它:
1 | C:\Java9Revealed>java --add-opens java.base/java.lang=ALL-UNNAMED |
五. 总结
JDK 9的主要目标之一是将类型和资源封装在模块中,并仅导出其他模块要访问其公共类型的软件包。 有时,可能需要打破模块指定的封装,以启用白盒测试或使用不受支持的JDK内部API或库。 这可以通过在编译时和运行时使用非标准命令行选项来实现。 具有这些选项的另一个原因是向后兼容性。
JDK 9提供了两个命令行选项--add-exports
和-add-opened
,可以在模块声明中定义封装。 --add-exports
选项允许在模块中将未导出的包导出到编译时和运行时的其他模块。--add-opened
选项允许在模块中打开一个非开放的软件包到其他模块,以便在运行时进行深度反射。 这些选项的值为/=,其中<source-module>
是导出或打开<package>
到<target-module-list>
,它是以逗号分隔的目标模块名称列表。 可以使用ALL-UNNAMED
作为将所有未命名模块导出或打开的目标模块列表的特殊值。
有两个名为Add-Exports
和Add-Opens
的新属性可用于可执行JAR的manifest 文件的主要部分。 使用这些属性的效果与使用类似命名的命令行选项相同,只是这些属性将指定的包导出或打开到所有未命名的模块。 这些属性的值是以空格分隔的斜体分隔的module-name/package-name
对列表。 例如,可执行JAR的manifest文件的主要部分中的Add-Opens:java.base/java.lang
条目将为java.base模块中的所有未命名模块打开java.lang包。
在测试和调试过程中,有时需要一个模块读取另一个模块,其中第一个模块在其声明中不使用requires
语句来读取第二个模块。 这可以使用--add-reads
命令行选项来实现,该选项的值以<source-module>=<target-module-list>
的形式指定。<source-module>
是其定义被更新以读取在<target-module-list>
中指定的模块列表的模块,该模块是目标模块名称的逗号分隔列表。 目标模块列表的ALL-UNNAMED
的特殊值使得源模块读取所有未命名的模块。