深入了解Java的新特性——模块化开发

发表时间: 2024-07-06 09:45

阶行 大淘宝技术 2024年07月05日 18:57 浙江




Java平台从Java 8向Java 9及更高版本的进化,其中引入了一个重要的新特性——模块系统(Project Jigsaw)。模块系统的目的是解决大型应用的依赖管理问题,提升性能,简化JRE,增强兼容性和安全性,并提高开发效率。通过模块化,Java能够更好地支持微服务架构,提供更细粒度的封装和控制,以及更清晰的依赖关系。文章详细介绍了模块系统的概念,如MODULE DESCRIPTOR、主要参数、关键指令,以及模块化策略。此外,本文还提供了最佳实践建议,帮助开发者更好地理解和应用Java模块系统。


模块系统简介


▐发展历史


如果把 Java 8 比作单体应用,那么引入模块系统之后,从 Java 9 开始,Java 就华丽的转身为微服务。模块系统,项目代号 Jigsaw,最早于 2008 年 8 月提出(比 Martin Fowler 提出微服务还早 6 年),2014 年跟随 Java 9 正式进入开发阶段,最终跟随 Java 9 发布于 2017 年 9 月。


▐模块系统定义


官方的定义:

A uniquely named, reusable group of related packages, as well as resources (such as images and XML files) and a module descriptor.


如图所示,模块的载体是 jar 文件,一个模块就是一个 jar 文件,但相比于传统的 jar 文件,模块的根目录下多了一个 module-info.class 文件,也即 module descriptor,包含信息:模块名称、依赖哪些模块、导出模块内的哪些包[允许直接 import 使用]、开放模块内的哪些包[允许通过 Java 反射访问]、提供哪些服务、依赖哪些服务。


▐带来好处


任意一个 jar 文件,只要加上一个合法的 module descriptor,就可以升级为一个模块。这个看似微小的改变,在我看来,至少带来四方面的好处:


一、清晰的依赖管理

  • Java 可以根据 module descriptor 计算出各个模块间的依赖关系,一旦发现循环依赖,启动就会终止。
  • 由于模块系统不允许不同模块导出相同的包(即 split package,分裂包),所以在查找包时,Java 可以精准的定位到一个模块,从而获得更好的性能。


二、精简 JRE

引入模块系统之后,JDK 自身被划分为 94 个模块(参见图)。通过 Java 9 新增的 jlink 工具,开发者可以根据实际应用场景随意组合这些模块,去除不需要的模块,生成自定义 JRE,从而有效缩小 JRE 大小。得益于此,JRE 11 的大小仅为 JRE 8 的 53%,从 218.4 MB缩减为 116.3 MB,JRE 中广为诟病的巨型 jar 文件 rt.jar 也被移除。更小的 JRE 意味着更少的内存占用,这让 Java 对嵌入式应用开发变得更友好。

三、更好的兼容性 & 安全性

Java一直以来,就只有 4 种包可见性,这让 Java 对面向对象的三大特征之一封装的支持大打折扣,类库维护者对此叫苦不迭,只能一遍又一遍的通过各种文档或者奇怪的命名来强调这些或者那些类仅供内部使用,擅自使用后果自负云云。Java 9 之后,利用 module descriptor 中的 exports 关键词,模块维护者就精准控制哪些类可以对外开放使用,哪些类只能内部使用,换句话说就是不再依赖文档,而是由编译器来保证。类可见性的细化,除了带来更好的兼容性,也带来了更好的安全性。


四、提升 Java 语言开发效率

Java 9 之后,Java 像开挂了一般,一改原先一延再延的风格,严格遵循每半年一个大版本的发布策略,从 2017 年 9 月到 2020 年 3 月,从 Java 9 到 Java 14,三年时间相继发布了 6 个版本,无一延期,参见图-4。这无疑跟模块系统的引入有莫大关系。前文提到,Java 9 之后,JDK 被拆分为 94 个模块,每个模块有清晰的边界(module descriptor)和独立的单元测试,对于每个 Java 语言的开发者而言,每个人只需要关注其所负责的模块,开发效率因此大幅提升。这其中的差别,就好比单体应用架构升级到微服务架构一般,版本迭代速度不快也难。


▐七大优势


  • 强封装性:模块化使得开发者可以明确指定哪些是模块的公开API,哪些是内部实现,从而加强了封装性。这样一来,开发者可以控制他们的代码对外部世界的可见性,降低了耦合度,并提升了代码的安全性。

  • 清晰的依赖管理:在模块化系统中,每个模块必须明确声明其依赖的其他模块。这种显式依赖声明促使了更清晰、更稳定的依赖管理机制,便于构建和维护大型项目。

  • 提高性能:模块系统可以帮助JVM和编译器作出更优化的决策,因为它们明确知道哪些模块将会被使用,哪些不会。这可能导致更快的启动时间和更小的内存占用,尤其是在微服务和云原生应用场景下。

  • 更易于构建大型系统:模块化系统鼓励开发者将大型复杂的程序拆分为更小、更易于管理的部分。这种方式使得大型系统的构建、测试和维护变得更容易,同时提高了代码的重用性。

  • 更好的安全性:模块化系统限制了不必要的对模块内部实现的访问,从而降低了安全风险。它允许应用程序明确地控制哪些部分是可以被外界访问的,从而增强了整个应用的安全性。

  • 减少了应用的体积:由于模块化允许应用仅包含所需的模块,因此可以去除未使用的模块,减少应用程序的总体积。这在需要部署到资源受限的环境中时特别有价值。

  • 促进了模块间的明确界限:通过强制执行模块间的清晰界限,模块化帮助避免了"jar地狱"(jar hell)问题,即多个版本的jar文件在项目中造成的冲突和混乱。


核心概念


▐模块描述符


  • 主要作用


模块的核心在于 module descriptor(模块描述符),对应根目录下的 module-info.class 文件,而这个 class 文件是由源代码根目录下的 module-info.java 编译生成。Java 为 module-info.java 设计了专用的语法,包含 module、 requires、exports 等多个关键词。


  • 语法解读


  • [open] module <module>:声明一个模块,模块名称应全局唯一,不可重复。加上 open 关键词表示模块内的所有包都允许通过 Java 反射访问,模块声明体内不再允许使用 opens 语句。

  • requires [transitive] <module>: 声明模块依赖,一次只能声明一个依赖,如果依赖多个模块,需要多次声明。加上 transitive 关键词表示传递依赖,比如模块 A 依赖模块 B,模块 B 传递依赖模块 C,那么模块 A 就会自动依赖模块 C,类似于 Maven。

  • exports <package> [to <module1>[, <module2>...]]:导出模块内的包(允许直接 import 使用),一次导出一个包,如果需要导出多个包,需要多次声明。如果需要定向导出,可以使用 to 关键词,后面加上模块列表(逗号分隔)。

  • opens <package> [to <module>[, <module2>...]]:开放模块内的包(允许通过 Java 反射访问),一次开放一个包,如果需要开放多个包,需要多次声明。如果需要定向开放,可以使用 to 关键词,后面加上模块列表(逗号分隔)。

  • provides <interface | abstract class> with <class1>[, <class2> ...]:声明模块提供的 Java SPI 服务,一次可以声明多个服务实现类(逗号分隔)。

  • uses <interface | abstract class>:声明模块依赖的 Java SPI 服务,加上之后模块内的代码就可以通过ServiceLoader.load(Class) 一次性加载所声明的 SPI 服务的所有实现类。


  • 示例说明



  • mod1 模块: 主模块,展示了使用服务实现类的两种方式。


module-info.java:

import mod3.exports.IEventListener;module mod1 {    requires mod2a;    requires mod4;    uses IEventListener;}


EventCenter.java(主函数):

package mod1;import mod2a.exports.EchoListener;import mod3.exports.IEventListener;import mod4.Events;import java.util.ArrayList;import java.util.ServiceLoader;import java.util.stream.Collectors;public class EventCenter {   // 方式1:通过exports和opens    System.out.println("Demo: Direct Mode");    var listeners = new ArrayList<IEventListener>();    // 使用导出类    listeners.add(new EchoListener());    // 使用开放类    // compile error: listeners.add(new ReflectEchoListener());    listeners.add((IEventListener<String>) Class.forName("mod2a.opens.ReflectEchoListener").getDeclaredConstructor().newInstance());    var event = Events.newEvent();    listeners.forEach(l -> l.onEvent(event));    System.out.println();    // 方式2:通过SPI    System.out.println("Demo: SPI Mode");    // 加载所有的IEventListener实现类,无视其导出/开放与否    var listeners2 = ServiceLoader.load(IEventListener.class).stream().map(ServiceLoader.Provider::get).collect(Collectors.toList());    // compile error: listeners.add(new InternalEchoListener());    // compile error: listeners.add(new SpiEchoListener());    var event2 = Events.newEvent();    listeners2.forEach(l -> l.onEvent(event2));}


shell 执行脚本:

#!/bin/zsh# mod4javac -d out/mods/mod4 mod4/src/**/*.javajar -cvf out/mods/mod4.jar -C out/mods/mod4 .# mod3javac -d out/mods/mod3 mod3/src/**/*.javajar -cvf out/mods/mod3.jar -C out/mods/mod3 .# mod2bjavac -p out/mods/mod3.jar -d out/mods/mod2b mod2b/src/**/*.javajar -cvf out/mods/mod2b.jar -C out/mods/mod2b .# mod2ajavac -p out/mods/mod3.jar -d out/mods/mod2a mod2a/src/**/*.javajar -cvf out/mods/mod2a.jar -C out/mods/mod2a .# mod1javac -p out/mods/mod2a.jar:out/mods/mod2b.jar:out/mods/mod3.jar:out/mods/mod4.jar -d out/mods/mod1 mod1/src/**/*.javajar -cvf out/mods/mod1.jar -C out/mods/mod1 .# runjava -p out/mods/mod1.jar:out/mods/mod2a.jar:out/mods/mod2b.jar:out/mods/mod3.jar:out/mods/mod4.jar -m mod1/mod1.EventCenter


  • mod2a 模块: 分别导出和开放了一个包,并声明了两个服务实现类。


module-info.java:

import mod3.exports.IEventListener;module mod2a {    requires transitive mod3;    exports mod2a.exports;    opens mod2a.opens;    provides IEventListener            with mod2a.exports.EchoListener, mod2a.opens.ReflectEchoListener;}


EchoListener.java & ReflectEchoListener.java:

package mod2a.exports;import mod3.exports.IEventListener;public class EchoListener implements IEventListener<String> {    @Override    public void onEvent(String event) {        System.out.println("[echo] Event received: " + event);    }}package mod2a.opens;import mod3.exports.IEventListener;public class ReflectEchoListener implements IEventListener<String> {    @Override    public void onEvent(String event) {        System.out.println("[reflect echo] Event received: " + event);    }}


  • mod2b 模块: 声明了一个未公开的服务实现类。


module-info.java:

import mod3.exports.IEventListener;module mod2b {    requires transitive mod3;    provides IEventListener            with mod2b.SpiEchoListener;}


SpiEchoListener.java:

package mod2b;import mod3.exports.IEventListener;public class SpiEchoListener implements IEventListener<String> {    @Override    public void onEvent(String event) {        System.out.println("[spi echo] Event received: " + event);    }}


  • mod3 模块: 定义 SPI 服务(IEventListener),并声明了一个未公开的服务实现类。


module-info.java:

import mod3.exports.IEventListener;module mod1 {    requires mod2a;    requires mod4;    uses IEventListener;}


IEventListener.java & InternalEchoListener.java:

package mod3.exports;public interface IEventListener<T> {    void onEvent(T event);}package mod3.internal;import mod3.exports.IEventListener;public class InternalEchoListener implements IEventListener<String> {    @Override    public void onEvent(String event) {        System.out.println("[internal echo] Event received: " + event);    }}


  • mod4 模块: 导出公共模型类。


module-info.java:

module mod4 {    exports mod4;}


Events.java:

package mod4;import java.util.UUID;public class Events {    public static String newEvent() {        return UUID.randomUUID().toString();    }}


▐主要参数


  • Java 9 引入了一系列新的参数用于编译和运行模块,其中最重要的两个参数是 -p 和 -m。-p 参数指定模块路径,多个模块之间用 “:”(Mac, Linux)或者 “;”(Windows)分隔,同时适用于 javac 命令和 java 命令,用法和Java 8 中的 -cp 非常类似。-m 参数指定待运行的模块主函数,输入格式为模块名/主函数所在的类名,仅适用于 java 命令。两个参数的基本用法如下:
    • javac -p <module_path> <source>
    • java -p <module_path> -m <module>/<main_class>

  • 最快的速度判别它是不是一个模块:jar -d -f <jar_file>


▐关键指令


  • --add-exports 访问内部API
    • 如果旧代码迁移到JDK9+, 编译报package x.x.x is not visible 的错误时,是因为旧代码访问了模块的内部API;为了能够访问内部API,需要在编译时加上--add-exports java.xx/x.x.x=ALL-UNNAMED

  • --add-open 反射访问内部API
    • 但是,这种方式只是在编译期起作用;那些在x在运行时才知道访问了内部API的问题需要新的方式解决; 错误异常通常是 java.lang.reflect.InaccessibleObjectException..
    • 解决方式是运行时加上--add-opens x.x/x.x.x=All-UNNAMED通过反射调用的类和方法;


  • --add-modules 添加依赖模块
    • 如项目依赖Java EE的相关xml代码, 编译和运行时就需要添加对应的模块javac --add-moudles java.xml.bind


  • --patch-modules指定到特定模块
    • 当迁移过程中讨论拆包装,我们看到了一个使用注释的项目的例子 @ 生成(从java.xml.ws.annotation模块)和 @ 非空(从JSR 305实现)。我们发现了三件事:
      • 两个注释都在 javax .annotation包中,因此创建了一个分割
      • 需要手动添加模块,因为它是一个Java EE模块
      • 这样做会使拆分包的JSR 305部分不可见

我们可以使用 --patch-module来修补分割:

java --add-modules java.xml.ws.annotation     --patch-module java.xml.ws.annotation=jsr305-3.0.2.jar     --class-path $dependencies     -jar $appjar


  • --add-reads 模块读取另一个模块


模块化策略


兼容老版本的应用,先来了解两个高级概念:未命名模块(unnamed module)和 自动模块(automatic module)


判断:一个未经模块化改造的 jar 文件是转为未命名模块还是自动模块,取决于这个 jar 文件出现的路径,如果是类路径,那么就会转为未命名模块,如果是模块路径,那么就会转为自动模块。

  • 注意:自动模块也属于命名模块的范畴,其名称是模块系统基于 jar 文件名自动推导得出的,比如 com.foo.bar-1.0.0.jar 文件推导得出的自动模块名是 com.foo.bar。
  • 关键区别:分裂包规则适用于自动模块,但对未命名模块无效,也即多个未命名模块可以导出同一个包,但自动模块不允许。


未命名模块和自动模块存在的意义在于,无论传入的 jar 文件是否一个合法的模块(包含 module descriptor),Java 内部都可以统一的以模块的方式进行处理,这也是 Java 9 兼容老版本应用的架构原理。运行老版本应用时,所有 jar 文件都出现在类路径下,也就是转为未命名模块,对于未命名模块而言,默认导出所有包并且依赖所有模块,因此应用可以正常运行。进一步的解读可以参阅官方白皮书的相关章节。


▐自底向上


  • 思路:
    • 根据 jar 包依赖关系( jdeps 工具进行分析),沿着依赖树自底向上对 jar 包进行模块化改造(在 jar 包的源代码根目录下添加合法的模块描述文件 module-info.java)
    • 初始时,所有 jar 包都是非模块化的,全部置于类路径下(转为未命名模块),应用以传统方式启动
    • 然后,开始自底向上对 jar 包进行模块化改造,改造完的 jar 包就移到模块路径下,这期间应用仍以传统方式启动
    • 最后,等所有 jar 包都完成模块化改造,应用改为 -m 方式启动,这也标志着应用已经迁移为真正的 Java 9 应用。

  • 举例说明:

    • 所有 jar 包都是非模块化的,运行命令为:java -cp mod1.jar:mod2a.jar:mod2b.jar:mod3.jar:mod4.jar mod1.EventCenter
    • mod3 和 mod4 模块化后,运行命令为:java -cp mod1.jar:mod2a.jar:mod2b.jar -p mod3.jar:mod4.jar --add-modules mod3,mod4 mod1.EventCenter


对比上一步的命令,首先 mod3.jar 和 mod4.jar 从类路径移到了模块路径,这个很好理解,因为这两个 jar 包已经改造成了真正的模块。其次,多了一个额外的参数 --add-modules mod3,mod4,这是为什么呢?这就要谈到模块系统的模块发现机制了。


不管是编译时,还是运行时,模块系统首先都要确定一个或者多个根模块(root module),然后从这些根模块开始根据模块依赖关系在模块路径中循环找出所有可观察到的模块(observable module),这些可观察到的模块加上类路径下的 jar 文件最终构成了编译时环境和运行时环境。那么根模块是如何确定的呢?对于运行时而言,如果应用是通过 -m 方式启动的,那么根模块就是 -m 指定的主模块;如果应用是通过传统方式启动的,那么根模块就是所有的 java.* 模块即 JRE。回到前面的例子,如果不加 --add-modules 参数,那么运行时环境中除了 JRE 就只有 mod1.jar、mod2a.jar、mod2b.jar,没有 mod3、mod4 模块,就会报 java.lang.ClassNotFoundException 异常。如你所想,--add-modules 参数的作用就是手动指定额外的根模块,这样应用就可以正常运行了。


  • mod2a、mod2b 的模块化后,运行命令为:java -cp mod1.jar -p mod2a.jar:mod2b.jar:mod3.jar:mod4.jar --add-modules mod2a,mod2b,mod4 mod1.EventCenter
    • 由于 mod2a、mod2b 都依赖 mod3,所以 mod3 就不用加到 --add-modules 参数里了。

  • 最后完成 mod1 的模块化改造,最终运行命令就简化为:java -p mod1.jar:mod2a.jar:mod2b.jar:mod3.jar:mod4.jar -m mod1/mod1.EventCenter
    • 注意此时应用是以 -m 方式启动,并且指定了 mod1 为主模块(也是根模块),因此所有其他模块根据依赖关系都会被识别为可观察到的模块并加入到运行时环境,应用可以正常运行。


▐自上而下


  • 问题:自底向上策略很容易理解,实施路径也很清晰,但它存在一个有些模块无法进行模块化改造的问题。

  • 思路:根据 jar 包依赖关系,从主应用开始,沿着依赖树自上而下分析各个 jar 包模块化改造的可能性,将 jar 包分为两类:
    • 第一类:可以改造的,我们仍然采用自底向上策略进行改造,直至主应用完成改造;
    • 第二类:无法改造的,需要从一开始就放入模块路径,即转为自动模块。这里就要谈一下自动模块设计的精妙之处,首先,自动模块会导出所有包,这样就保证第一类 jar 包可以照常访问自动模块,其次,自动模块依赖所有命名模块,并且允许访问所有未命名模块的类(这一点很重要,因为除自动模块之外,其它命名模块是不允许访问未命名模块的类),这样就保证自动模块自身可以照常访问其他类。等到主应用完成模块化改造,应用的启动方式就可以改为 -m 方式。

  • 举例:还是以示例工程为例,假设 mod4 是一个第三方 jar 包,无法进行模块化改造,那么最终改造完之后,虽然应用运行命令和之前一样还是java -p mod1.jar:mod2a.jar:mod2b.jar:mod3.jar:mod4.jar -m mod1/mod1.EventCenter,但其中只有 mod1、mod2a、mod2b、mod3 是真正的模块,mod4 未做任何改造,借由模块系统转为自动模块。



  • 不完美之处:看上去很完美,不过等一下,如果有多个自动模块,并且它们之间存在分裂包呢?前面提到,自动模块和其它命名模块一样,需要遵循分裂包规则。对于这种情况,如果模块化改造势在必行,要么忍痛割爱精简依赖只保留其中的一个自动模块,要么自己动手丰衣足食 Hack 一个版本。当然,你也可以试试找到这些自动模块的维护者们,让他们 PK 一下决定谁才是这个分裂包的主人。


最佳实践


▐实践建议


  • 模块命名规范:给模块取一个有意义的名字,通常使用逆域名表示法(例如:com.example.myapp)。
  • 明确的依赖关系:在module-info.java文件中明确声明模块的依赖关系,以确保应用程序的模块之间的依赖关系清晰可见。
  • 最小依赖原则:尽量减少模块之间的依赖关系,只依赖于真正需要的模块。
  • 版本化的依赖关系:如果可能的话,使用版本化的依赖关系来确保模块依赖的是正确的版本。
  • 单一责任原则:将每个模块限制为一个特定的功能或领域,以提高可维护性和可重用性。
  • 测试和验证:确保模块之间的依赖关系和交互在编译时和运行时都能正常工作。
  • 模块路径管理:管理模块路径以确保应用程序能够正确加载和运行。


▐注意事项


  • 模块依赖关系:仔细考虑您的模块之间的依赖关系。确保模块之间的依赖关系是明确的,避免循环依赖。使用requires语句声明依赖关系,并根据需要使用requires transitive或requires static。

  • 版本管理:了解模块之间的版本管理。Java 9引入了模块化版本的概念,允许模块依赖于特定版本的其他模块。考虑使用requires static来声明可选的、仅在特定版本下才有效的依赖关系。

  • 模块命名:为您的模块选择合适的名称。模块名称应该唯一且易于理解。遵循Java的包命名约定,使用反向域名(例如com.example.mymodule)。

  • 模块路径:在运行应用程序时,使用--module-path选项指定模块路径。确保正确设置模块路径,以便Java可以找到并加载您的模块。

  • 非模块化库:如果您使用了非模块化的JAR文件,将其包装为自动模块或创建模块化的版本。非模块化库的依赖关系可能会引入复杂性。

  • 模块化库:考虑使用已经模块化的库,以减少与模块路径和版本管理相关的问题。

  • 运行时镜像:如果您使用jlink创建自定义运行时镜像,请确保包括了所有必要的模块,并排除不必要的模块,以减小应用程序的大小。

  • 测试:编写单元测试以确保模块化应用程序的正确性。使用模块路径和--module选项来模拟模块化环境进行测试。

  • 模块描述文件:模块描述文件(module-info.java)是模块化应用程序的关键组成部分。确保正确声明依赖关系、导出和打包模块,以及使用其他关键字来管理可见性。


  • 模块间通信:模块之间的通信应该在依赖模块的基础上进行。不要尝试绕过模块系统的可见性控制。

  • 跨模块访问:如果需要在模块之间共享数据或访问非公开成员,请使用opens和opens...to语句,以允许受信任的模块进行反射操作。

  • 性能和内存开销:模块化应用程序的启动时间和内存开销可能会有所增加。在部署和测试应用程序时,要考虑性能方面的因素。

  • 迁移:如果您正在迁移现有的应用程序到模块化架构,确保逐步迁移,以减少中断和问题。

  • 文档和培训:为开发团队提供关于模块化的文档和培训,以确保所有开发人员都理解和遵守模块化的最佳实践。

  • 工具支持:使用Java 9及更高版本,以充分利用模块化系统和相关的工具,如jdeps、jlink和jmod。


参考资料


  • 《关于 Java 模块系统,看这一篇就够了》:https://blog.csdn.net/eMac/article/details/107131444


  • 《JDK8升级JDK11最全实践干货来了》:https://mp.weixin.qq.com/s/BBJQXCbqcjhlaXGh7mta4Q


  • 《Java技术栈模块化的七大优势,你了解多少?》:https://blog.csdn.net/atgfg/article/details/139015388