模块的意义
Ron Pressler 于 2021 年 9 月 10 日每当 Java 模块的话题在网络讨论中出现时,我都会感觉到许多人误解了模块的真正作用。这是可以理解的,因为“模块”这个名称并不能准确地表达该特性的目的。模块有很多种,即使在 Java 世界中也是如此,每种模块都与不同类型的软件模块化有关,并提供针对不同目标设计的一组不同的功能。Java 的模块系统侧重于模块化的特定方面,其功能是独一无二的。
过去,我曾尝试用建筑学来解释模块系统和构建工具的不同作用。就像蓝图一样,Java 模块会说“这里需要一个这样大小的窗户”,而构建系统就像材料清单一样,会说“我们的项目使用这种品牌和型号的窗户”。构建工具负责获取正确版本的部分,而模块系统则说明如何将它们组合在一起。但是,虽然这种解释有助于理解 Java 模块的理念,以及为什么模块声明和构建文件之间存在少量重叠(它们都提到了“窗口”),但这种解释是抽象的,并没有阐明模块的确切作用。
在这篇文章中,我将尽量具体地解释。
核心原则
**Java 模块是**一组包,它声明了哪些包构成可供其他模块访问的 API,哪些包是内部的和封装的——类似于类如何定义其成员的可见性。模块还声明了它运行时需要哪些其他模块。库作者可以选择将其放在模块中,应用程序作者可以选择将其组件放在模块中,当然,JDK 本身也是由模块组成的。
**Java 模块是*做什么的?* ** 它们在运行时强制执行两组保证
- *可靠的配置:* 确保应用程序的所有组件都已就绪,每个类都只有一个实例,并且可以选择与构建时使用的实例相同,
- *强封装:* 除非模块声明、其代码或应用程序(通过命令行标志)明确授权,否则其他模块不能访问任何代码,无论是直接访问还是通过反射访问。
模块系统不关心从某个通用目录中为您的应用程序挑选和获取正确的组件——这是构建工具的工作——架构的整洁性本身也不是其主要目的。它的存在是为了在*运行时*实现我提到的那些重要的保证,而这些保证是模块系统独有的。
模块系统的几乎每个元素都服务于其中一个或两个目的。模块声明的 requires
和 uses
子句?可靠的配置。exports
和 opens
子句?强封装。provides
子句?两者都有。 层服务于可靠的配置,而 查找已被改进以服务于强封装,jlink
除其他外还服务于可靠的配置,即使它用于为任何 Java 应用程序创建运行时,无论是否模块化,模块都允许您更轻松、更可靠地使用 jlink 来生成紧凑、启动更快的运行时。
人们对这些保证的关心程度差异很大。如果您不负责维护、安全或部署,或者即使您负责,但您的软件足够小,没有模块也能轻松完成这些工作,那么您可能理所当然地不关心这些保证。但是,正如我们将看到的,对于某些软件项目,包括 JDK 本身,但当然不限于此,这些是非常重要的保证,而且软件越大、越流行,它们就越重要。
我希望即使您选择不编写和部署自己的模块,这篇文章也能帮助您理解它们的重要性。虽然只有已经以模块化方式构建的代码才能放入模块中,因此很难对大型旧代码库进行适当的重构,但新的、不受约束的代码很容易模块化。如果您是大型新项目的团队负责人或架构师,或者只是希望您的软件有朝一日能够发展壮大或流行起来,那么您可能会以很少的努力从模块中获得巨大的回报。但是,无论您是否编写自己的模块,是否使用模块路径——如果您使用 Java,**您就已经一直在大量使用模块系统**,因为它是平台的基础之一,因此了解它的作用可能会有所帮助。
那么,为什么这些保证很重要呢?
可靠的配置
类路径有什么问题?
第一组保证,*可靠的配置*,可以防止通过配置引入一些隐蔽的错误,比如那些被称为“类路径地狱”的错误,这些错误是由脆弱的类路径造成的。
每次 Java 程序遇到一个新类时,虚拟机都会按顺序扫描类路径,直到找到第一个匹配的类文件。它不关心类是如何打包到 JAR 文件中的;这种组织方式被忽略了。例如,假设您将包含版本 2 的 leftpad 库的 leftpad2.jar
放在类路径上,并且可能不小心将旧的 leftpad1.jar
也留在了类路径上。
如果您将 leftpad2.jar
放在 leftpad1.jar
*之后*,当您第一次接触 com.acme.leftpad.Bar
(版本 2 中令人兴奋的新增功能)时,虚拟机将扫描类路径上的所有 JAR,直到在 leftpad2.jar
中找到 Bar.class
。但是,当您第一次使用旧的 com.acme.leftpad.Foo
时,虚拟机将再次扫描类路径,并在 leftpad1.jar
中找到 Foo.class
,因为这是它第一次出现。这种不匹配会导致一些令人惊讶的行为,而且不容易调试。
我们之所以能够使用类路径,完全是因为有 Maven 这样的工具为我们组装类路径,但类路径非常脆弱,不能作为可靠的基础。如果 leftpad 库是模块化的,并且两个 JAR 文件不小心都被放在了模块路径上,那么虚拟机将在启动时立即检查 com.acme.leftpad
是否只存在于一个模块中。此外,在使用 jlink
为我们的应用程序创建 Java 运行时时,它会将模块嵌入到映像文件中,从而确保我们在构建时使用的版本与我们在运行时使用的版本相同。
强封装
模块如何帮助提高可维护性和安全性?
读到这里,我相信有些读者会想,“我从来没有遇到过这种情况”,而另一些读者则会回想起当他们的一个依赖项将一个包从一个工件移动到另一个工件,而他们只升级了其中一个工件时所经历的恐怖。但是,通过谨慎、规范以及使用 Maven 等工具,甚至可能还有 Docker 等容器,即使没有运行时保证,也可以在很大程度上避免类路径问题。因此,模块的第二个保证,*强封装*,是更重要的一个。在*运行时*严格执行定义明确、显式的 API 对可维护性和安全性都有重大影响。
Java 编译器不允许您编译访问另一个类私有字段的代码;但即使您针对字段公开的旧版本编译代码,或者使用某些 Java 代理生成的字节码,JVM 也会在运行时执行访问检查并阻止该尝试。平台的完整性取决于它。模块以类似的方式工作。编译器将根据模块配置检查访问权限(尽管不适用于反射访问!),但在运行时,VM 将强制所有访问(甚至是反射访问)都通过模块声明的 API 进行。
如果没有在运行时强制执行的显式 API,则**库**中的每个类、方法和字段都可能成为事实上的 API 的一部分,因为客户端选择访问它并将其用作 API。即使库作者被允许(根据他们与用户之间的不成文约定)随意更改内部类,但这样做最终可能会破坏代码,而这反过来又意味着新版本的采用率降低,并且总体上减缓了库的演进。这正是 Java 9 发生的事情。尽管 Java 的规范(其记录的 API)仍然向后兼容 JDK 8(除了几乎没有人使用过的一些方法之外),但许多库都访问了内部 JDK 类。当这些内部结构在 9 中发生变化时,这些库就会崩溃,这减缓了新 JDK 版本的采用速度。强大的封装使库更易于**维护**,因为它们的作者可以自由更改内部结构,而不必担心它们已被某些用户用作临时 API。
当然,向后兼容性远远超出了接口,逻辑上的变化也经常会导致问题,但强大的封装消除了一些问题,并显着降低了挑战。如果 Java 从一开始就拥有模块系统,那么升级 JDK 版本就会容易得多。这不是因为 JDK 的某些特殊性质,而是因为它很受欢迎。虽然没有多少库像 JDK 那样庞大和流行,但有些库(例如 Spring 或 JUnit)足够大,并且与一些相当流行的编程语言一样流行。当这些库采用强封装时,它们的演进将会更加顺利。
在其生命周期中,由多个团队维护的大型**应用程序**会遇到与整个生态系统类似的症状。一个团队不会等待另一个团队为他们需要的某些操作添加 API,而是会为自己雕刻该 API,仅仅是因为它更快,通过深入内部并绕过另一个团队组件的文档化 API(可能是直接的,可以在构建时检测到,或者可能是反射的,只能在运行时检测到)。随着时间的推移,应用程序的组件变得纠缠不清,使得演进变得痛苦,因为任何地方的任何变化都可能影响几乎所有其他内容。通过不可妥协的 API 更松散地耦合组件是微服务架构的主要动机之一;Java 模块在单个 Java 进程中为您提供了这一点。
最后,强大的封装提高了**安全性**。假设您的应用程序包含如下代码
public UserData safelyRetrieveUserData() {
if (currentUserIsAuthorized())
return getUserData();
}
此代码(类似于所有授权代码)假定敏感操作 getUserData
仅在进行适当的凭据检查后由 safelyRetrieveUserData
调用。但是,如果没有强大的封装,即使 getUserData
是私有的,也可以直接调用它,通过所谓的“深度反射”(使用 setAccessible
禁用访问检查)绕过 safelyRetrieveUserData
。*这不需要您的应用程序中包含任何恶意代码*。相反,这种攻击需要一个善意的软件组件(该组件已经出于良性目的执行深度反射)和一个漏洞,该漏洞将允许远程攻击者通过巧妙地操作输入来欺骗该善意组件将其深度反射应用于 getUserData
(例如,JSON 序列化库可能会根据其输入中出现的字符串使用深度反射来访问私有字段或方法)。此类易受攻击的代码可能位于某些传递依赖项中,而您甚至不知道它的存在。
强大的封装已经消除了 JDK 中的漏洞,它可能会对您的应用程序起到同样的作用。良好的安全性需要(除其他外)一个定义明确的、最好是小的边界(其“攻击面”),然后对其进行防御。如果没有边界,应用程序中的每个方法和每个字段都将成为其边界的一部分,从而难以进行有效的防御。模块系统的强大封装是安全性的坚实基础,并且正在逐渐成为平台安全策略的核心。
总之,
虽然我们无法仅从它们的名称中得知,但 Java 模块的主要目的(也是它们独有的目的)是在*运行时*做出两种强大的保证:可靠的配置和强大的封装。可靠的配置可以防止某些难以捉摸的配置错误。更重要的强封装确保仅通过其显式 API 与代码单元进行交互,并且对长期可维护性以及 Java 代码的安全性具有重大意义。
要详细了解模块,您可以观看 Alex Buckley 在Java 频道上发布的这些视频:JDK 9 中的模块和模块和服务。关于这个主题还有两本好书:Paul Bakker 和 Sander Mak 合著的Java 9 模块化,以及 Nicolai Parlog 撰写的Java 模块系统。
附录:多版本共存问题
模块是否允许我同时使用两个版本的库?
模块的存在是为了完成一项重要的工作,而且它们做得很好,但我们可能希望它们也能完成其他工作,例如解决我称之为“多版本共存”的问题。
假设您的应用程序使用了两个库 libA
和 libB
,它们都使用了一个名为 superlogger
的日志记录库,但是 libA
使用的是 superlogger
的版本 1,而 libB
依赖于不兼容的版本 2。有些人曾希望模块系统可以允许两个不兼容版本的日志记录库在同一个进程中共存,而不会产生有害干扰。具体来说,可以在同一个进程中支持多个版本类的机制是类加载器隔离。模块系统可以为每个模块提供自己的类加载器,并按照模块依赖关系图派生的层次结构进行排列,但它没有这样做,至少默认情况下没有这样做。
首先,这样做对 Java 生态系统的破坏性将超过 JDK 8 和 9 中所有更改的总和。即使 Java SE 规范保持向后兼容,JDK 行为的更改也可能会破坏对 Java 运行时工作方式做出假设的内容,而这些假设不受规范的保证。运行时的类加载器层次结构很浅并且是预先知道的,这种假设在生态系统中根深蒂固,以至于如果模块默认情况下强制进行类加载器隔离,那么太多流行的框架将无法正常工作。
其次,类加载器隔离不足以允许多个库实例共存。假设记录器使用如下系统属性进行配置 -Dcom.acme.superlogger.logfile=myApp.log
。然后,两个版本都将使用相同的输出文件,并且由于它们可能都使用锁同步对文件的写入,因此两个实例将意味着两个锁和一个损坏的日志(或者版本 2 甚至可能更改了文件格式)。类加载器隔离仅足以实现*某些*库(而不是其他库)的多版本共存,具体取决于它们的操作方式。
鉴于类加载器隔离既具有破坏性,又无法在一般情况下解决问题,而只是尽力而为,因此模块系统*默认情况下*不会这样做。尽管如此,模块确实支持类加载器隔离(无论其价值如何),使用的是模块层。层在某些时候可能是合适的(例如,它们对于插件架构很有用),而像Layrry这样的第三方库可以根据配置文件构建层层次结构。