Last Updated:

模块是关于什么的

银狐

当 Java 模块的主题经常出现在在线讨论中时,我感觉很多人都误解了模块应该做什么。这是可以理解的,因为名称“模块”并没有唤起该功能的确切用途。有很多种模块,即使在 Java 世界中,每种模块都涉及不同类型的软件模块性,并提供为不同目标设计的不同功能集。Java 的模块系统侧重于模块化的特定方面,其特性是独一无二的。

过去,我尝试使用构建的类比来解释模块系统和构建工具的不同角色。就像蓝图一样,Java 模块说,“这里有一个这样大小的窗口”,而构建系统,就像一个材料清单,说“我们的项目使用了这样那样的制造和模型的窗口”。构建工具负责获取正确版本的正确部分,而模块系统则说明它们是如何组合在一起的。但是,虽然有助于理解 Java 模块的哲学以及为什么模块声明和构建文件之间有一些小的重叠(它们都提到“窗口”),但这种解释是抽象的,并没有阐明它到底是什么,那个模块做。

在这篇文章中,我会尽量做到具体。

核心原则

Java 模块是一组包,它们声明其中哪些构成了其他模块可访问的 API,哪些是内部的和封装的——类似于类定义其成员可见性的方式。一个模块还声明它的操作需要哪些其他模块。库作者可以选择将其放置在模块中,应用程序作者可以选择将其组件放置在模块中,当然,JDK 本身也是由模块组成的。

Java 模块有什么作用它们在运行时强制执行两组保证:

  1. 可靠的配置:所有应用程序的组件都在手,每个类的实例不超过一个,并且可选地,它们与构建时使用的那些相同,
  2. 强封装:任何代码都不能被其他模块直接或通过反射访问,除非由模块声明、其代码或应用程序(通过命令行标志)明确授权。

模块系统不关心从某个通用目录中为您的应用程序挑选和获取正确的组件——这是构建工具的工作——也不是为了它的主要目的而架构清洁。它的存在是为了在运行时提供我提到的那些重要保证,并且是模块系统独有的。

实际上,模块系统的每个元素都服务于一个或两个。模块声明requiresuses子句?可靠的配置。在exportsopens条款?强封装。该provides条款?两个都。层为可靠的配置提供服务,而查找已被改造为服务强封装,并jlink提供可靠的配置等服务,即使它用于为任何 Java 应用程序创建运行时,模块化或其他方式,模块让您更轻松可靠地使用 jlink产生一个紧凑的、更快启动的运行时。

人们在多大程度上关心这些保证差异很大。如果您不负责维护、安全或部署,或者即使您负责但您的软件足够小以至于这些工作在没有模块的情况下也很容易,那么您可能完全不在乎。但是,正如我们将看到的,对于一些软件项目,包括 JDK 本身但当然不限于它,这些是非常重要的保证,软件越大越流行,它们就越重要。

我希望即使您选择不创作和部署自己的模块,这篇文章也能帮助您了解它们的重要性。虽然只有已经以模块化方式构建的代码才能放入模块中,因此大型旧代码库难以适当重构,但新的无障碍代码很容易模块化。如果您是一个大型新项目的团队负责人或架构师,或者只是希望您的软件有一天会变得更大和/或流行,那么您很可能会不费吹灰之力就从模块中获得巨大的收益。但是无论你是否编写自己的模块,使用模块路径与否——如果你使用 Java,那么你一直在大量使用模块系统,因为它是平台的基础之一,所以它可能会有所帮助了解它的作用。

现在,为什么这些保证很重要?

可靠的配置

类路径有什么问题?

第一组保证,可靠的配置,防止一些可能通过配置引入的偷偷摸摸的错误,比如那些被称为“类路径地狱”并由脆弱的类路径导致的错误。

每次 Java 程序遇到新类时,VM 都会按顺序扫描类路径,直到找到第一个匹配的类文件。它不关心类被打包成 JAR 文件的方式;该组织被忽略。例如,假设您将 leftpad2.jarleftpad 库的第 2 版放在类路径上,并且可能不小心将旧的leftpad1.jar, 也放在类路径上。

如果你把 放在leftpad2.jar 之后 leftpad1.jar,当你第一次接触com.acme.leftpad.Bar——版本 2 中新的令人兴奋的添加——VM 将扫描类路径上的所有 JAR,直到它Bar.classleftpad2.jar. 但是当你第一次使用旧 com.acme.leftpad.Foo 的虚拟机时,它会再次扫描类路径并找到Foo.classin leftpad1.jar,因为这是它第一次出现。不匹配会导致一些令人惊讶的行为,而不是容易调试的行为。

我们之所以能够与类路径共存,只能归功于像 Maven 这样为我们组装它的工具的帮助,但是类路径是如此脆弱,以至于它不能作为一个受人尊敬的基础。如果 leftpad 库是模块化的,并且两个 JAR 不小心放在模块路径上,VM 将在启动时立即检查它们是否com.acme.leftpad存在于单个模块中。此外,当使用 为我们的应用程序创建 Java 运行时 jlink,它会将模块嵌入到图像文件中,确保我们在构建时使用的版本与我们在运行时使用的版本相同。

强封装

模块如何帮助可维护性和安全性?

阅读上面的场景,我敢肯定一些读者认为,“这从来没有发生在我身上”,而其他人则被触发,回想起他们所经历的恐怖,当他们的一个依赖项将一个包从一个人工制品移到另一个人工制品时,他们只升级了其中之一。但是,通过谨慎和纪律以及使用 Maven 之类的工具,甚至可能是 Docker 之类的容器,即使没有运行时保证,也可以在很大程度上避免类路径问题。所以模块的第二个保证,强封装,是更重要的一个。在运行时严格执行定义明确的显式 API 会对可维护性和安全性产生重大影响。

Java 编译器不允许您编译访问另一个类的私有字段的代码;但是,即使您针对字段公开的旧版本编译代码,或者使用某个 Java 代理生成的字节码,JVM 也会在运行时执行访问检查并阻止尝试。平台的完整性取决于它。模块以类似的方式工作。编译器将根据模块配置检查访问(虽然不是反射访问!)但在运行时,VM 将强制所有访问,甚至反射访问,都通过模块声明的 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();
}

此代码(类似于所有授权代码)假定敏感操作getUserDatasafelyRetrieveUserData在经过适当的凭据检查后才被调用。但是如果没有强封装,即使getUserData是私有的,也可以直接调用,绕过safelyRetrieveUserData所谓的“深度反射”——使用 setAccessible禁用访问检查。这不需要您的应用程序中有任何恶意代码。相反,这种利用需要一个善意的软件组件,该组件已经为良性目的执行了深度反射,并且需要一个漏洞,允许远程攻击者欺骗该善意组件将其深度反射应用于getUserData通过巧妙地操作输入(例如,JSON 序列化库可能会使用深度反射来访问基于其输入中出现的字符串的私有字段或方法)。这种易受攻击的代码可能存在于某种传递依赖中,而您甚至都不知道它的存在。

强封装关闭了 JDK 中的漏洞,它可能会对您的应用程序做同样的事情。良好的安全性需要(除其他外)一个明确定义的、最好是小的边界——它的“攻击表面区域”——然后加以防御。如果没有,应用程序中的每一种方法和每一个领域都将成为其边界的一部分,从而使有效防御变得困难。模块系统的强封装是安全的坚实基础,正逐渐成为平台安全策略的核心。

综上所述,

虽然我们不会仅仅从它们的名字中得知,Java 模块的主要目的,也就是它们独有的,是在运行时做出两种强有力的保证:可靠的配置和强大的封装。可靠的配置可防止某些不稳定的配置错误。更重要的强封装确保与代码单元的交互仅通过其显式 API 发生,并且对长期可维护性以及 Java 代码的安全性具有重要意义。

要了解更多关于模块,您可以观看这些Java的通道由亚历克斯·巴克利视频:模块在JDK 9,和模块和服务。还有两本关于这个主题的好书:Paul Bakker 和 Sander Mak 的Java 9 Modularity和Nicolai Parlog 的The Java Module System


附录:多版本共存问题

模块是否让我同时使用两个版本的库?

模块的存在是为了完成一项重要的工作,而且它们做得很好,但我们可能希望它们也能完成其他工作,例如解决我称之为“多版本共存”的问题。

假设你的应用程序采用了两个库,libA并且libB,无论是使用一个被称为日志库superlogger,但而libA使用版本1 superloggerlibB依赖于不兼容的版本2.有些人希望的模块系统可以使日志库的两个不兼容的版本共存的无有害干扰的相同过程。具体来说,可以在同一个进程中支持多个版本的类的机制是类加载器隔离。模块系统可以给每个模块自己的类加载器,按照从模块的依赖关系图派生的层次结构排列,但它没有,至少在默认情况下没有。

一方面,这样做比 JDK 8 和 9 中的所有更改加起来对 Java 生态系统的破坏更大。即使 Java SE 规范保持向后兼容,对 JDK 行为的更改也可能会破坏规范无法保证的关于 Java 运行时工作的假设。运行时的类加载器层次结构很浅且事先已知的假设在生态系统中如此深入和广泛,以至于如果模块默认强制执行类加载器隔离,那么太多流行框架就会停止正常运行。

另一方面,类加载器隔离不足以允许库的多个实例共存。假设记录器配置了像这样的系统属性-Dcom.acme.superlogger.logfile=myApp.log。然后,两个版本将使用相同的输出文件,并且由于它们每个都可能使用锁同步对文件的写入,因此两个实例将意味着两个锁和损坏的日志(或者版本 2 甚至可能更改了文件格式)。类加载器隔离仅适用于某些库而不是其他库的多版本共存,具体取决于它们的操作方式。

鉴于类加载器隔离既具有破坏性,在一般情况下甚至不能解决问题,而只是尽力而为,默认情况下模块系统不会这样做。尽管如此,模块确实支持类加载器隔离,无论它的价值如何,都带有模块层。层有时可能是合适的(例如,它们对插件架构很有用),像Layrry这样的第三方库可以基于配置文件构建层层次结构。

来自:https://inside.java/2021/09/10/what-are-modules-about/