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
这些模块分为四大组:java、javafx、jdk 和 Oracle。
- 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.module 对 module.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;
}
需区分 requires
和 uses
指令:
- 可能 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)
,也无法访问非公共成员。
可使用 open
、opens
和 opens…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")
命令包含两部分:javac
和 find
。
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…with
和 uses
指令。
在 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 仓库。