什麼是好的程式碼?從 SOLID 的設計原則出發(三)依賴反轉原則 (Dependency Inversion Principle, DIP)

ChengYang
8 min readJul 23, 2022
澎湖星空

依賴反轉原則 (Dependency Inversion Principle, DIP)

“A. High-level modules should not depend on low-level modules. Both should depend on abstractions.

B. Abstractions should not depend upon details. Details should depend upon abstractions.”

  • 上篇我們得知 OCP 是良好軟體架構的目標。為了達成 OCP 、限制系統每次被修改時所影響的範圍,我們需要兩項實踐方法:
    1. 找出組件修改合理範圍的方法
    2. 能夠使組件間依賴關係的組織更有彈性的方法

組件間的修改合理範圍-分層 (Layering)

  • Grady Booch:「所有良好的結構化物件導向架構都有著清晰定義的層級,每個層級都透過定義良好並受控的介面來提供一些功能上一致的服務」。
  • 根據早期物件導向設計方法論與後期的 Onion Architecture (洋蔥架構) 與 Clean Architecture 建議,一般認為軟體系統架構約可分為 UI層 , 應用層 , 領域層 , 儲存層 以及 外部依賴等層

每個曾時都有專屬的職責(請看上圖)

  • UI 層/展示層:負責處理面向調用方的 request 和 response。
  • 應用層:負責業務需求 use case 流程的實現,極易隨著需求變動而修改。
  • 領域層:該業務核心資料結構與邏輯所在的層次,內容較為穩定。
  • 儲存層:會隨著資料實際儲存方式而變,也較易變動。

基於功能的一致性外,每個層次都可以再根據 SRP 所描述的業務能力單一性要求進行切分出實際的類別。像是:LoginController, UserService, AuthService, UserMapper 等。

現在我們知道怎麼找組件,也知道如何定義它們,有了這些組件後,關鍵的就是要如何「彈性的組織它們之間的依賴關係」

在早期的開發實踐中,開發人員習慣順著 control flow (控制流;即邏輯上程式碼的執行方向)撰寫程式碼、使用所依賴組件,因此「原始碼的依賴方向必然與控制流方向相同」。

最常見的就是我們有一個 Business Rule 組件 ,為了要對資料層存取,所以會引入 Storage 組件 ,並在 Business Rule 中的組件撰寫使用 Storage 相關的程式碼,這樣就很明顯是高層( Business 組件)開始對低層( Storage 組件)造成依賴了。

舉一個簡單的實際例子:我們有一個 UserAvatarService 主要可以透過 uploadAvatar(image:completion:) 來上傳 User 頭像圖片,然後使用 FirebaseStorage 做頭像圖片的儲存。

看似沒什麼問題,但是這段上傳照片的邏輯裡,使用了 FirebaseStorage 這個組件, UploadAvatarService 對它產生依賴,所以未來若 SDK 有調整,或者我們想要調整成其他 Server ,整段 Code 就需要調整,其實仔細想想,「上傳頭像」這個組件的角度來看從資料取得、圖片處理邏輯(轉換圖片方向、壓縮畫質)到上傳頭像的整個過程,和「該怎麼使用某個服務上傳圖片」有很大的關係嗎?

當然沒有很大的關係,仔細想就會發現這個業務主流程,對低層組件實現產生極大依賴,只要那個低層組件有修改調整,高層組件一定就會被影響。

那我們該怎麼做勒?

  • 我們需要一個能讓我們彈性地組織組件之間程式碼依賴的方法
  • 我們在實現組件時,將期望由其他組件提供的程式碼能力抽象為業務規則,並以介面的方法定義來表示這些業務規則
  • 接著,被依賴的組件再透過實作這些介面來滿足這些業務規則所需要的操作與細節。

使用這張圖來解釋的話,一開始我們的做法就像左邊這樣,高層組件直接使用低層組件,造成直接的依賴,而好的做法就是透過一個介面來隔離他們之間的依賴。

所以在回來我們剛剛的例子,開始做一些調整後

我們在 3–5 行新增了一個介面 (Uploadable),目的是來隔離他們之間的依賴(Business Rules 組件與 Storage 組件)。
所以 Confirm 這個 Protocol 的人就要實作它的功能,我們透過第 7 行,UploadService (低層 Storage 組件)來實作功能,這樣等同 UploadService 要依賴 Uploadable
最後可以看到 27 以及 36 行,在 27 行我們限制這個變數要有這個介面,因為我們想讓這層業務邏輯組件與介面依賴就好,後續假設換了 UploadService 實作方式(例如換了 SDK, 換其他 Server 什麼的...),也沒關係,業務邏輯組件可以完全不用修改,因為它只知道有這個介面會提供上傳圖片功能給它。

所以用這個範例來看,我們就能發現 Business Rules 組件的程式碼不再出現對 Storage 組件的具體操作及細節了,就實現程式碼依賴的解耦。

反而是 Storage 組件的實現現在需要滿足該介面的業務規則,因此原始碼層級的依賴關係現在被反轉了。(後續假設除了新增或刪除功能,例如影片上傳或檔案上傳…,這些事情 Business Rules 無需再關心,而是 Storage 組件需要關心了)。

所以依賴反轉的關鍵意義是:透過將組件的依賴轉移至介面/抽象類上,來避免組件之間出現程式碼級的依賴。

如此一來,開發人員即能控制、任意調整原始碼之間的依賴關係,而不再受到控制流的限制。最終能專注在開發功能明確,內聚的業務邏輯模組上。

小結

  • 為了達成 OCP (開放閉合原則) 「限制系統每次被修改時所影響的範圍」的目標,我們在設計系統架構時,需要 組件的切分方式能彈性調整組件之間依賴關係的
  • DIP (依賴反轉原則) 建議,如果要設計一個靈活的系統,程式碼的依賴關係就應該多使用介面(抽象層),而非具體實現
  • 識別出系統中較常變動的具體實現組件與較穩定的組件後,在妥善搭配 DIP 對這些組件的程式碼依賴進行解耦,即可有效抑制變更時發生的組件間變化擴散,達成 OCP 的目標。
  • DIP 是進行架構設計時的組織原則,也是 OCP 的關鍵實現方案。

我們可以發現先前講的「 SRP 單一職責原則」、「 OCP 開放閉合原則」和 DIP 依賴反轉原則」的關係是很緊密的。
關係圖如下:

--

--