Java平台模块系统(1)- 模块声明

在提到Java 9时,最重要的话题是Project Jigsaw,也就是Java平台模块系统(Java Platform Module System,JPMS)。JPMS把模块化引入了Java平台中。Project Jigsaw本来计划作为Java 8的一部分,但是由于所涉及的改动过大,因此推迟到了Java 9中。模块系统不仅给Java平台本身带来了巨大的改动,也给在Java平台上运行的应用程序带来了革命性的变化。

在Java 9中,Java SE平台和JDK本身都以模块化的方式来组织,因此在经过缩减之后可以运行在小型设备上。在Java 9 之前,JDK和JRE的安装是一个不开切分的整体。JRE中包含了不同的应用程序所需要使用的各种工具和标准库。但对于一个特定的应用来说,在绝大多数情况下都只会用到其中一部分的工具和标准库。这就意味着JRE中的部分内容其实是完全多余的。举例来说,一个API代理程序很可能永远都用不到与用户界面相关的AWT/Swing库。在完成Java平台本身的模块化之后,开发人员就可以通过移除不必要的模块的方式来为应用打造专属的JRE,而只保留该应用所需要的模块。这可以极大的减少应用程序安装包的大小,从而节省存储空间和网络传输带宽。

Java社区一直以来都希望有一种方式来构建模块化Java应用。OSGi是目前比较流行的一个选择。Project Jigsaw同样可以让开发人员构建模块化的Java库和应用。相较于OSGi而言,Java平台自身提供的模块化实现显然更有吸引力。

Project Jigsaw本身是一个非常复杂的实现,其基本内容定义在JSR 376: JavaTM Platform Module System中,还有与之对应的6个JEP。

  • 200:模块化JDK

  • 201:模块化源代码

  • 220:模块化运行时镜像

  • 260:封装大部分内部API

  • 261:模块系统

  • 282:jlink - Java链接器

本章中包含与JPMS相关的重要内容。

模块概述

根据Oracle的Java平台集团的首席架构师Mark Reinhold在一篇文章中的论述:

模块是一个命名的,自我描述的代码和数据的集合。模块的代码被组织成多个包,每个包中包含Java类和接口;模块的数据则包括资源文件和其他静态信息。

从上述对模块的定义可以知道,模块只是按照预先定义的结构来进行组织的编译后的Java代码。如果你已经使用Maven的多模块功能,或是Gradle的多项目功能,那么每个Maven模块或Gradle项目都可以很容易地转换成JPMS模块。

每个模块都需要有一个名字。模块应该遵循与Java包同样的命名惯例,也就是翻转域名模式,如com.mycompany.mymodule。

一个JPMS模块通过根目录中的module-info.java文件来描述。该文件被编译成module-info.class。在这个文件中,我们使用新的关键词module来声明一个模块。下面的代码中给出了模块com.mycompany.mymodule所对应的module-info.java文件的内容。该文件只是声明了一个模块,并没有对它进行具体的描述。相关的内容会在后续的小节中提到。

module com.mycompany.mymodule {
                              
}

示例应用

为了更好地说明模块系统的使用方式,本章中会使用一个简单的电子商务应用来作为示例。该应用只提供了非常有限的功能,其主要目的是为了展示模块之间的依赖关系。该应用的名称空间是io.vividcode.store。下表给出了示例应用中的模块。表中名字为common的模块,其实际的名称是io.vividcode.store.common。

  • common - 通用API

  • common.persistence - 通用持久化API

  • filestore - 基于文件的持久化实现

  • product - 产品API

  • product.persistence - 产品持久化实现

  • runtime - 应用启动

模块声明

模块声明文件module-info.java是了解模块系统的第一步。

requires和exports

在引入了模块系统之后,Java应用程序应该被组织成不同的模块。每个模块可以通过requires来声明其对其他模块的依赖关系。依赖一个模块并不意味着就自动获得了访问该模块中所包含的Java类型的许可。一个模块可以声明其中所包含的哪些包是可供其他模块访问的。只有被导出的包才能被其他模块所访问。而在默认情况下,是没有任何包会被导出的。我们可以通过exports声明来导出包。导出的包中包含的public和protected类型,以及这些类型中包含的public和protected成员是可以被其他模块访问的。

下面的代码中给出了模块io.vividcode.store.common.persistence的module-info.java文件的内容。该文件使用了两个requires声明来声明该模块对模块slf4j.api和io.vividcode.store.common的依赖关系。模块slf4j.api由SLF4J库提供,而模块io.vividcode.store.common则是项目中的另外一个模块。模块io.vividcode.store.common.persistence导出了其中的包io.vividcode.store.common.persistence。

module io.vividcode.store.common.persistence {
  requires slf4j.api;
  requires io.vividcode.store.common;
  exports io.vividcode.store.common.persistence;
}

需要注意的是,当导出一个包时,只有该包中的类型会被导出,子包中的类型不会被自动导出。如果声明导出的包为com.mycompany.mymodule,类似com.mycompany.mymodule.A和com.mycompany.mymodule.B这样的类型会被导出。而类似com.mycompany.mymodule.impl.C和com.mycompany.mymodule.test.demo.D这样的类型则不会。如果需要导出子包,必须使用exports来对每个子包进行显式声明。

如果一个模块中的类型不能被其他模块所访问,那么该类型等同于该模块中的私有类型或类型中的私有成员。试图使用这些类型或成员会产生编译错误。在运行时则会由JVM抛出java.lang.IllegalAccessError错误。如果试图通过Java反射API来访问,则会抛出java.lang.IllegalAccessException异常。

所有的模块,除了java.base模块本身,都有一个隐式的和强制的对于java.base模块的依赖关系。你不需要在module-info.java文件中进行声明。模块java.se中包含了Java SE的核心包,如java.lang等。

传递依赖

当模块A依赖模块B时,模块A可以访问模块B中导出的public和protected类型。我们把这种关系称为模块A读取(read)模块B。同理,如果模块B读取模块C,模块B也可以访问模块C导出的public和protected类型。也就是说,模块B可以在其包含的代码中,使用模块C中的类型来作为方法的参数或是返回类型。下面的代码给出了模块C的module-info.java文件。模块C导出了包ctest。

module C {
  exports ctest;
}

下面的代码给出了模块C中的类ctest.MyC。其中的方法sayHi用来在控制台打印出一条消息。

package ctest;
    
public class MyC {
  public void sayHi() {
    System.out.println("Hi from module C!");
  }
}

下面的代码给出了模块B的module-info.java文件。模块B通过requires声明了对模块C的依赖,同时导出了包btest

module B {
  requires C;
  exports btest;
}

下面的代码给出了类btest.MyB的getC方法,该方法返回一个类MyC的新实例。

package btest;
    
import ctest.MyC;
    
public class MyB {
  public MyC getC() {
    return new MyC();
  }
}

在模块A的module-info.java文件中,只声明了对于模块B的依赖关系。

module A {
  requires B;
}

我们可以在模块A中的类atest.MyA中使用类MyC,如下面的代码所示。

package atest;
      
import btest.MyB;
      
public class MyA {
  public static void main(String[] args) {
    new MyB().getC().sayHi();
  }
}

当我们尝试去编译上面的代码时,会发现出现如下的编译错误。这是因为模块A在其module-info.java中并没有声明对模块C的依赖关系,因此模块A并没有读取模块C。模块的读取关系默认并不是传递的。

/<code_path>/A/atest/MyA.java:7: error: MyC.sayHi() in package ctest is not
 accessible
    new MyB().getC().sayHi();
                    ^
  (package ctest is declared in module C, but module A does not read it)
1 error

为了使得类atest.MyA通过编译,我们可以在其module-info.java文件中添加“requires C;”来声明对模块C的依赖关系。这种方式如果要应用在所有可能的通过传递关系产生的依赖时,手动添加这些依赖声明是一件繁琐的工作。由于传递依赖关系是一个常见的需求,Java 9提供了专门的方式来处理这种情况。requires可以添加一个新的描述符transitive来声明一个依赖关系是传递的。一个模块中声明为可传递的依赖模块,可以被依赖该模块的其他模块来读取。这种读取关系成为隐式可读性(implicit readability)。对于上面的例子,只需要把模块B对模块C的依赖关系声明为可传递即可。这样模块B的可传递依赖模块C,就可以被依赖模块B的模块A所读取,从而模块A的代码可以被成功编译。

module B {
  requires transitive C;
  exports btest;
}

一般来说,对于一个模块所导出的Java类型来说,如果其中的方法型构中引用了来自另外一个模块的类型,那么在当前模块的module-info.java文件中,对于该模块的依赖声明应该使用requires transitive而不是requires。正如同在上面的例子中,类MyB中的方法getC的返回值类型是模块C中的类MyC,因此模块B的声明中应该使用requires transitive C,而不是requires C。

静态依赖

静态依赖是一种特殊的依赖关系,通过requires static来进行声明。静态依赖所声明的模块在编译时是必须的,但是在运行时是可选的。

module demo {
  requires static A;
}

静态依赖对于框架和第三方库来说比较实用。假设我们需要开发一个可以和不同数据库交互的库。这个库所在的模块可以使用静态依赖来声明对所支持的数据库驱动的依赖关系。在编译时,库中的代码可以访问这些驱动中的类型;在运行时,用户只需要添加所需要使用的驱动即可。如果不使用静态依赖,用户必须要添加所有支持的驱动才能完成模块的解析。

服务

Java平台有自己的服务接口和服务提供者机制。这是通过类java.util.ServiceLoader来完成服务提供者的查找。服务机制主要用在JDK本身以及第三方框架和库中。服务机制的一个典型应用是JDBC驱动。每个JDBC驱动都需要提供服务接口java.sql.Driver的实现。驱动的JAR文件的META-INF/services目录中需要包含一个名为java.sql.Driver的文件。比如,Apache Derby的JAR文件中的java.sql.Driver文件的内容如下所示。其中org.apache.derby.jdbc.AutoloadedDriver是服务接口java.sql.Driver的实现类的名称。

org.apache.derby.jdbc.AutoloadedDriver

在Java 9之前,ServiceLoader通过扫描CLASSPATH来查找特定服务接口的实现类。在Java 9中,模块成了代码的组织单元。模块声明文件module-info.java提供了与服务使用者和提供者相关的声明。

下面的代码给出了模块io.vividcode.store.common中的服务接口PersistenceService的声明。

package io.vividcode.store.common;
     
public interface PersistenceService {
  void save(final Persistable persistable) throws PersistenceException;
}

该服务接口被模块io.vividcode.store.common.persistence所使用。在该模块的module-info.java文件中,我们通过关键词uses来声明对服务接口io.vividcode.store.common.PersistenceService的使用。

module io.vividcode.store.common.persistence {
  requires slf4j.api;
  requires transitive io.vividcode.store.common;
  exports io.vividcode.store.common.persistence;
  uses io.vividcode.store.common.PersistenceService;
}

在修改了模块描述文件之后,可以通过ServiceLoader来查找该服务接口的提供者,并使用该接口来完成所需功能。对于一个服务接口,可能有多个服务提供者实现。ServiceLoader的load()方法返回是一个Stream对象。这里我们通过findFirst()方法来获取第一个实现。然后使用PersistenceService接口的save()方法来保存对象。

public class DataStore<T extends Persistable> {
    
  private final Optional<PersistenceService> persistenceServices;
    
  public DataStore() {
    this.persistenceServices = ServiceLoader
        .load(PersistenceService.class)
        .findFirst();
  }
    
  public void save(final T object) throws PersistenceException {
    if (this.persistenceServices.isPresent()) {
      this.persistenceServices.get().save(object);
    }
  }
}

服务接口io.vividcode.store.common.PersistenceService的提供者在模块io.vividcode.store.filestore中,是一个基于文件系统的持久化实现。下面的代码给出了模块io.vividcode.store.filestore的module-info.java文件。“provides io.vividcode.store.common.PersistenceService with io.vividcode.store.filestore.FileStore“的含义是该模块提供了服务接口io.vividcode.store.common.PersistenceService的实现类io.vividcode.store.filestore.FileStore。

受限导出

当一个模块的声明中使用exports来导出一个包时,所有其他通过requires来声明对其依赖关系的模块都可以访问此包中的类型。在某些情况下,我们会希望可以限制某些包对于其他模块的可见性。举例来说,一个包可能在最早的设计中是对所有模块都公开的,但是该包在后来的版本更新中被新的包所替代,因此被声明为废弃的。这个被废弃的包应该只能被遗留代码所使用。在升级到Java 9的模块系统之后,包含该包的模块应该只是把该包导出给还使用遗留代码的模块。这样可以确保遗留代码不会被继续使用。通过在exports声明后添加to语句,可以指定允许访问该包的模块名称。

下面的代码给出了JDK模块java.rmi的描述文件。从中可以看到包com.sun.rmi.rmid只对模块java.base可见,而包sun.rmi.server则只对模块jdk.management.agent、jdk.jconsole和java.management.rmi可见。

module java.rmi {
  requires java.logging;
    
  exports java.rmi.activation;
  exports com.sun.rmi.rmid to java.base;
  exports sun.rmi.server to jdk.management.agent,
      jdk.jconsole, java.management.rmi;
  exports javax.rmi.ssl;
  exports java.rmi.dgc;
  exports sun.rmi.transport to jdk.management.agent,
      jdk.jconsole, java.management.rmi;
  exports java.rmi.server;
  exports sun.rmi.registry to jdk.management.agent;
  exports java.rmi.registry;
  exports java.rmi;
    
  uses java.rmi.server.RMIClassLoaderSpi;
}

开放模块和包

在模块声明文件中,可以在module之前添加open描述符来把该模块声明为开放的。一个开放的模块在编译时只允许其他模块访问其通过exports声明来显式导出的包。而在运行时,模块中的所有包都是被导出的,包括那些没有通过exports声明的包。同样的,也可以通过Java反射API来访问所有包中的所有Java类型。所有Java类型中包括私有类及其私有成员。如果使用Java反射API来绕开Java语言的访问检查机制,如AccessibleObject类的setAccessible()方法,就可以访问开发模块中的私有类型和成员。

对于每个具体的包,也可以使用opens来把它声明为开放的。开放的包可以通过Java反射API来访问。就如同开放模块一样,使用反射API可以访问开发包中的所有类型及其所有成员。开放包的声明也支持通过to语句来指定可访问的模块名称。下面的代码给出了一个开放模块E的描述文件。

open module E {
  exports etest;
}

下面的代码则给出了使用开放包的模块声明文件。模块F中声明了两个包是开放的。如果所声明开放的包在模块中不存在,编译器会给出警告;同样的,如果开放模块所限制访问的目标模块不存在,编译器也会给出警告。

module F {
  opens ftest1;
  opens ftest2 to G;
}

开放模块和开放包的主要作用是解决向后兼容性的问题。在迁移使用反射API的遗留代码时,可能会需要用到它们。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342