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


「壹」


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

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

module me.media {

}

      它必须放在模块目录的根目录下:.../me.media/module-info.java
      其中me.media就是模块根目录
      假设me.media模块里有一个包 com.me.media.package,包中有一个类Reader,那么它的路径应该是.../me.media/com/me/media/package/Reader.java。这就是$模块$/包/#类的目录结构。


      如果在这个模块中,我要使用java.desktop模块下的 某个包/某个类 ,则必须先在模块描述符module-info.java里导入该模块。
      使用requires关键字导入另一个模块:

module me.media {
    requires java.desktop;
}

(这时我们说mcsw.media 依赖于 java.desktop)

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

package com.me.media.package;

import javax.sound.sampled.spi.AudioFileReader;

public class Reader extends AudioFileReader {
    ...
}

      现在,假设在com.me.media模块里有一个成熟的做好了的包com.me.media.ogg。我想让它能被另一个模块 me.video 使用,于是在me.video.../me.video/module-info.java里写:

module me.video {
    requires me.media;
}

      然而报错了:me.game里的类不能导入com.me.media.ogg包的类,报错提示:“com.me.media.ogg没有被导出”


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

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

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

      所以使用关键字exports导出包:

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

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

      但也有人不是为企业而编程。



「贰」

      学习其他关键字之前,先补充一些模块知识:
所有 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;
}

      有点麻烦了。Java规范提供了更方便的写法:关键字transitive

module A {
    requires B;
}

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

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

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



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

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

      在编译模块 A 时,Windows和Linux模块 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),不带拓展名。
  5. 文件里只写实现类的全限定类名(比如com.mcsw.media.ogg.OggFileReader)。(用ASCII)
  6. 第三方Jar包可以分发在网络上,需要该服务功能的用户下载Jar包并放在classpath下。
  7. 从此,用户调用Java原生库的某个SPI方法(API文档会注明调用SPI接口)时,此方法调用了java.util包的ServiceLoader类,这个类负责加载一切SPI提供类(程序员也可以手动调用)。它的方法findFirst() iterator() stream()都可以发现并通过ClassLoader加载SPI提供类,通过 反射 把这些类的实例对象提供给原生库调用。
  8. SPI调用、加载、反射的整个过程是对用户隐蔽的,用户一无所知,只能感觉到“我调用了原生库的方法”,不能知道“这个方法加载了我下载的某个SPI类”。
  9. 提供者也可以写多个提供类,打包在同一Jar包中,在META-INF/services/目录里每个提供类都要写在相应的文件里;如果多个提供类继承自同一SPI类,每个提供类的类名都写同一文件里,用换行符分隔。
  10. 程序员也可以自己创造新SPI接口,用ServiceLoader的静态方法`load(Class) load(Class, ClassLoader) 可以得到ServiceLoader的对应实例。这个比较复杂,本篇不提。

      以上都是无模块系统下的SPI。ServiceLoader后来添加了一个load(ModuleLayer, Class)方法以搭配模块系统;但另一方面,模块系统提供了三个关键字来搭配SPI:provides withuses

      我们假设,在原生库(当然也可以是你自己的SPI接口)的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 SE模块图

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐