Java 模块系统怎样导出只读 API 包
来源:17golang原创
时间:2026-10-26 04:02:20 293浏览 收藏
如果你想让其他模块只能使用一组稳定的 Java API,同时不让它们直接依赖仓储、缓存或实现类,做法不是寻找一个叫“只读导出”的特殊关键字,而是把公开类型放进 API 包,再在 module-info.java 中使用 exports。真正的只读语义还要靠没有 setter 的类型、接口或不可变数据对象来完成。
最小方案是:导出com.example.catalog.api,不导出com.example.catalog.internal;消费者模块声明requires com.example.catalog,只能依赖 API 包中的 public 类型。若只允许某个模块使用,再改成限定导出exports ... to ...。
官方参考地址:https://docs.oracle.com/javase/specs/jls/se25/html/jls-7.html
先区分导出边界与只读语义
这次问题通常表现为两种症状:消费者模块要么无法 import 提供者模块里的类,要么虽然能 import,却能通过 setter 或可变集合改动提供者返回的对象。前一种是模块导出边界没有配置好,后一种不是 exports 失效,而是 API 类型本身暴露了写入口。
Java 模块系统把“能不能从模块外访问”拆成了两层:
- 类型必须是
public,否则其他包不能正常使用它; - 类型所在的包必须被模块导出,否则即使类型是
public,也只对模块内部代码可见。
所以,下面的 API 可以被称为“只读 API 表面”:消费者得到的是 ProductView,它没有 setter;实现细节藏在未导出的内部包中。这里的只读是设计结果,不是模块系统替你冻结了所有对象。

把 API 包和实现包放在不同边界
先按职责拆包。com.example.catalog.api 只放消费者需要知道的类和不可变结果;com.example.catalog.internal 放仓储、缓存和查询实现。包名是精确边界,导出前者不会顺带导出名字更深的子包。
// API 包:只暴露查询入口和不可变结果对象
package com.example.catalog.api;
public record ProductView(String id, String name) {
// record 组件只有访问器,没有 setter,适合作为只读结果视图
}
public final class Catalog {
private final com.example.catalog.internal.CatalogRepository repository;
public Catalog() {
// 实现类仍可在同一模块内部被使用
this.repository = new com.example.catalog.internal.CatalogRepository();
}
public ProductView find(String id) {
// 对外只返回 API 包里的结果类型,不泄露仓储对象
return repository.find(id);
}
}
// 内部包:类可以是 public,但整个包不导出时模块外仍不可访问
package com.example.catalog.internal;
import com.example.catalog.api.ProductView;
public final class CatalogRepository {
public ProductView find(String id) {
// 示例只表达对象边界;真实项目中这里可以接数据库或缓存
return new ProductView(id, "示例商品");
}
}
这里有一个容易忽略的点:CatalogRepository 即使声明为 public,只要它所在的 internal 包没有导出,外部模块仍不能把它当作普通 API 使用。这样做可以让仓储实现随时替换,而不要求调用方跟着改依赖。
在 module-info.java 导出 API 包
提供者模块的描述文件只需要导出 API 包。requires java.base 不必手写,因为它会被隐式读取;如果项目还依赖其他命名模块,再补充对应的 requires。
// 提供者模块:只把稳定 API 包放到模块的对外表面
module com.example.catalog {
exports com.example.catalog.api;
}
消费者模块必须明确读取提供者模块:
// 消费者模块:声明它要读取 catalog 模块
module com.example.app {
requires com.example.catalog;
}
消费者代码只依赖导出的类型:
package com.example.app;
import com.example.catalog.api.Catalog;
import com.example.catalog.api.ProductView;
public final class Main {
public static void main(String[] args) {
// 调用方拿到只读视图,而不是内部仓储
ProductView view = new Catalog().find("p-100");
System.out.println(view.name());
}
}
在模块化源码目录中,常见的编译入口可以写成下面这样。命令中的注释只解释参数用途,不代表本文在本机执行了这组代码:
# 将多个模块编译到按模块分组的输出目录 javac -d out --module-source-path src -m com.example.catalog,com.example.app # 从模块路径启动消费者模块的 Main 类 java --module-path out -m com.example.app/com.example.app.Main
如果消费者尝试导入 com.example.catalog.internal.CatalogRepository,问题不在 classpath,而在模块声明:该包没有出现在 exports 中。把整个模块改成 open module 也不会让它变成正常编译期 API。
需要收窄时使用限定导出
无条件导出适合公共库;如果 API 只给一个应用模块或一组明确的协作模块使用,可以加 to。这叫限定导出,模块外只有列出的命名模块可以访问这个包。
// 只允许指定应用模块读取 API 包
module com.example.catalog {
exports com.example.catalog.api to com.example.app;
}
例如,com.example.app 可以读取 com.example.catalog.api,而没有列在 to 后面的 com.example.test 即使声明了 requires com.example.catalog,也不能把这个包当作普通可见包使用。内部包仍然不导出。

可以用下面的表快速判断该选哪种声明:
| 声明 | 外部可见范围 | 适合场景 |
|---|---|---|
exports api; | 所有读取该模块的命名模块 | 公共库的稳定 API |
exports api to app; | 仅指定命名模块 | 应用与库之间的受控协作 |
opens api; | 主要面向反射访问 | 框架需要反射读取成员时 |
| 不写 exports | 模块内部代码 | 实现包、测试辅助或内部适配器 |
按可见性和反射边界排查
遇到“导出了却不能用”时,按下面的顺序判断,通常比直接添加 --add-exports 更容易找到根因:
- 确认导出的包名和 import 的包名完全一致。
com.example.catalog.api不包含com.example.catalog.api.internal。 - 确认被引用的顶层类或接口是
public。导出包不会把包级可见类自动变成 public。 - 确认消费者模块写了
requires com.example.catalog,并且编译、运行时使用的是模块路径。 - 如果只是框架反射失败,再检查是否应该使用
opens;不要为了普通 import 把内部包开放给反射。 - 如果要求“只读”,检查返回值是否暴露可变集合、setter 或能修改提供者状态的对象引用。
最后一点很关键:exports 解决“谁能看到这个包”,不解决“看到之后能否改变对象”。例如,对外返回 List 时,调用方仍可能尝试修改列表;如果 API 需要只读集合,应在返回边界提供不可变视图或不可变副本,并在文档中写清所有权。
结论:把“只读 API”拆成两项配置
Java 模块系统导出只读 API 包的稳定做法可以压缩成两项:第一,在包结构上只导出 api,不导出 internal;第二,在类型设计上让 API 只提供查询、不可变结果和受控接口。默认公共库用无条件 exports,内部协作用限定 exports ... to ...,反射框架才考虑 opens。
这样一来,消费者依赖的是一份清晰的契约,而不是提供者当前的实现目录。以后更换仓储、缓存或内部类时,只要 API 包的契约不变,模块边界就能继续替你挡住不必要的耦合。
相关问题
导出一个包会自动导出它的子包吗?
不会。Java 模块声明按完整包名匹配,父包与子包需要分别决定是否导出。
只写 public 不写 exports 可以吗?
对模块外的命名模块不够。顶层类型除了要是 public,所在包也必须被导出。
exports 和 opens 有什么区别?
exports 面向普通编译与运行期 API 访问;opens 主要面向反射,并不替代消费者对公开类型的正常模块依赖。
限定导出能阻止 API 对象被修改吗?
不能。限定导出只限制哪些模块能访问包,是否可修改要由 public 方法、字段、集合和对象模型共同决定。
-
479 收藏
-
337 收藏
-
128 收藏
-
149 收藏
-
202 收藏
-
189 收藏
-
456 收藏
-
179 收藏
-
128 收藏
-
315 收藏
-
189 收藏
-
307 收藏
-
161 收藏
-
441 收藏
-
435 收藏
-
280 收藏
-
147 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习