「Java 包」章节已经把心跳控制器、心跳服务和心跳数据拆分到不同包中。HealthController 通过 import 使用 cn.usehook.spring.health 包公开的类型,但这些包仍然一起运行在 classpath 的未命名模块中。
Java 平台模块系统(Java Platform Module System,JPMS)从 Java 9 开始提供。它在包边界之外增加模块名称、依赖声明、包导出和反射开放规则,让编译器和 JVM 都能检查更高层次的代码边界。这是可选的 Java 进阶能力,阅读前应先理解类、包、访问控制和 Maven;当前 Spring Boot 心跳项目不会因此改造成命名模块。
1. 从包到模块
当前 spring 项目包含多个职责不同的包:
包解决了类型命名和包级访问问题:
cn.usehook.spring.controller与cn.usehook.spring.health可以声明同名类型。HealthController通过import使用另一个包中的public类型。- 不写访问修饰符的类型和成员只能在同一个包中使用。
但是,包本身不能声明「我依赖哪个包」,也不能阻止同一 classpath 上的代码导入任意可访问类型。随着项目拆分为多个库,单靠包和 public 很难表达哪些包是真正的公共 API,哪些包只是内部实现。
模块位于包的外层:
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,与包目录处于同一级:
模块声明的基本结构如下:
1module 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 |
| 编译结果 | 一个或多个类型的 .class | module-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.health | cn.usehook.spring.health | 提供心跳状态和查询方法 |
cn.usehook.spring.app | cn.usehook.spring.app | 调用心跳模块并显示结果 |
目录结构如下:
心跳模块通过 exports 导出准备给其他模块使用的包:
1module cn.usehook.spring.health {2exports cn.usehook.spring.health;3}
这个包包含两个 public 类型:
1package cn.usehook.spring.health;23public record HealthStatus(String status, String service) {}
1package cn.usehook.spring.health;23public class HealthService {45public HealthStatus check() {6return new HealthStatus("UP", "spring");7}8}
调用方模块使用 requires 声明读取心跳模块:
1module cn.usehook.spring.app {2requires cn.usehook.spring.health;3}
Main 仍然使用前面学过的 import 和对象方法调用。模块声明没有替代包或类的语法:
01package cn.usehook.spring.app;0203import cn.usehook.spring.health.HealthService;04import cn.usehook.spring.health.HealthStatus;0506public class Main {0708public static void main(String[] args) {09HealthService service = new HealthService();10HealthStatus health = service.check();11System.out.printf("status=%s, service=%s%n", health.status(), health.service());12}13}
调用能够成立,是因为调用方模块通过 requires 读取了心跳模块,心跳模块通过 exports 导出了目标包,并且 HealthService、HealthStatus 与 check() 都允许外部访问。
4. 编译与运行
在 java-modules 根目录执行:
1javac -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 会先读取两个模块描述符并解析依赖图,再编译普通源码。输出目录先按模块名分层,模块内部再按包名分层:
运行时把 out 放到 module path,并指定入口模块和入口类:
1java --module-path out --module cn.usehook.spring.app/cn.usehook.spring.app.Main
斜杠左侧的 cn.usehook.spring.app 是模块名,右侧的 cn.usehook.spring.app.Main 是包含包名的完整类名。运行结果为:
1status=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 直接暴露了另一个模块中的类型,可以使用传递依赖:
1module cn.usehook.spring.order {2requires transitive cn.usehook.spring.common;3exports cn.usehook.spring.order;4}
任何读取 cn.usehook.spring.order 的模块,也会自动读取 cn.usehook.spring.common。只有依赖确实构成当前模块公开 API 的一部分时,才应该使用 transitive。
requires static 表示依赖在编译阶段必须存在,但运行阶段可以不参与模块解析。它适合编译期工具或真正可选的能力;如果运行中的代码实际加载了该模块提供的类型,缺少依赖仍然会失败。
限定导出可以只向明确的使用方公开包:
1module cn.usehook.spring.health {2exports cn.usehook.spring.health;3exports 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 开放模型包:
1module cn.usehook.spring.order {2requires com.fasterxml.jackson.databind;3opens 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 ... 注册实现:
1module cn.usehook.spring.app {2requires cn.usehook.spring.health;3uses cn.usehook.spring.health.HealthChecker;4}
1module cn.usehook.spring.health {2exports cn.usehook.spring.health;3provides cn.usehook.spring.health.HealthChecker4with cn.usehook.spring.health.internal.DefaultHealthChecker;5}
HealthChecker 是公开的服务接口,DefaultHealthChecker 是实现。使用方可以通过 ServiceLoader 发现实现,不需要直接依赖实现类型;实现包也不必通过 exports 公开。提供者类型仍需满足 ServiceLoader 的实例化规则,例如提供可访问的无参数构造方法或符合要求的 provider() 方法。
7. Spring Boot 中的模块
回到课程中的 spring 项目,目前的结构可以准确描述为:
1spring 是一个 Maven 项目2spring 当前只有一个 Maven 构建单元3cn.usehook.spring、cn.usehook.spring.config 是 Java 包4cn.usehook.spring.controller、cn.usehook.spring.health 也是 Java 包5Application、HealthProperties、HealthController、HealthService、HealthStatus 是 Java 类型6源码没有 module-info.java7应用通过 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。实际启用命名模块需要根据项目依赖完成这些工作:
- 使用
requires声明源码直接使用的 Spring 模块和第三方模块。 - 使用
exports公开真正需要被其他模块调用的 API 包。 - 使用
opens向 Spring、Jackson、Hibernate 等需要反射的模块开放对应包。 - 让 Maven 编译、测试和应用启动使用正确的 module path。
- 使用
jar --describe-module --file <依赖.jar>核对第三方 JAR 的实际模块名。
不存在一份适用于所有 Spring Boot 项目的通用模块描述符。依赖和反射对象不同,需要声明的 requires、exports 与 opens 也不同。当前项目继续使用 classpath,可以先学习 Spring Boot 的 Web、配置和数据库能力;当项目确实需要发布模块化库或强制限制跨模块访问时,再引入 JPMS。