CodeGym /课程 /JAVA 25 SELF /将项目拆分为模块:最佳实践

将项目拆分为模块:最佳实践

JAVA 25 SELF
第 60 级 , 课程 3
可用

1. 何时以及为何将项目拆分为模块

为什么不应该把所有东西都放在一个模块里

如果你只是在写一个小实验或 "Hello, World!" 程序,模块系统可能显得多余。但随着项目增长——成打上百的类、众多包和第三方库——混乱几乎不可避免。这就像一个没有书架的图书馆:书少还凑合,再多就很难找到。模块就是你的书架:它们帮助整理秩序,隐藏“后厨”(实现),只把“橱窗”(API)暴露在外。

为什么要拆分为模块

  • 职责分离:每个模块负责自己的领域(例如,数据库、业务逻辑、UI)。
  • 代码复用:模块可以接入到其他项目。
  • 提升可测试性:模块可以独立测试。
  • 安全与封装:对外只可见 API,实现被隐藏。
  • 易于维护:更少的“神奇”耦合,清晰的依赖关系图。
  • 更快的构建与部署:只需重新构建有变更的模块。

何时拆分为模块

  • 当项目大到一个开发者难以把控(或 IDE 开始“卡顿”)。
  • 当项目边界清晰:coreuiutilsapiimpl 等。
  • 计划在其他项目中复用代码。
  • 存在仅部分项目需要的外部依赖。
  • 需要隐藏实现细节(算法、内部类)。

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 BB 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
  • 如果忘记 exportsrequires —— 将出现编译错误。

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”。

1
任务
JAVA 25 SELF, 第 60 级, 课程 3
已锁定
为事件日志开启通信通道 📢
为事件日志开启通信通道 📢
1
任务
JAVA 25 SELF, 第 60 级, 课程 3
已锁定
数字地址簿架构师 🗺️
数字地址簿架构师 🗺️
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION