1. 概述

Java 9 引入了一个高于包的新抽象层,正式称为 Java 平台模块系统(JPMS),简称“模块”。

本教程将深入探讨这个新系统的各个方面,并通过一个简单项目演示所有核心概念。

2. 什么是模块?

在理解如何使用模块之前,必须先明确模块的定义:

模块是一组紧密相关的包和资源,外加一个模块描述符文件。

简单来说,它是一种“包的包”抽象,让代码复用性更上一层楼。

2.1. 包

模块内的包与 Java 诞生以来使用的包完全相同。

创建模块时,我们像传统项目一样在内部用包组织代码

除了组织代码,包还用于确定哪些代码能被模块外部访问。后续会详细讨论这点。

2.2. 资源

每个模块负责管理自己的资源(如媒体文件或配置文件)。

过去我们常把所有资源放在项目根目录,手动管理不同部分的资源归属。现在模块化后,我们可以将所需图片和 XML 文件随模块一起分发,让项目管理更轻松。

2.3. 模块描述符

创建模块时需包含描述符文件,定义模块的关键属性:

  • 名称 – 模块名
  • 依赖 – 该模块依赖的其他模块列表
  • 公开包 – 允许外部访问的包列表
  • 提供的服务 – 可供其他模块消费的服务实现
  • 消费的服务 – 当前模块消费的服务
  • 反射权限 – 显式允许其他类通过反射访问包的私有成员

模块命名规则与包类似(允许点号,禁止连字符)。常见命名风格有项目式(my.module)或反向域名式(com.baeldung.mymodule)。本指南采用项目式。

必须显式列出所有公开包,因为默认所有包都是模块私有的。

反射同理:默认无法对导入的其他模块类使用反射。后续会展示描述符文件的使用示例。

2.4. 模块类型

新模块系统包含四种类型:

  • 系统模块 – 执行 java --list-modules 命令列出的模块,包括 Java SE 和 JDK 模块
  • 应用模块 – 使用模块时通常构建的模块,在打包 JAR 中包含编译后的 module-info.class 文件
  • 自动模块 – 将现有 JAR 添加到模块路径时创建的模块,名称从 JAR 文件名派生,可完全访问路径中所有其他模块
  • 未命名模块 – 加载到类路径而非模块路径的类或 JAR,为保持向后兼容性而设计的兜底模块

2.5. 分发

模块可通过 JAR 文件或“解压”的编译项目分发,与传统 Java 项目无异。

可构建包含“主应用”和多个库模块的多模块项目。

⚠️ 注意:每个 JAR 文件只能包含一个模块。

配置构建文件时,需确保项目中的每个模块打包为独立 JAR。

3. 默认模块

安装 Java 9 后会发现 JDK 结构已重构:所有原始包被迁移到新模块系统。

通过命令行可查看这些模块:

java --list-modules

这些模块分为四大组:javajavafxjdkOracle

  • java 模块:核心 SE 语言规范的实现类
  • javafx 模块:FX UI 库
  • JDK 自身所需内容保留在 jdk 模块中
  • Oracle 特有内容位于 oracle 模块中

4. 模块声明

创建模块需在包根目录放置特殊文件 module-info.java

此文件即模块描述符,包含构建和使用模块所需的所有数据。

模块声明以 module 关键字开头,后跟模块名,主体可为空或包含模块指令:

module myModuleName {
    // 所有指令都是可选的
}

实际开发中通常需要更多信息,这时就需要模块指令。

4.1. Requires

requires 指令用于声明模块依赖:

module my.module {
    requires module.name;
}

现在 my.modulemodule.name 同时拥有编译时和运行时依赖

使用此指令后,依赖项导出的所有公共类型都可在当前模块中访问。

4.2. Requires Static

有时代码会引用其他模块,但库用户并不需要这些功能。

例如,我们可能编写一个工具函数:当存在日志模块时美化打印内部状态。但并非所有用户都需要此功能,也不想引入额外日志库。

这种场景下需要可选依赖。使用 requires static 指令创建仅编译时依赖:

module my.module {
    requires static module.name;
}

4.3. Requires Transitive

我们常使用库简化开发,但需确保引入我们代码的模块也自动获取这些“传递”依赖,否则无法工作。

幸运的是,requires transitive 指令能强制下游消费者也读取我们的依赖:

module my.module {
    requires transitive module.name;
}

现在当开发者 requires my.module 时,无需再显式 requires module.name 即可正常工作。

4.4. Exports

默认情况下,模块不向外部暴露任何 API。这种强封装是创建模块系统的核心动机之一。

虽然代码安全性显著提升,但若要使 API 可用,必须显式开放。

使用 exports 指令暴露指定包的所有公共成员:

module my.module {
    exports com.my.package.name;
}

现在当其他模块 requires my.module 时,只能访问 com.my.package.name 包的公共类型,其他包仍不可见。

4.5. Exports … To

虽然 exports…to 可开放公共类给全局,但若不想让所有模块访问 API 呢?

使用 exports…to 指令限制可访问 API 的模块列表:

module my.module {
    export com.my.package.name to com.specific.package;
}

4.6. Uses

服务是特定接口或抽象类的实现,可被其他类消费

使用 uses 指令声明模块消费的服务。

注意:我们 use 的类名是服务的接口或抽象类,而非实现类

module my.module {
    uses class.name;
}

需区分 requiresuses 指令:

  • 可能 require 提供所需服务的模块,但该服务实现来自其传递依赖
  • 为避免强制模块依赖所有传递依赖,使用 uses 指令将所需接口添加到模块路径

4.7. Provides … With

模块也可作为服务提供者供其他模块消费。

指令第一部分是 provides 关键字,指定接口或抽象类名。

接着是 with 部分,提供实现类名(实现接口或继承抽象类):

module my.module {
    provides MyInterface with MyInterfaceImpl;
}

4.8. Open

前文提到封装是模块系统的核心设计动机。Java 9 之前,反射可检查包中所有类型和成员(包括私有成员),没有真正的封装,这给库开发者带来诸多问题。

由于 Java 9 强制强封装现在必须显式授权其他模块反射访问我们的类。

若要像旧版 Java 一样允许完全反射,可直接开放整个模块:

open module my.module {
}

4.9. Opens

若需允许私有类型反射,但不想暴露所有代码,使用 opens 指令开放特定包

但注意:这会向全局开放包,请确保这是你想要的:

module my.module {
  opens com.my.package;
}

4.10. Opens … To

反射虽好,但我们仍希望尽可能利用封装的安全性。使用 opens…to 指令选择性地向预批准模块列表开放包:

module my.module {
    opens com.my.package to moduleOne, moduleTwo, etc.;
}

5. 命令行选项

目前 Maven 和 Gradle 已支持 Java 9 模块,无需手动构建项目。但了解命令行操作仍有价值。

后续完整示例将使用命令行,帮助巩固对整个系统的理解。

  • --module-path 指定模块路径(包含模块的目录列表)
  • --add-reads – 命令行版 requires 指令
  • --add-exports 命令行版 exports 指令
  • --add-opens 命令行版 open 子句
  • --add-modules 将模块列表添加到默认模块集
  • --list-modules 打印所有模块及其版本字符串
  • --patch-module – 向模块添加或覆盖类
  • --illegal-access=permit|warn|deny – 控制强封装行为:显示单次全局警告/显示所有警告/报错失败,默认为 permit

6. 可见性

需要重点讨论代码可见性问题。

许多库依赖反射实现魔法功能(如 JUnit 和 Spring)。

Java 9 默认只能访问导出包中的公共类/方法/字段。即使使用反射并调用 setAccessible(true),也无法访问非公共成员。

可使用 openopensopens…to 选项授予仅运行时反射访问权限。注意:这仅限运行时!

无法针对私有类型编译,也永远不需要这样做。

若必须反射访问某模块且不是其所有者(即无法使用 opens…to),可使用命令行 --add-opens 选项在运行时允许自己的模块反射访问该模块。

唯一限制是需要有运行模块的命令行参数访问权限。

7. 综合实践

现在理解了模块的概念和用法,让我们构建一个简单项目演示所学知识。

为保持简洁,不使用 Maven 或 Gradle,而是通过命令行工具构建模块。

7.1. 项目设置

首先创建项目结构:

mkdir module-project
cd module-project

这是整个项目的基础目录,可放置 Maven/Gradle 构建文件、其他源目录和资源。

同时创建存放项目特定模块的目录:

mkdir simple-modules

项目结构如下:

module-project
|- // 默认包的 src 目录
|- // 构建文件也在此层级
|- simple-modules
  |- hello.modules
    |- com
      |- baeldung
        |- modules
          |- hello
  |- main.app
    |- com
      |- baeldung
        |- modules
          |- main

7.2. 第一个模块

simple-modules 目录下创建 hello.modules 目录。

可自由命名,但需遵循包命名规则(如用点号分隔单词等)。甚至可用主包名作为模块名,但通常建议与 JAR 命名保持一致。

在新模块下创建包结构:com.baeldung.modules.hello

添加 HelloModules.java 类:

package com.baeldung.modules.hello;

public class HelloModules {
    public static void doSomething() {
        System.out.println("Hello, Modules!");
    }
}

最后在 hello.modules 根目录添加模块描述符 module-info.java

module hello.modules {
    exports com.baeldung.modules.hello;
}

为简化示例,我们仅导出 com.baeldung.modules.hello 包的所有公共成员。

7.3. 第二个模块

第一个模块功能简单,现在创建使用它的第二个模块。

simple-modules 目录下创建 main.app 目录,先添加模块描述符:

module main.app {
    requires hello.modules;
}

无需向外部暴露任何内容,只需依赖第一个模块即可访问其导出的公共类。

创建包结构:com.baeldung.modules.main

添加 MainApp.java 类:

package com.baeldung.modules.main;

import com.baeldung.modules.hello.HelloModules;

public class MainApp {
    public static void main(String[] args) {
        HelloModules.doSomething();
    }
}

接下来通过命令行构建和运行代码。

7.4. 构建模块

在项目根目录创建构建脚本 compile-simple-modules.sh

#!/usr/bin/env bash
javac -d outDir --module-source-path simple-modules $(find simple-modules -name "*.java")

命令包含两部分:javacfind

find 命令列出 simple-modules 下所有 .java 文件,直接传递给 Java 编译器。

与旧版 Java 的唯一区别是提供 --module-source-path 参数告知编译器正在构建模块。

运行后将在 outDir 中生成两个编译好的模块。

7.5. 运行代码

创建运行脚本 run-simple-module-app.sh

#!/usr/bin/env bash
java --module-path outDir -m main.app/com.baeldung.modules.main.MainApp

运行模块至少需要提供 --module-path 和主类。成功执行将输出:

>$ ./run-simple-module-app.sh 
Hello, Modules!

7.6. 添加服务

现在对模块构建有了基本理解,让我们增加复杂度,演示 provides…withuses 指令。

hello.modules 模块中定义 HelloInterface.java 接口:

public interface HelloInterface {
    void sayHello();
}

用现有 HelloModules.java 类实现该接口:

public class HelloModules implements HelloInterface {
    public static void doSomething() {
        System.out.println("Hello, Modules!");
    }

    public void sayHello() {
        System.out.println("Hello!");
    }
}

至此服务创建完成。现在需声明模块提供此服务。

module-info.java 中添加:

provides com.baeldung.modules.hello.HelloInterface with com.baeldung.modules.hello.HelloModules;

声明接口及其实现类。

接下来在 main.app 模块的 module-info.java 中消费服务:

uses com.baeldung.modules.hello.HelloInterface;

最后在主方法中通过 ServiceLoader 使用服务:

Iterable<HelloInterface> services = ServiceLoader.load(HelloInterface.class);
HelloInterface service = services.iterator().next();
service.sayHello();

编译运行:

#> ./run-simple-module-app.sh 
Hello, Modules!
Hello!

这些指令让代码使用方式更明确。可将实现放在私有包中,仅公开接口,在几乎无额外开销的情况下大幅提升代码安全性。

尝试其他指令以深入理解模块工作原理。

8. 向未命名模块添加模块

未命名模块概念类似默认包,不被视为真实模块,但可视为默认模块。

不属于命名模块的类将自动成为未命名模块的一部分。

有时为确保模块图中包含特定平台/库/服务提供者模块,需向默认根集添加模块。例如用 Java 9 编译器运行 Java 8 程序时可能需要添加模块。

向默认根模块集添加命名模块的选项是 ***--add-modules **(,)**,其中 是模块名。

例如提供对所有 java.xml.bind 模块的访问:

--add-modules java.xml.bind

在 Maven 中可将其嵌入 maven-compiler-plugin

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.8.0</version>
    <configuration>
        <source>9</source>
        <target>9</target>
        <compilerArgs>
            <arg>--add-modules</arg>
            <arg>java.xml.bind</arg>
        </compilerArgs>
    </configuration>
</plugin>

9. 总结

本指南全面介绍了 Java 9 模块系统的基础知识:

  • 从模块定义开始
  • 探讨如何发现 JDK 包含的模块
  • 详细解析模块声明文件
  • 介绍构建模块所需的命令行参数
  • 通过实践项目综合应用所学知识

完整代码示例请访问 GitHub 仓库


原始标题:A Guide to Java 9 Modularity | Baeldung