書籍 依賴注入:原理、實作與設計模式——Steven van Deursen, Mark Seemann
是一套軟體設計的理論與模式,是一種為了開發出具備鬆耦合特性程式碼的手段
以鬆耦合架構撰寫,可提升程式碼的可擴展、可維護性
要針對介面編寫程式,而不是實作
——《Design Patterns: Elements of Reusable Object-Oriented Software》
由外部注入相依物件,而不是在類別內部創建它們;
簡單來說,就是注入外部相依來實現依賴反轉的過程
雖然依賴注入定義上並不限於使用抽象或介面,但書中提到的各式實踐方法都是以注入介面來說明
在 DI 的過程中,可以應用這些設計模式來協助解決問題
廉價旅館的吹風機,電線直接連接在牆上:若吹風機壞掉得請專業水電工來處理
只要插頭(實體類別)與插座(介面)能夠搭配在一起(介面實作)
且電器能承受其電壓及頻率大小(遵循介面設計)那麼就能使用各種不同的電器
同樣使用插頭插座這組機制,可以無痛將吹風機拔起來換成接電腦;
即使什麼也不連接,插座或電器都不會因此而爆炸
這就是「里氏替換原則」
👉 里氏替換原則 Liskov Substitution Principle
不需更動介面便可以實作不同方法
子型態必須遵從父型態的行為進行設計,所以對介面的實作應該要是能替換的,而且不需要破壞客戶端的介面或實作
保護蓋本身不具電線或電器等實作,但形狀吻合於插座
可以隨時被蓋上或拿掉而不會造成任何問題
可用於防止移除服務後客戶端出現異常例外
用另一個實作來攔截同樣的介面實作,能在不更動現有程式碼的情況下,逐次新增功能或是在流程中以橫切的方式加入其他邏輯
電腦與 UPS 分別有著各自的「單一職責」兩者間不互相干預,UPS 也可以改運用其他電器上
👉 單一職責原則 Single Responsibility Priciple
每個類別都應該只負責一樣職責,要修改一個類別時應該只因為某一種特定理由
用一個共通的介面,將既有的實作重構並依次加入新的實作,把數個實作整合為同一個
連接在延長線上的電器可以自由拔插;在合成模式下,只要調整所組裝的介面實作群就能輕易的新增或移除功能
利用一個能與兩種不同的介面相合的轉接中介,將兩個無法直接契合的介面連接起來
通常用於轉換第三方API為自身所需的介面
👉 開放封閉原則 Open/Closed Principle
不修改既有程式碼的前提下,對應用程式進行擴展
好處 | 說明 | 效益情境 |
---|---|---|
晚期繫結 | 可在不需要重新編譯程式的情況下,進行服務的替換 | 有著特定執行環境的企業級應用除外 |
可擴展性 | 可以重新利用程式碼並作擴展,達到最初設計中沒有預計的功能 | any |
平行開發 | 開發工作可以平行進行 | 大型複雜的專案 |
可維護性 | 清楚劃分職責的類別,更加易於維護 | any |
可測試性 | 類別可被單元測試 | any |
繫結(Binding)有兩種不同的概念,分別為 早期 / 晚期,與語言特性有關
package main
import (
"fmt"
"math"
)
// Shape是一個形狀的接口,包含計算面積的方法
type Shape interface {
Area() float64
}
// Circle是圓形的結構體
type Circle struct {
Radius float64
}
// Area計算圓形的面積
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
// Rectangle是矩形的結構體
type Rectangle struct {
Width float64
Height float64
}
// Area計算矩形的面積
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
func main() {
// 創建一個Shape切片,並將Circle和Rectangle的實例加入其中
shapes := []Shape{
Circle{Radius: 5},
Rectangle{Width: 3, Height: 4},
}
// 使用晚期繫結,根據每個形狀的實際類型進行計算面積
for _, shape := range shapes {
fmt.Printf("形狀的面積為: %.2f\n", shape.Area())
}
}
如果是直接針對兩種形狀個別去 call 不同方法,就會是早期繫結
在不修改太多既有程式碼的前提下,具備可以隨時增加新功能或擴展既有功能的高彈性。前述的幾個設計模式包含在內
如:由外部注入連線物件或身分驗證方法
假設我們有一個 Logger 介面定義如下
// Logger 介面定義
type Logger interface {
Log(message string)
}
然後我們實作兩個 Logger 介面的具體類別
// ConsoleLogger 是 Logger 介面的實作,將日誌訊息輸出到控制台
type ConsoleLogger struct{}
func (cl ConsoleLogger) Log(message string) {
fmt.Println("Console Logger:", message)
}
// FileLogger 是 Logger 介面的實作,將日誌訊息寫入檔案
type FileLogger struct {
filePath string
}
func NewFileLogger(filePath string) *FileLogger {
return &FileLogger{filePath: filePath}
}
func (fl *FileLogger) Log(message string) {
file, err := os.OpenFile(fl.filePath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
fmt.Println("Failed to open log file:", err)
return
}
defer file.Close()
logMessage := fmt.Sprintf("File Logger: %s\n", message)
_, err = file.WriteString(logMessage)
if err != nil {
fmt.Println("Failed to write log:", err)
return
}
}
接著,我們可以定義一個需要 Logger 依賴的服務
type MyService struct {
logger Logger
}
func NewMyService(logger Logger) *MyService {
return &MyService{logger: logger}
}
func (ms *MyService) DoSomething() {
// 假設這裡有一些邏輯需要寫入日誌
ms.logger.Log("Doing something...")
}
現在,我們可以在主程式中進行依賴注入
func main() {
// 使用 ConsoleLogger 來實例化 MyService
consoleLogger := ConsoleLogger{}
myService := NewMyService(consoleLogger)
myService.DoSomething()
// 使用 FileLogger 來實例化 MyService
fileLogger := NewFileLogger("logfile.txt")
myService = NewMyService(fileLogger)
myService.DoSomething()
}
使用依賴注入將不同的 Logger 介面的實作注入到 MyService 中,這樣 MyService 就可以根據注入的 Logger 來實現不同的日誌處理方式
因為降低了功能間的直接依賴,且類別間有著明確的分野,就能同時對各自的程式進行開發
通常每個團隊都會負責其中一個工作職責劃分(模組),最後將這些模組組裝進最終的產品中,但當中多少還是會有相依性存在
由於遵守單一職責原則以及開放封閉原則,在職責定義明確的情況下偵錯也變得容易
指得是能夠進行單元測試(unit testing)
便於替換掉外部依賴的關係對象,對待測對象的依賴性關係進行替換、攔截或是模擬,即使是整合測試也一樣
假設使用一份標準的 MVC 架構,三層之間互相耦合,箭頭為依賴方向
展示層會直接 call 業務層的 function (或 new 出業務層的 class) 來用
業務層會直接 call 資料層的 function 來用,並引用資料層的型別
假設兩種情境
換掉展示層
Q: 想把展示層整個換掉,用其他種框架寫
A: 不會有什麼問題,因為沒有任何模組依賴於網頁展示層,因此可以輕易地替換掉
換掉資料層
Q: 想換一套資料庫來用,連線與存取方式都要換掉
A: 不可能在不動到其他層的情況下去移除原有的資料層,因為依賴對象不存在會導致無法編譯
若是要逐步替換,也不能只修改資料層的程式,業務層必須一起動作,把依賴切換到新的資料層
從第二種情境的解答來看,這個緊耦合架構不具備上述鬆耦合架構的所有優點
鬆耦合的優點 | 具備 | 原因 |
---|---|---|
晚期繫結 |
Image Not Showing
Possible Reasons
|
無法按需載入所需的資料層 |
可擴展性 |
Image Not Showing
Possible Reasons
|
若要插入橫切關注點,需要大幅度修改程式 |
平行開發 |
Image Not Showing
Possible Reasons
|
各層間相依性強,容易在開發期間動到重疊的程式碼導致衝突 |
可維護性 |
Image Not Showing
Possible Reasons
|
程式隨著需求擴展後會變得愈來愈複雜,難以維護 |
可測試性 |
Image Not Showing
Possible Reasons
|
難以將外部依賴替換掉,單元測試無法進行 |
👉 橫切關注點 Cross-Cutting Concerns
是指與應用程式主要邏輯無直接關係,但影響多個模組或功能的機能需求或功能。這些橫切關注點通常是應用程式中的共用功能,例如日誌記錄、安全性驗證、性能監控、錯誤處理等
透過介面的抽換,實現依賴反轉
抽象介面的定義是掌握在作為使用方的模組中,而不是由實作方來負責
👉 依賴反轉 Dependency Inversion
模組之間的相依性應該由抽象來定義,而不是由具體實現來定義
👉 依賴注入?依賴反轉?
依賴注入是一種具體的實現方式,用於實現依賴反轉這一設計原則,
以達到程式碼解耦合和可測試性的目標
所有的依賴性關係都需要抽介面嗎?答案是不一定
例如引用一些基礎的官方或第三方函式庫時,並不會損及程式的耦合度,也就不需要設計為可抽換的模式
如何分辨哪些是安全的、哪些會導致耦合度縮緊,可以分為穩定(stable)或是不穩定(volatile)兩種依賴性關係
類別或模組 是可重複利用的功能,且
須依靠外部資源才能完成操作的功能,例如 資料庫、message queue、網路式服務或是系統檔案操作等,這類模組本身缺乏對晚期繫結與擴展性的支援,且無法被測試。
不穩定性依賴關係的對象,可能為如下狀況
明確區分類別間的職責,在 DI 架構中是重要的構成因素之一
每當從類別中抽離一份職責出來,就代表增加一條依賴性關係。這意味著類別交出了對該依賴關係的控制權,但對開發者來說,這只是控制權的轉移
將上圖簡化為依賴注入,演示嵌套組合成一個應用程式
物件本身也是一個類別或結構體
Golang 中並沒有類別(class)的概念,而是透過結構體(struct)去定義自訂型別來實現類似的作用
應用程式是由一個個獨立的類別所組成(把一台台電器連上插座)
可以隨需求變化,重新組合要使用的類別,而不用修改類別本身的內容(從插座上自由拔插電器)
為了達到物件的組合,需要將實體物件的建立作業,全部集中化到一處
通常被安排在靠近啟動點的地方,如 main()
👉 依賴注入 (DI) vs 控制反轉 (IoC)
在某些時候這兩個稱呼是可以互通的,但DI只能算是IoC某種情境下的一項意義而已
當物件從類別中抽離出來時,類別不僅喪失了要使用哪份物件的決定權,也失去了決定何時建立或令其失效的管理能力
為了不造成記憶體空間的負擔,物件何時被釋放(令其失效)是個重要的議題,大致有幾種情況
多個作用域共用一個物件
當元件不屬於執行緒安全時(如無狀態的服務、無法被更動的型別),持續持有同一份物件的參照,
並在每次請求時被反覆提供出去。除非整個組合器離開了作用域,否則這份物件永久有效
👉 執行緒安全(Thread Safety)
是指在多執行緒環境下,程式或程式碼能夠正確地處理並避免併發相關的問題,包括競爭條件(Race Condition)、死鎖(Deadlock)、活鎖(Livelock)、資源競爭(Resource Contention)等。
執行緒安全的設計確保在多個執行緒同時訪問共享的資源時,操作的結果是正確的且不會破壞資源的完整性
一個作用域有多個相同類型的物件
每次收到物件的請求時,都會提供一份全新的物件出去,大多會由垃圾回收機制協助廢棄物件
可能會有多個使用方分別持有多份相同依賴對象種類的物件
一個作用域只會有一個該類型的物件
各自平行處理的兩個獨立作業,有著各自的作用域
每個作用域中同一種類的依賴對象最多只有一份物件存在
👉 攔截 Interception
一種可以介入兩個協作元件之間的呼叫行為,以此強化或改變依賴對象,但又不需要修改兩個元件本身的內容
裝飾者設計模式是攔截機制的基礎,它可以像俄羅斯套娃一樣套用一層層的裝飾元件於實際的作業元件之上
範例程式碼
IGreeter greeter =
new NiceToMeetYouGreeterDecorator (
new TitledGreeterDecorator(
new FormalGreeter()));
string greet = greeter.Greet("Samuel L. Jackson");
Console.WriteLine(greet);
會輸出
舉例,有個關於下單流程的類別,需要進行的一連串作業如下
作業 | 依賴需求 |
---|---|
更新訂單 | IOrderRepository |
寄發收訖信件予客戶 | IMessageService |
告知會計系統開立發票 | IBillingSystem |
根據訂單中的品項以及運送目標位置,挑選適當的倉儲 | ILocationService |
將訂單中的品項分配給備選中的倉儲,處理揀貨與寄送 | IInventoryManagement |
該訂單系統類別的依賴關係圖如下,整個類別有違反單一職責原則的潛在可能
👉 前台模式 Facade Service
利用單一個抽象介面作為前台,把作業流程上互為緊密的依賴關係與其作業,隱藏於後台
思路為 確認哪些依賴在作業流程中互為依賴,將其做整合
ILocationService
的資料也正好是 IlnventoryManagement
所需要的,兩著在業務邏輯上緊密的相依可以將兩個依賴對象抽離出來,合併為一項
IMessageService
IBillingSystem
及剛抽出來的OrderFulfilment
三者擁有相同的特性替這類通知的作業定義一個通用的抽象介面,利用合成者模式將他們打包在一起,
取得轉達呼叫的效果
至此,注入數量從五個減至兩個