軟體設計

【模組化 #2】模組相依設計總整理:循環相依為何產生、如何切斷

上一篇文章將模組化整理為「把一起變動的東西歸在一起,建立邊界」。

閱讀 4 分鐘
【模組化 #2】模組相依設計總整理:循環相依為何產生、如何切斷 封面圖

上一篇文章將模組化整理為「把一起變動的東西歸在一起,建立邊界」。

但拆分模組後,真正的問題才開始。拆出的片段會開始互相參照。

A 使用 B,B 使用 C,某天 C 又使用 A。三者實際上變成同一個整體,模組化的優點也就全部消失了。

先說結論:模組相依設計有兩項原則。讓相依方向維持單向,並讓經常變動的一方相依於穩定的一方。

本文整理決定相依方向的標準、循環相依的典型形成路徑,以及三種切斷方法。


相依方向應該往哪裡流動?

相依性具有方向。模組 A import 模組 B 時,A 就相依於 B。

箭頭應該指向哪裡,有一個經典答案。

不穩定的東西應相依於穩定的東西。反過來時,小幅變更就會擴散到整個系統。

穩定模組是不常變動的模組,例如領域模型與共用協定。

不穩定模組是經常變動的模組,例如畫面(UI)及隨需求持續修改的事件處理邏輯。

因此,健康的相依圖通常會呈現以下方向。

  • 畫面・功能模組 → 領域模組 → 共用介面模組
  • 箭頭只往下方的穩定側流動,不會逆向往上。

Robert C. Martin 將此稱為穩定相依原則(SDP,Stable Dependencies Principle)。


循環相依為何產生?

循環相依多半不是出於惡意,而是很自然地形成。典型路徑如下。

Order 模組參照 User 模組,因為訂單需要訂購者資訊。

一段時間後,需求要求在會員畫面顯示「最近的訂單清單」。User 模組一參照 Order 模組,循環就完成了。

Order 與 User 互相呼叫時,就會形成這樣的迴圈。
Order 與 User 互相呼叫時,就會形成這樣的迴圈。

出現循環後,三件事會崩潰。

  1. 建置單位分離失敗:編譯器無法分別處理兩者,實際上會變成一個模組(Swift 會直接以編譯錯誤禁止模組間循環 import)。
  2. 無法獨立測試:測試 A 需要 B,測試 B 又需要 A,形成死結。
  3. 無法預測影響範圍:無論修改哪一方,都必須重新檢查另一方。

如何切斷循環相依?

實務上主要有三種方法。

第一,把共用片段往下移。

如果雙方真正需要的是「對方的一部分」,就把那部分抽取到更穩定的下層模組。例如只把 Order 與 User 共同需要的型別移到領域模型模組。

第二,透過介面反轉方向(DIP)。

將一側的相依替換為協定。User 模組只定義「提供訂單清單的某個東西」這個協定,實作則由 Order 模組負責。

// User 模組:只以協定宣告所需能力
public protocol OrderHistoryProviding {
    func recentOrders(of userID: String) -> [OrderSummary]
}

// Order 模組: User 採用模組的協定並實作
public struct OrderHistoryProvider: OrderHistoryProviding {
    public func recentOrders(of userID: String) -> [OrderSummary] {
        // 查詢訂單儲存庫
    }
}

如此一來只剩下 Order → User 的單向箭頭。上一篇 DIP 文章介紹的相依反轉,就原樣套用在模組層級。

第三,把組裝往上移。

不要直接連接兩個模組,而由同時了解兩者的上層模組(App target、組裝層)負責連接。功能模組需要互相呼叫來進行畫面切換時特別有用。

情境 切斷方法
雙方只需要對方部分型別 將共用型別抽取到下層模組
一方呼叫另一方的功能 定義協定後反轉相依性
功能模組之間進行畫面切換 在上層組裝層連接

何時適用,何時會過度?

管理相依方向也有成本,因為會增加協定與組裝程式碼。

  • 如果有 3~4 個以上模組且團隊已分組,值得將方向規則文件化並用工具監控循環。
  • 如果是只有 2 個模組的小型專案,只要避免循環即可。把所有參照都包成協定會過度設計。
  • 如果是已有循環的舊系統,不要一次全部切斷,先從最常變動的模組整理箭頭。

面試時會這樣問

Q. 為什麼模組間循環相依是問題?你會如何解決?

循環會讓兩個模組在建置、測試、部署上成為一體,模組化的優點因而消失。可抽取共用型別至下層模組、定義協定反轉相依方向(DIP),或在上層組裝層連接兩個模組,將箭頭整理為單向。

Q. 請說明穩定相依原則(SDP)。

模組只能相依於比自己更穩定(變更較少)的模組。若經常變動的模組位於相依關係的下游,變更就會傳遍整個上游,因此應將低變更頻率的領域與介面模組放在圖的下方。

有時候連切斷一支箭頭都需要開會。
有時候連切斷一支箭頭都需要開會。

下一篇將介紹許多團隊在模組化過程中會掉入的陷阱:光是名稱就很危險的 Common 模組。

我們會探討「反正是共用的,就放進 Common 吧」如何讓整個模組再次變成一個整體,以及如何用分層結構預防。