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 B,而 Brequires 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

  • 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」。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION