• 本文介绍了Java 9新增的模块系统中 module-info 【模块描述符】文件的格式,不涉及对模块系统的完整解析。读者应了解模块基本知识。
• 本文核心参考【OpenJDK教程】《模块系统入门》

「壹」

      网络上已经有很多完整的Java模块教程了,那为什么要写一篇 只介绍 module-info 部分 的文章呢?

      下面是一个空的module-info.java文件:

module cmcsw.media {
}

      如果要想让它起作用,它必须放在模块目录的根目录下:.../mcsw.media/module-info.java 其中mcsw.media就是模块根目录。mcsw.media模块里有一个包 com.mcsw.media.ogg,包中有一个类OggFileReader,那么它应该放在.../mcsw.media/com/mcsw/media/ogg/OggFileReader.java。也就是$模块$/包/#类的目录结构。

      很好,如果在这个模块中,我要使用java.desktop模块下的 某个包/某个类 ,则必须在模块描述符里进行导入,使用require关键字导入另一个模块:(这时说mcsw.media 依赖于 java.desktop)

module mcsw.media {
    requires java.desktop;
}

      然后,我们就可以在OggFileReader里放肆地导入java.desktop已公开的包的任何一个类。

package com.mcsw.media.ogg;

import javax.sound.sampled.spi.AudioFileReader;

public class OggFileReader extends AudioFileReader {
    ...
}

      现在,假设在com.mcsw.media模块里有一个成熟完整的包com.mcsw.media.ogg。我想让它能被另一个模块 mcsw.game 使用,于是我在mcsw.game的里写:

module mcsw.game {
    requires mcsw.media;
}

      然而这是一厢情愿:mcsw.game里的类不能导入com.mcsw.media.ogg包的类,报错提示:“com.mcsw.media.ogg没有被导出”

      于是乎,我们知道:一个类如果没有放在模块内,则编译器会给它分配一个未命名模块(类似未命名包),这个类可以肆意导入任何模块任何包的public类(代价是此类本身不能被任何包的类访问);一旦这个类有了模块(有没有包倒无所谓),它要访问某一个模块的包必须满足

(1)模块 依赖(requires) 目标模块
(2)目标模块 导出(exports) 或 开放(opens)目标包
(3)目标类 公开(public)

不满足以上条件,编译时会抛出IllegalAccessError错误;如果用反射,运行时抛出IllegalAccessException异常。java.base模块不需要显式依赖(编译器自动依赖),且java.base所有的包都已导出。

      在module-info.java使用关键字exports导出包:

module mcsw.media {
    requires java.desktop;
    exports com.mcsw.media.ogg;
}

      我们现在学了两个关键字,大多数教程也就到此为止了。原因大概是:剩下关键字在企业开发中没用(事实上整个模块系统对于企业用处不大,更何况企业JDK死死拖在Java 8)。


「贰」

      在学习其他关键字之前,先补充一下模块知识:所有 Java SE 标准API 的模块开头都为 java. ,取决于具体jdk(即取决于系统)的模块开头为 jdk. 。模块的命名规则和包是一样的:即 域名倒置 + 实际名。下面实例中的模块名是不合规范的,仅举例使用。

      已知 模块B 依赖于 模块C、D,而模块A依赖于B、C、D。那应该这么写:

module A {
    requires B;
    requires C;
    requires D;
}

module B {
    requires C;
    requires D;
}

      而我们已知B依赖于C、D,在这里Java规范提供了更方便的写法:新关键字transitive

module A {
    requires B;
}

module B {
    requires transitive C;
    requires transitive D;
}

      transitive意为“可迁移的”,搭配requires使用,表示任何依赖本模块的模块,同时自动依赖于本模块requires transitive的模块。“需要我的,需要我所需要的”。requires transitive称「传递依赖」。

      Q:如果 A 迁移依赖B,B又迁移依赖A,那么JVM加载模块时,会不会无限循环到死机?
      A:你想得到,Java想不到吗?JVM加载模块时如果发现两个模块形成了“环”,就会忽略transitive修饰符。

      对于第三方库来说,一个有用的关键字是staticrequires static叫做静态依赖,表示编译时必须存在,但运行时不一定需要,JVM解析模块时不会加载静态依赖的模块(即使不存在,也不报错)。例如模块 A 可以访问 模块 B 和 C,但模块B 适用于Windows系统,模块C适用于Linux系统,用户使用模块A时只会搭配B和C中的一个,就可以这样写:

module A {
    requires static B;
    requires static C;
}

      在编译模块 A 时,模块 B 、C 都在开发者的电脑里可供编译;运行时,JVM不会强制寻找、加载B和C;用户只要根据需要选择一个模块下载即可,模块 A 照常运行。

      requires有搭配关键字,exports也有。

      模块之间的访问在 依赖关系 的基础上,还可以再套一层保护:「定向导出」,使用关键字to搭配exports,限定将包导出到某些模块,其它模块不可访问:

module A {
    exports A.a to B;
    exports A.b to B, C; //逗号分隔
}


「叁」

      你知道SPI吗?Service Provide Interface【服务提供接口】是Java一项独门秘笈。Java提供的某些服务(比如图片、音频、网络服务)常常随时代变迁(网络协议、图片/音频格式等等都在不断更新),不可能让Java原生库永远紧跟时代步伐,SPI就是让开发者都可以提供这类服务,并且内嵌在原生库中。最常见的步骤是:

  1. 服务提供者编写一个Java类,继承自标注SPI的类(例如javax.sound.sampled.spi包的所有类都是SPI类。SPI都是抽象类,必须重写其抽象方法)。
  2. 此类重写SPI类的抽象方法,实现了某些服务功能(可以有非SPI类作为辅助)。
  3. 将此类(以及它需要的工具类)编译成字节码class文件,一起打成Jar包。
  4. 在Jar包自带的META-INF/目录下创建目录META-INF/services。加一个文件,文件名就是所继承的SPI类的全限定类名(比如javax.sound.sampled.spi.AudioFileReader),文件不带拓展名。文件内容是(ASCII)实现类的全限定类名(比如com.mcsw.media.ogg.OggFileReader)。
  5. Jar包可以分发在网络上,需要服务功能的用户下载Jar包并放在classpath某个路径下。
  6. 用户调用Java原生库的某个SPI方法(API文档注明调用SPI接口)时,此方法调用了java.util包的ServiceLoader类,这个类负责加载一切SPI(程序员也可以手动调用)提供类,它的实例方法findFirst() iterator() stream()都可以发现并通过ClassLoader加载SPI提供类,通过反射把这些类的实例对象提供给原生库调用。
  7. 在SPI调用、加载、反射的整个过程中,用户一无所知,用户只能感觉到“我调用了方法”,而不能知道“这个方法加载了我下载的某个SPI提供类”。
  8. 提供者也可以写多个提供类,打包在同一Jar包中,META-INF/services/目录里每个提供类都要写在相应的文件里;如果多个提供类继承自同一SPI类,每个提供类的类名都写在该SPI的文件里,换行符分隔。
  9. 程序员也可以自己创造SPI类,用ServiceLoader的静态方法`load(Class) load(Class, ClassLoader) 可以得到ServiceLoader的对应实例。

      以上都是无模块系统的SPI。ServiceLoader后来添加了一个load(ModuleLayer, Class)方法补充模块系统;但模块系统提供了三个关键字来管理SPI:provides with uses

      让我们假设,在A模块里定义了一个SPI类A.spi.AProvider那么它的module-info.java应当这样写:

module A {
    exports A.spi;        // 首先要导出spi包,让SPI类可以被访问
    uses A.spi.AProvider; // 用关键字uses表示SPI类
}

      在模块B里有一个类B.test.BProvider继承自AProvider,模块B确认此类为AProvider的提供类,那么B的module-info.java:

module B {
     requires A;                // 要使用模块A的类,首先导入A
     provides A.spi.AProvider   // provides + spi类全限定类名
         with B.test.BProvider; // with + 提供类全限定类名
}

      当然多个提供类继承自同一SPI类也可以,如下:

module B {
    requires A;
    provides A.spi.AProvider    // provides后只能跟一个SPI类
        with B.test.BProvider,  // 逗号分隔
             B.good.Provider;
}

      模块也可以自己 提供 自己的SPI:

module A {
    exports A.spi;
    uses A.spi.AProvider;
    provides A.spi.AProvider
        with A.inner.Provider; //自给自足式
}

      模块系统的SPI 与 Jar包的SPI 是不冲突的(为了向前兼容)。而且模块也可以被打成Jar包,此时module-info里的provides … with … 和 Jar包META-INF/services里的文件 必须同时存在;两者也同时用于ServiceLoader的查找。请注意,一个Jar包里只能存在一个模块



「肆」

      最后一个关键字是opens / open。类似exports,都用于导出类,但权限不同。而且模块本身可以用open修饰:

open module A { }

      一个开放(open)的模块,编译时一切照常。但运行时,任何一个类都可以通过反射API随意访问模块中成员,不设限制。

      模块也可以选择开放具体某些包:

module A {
    opens A.test;
}

      开放的包同开放的模块一样,编译时遵守访问限制,运行时可被任何类反射访问。类似exportsopens也可以配合to进行「定向开放」。

      最后,把Java SE标准的模块关系图奉上:

java 多module的好处 java module info_开闭原则