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
- IDEA 在建立 Java 模組時能自動產生 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