書籍 依賴注入:原理、實作與設計模式——Steven van Deursen, Mark Seemann ## 依賴注入(Dependency Injection) #### what 是一套軟體設計的理論與模式,是一種為了開發出具備鬆耦合特性程式碼的手段 #### why 以鬆耦合架構撰寫,可提升程式碼的可擴展、可維護性 > 要針對介面編寫程式,而不是實作 > ——《Design Patterns: Elements of Reusable Object-Oriented Software》 #### how 由外部注入相依物件,而不是在類別內部創建它們; 簡單來說,就是注入外部相依來實現依賴反轉的過程 雖然依賴注入定義上並不限於使用抽象或介面,但書中提到的各式實踐方法都是以注入介面來說明 ``` mermaid graph LR 組合根 -. 建立 .->物件 組合根 -. 建立&使用 .->類別 物件 --實作--> id1[介面] 類別 -. 使用 .->id1[介面] style id1 stroke-dasharray:4 ``` ## 與 DI 有關的設計模式 在 DI 的過程中,可以應用這些設計模式來協助解決問題 #### 緊耦合 -> 吹風機與牆壁 廉價旅館的吹風機,電線直接連接在牆上:若吹風機壞掉得請專業水電工來處理 ![](https://hackmd.io/_uploads/Hy-3G-Loh.png) #### 鬆耦合 -> 吹風機與插座 只要插頭(實體類別)與插座(介面)能夠搭配在一起(介面實作) 且電器能承受其電壓及頻率大小(遵循介面設計)那麼就能使用各種不同的電器 ![](https://hackmd.io/_uploads/BJ1vfZIi2.png) 同樣使用插頭插座這組機制,可以無痛將吹風機拔起來換成接電腦; 即使什麼也不連接,插座或電器都不會因此而爆炸 這就是「里氏替換原則」 ![](https://hackmd.io/_uploads/ryce8WIs3.png) :::success 👉 **里氏替換原則 Liskov Substitution Principle** 不需更動介面便可以實作不同方法 子型態必須遵從父型態的行為進行設計,所以對介面的實作應該要是能替換的,而且不需要破壞客戶端的介面或實作 ::: ### 電線對設計模式的比喻 #### 空值物件模式(Null Object) -> 插座保護蓋 保護蓋本身不具電線或電器等實作,但形狀吻合於插座 可以隨時被蓋上或拿掉而不會造成任何問題 可用於防止移除服務後客戶端出現異常例外 ![](https://hackmd.io/_uploads/HJxF2MIoh.png) #### 裝飾者模式(Decorator)-> 不斷電保護裝置(UPS) 用另一個實作來攔截同樣的介面實作,能在不更動現有程式碼的情況下,逐次新增功能或是在流程中以橫切的方式加入其他邏輯 電腦與 UPS 分別有著各自的「單一職責」兩者間不互相干預,UPS 也可以改運用其他電器上 ![](https://hackmd.io/_uploads/rkumeQIs2.png) :::success 👉 **單一職責原則 Single Responsibility Priciple** 每個類別都應該只負責一樣職責,要修改一個類別時應該只因為某一種特定理由 ::: #### 合成模式(Composite)-> 延長線 用一個共通的介面,將既有的實作重構並依次加入新的實作,把數個實作整合為同一個 連接在延長線上的電器可以自由拔插;在合成模式下,只要調整所組裝的介面實作群就能輕易的新增或移除功能 ![](https://hackmd.io/_uploads/Bk-ZEQUih.png) #### 轉接器模式(Adapter) -> 插頭轉接器 利用一個能與兩種不同的介面相合的轉接中介,將兩個無法直接契合的介面連接起來 通常用於轉換第三方API為自身所需的介面 ![](https://hackmd.io/_uploads/r1cQLQLi3.png) :::success 👉 **開放封閉原則 Open/Closed Principle** 不修改既有程式碼的前提下,對應用程式進行擴展 ::: ## 鬆耦合的優點 | 好處 | 說明 | 效益情境 | | -------- | -------- | -------- | | 晚期繫結 | 可在不需要重新編譯程式的情況下,進行服務的替換 | 有著特定執行環境的企業級應用除外 | | 可擴展性 | 可以重新利用程式碼並作擴展,達到最初設計中沒有預計的功能 | any | | 平行開發 | 開發工作可以平行進行 | 大型複雜的專案 | | 可維護性 | 清楚劃分職責的類別,更加易於維護 | any | | 可測試性 | 類別可被單元測試 | any | ### 繫結 繫結(Binding)有兩種不同的概念,分別為 早期 / 晚期,與語言特性有關 #### 早期繫結(Early Binding) * 也稱為靜態繫結或編譯時繫結 * 在編譯時刻已經確定了要調用的方法 * 速度較快 * 語言:C++、Java 等 #### 晚期繫結(Late Binding) * 也稱為動態繫結或運行時繫結 * 在運行時根據實際對象的類型來調用相應的方法 * 速度較慢 * 靈活性高 * 實現多型(Polymorphism) * 語言:Javescript、Golang等 #### Golang 範例 - 晚期繫結 ```go 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 不同方法,就會是早期繫結 ### 可擴展性 在不修改太多既有程式碼的前提下,具備可以隨時增加新功能或擴展既有功能的高彈性。前述的幾個設計模式包含在內 如:由外部注入連線物件或身分驗證方法 #### Golang 範例 - 注入 log 假設我們有一個 Logger 介面定義如下 ```go // Logger 介面定義 type Logger interface { Log(message string) } ``` 然後我們實作兩個 Logger 介面的具體類別 ```go // 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 依賴的服務 ```go type MyService struct { logger Logger } func NewMyService(logger Logger) *MyService { return &MyService{logger: logger} } func (ms *MyService) DoSomething() { // 假設這裡有一些邏輯需要寫入日誌 ms.logger.Log("Doing something...") } ``` 現在,我們可以在主程式中進行依賴注入 ```go 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 架構,三層之間互相耦合,箭頭為依賴方向 ``` mermaid graph TD 展示層--> 業務層 -->資料層 ``` ``` 展示層會直接 call 業務層的 function (或 new 出業務層的 class) 來用 業務層會直接 call 資料層的 function 來用,並引用資料層的型別 ``` 假設兩種情境 1. **換掉展示層** Q: 想把展示層整個換掉,用其他種框架寫 A: 不會有什麼問題,因為沒有任何模組依賴於網頁展示層,因此可以輕易地替換掉 2. **換掉資料層** Q: 想換一套資料庫來用,連線與存取方式都要換掉 A: 不可能在不動到其他層的情況下去移除原有的資料層,因為依賴對象不存在會導致無法編譯 若是要逐步替換,也不能只修改資料層的程式,業務層必須一起動作,把依賴切換到新的資料層 <br> 從第二種情境的解答來看,這個緊耦合架構不具備上述鬆耦合架構的所有優點 | 鬆耦合的優點| 具備 | 原因 | | -------- | --- |-------- | | 晚期繫結 | :x: |無法按需載入所需的資料層 | | 可擴展性 | :x: |若要插入橫切關注點,需要大幅度修改程式 | | 平行開發 | :x: |各層間相依性強,容易在開發期間動到重疊的程式碼導致衝突 | | 可維護性 | :x: |程式隨著需求擴展後會變得愈來愈複雜,難以維護 | | 可測試性 | :x: |難以將外部依賴替換掉,單元測試無法進行 | :::info 👉 **橫切關注點 Cross-Cutting Concerns** 是指與應用程式主要邏輯無直接關係,但影響多個模組或功能的機能需求或功能。這些橫切關注點通常是應用程式中的共用功能,例如日誌記錄、安全性驗證、性能監控、錯誤處理等 ::: ### 改為鬆耦合架構,實現依賴反轉 透過介面的抽換,實現依賴反轉 抽象介面的定義是掌握在作為使用方的模組中,而不是由實作方來負責 ``` mermaid graph LR 資料層--實作--> 資料層介面 subgraph 業務層 業務核心-.使用.-> 展示層介面 業務核心-.使用.-> 資料層介面 end 展示層--實作--> 展示層介面 ``` :::success 👉 **依賴反轉 Dependency Inversion** 模組之間的相依性應該由抽象來定義,而不是由具體實現來定義 ::: :::info 👉 **依賴注入?依賴反轉?** 依賴注入是一種具體的實現方式,用於實現依賴反轉這一設計原則, 以達到程式碼解耦合和可測試性的目標 ::: ## 判斷適不適合注入 所有的依賴性關係都需要抽介面嗎?答案是不一定 例如引用一些基礎的官方或第三方函式庫時,並不會損及程式的耦合度,也就不需要設計為可抽換的模式 如何分辨哪些是安全的、哪些會導致耦合度縮緊,可以分為穩定(stable)或是不穩定(volatile)兩種依賴性關係 ### 穩定性依賴 ==類別或模組== 是可重複利用的功能,且 * 不需要額外準備或安裝 * 即使版本更新也不會造成程式運作異常 * 經過驗證測試有著可預期的行為模式 * 你不會想將它用其他的類別或模組,進行替換、包裝、裝飾或是中介攔截 ### 不穩定性依賴 須依靠外部資源才能完成操作的功能,例如 資料庫、message queue、網路式服務或是系統檔案操作等,這類模組本身缺乏對晚期繫結與擴展性的支援,且無法被測試。 ==不穩定性依賴關係的對象==,可能為如下狀況 * 尚在開發中 * 不是所有專案都能直接安裝並使用它,需在運行環境內安裝別的第三方服務支援 * 例如 redis 連線庫,必須要有實際存在的 redis 服務才能連 * 有著無法被預期的行為模式 * 例如根據日期或時間產出隨機數 ## DI 在軟體中涉及的範疇 明確區分類別間的職責,在 DI 架構中是重要的構成因素之一 每當從類別中抽離一份職責出來,就代表增加一條依賴性關係。這意味著類別交出了對該依賴關係的控制權,但對開發者來說,這只是控制權的轉移 ``` mermaid graph LR 組合根 -. 建立 .->物件 組合根 -. 建立&使用 .->類別 物件 --實作--> id1[介面] 類別 -. 使用 .->id1[介面] style id1 stroke-dasharray:4 ``` 將上圖簡化為依賴注入,演示嵌套組合成一個應用程式 ``` mermaid flowchart newLines["類別 or 物件"] subgraph app 物件-- 依賴注入 --> newLines -- 依賴注入 -->類別 end ``` <u>物件本身也是一個類別或結構體</u> > Golang 中並沒有類別(class)的概念,而是透過結構體(struct)去定義自訂型別來實現類似的作用 ### 物件的組合 應用程式是由一個個獨立的類別所組成(把一台台電器連上插座) 可以隨需求變化,重新組合要使用的類別,而不用修改類別本身的內容(從插座上自由拔插電器) #### 組合根 Composition Root 為了達到物件的組合,需要將實體物件的建立作業,全部集中化到一處 通常被安排在靠近啟動點的地方,如 `main()` ![s](https://hackmd.io/_uploads/ry39C6uon.png =400x200) <br> :::info 👉 **依賴注入 (DI) vs 控制反轉 (IoC)** 在某些時候這兩個稱呼是可以互通的,但DI只能算是IoC某種情境下的一項意義而已 ::: ### 物件的生命週期 當物件從類別中抽離出來時,類別不僅喪失了要使用哪份物件的決定權,也失去了決定何時建立或令其失效的管理能力 #### 處理依賴廢棄的方法 為了不造成記憶體空間的負擔,物件何時被釋放(令其失效)是個重要的議題,大致有幾種情況 1. 由於物件是**可以**共用的,若沒有任何一方可以完全掌握物件的生命週期,可以仰賴垃圾回收機制去處理,離開作用域後就會自行失效 2. 物件有實作可廢棄介面,可以透過釋放(Release)去廢棄它,一般建議在組合根建立的就於組合根去做釋放 3. 物件的生命週期型態較短,使用完畢後可以馬上廢棄它 #### 生命週期的型態彙整 ##### 單例型態 *多個作用域共用一個物件* 當元件不屬於執行緒安全時(如無狀態的服務、無法被更動的型別),持續持有同一份物件的參照, 並在每次請求時被反覆提供出去。除非整個組合器離開了作用域,否則這份物件永久有效 :::info 👉 **執行緒安全(Thread Safety)** 是指在多執行緒環境下,程式或程式碼能夠正確地處理並避免併發相關的問題,包括競爭條件(Race Condition)、死鎖(Deadlock)、活鎖(Livelock)、資源競爭(Resource Contention)等。 執行緒安全的設計確保在多個執行緒同時訪問共享的資源時,操作的結果是正確的且不會破壞資源的完整性 ::: ##### 一次性型態 *一個作用域有多個相同類型的物件* 每次收到物件的請求時,都會提供一份全新的物件出去,大多會由垃圾回收機制協助廢棄物件 可能會有多個使用方分別持有多份相同依賴對象種類的物件 ##### 作用域型態 *一個作用域只會有一個該類型的物件* 各自平行處理的兩個獨立作業,有著各自的作用域 每個作用域中同一種類的依賴對象最多只有一份物件存在 ### 物件的攔截 :::info 👉 **攔截 Interception** 一種可以介入兩個協作元件之間的呼叫行為,以此強化或改變依賴對象,但又不需要修改兩個元件本身的內容 ::: ``` mermaid graph LR subgraph 被攔截的服務 使用方-. 使用 .->攔截程式-.使用.->服務 end subgraph 使用方直接與服務互動 -使用方-- 使用 -->-服務 end ``` #### 裝飾者模式 裝飾者設計模式是攔截機制的基礎,它可以像俄羅斯套娃一樣套用一層層的裝飾元件於實際的作業元件之上 範例程式碼 ```c# IGreeter greeter = new NiceToMeetYouGreeterDecorator ( new TitledGreeterDecorator( new FormalGreeter())); string greet = greeter.Greet("Samuel L. Jackson"); Console.WriteLine(greet); ``` 會輸出 ![](https://hackmd.io/_uploads/rytSsCFs2.jpg =500x180) ## 程式異樣 ### 過多的依賴 舉例,有個關於下單流程的類別,需要進行的一連串作業如下 | 作業 | 依賴需求 | | -------- | -------- | | 更新訂單 | IOrderRepository | | 寄發收訖信件予客戶 | IMessageService | | 告知會計系統開立發票 | IBillingSystem | | 根據訂單中的品項以及運送目標位置,挑選適當的倉儲 | ILocationService | | 將訂單中的品項分配給備選中的倉儲,處理揀貨與寄送 | IInventoryManagement | 該訂單系統類別的依賴關係圖如下,整個類別有違反單一職責原則的潛在可能 ```mermaid graph TD OrderService-.->IOrderRepository:::c OrderService-.->IMessageService:::c OrderService-.->IBillingSystem:::c OrderService-.->ILocationService:::c OrderService-.->IInventoryManagement:::c classDef c stroke-dasharray:4 ``` #### 以前台模式重構 :::info 👉 **前台模式 Facade Service** 利用單一個抽象介面作為前台,把**作業流程**上互為緊密的依賴關係與其作業,隱藏於後台 ::: 思路為 ==確認哪些依賴在作業流程中互為依賴==,將其做整合 1. 挑選倉儲後便是要通知倉儲來處理訂單,`ILocationService` 的資料也正好是 `IlnventoryManagement` 所需要的,兩著在業務邏輯上緊密的相依 可以將兩個依賴對象抽離出來,合併為一項 ```mermaid flowchart OrderService-.->IOrderRepository:::c OrderService-.->IMessageService:::c OrderService-.->IBillingSystem:::c OrderService-.->B[OrderFulfilment] A[OrderFulfilment]-.->ILocationService:::c A[OrderFulfilment]-.->IInventoryManagement:::c B[OrderFulfilment]-- implement ---A[OrderFulfilment] classDef c stroke-dasharray:4 style B stroke-dasharray:4 ``` 2. 將訂單資訊分發通知至其他系統,`IMessageService` `IBillingSystem` 及剛抽出來的`OrderFulfilment` 三者擁有相同的特性 替這類通知的作業定義一個通用的抽象介面,利用合成者模式將他們打包在一起, 取得轉達呼叫的效果 ```mermaid flowchart OrderService-.->IOrderRepository:::c OrderService-.->INotificationService:::c INotificationService:::c-- 0..* ---newLines newLines-->INotificationService:::c classDef c stroke-dasharray:4 newLines["Composite-NotificationService 0~多個元件組裝"] ``` 至此,注入數量從五個減至兩個