跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

「Java 包」章节已经把心跳控制器、心跳服务和心跳数据拆分到不同包中。HealthController 通过 import 使用 cn.usehook.spring.health 包公开的类型,但这些包仍然一起运行在 classpath 的未命名模块中。

Java 平台模块系统(Java Platform Module System,JPMS)从 Java 9 开始提供。它在包边界之外增加模块名称、依赖声明、包导出和反射开放规则,让编译器和 JVM 都能检查更高层次的代码边界。这是可选的 Java 进阶能力,阅读前应先理解类、包、访问控制和 Maven;当前 Spring Boot 心跳项目不会因此改造成命名模块。

1. 从包到模块

当前 spring 项目包含多个职责不同的包:

pom.xmlMaven 构建配置
Application.javaSpring Boot 入口
HealthProperties.java心跳配置
HealthController.javaGET /api/v1/health
HealthService.java心跳服务
HealthStatus.java心跳数据

包解决了类型命名和包级访问问题:

  • cn.usehook.spring.controller 与 cn.usehook.spring.health 可以声明同名类型。
  • HealthController 通过 import 使用另一个包中的 public 类型。
  • 不写访问修饰符的类型和成员只能在同一个包中使用。

但是,包本身不能声明「我依赖哪个包」,也不能阻止同一 classpath 上的代码导入任意可访问类型。随着项目拆分为多个库,单靠包和 public 很难表达哪些包是真正的公共 API,哪些包只是内部实现。

模块位于包的外层:

hierarchy.txt
1
项目
2
└── 一个或多个模块
3
└── 一个或多个包
4
└── 类、接口、枚举、注解和 record

这里还要区分 Maven 与 JPMS。Maven 项目由 pom.xml 描述;Maven 多模块项目由父 POM 聚合多个构建单元;Java 命名模块由 module-info.java 描述。一个 Maven 子项目可以不使用 JPMS,一个 Java 模块也不要求必须通过 Maven 编译。

2. 模块描述符

命名模块需要一个模块描述符 module-info.java。在标准 Maven 项目中,它位于 src/main/java,与包目录处于同一级:

pom.xmlMaven 构建配置
module-info.java描述整个 cn.usehook.spring 模块
Application.java
HealthController.java
HealthService.java
HealthStatus.java

模块声明的基本结构如下:

src/main/java/module-info.java
1
module cn.usehook.spring {
2
// requires、exports、opens、uses 和 provides 写在这里
3
}

这段代码只用来展示模块声明的结构,并不是当前 Spring Boot 项目可以直接使用的完整描述符。项目引用了 Spring 和 Jackson 等库,真正启用 JPMS 时还需要声明对应模块依赖和反射开放规则。

module-info.java 与普通 Java 源文件不同:

对比项普通 .java 文件module-info.java
主要声明类、接口、枚举、注解或 record一个模块
是否声明包通常使用 package不使用 package
编译结果一个或多个类型的 .classmodule-info.class
是否可以创建对象类型可以按规则实例化不是普通类,不能实例化
作用范围当前编译单元中的类型整个模块中的所有包

一个模块通常只有一个模块描述符。编译器根据源码路径或构建配置,把普通编译单元关联到这个模块,不需要在 module-info.java 中逐个登记类。

模块名与包名互相独立,只是都常用反向域名命名。module cn.usehook.spring.health 可以包含 cn.usehook.spring.health、cn.usehook.spring.health.internal 等多个包。所有命名模块都会隐式依赖 java.base,因此不需要手动声明 requires java.base。

Java 运行时常见的模块身份包括:

身份来源边界
命名模块存在 module-info.class按 requires、exports 和 opens 检查
自动模块普通 JAR 被放到 module path从清单或文件名确定模块名,并临时导出其中全部包
未命名模块classpath 中的代码保持传统 classpath 的宽松访问方式

当前 spring 应用属于未命名模块。创建 module-info.java 只是第一步,编译和运行时还必须使用 module path,才能按照命名模块解析。

3. 心跳模块

为了只观察 JPMS 本身,下面创建一个独立的纯 Java 实验 java-modules。它沿用课程中的心跳领域名称,但不使用 Spring 注解,也不会替换 spring 项目中的 @Service 和 HealthController。

实验包含两个命名模块:

模块包职责
cn.usehook.spring.healthcn.usehook.spring.health提供心跳状态和查询方法
cn.usehook.spring.appcn.usehook.spring.app调用心跳模块并显示结果

目录结构如下:

module-info.java心跳模块描述符
HealthStatus.java心跳数据 record
HealthService.java心跳查询服务
module-info.java调用方模块描述符
Main.java程序入口类

心跳模块通过 exports 导出准备给其他模块使用的包:

src/cn.usehook.spring.health/module-info.java
1
module cn.usehook.spring.health {
2
exports cn.usehook.spring.health;
3
}

这个包包含两个 public 类型:

src/cn.usehook.spring.health/cn/usehook/spring/health/HealthStatus.java
1
package cn.usehook.spring.health;
2
3
public record HealthStatus(String status, String service) {}
src/cn.usehook.spring.health/cn/usehook/spring/health/HealthService.java
1
package cn.usehook.spring.health;
2
3
public class HealthService {
4
5
public HealthStatus check() {
6
return new HealthStatus("UP", "spring");
7
}
8
}

调用方模块使用 requires 声明读取心跳模块:

src/cn.usehook.spring.app/module-info.java
1
module cn.usehook.spring.app {
2
requires cn.usehook.spring.health;
3
}

Main 仍然使用前面学过的 import 和对象方法调用。模块声明没有替代包或类的语法:

src/cn.usehook.spring.app/cn/usehook/spring/app/Main.java
01
package cn.usehook.spring.app;
02
03
import cn.usehook.spring.health.HealthService;
04
import cn.usehook.spring.health.HealthStatus;
05
06
public class Main {
07
08
public static void main(String[] args) {
09
HealthService service = new HealthService();
10
HealthStatus health = service.check();
11
System.out.printf("status=%s, service=%s%n", health.status(), health.service());
12
}
13
}

调用能够成立,是因为调用方模块通过 requires 读取了心跳模块,心跳模块通过 exports 导出了目标包,并且 HealthService、HealthStatus 与 check() 都允许外部访问。

4. 编译与运行

在 java-modules 根目录执行:

terminal.bash
1
javac -d out --module-source-path src --module "cn.usehook.spring.health,cn.usehook.spring.app"

各参数的含义如下:

参数作用
-d out把编译结果写入 out
--module-source-path src在 src/<模块名> 下查找各模块源码
--module指定本次需要编译的模块

javac 会先读取两个模块描述符并解析依赖图,再编译普通源码。输出目录先按模块名分层,模块内部再按包名分层:

module-info.class模块元数据
HealthStatus.class
HealthService.class
module-info.class模块元数据
Main.class

运行时把 out 放到 module path,并指定入口模块和入口类:

terminal.bash
1
java --module-path out --module cn.usehook.spring.app/cn.usehook.spring.app.Main

斜杠左侧的 cn.usehook.spring.app 是模块名,右侧的 cn.usehook.spring.app.Main 是包含包名的完整类名。运行结果为:

terminal.txt
1
status=UP, service=spring

JVM 会在执行 main() 之前解析模块图。如果 module path 中缺少 cn.usehook.spring.health,或者模块声明形成无法满足的依赖,应用会在启动阶段终止,而不是等到 check() 真正执行时才发现类缺失。

5. 依赖与导出

另一个命名模块要调用 HealthService.check(),需要同时满足模块、包、类型和成员四层条件:

层次示例中的条件负责的语法
模块可读调用方读取心跳模块requires cn.usehook.spring.health
包已导出心跳 API 包对外公开exports cn.usehook.spring.health
类型可访问HealthService、HealthStatus 对外可见public
成员可访问check() 可以被外部调用public

「Java 包」章节已经介绍了类型和成员的访问控制。本章新增的是前两层:requires 从使用方声明「我要读取哪个模块」,exports 从提供方声明「我的哪个包是公共 API」。

模块描述符中常用的依赖和导出形式如下:

指令含义
requires other.module当前模块直接读取另一个模块
requires transitive other.module让当前模块的使用方也自动读取该依赖
requires static other.module编译时必须存在,运行时不强制解析
exports package.name向所有读取当前模块的模块导出包
exports package.name to client.module只向指定模块导出包

如果模块的公共 API 直接暴露了另一个模块中的类型,可以使用传递依赖:

module-info.java
1
module cn.usehook.spring.order {
2
requires transitive cn.usehook.spring.common;
3
exports cn.usehook.spring.order;
4
}

任何读取 cn.usehook.spring.order 的模块,也会自动读取 cn.usehook.spring.common。只有依赖确实构成当前模块公开 API 的一部分时,才应该使用 transitive。

requires static 表示依赖在编译阶段必须存在,但运行阶段可以不参与模块解析。它适合编译期工具或真正可选的能力;如果运行中的代码实际加载了该模块提供的类型,缺少依赖仍然会失败。

限定导出可以只向明确的使用方公开包:

module-info.java
1
module cn.usehook.spring.health {
2
exports cn.usehook.spring.health;
3
exports cn.usehook.spring.health.internal to cn.usehook.spring.app;
4
}

exports 不会导出子包。cn.usehook.spring.health 和 cn.usehook.spring.health.internal 是两个独立包,必须分别声明。命名模块还不能把同一个包拆分到多个模块中,否则会形成 split package 并导致编译或模块解析失败。

6. 反射与服务

exports 控制其他模块能否在普通 Java 代码中使用一个包的 public API。Spring、Jackson、Hibernate 等框架还经常通过反射读取构造方法、字段或方法,这类深层反射由 opens 控制。

例如,只向 Jackson 开放模型包:

module-info.java
1
module cn.usehook.spring.order {
2
requires com.fasterxml.jackson.databind;
3
opens cn.usehook.spring.order.model to com.fasterxml.jackson.databind;
4
}

opens cn.usehook.spring.order.model 会向所有模块开放该包的深层反射;增加 to 后,只向指定模块开放。open module cn.usehook.spring.order { ... } 会开放模块中的全部包,适合迁移阶段,但边界比逐包 opens 更宽。

opens 不会让其他模块在源码中直接 import 这个包。普通 API 访问仍由 exports 决定。

JPMS 也支持基于 ServiceLoader 的服务发现。服务使用方通过 uses 声明要查找的接口,实现方通过 provides ... with ... 注册实现:

src/cn.usehook.spring.app/module-info.java
1
module cn.usehook.spring.app {
2
requires cn.usehook.spring.health;
3
uses cn.usehook.spring.health.HealthChecker;
4
}
src/cn.usehook.spring.health/module-info.java
1
module cn.usehook.spring.health {
2
exports cn.usehook.spring.health;
3
provides cn.usehook.spring.health.HealthChecker
4
with cn.usehook.spring.health.internal.DefaultHealthChecker;
5
}

HealthChecker 是公开的服务接口,DefaultHealthChecker 是实现。使用方可以通过 ServiceLoader 发现实现,不需要直接依赖实现类型;实现包也不必通过 exports 公开。提供者类型仍需满足 ServiceLoader 的实例化规则,例如提供可访问的无参数构造方法或符合要求的 provider() 方法。

7. Spring Boot 中的模块

回到课程中的 spring 项目,目前的结构可以准确描述为:

current-project.txt
1
spring 是一个 Maven 项目
2
spring 当前只有一个 Maven 构建单元
3
cn.usehook.spring、cn.usehook.spring.config 是 Java 包
4
cn.usehook.spring.controller、cn.usehook.spring.health 也是 Java 包
5
Application、HealthProperties、HealthController、HealthService、HealthStatus 是 Java 类型
6
源码没有 module-info.java
7
应用通过 classpath 运行,属于未命名模块

Spring 组件扫描和 JPMS 是两套机制。@SpringBootApplication 从 Application 所在的 cn.usehook.spring 根包向下查找 Controller、Service 和 Configuration;module-info.java 则由 Java 编译器和 JVM 用来检查模块读取、包导出和反射开放。组件能被 Spring 扫描到,不代表项目已经成为 Java 命名模块。

Maven 多模块也不等于 JPMS。项目可以拆成 spring-health、spring-web 等多个 Maven 子项目,并继续全部通过 classpath 运行。只有构建产物包含模块描述符,并通过 module path 编译和运行时,它们才是受 JPMS 检查的命名模块。

当前课程暂时不直接给 Spring Boot 项目添加 module-info.java。实际启用命名模块需要根据项目依赖完成这些工作:

  1. 使用 requires 声明源码直接使用的 Spring 模块和第三方模块。
  2. 使用 exports 公开真正需要被其他模块调用的 API 包。
  3. 使用 opens 向 Spring、Jackson、Hibernate 等需要反射的模块开放对应包。
  4. 让 Maven 编译、测试和应用启动使用正确的 module path。
  5. 使用 jar --describe-module --file <依赖.jar> 核对第三方 JAR 的实际模块名。

不存在一份适用于所有 Spring Boot 项目的通用模块描述符。依赖和反射对象不同,需要声明的 requires、exports 与 opens 也不同。当前项目继续使用 classpath,可以先学习 Spring Boot 的 Web、配置和数据库能力;当项目确实需要发布模块化库或强制限制跨模块访问时,再引入 JPMS。