1. 何时以及为何将项目拆分为模块
为什么不应该把所有东西都放在一个模块里
如果你只是在写一个小实验或 "Hello, World!" 程序,模块系统可能显得多余。但随着项目增长——成打上百的类、众多包和第三方库——混乱几乎不可避免。这就像一个没有书架的图书馆:书少还凑合,再多就很难找到。模块就是你的书架:它们帮助整理秩序,隐藏“后厨”(实现),只把“橱窗”(API)暴露在外。
为什么要拆分为模块
- 职责分离:每个模块负责自己的领域(例如,数据库、业务逻辑、UI)。
- 代码复用:模块可以接入到其他项目。
- 提升可测试性:模块可以独立测试。
- 安全与封装:对外只可见 API,实现被隐藏。
- 易于维护:更少的“神奇”耦合,清晰的依赖关系图。
- 更快的构建与部署:只需重新构建有变更的模块。
何时拆分为模块
- 当项目大到一个开发者难以把控(或 IDE 开始“卡顿”)。
- 当项目边界清晰:core、ui、utils、api、impl 等。
- 计划在其他项目中复用代码。
- 存在仅部分项目需要的外部依赖。
- 需要隐藏实现细节(算法、内部类)。
2. 常见的模块化方案
下面是流行的拆分方案,既适用于学习项目,也适用于生产项目。
“洋葱”架构(Onion Architecture)
外层可以依赖内层,但内层不能反向依赖外层.
[ app (UI) ]
↓
[ core (逻辑) ]
↓
[ utils (工具) ]
- app — 外层模块:图形界面、Web 应用、入口点(main)。
- core — 业务逻辑、模型、服务。
- utils — 辅助类。
规则:内层模块不应依赖外层。这样 core 就可以在不同界面中复用(命令行、Web、桌面)。
API 与实现分离的模块
对于库项目,通常将接口与其实现拆分为独立模块:
[ mylib.api ] ← 只导出接口
[ mylib.impl ] ← 包含实现,不导出
测试模块
测试通常会单独放在一个模块中,避免进入生产构件。
[ app ]
[ core ]
[ core.tests ]
示例:教学项目的方案
myeditor/
├─ app/ ← 入口点,启动应用
├─ core/ ← 业务逻辑(文件与文本处理)
└─ utils/ ← 工具(日志、解析)
3. 模块之间的依赖
在 Java 模块中,依赖需要在 module-info.java 中用关键字 requires 显式声明。这样既提升可读性,又让编译器/JVM 可以通过 exports 控制 API 的可见性。
依赖示例
core/module-info.java
module myeditor.core {
exports myeditor.core.api; // 对外只可见 api 包
requires myeditor.utils; // 使用工具模块
}
app/module-info.java
module myeditor.app {
requires myeditor.core; // 使用 core
requires myeditor.utils; // 可以直接使用工具模块
}
规则与最佳实践
- 避免循环依赖。如果 A requires B 且 B requires A —— 这是设计缺陷。通常通过抽取公共的 common/api 模块来解决。
- 尽量减少依赖。不需要的模块不要引入。
- 导出需要被使用的包。类必须位于通过 exports 声明的包中,否则会出现编译错误。
- 工具模块应尽可能独立。utils 不应依赖业务逻辑。
4. 实战:将教学项目拆分为 3 个模块的示例
目录结构
myeditor/
├─ app/
│ ├─ src/
│ │ └─ myeditor/app/Main.java
│ └─ module-info.java
├─ core/
│ ├─ src/
│ │ ├─ myeditor/core/api/TextService.java
│ │ └─ myeditor/core/impl/TextServiceImpl.java
│ └─ module-info.java
└─ utils/
├─ src/
│ └─ myeditor/utils/Logger.java
└─ module-info.java
module-info.java 示例
core/module-info.java
module myeditor.core {
exports myeditor.core.api;
requires myeditor.utils;
}
app/module-info.java
module myeditor.app {
requires myeditor.core;
requires myeditor.utils;
}
utils/module-info.java
module myeditor.utils {
exports myeditor.utils;
}
代码示例(TextService)
myeditor/core/api/TextService.java
package myeditor.core.api;
public interface TextService {
String toUpperCase(String text);
}
myeditor/core/impl/TextServiceImpl.java
package myeditor.core.impl;
import myeditor.core.api.TextService;
public class TextServiceImpl implements TextService {
@Override
public String toUpperCase(String text) {
return text.toUpperCase();
}
}
myeditor/app/Main.java
package myeditor.app;
import myeditor.core.api.TextService;
import myeditor.core.impl.TextServiceImpl;
public class Main {
public static void main(String[] args) {
TextService service = new TextServiceImpl();
System.out.println(service.toUpperCase("hello, modules!"));
}
}
在 IntelliJ IDEA 中是什么样子
- 每个目录在项目结构中都是一个独立的 Module。
- 每个模块都在其 src 根目录下拥有自己的 module-info.java。
- 从 app 运行 main 时,IDE 会自动设置 module-path。
- 尝试使用未导出的包中的类将导致编译错误。
5. 对构建的影响: Maven/Gradle 与模块
Maven
一个多模块项目由一个“父”项目(parent)和若干“子”模块组成。
myeditor/
├─ pom.xml ← parent
├─ app/
│ └─ pom.xml
├─ core/
│ └─ pom.xml
└─ utils/
└─ pom.xml
parent pom.xml 示例
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>myeditor</groupId>
<artifactId>myeditor-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>app</module>
<module>core</module>
<module>utils</module>
</modules>
</project>
注意事项:
- Maven 在编译时会考虑 module-info.java。
- 运行时使用 --module-path 而不是 --classpath。
- 如果忘记 exports 或 requires —— 将出现编译错误。
Gradle
通过 settings.gradle 与各模块的独立 build.gradle 来配置多模块项目。
settings.gradle
rootProject.name = 'myeditor'
include 'app', 'core', 'utils'
模块的 build.gradle
plugins {
id 'java'
}
java {
modularity.inferModulePath = true
}
IntelliJ IDEA
- 在创建 Java 模块时,IDEA 能自动生成 module-info.java。
- 使用 Maven/Gradle 时,模块结构会被自动识别。
- 从 app 运行 main 时,IDE 会配置好 module-path。
- 导入/导出对话框会提示包与模块的可见性。
将项目拆分为模块时的常见错误
错误 1:模块之间的循环依赖。 如果两个模块相互声明 requires,编译器会报错。这通常是架构“走样”的信号。解决方案——抽取一个公共的 api 模块,或重新划定边界。
错误 2:使用未导出的包中的类。 类可以是 public,但如果包没有在 module-info.java 中通过 exports 指定,其他模块将看不到它。结果就是编译错误。
错误 3:忘记为所用模块添加 requires。 从其他模块导入而未在 module-info.java 中做相应声明将无法通过编译。务必显式声明依赖。
错误 4:模块名重复。 模块名在一次构建范围内必须唯一(尤其配合 Maven/Gradle)。重复会破坏构建。
错误 5:目录结构不正确。 文件 module-info.java 必须位于对应模块的 src 根目录,否则编译器找不到该模块。
错误 6:运行时的 module-path 配置错误。 手动运行时请使用 --module-path 而不是 --classpath,否则会得到“module not found”。
GO TO FULL VERSION