Dependency Injection in Go

八月初的時候,我在 COSCUP2017 的 Golang 場分享了主題為 Dependency Injection in Go 的 talk (slide, video)。在這邊整理成一篇文章,後續若有新的見解會持續更新,也希望能夠幫助到對此議題感興趣的朋友們。

Introduction

在軟體設計領域,大家應該多少有耳聞 S.O.L.I.D 設計原則,而這個 SOLID 的最後一個 D 跟 Dependency Injection (DI) 是息息相關的,所以我們先來了解一下。

這個 D 代表的是 Dependency Inversion Principle (DIP),另一個常常與他形影不離的名詞 Inversion of Control (IoC) 相信大家也多少有看過。到底 DIP、IoC、DI 這三個名詞之間的關係是什麼呢?經過一番 survey 我覺得可以大致用下方這張圖來理解。

DIP, IoC and DI 關係圖
  • DIP (Dependency Inversion Principle): 建構低耦合軟體系統的建議原則
  • IoC (Inversion of Control): 將 DIP 應用在系統不同層面的各種具體方法
  • DI: 應用在物件依賴這個層面上的一種 IoC

DIP vs. IoC 可以理解為 戰術 vs. 戰略的對應關係,如上圖左右兩個大圈圈,而 DI 則是 IoC 的一個子集合。對這些名詞的關係有了大致的理解後,在後續的探討中才不至於迷失了方向。


接著,我們看一下維基百科對於 DIP 的解釋,兩句很有哲理的繞口令 😅。

High-level modules should not depend on low-level modules. Both should depend on abstractions. (高階模組不應依賴低階模組、兩者皆依賴抽象)
Abstractions should not depend on details. Details should depend on abstractions. (抽象不應依賴細節,細節應該依賴抽象)

高階?低階?抽象?細節?,當初看完這兩句話的我,心中滿是黑人問號。經過一番沈澱和歸納,其實這兩句話想傳達的是很簡短的兩個概念:1. Abstraction (抽象) 2. Inversion (依賴反轉)。而利用這兩個概念要怎麼設計出一個低耦合的系統呢?

在回答這個問題之前,我們要先回答為什麼我們需要低耦合,或者換句話說,高耦合到底帶來什麼樣的問題?

  1. Changes are risky (修改充滿風險)
  2. Testing is difficult (難以測試)
  3. Semantics is complex (可讀性低、理解不易)

舉個簡單的例子來感受一下,假設今天我們收到一個加密文件的需求: 從檔案讀出內容 -> 加密 -> 輸出加密結果至檔案,如下圖所示:

一個很直白的實現示意如下,Encryptor 類型有一個 Run 方法,接收 src , dst 兩個參數,分別代表輸入和輸出檔案的路徑:

如果需求不再更動,那這樣的實現當然沒有什麼問題。但世界不會完全按照個人的意願運行。假若今天,需求變動了,需要支援更多種類的輸入和輸出類型。如下圖:輸入端需要多支援可以從 database 讀資料,輸出端需要多支援將加密結果輸出到 webservice。

這時候,一種很高耦合的方式就是引入 switch 條件邏輯 (如下圖),針對不同的輸入和輸出類型,執行不同的程式邏輯。

讓我們把眼睛拉近一點,把上方的 readFromFilereadFromDatabase 方法拉出來看 (如下圖),不難想像這兩個方法各自會需要不一樣的參數,例如: readFromFile 需要 (x, y, z)readFromDatabase 需要 (i, j)

那麼我們要怎麼帶入這些參數呢,一種是透過類型 Encryptor 的成員帶入,另一種就是透過 Run 方法參數帶入。不管是哪一種,這都引入了依賴 (耦合)。意思是 EncryptorRun 方法現在變得依賴於不同 輸入類型讀取方法的實作

透過 Run 方法參數帶入

讓我們檢視一下上述耦合系統所帶來的缺點:

  1. Changes are risky (修改充滿風險)
    想像一下 Run 的調用方,假若今天又增加了許多輸入/輸出類型,那麼所有調用方都需要修改 (傳參變動 or 物件建構變動)。牽扯的層面越廣,風險越大。
  2. Testing is difficult (難以測試)
    今天要測試 Run 方法,我們要先準備好 輸入的檔案 or 資料庫,這些外部 infra 的依賴並非我們測試的本意 (加密結果是否正確、error handling 有沒有做好,才是測試的本意),但卻因為 Run 依賴於輸入讀取的實作,所以讓測試變得複雜且困難。
  3. Semantics is complex (可讀性低、理解不易)
    因為依賴於實作,所以 EncryptorRun 要想辦法滿足被其所調用 (低階) 的函數 readFromFilereadFromDatabase 。不管透過什麼方式,這都讓系統可讀性變低,更難一眼就看懂 (例: Run(srcType, dstType, x, y, z, i, j) 這樣的 function signature 語意不清)。

那麼,我們來看看 DIP 的兩個原則 1. Abstraction (抽象) 2. Inversion (依賴反轉) 怎麼改善上面的問題。

如上圖,我們從高階模組的角度 (也就是 EncryptorRun 方法) 來抽象化這個輸入讀取行為,在 Go 就是定義一個 IReader 接口,我們期望之後不管增加再多的輸入類型,Run 都只需要面對這個 IReader。原本是聽命於 readFromXXXRun 方法,現在反過來成為定規矩的角色,此為依賴反轉

原本我們直接透過 Encryptor 調用 readFromFilereadFromDatabase ,現在我們讓 IReader 成為 Run 方法的參數,也就是說這個抽象將由外部提供進來,此為依賴提供的反轉

實際 Run 的調用方程式碼大致如下:具體 fileReaderdbReader 實作了 IReader 抽象,在調用 Run 的時候,就可以根據我們的需求,帶入需要的實作。

我們再來 review 一下這樣低耦合的設計是否改善了先前的缺點:

  1. Changes are risky (修改充滿風險)
    Run 的調用方現在依賴於 IReaderIWriter 接口,要增加新的輸入輸出類型,只需要新增新的實作 (滿足這兩個接口) 即可,對 Run 的調用端邏輯幾乎沒有什麼影響。需求變動造成的修改風險相對比較小。
  2. Testing is difficult (難以測試)
    接口可以透過 mock 的方式來簡化測試對於外部 infra 的依賴,所以 Run 方法的測試只需要專注在 加密正確性 和 error handling 即可,變得較為容易。
  3. Semantics is complex (可讀性低、理解不易)
    Run(IReader, IWriter interface{}) 這樣的 function signature 讓人一目瞭然,不是嗎?

透過這個簡單的例子,我們檢視了 DIP 的兩個設計準則 1. 抽象 2. 依賴反轉 如何改善系統以達到低耦合的目的。接著我們來看一下 IoC 的部分,前面我們有提到 IoC 其實就是將 DIP 應用在軟體系統不同層面的各種具體方法,而 inversion of control 的這個 control,在不同層面當中的關注點我們舉幾個例子來看:

  1. The control of the interface
    這其實就是上述的範例,原本 Run 方法是被 readFromFilereadFromDatabase 所控制,而在我們應用了 DIP 這個控制被反轉了,變成資料讀取的實作要滿足 Run 方法制定的規則。
  2. The control of dependency creation and binding
    這在上述的例子也有出現,原本的實作是 Encryptor 直接擁有資料讀取的實作,也就是說 dependency creation and binding 的控制權在 Encryptor 身上。後來抽象化之後,我們將資料讀取的實作從 Run 方法的參數帶進去,其實就是將這個控制權轉給了 Run 方法的調用方。
  3. The control of flow (ex: procedural to event-driven)
    在 microservices 的設計典範中也能見到 IoC 的蹤跡,假設今天 A 模組透過 API 存取 B 模組提供的服務,某種程度來說即是 A 模組依賴於 B 模組 (即 A 必須滿足 B 所開出來的 API 接口)。若改採用 event-driven 的溝通方式,則反轉了這個控制,這個時候變成 A 制定事件訊息的格式,B subscribe 這個事件,怎麼解讀這個事件則是 B 的責任。這樣一來模組彼此間的耦合就降低了。

Dependency Injection

了解 DIP、IoC 之後,總算可以進入主題 DI 了,前面已經提過,DI 其實就是其中一種 IoC,即上述的 2. The control of dependency creation and binding

來看一個簡單的 dependency binding 例子,如下圖,我們有三個型別 FooBarWoo ,形成一個鏈形依賴關係。那麼,在準備 Foo 物件的時候,我們就需要先將 WooBar 給準備好,然後再建構 Foo

想像一下如果系統當中有上百個型別,且彼此之間的依賴關係是錯綜複雜的網狀,那麼建構物件的 boilerplate code 會是多麼複雜呢? DI framework 就是為了解決的問題。


今天和大家分享的是 facebookgo/inject 這個 injection framework,我針對自己的需求改了一個自己的版本 browny/inject。下面會以我的版本來介紹 DI framework 要怎麼改善上述的問題。

browny/inject 中的 inject_test.go 有一個簡單的範例,這個範例要建構的 dependency graph 如下圖所示:


如下,我們要建構 master 物件,但是透過 DI framework,現在不需要那些 dependency wiring 的 code 了。在一開始,我們先把系統中所需要的物件都先初始化出來。然後,在 depMap 裡面描述這個物件 (key of map) 會在哪裡 (value of map,有點類似地址的概念) 被需要。最後,將這個 depMap 放進 Weave 方法,DI framework 就會這些物件自動擺放到需要他的位置。

接著我們看一下這些類型的定義, MasterFood 成員後面帶了一串 inject:"example.Master.Food" 的 tag,這個其實就是上面提到的地址,也就是說 MasterFood 成員的實體會在 Weave 方法執行的時候,由 depMap 中擁有 example.Master.Food 的那個 key 物件,也就是 &farmer 所提供。

其實也沒有什麼 magic,只是原本物件的擺放是由建構方控制,透過 DI framework 將這個控制權反轉到物件的需求方。只要在後面加註一串地址,之後 DI framework 就會將對應的物件宅配到府,的確是一種 IoC 啊 ~


Conclusion

DI framework 雖然帶來了便利性,但也帶來一些缺點:

  1. DI framework dependent
    型別的封裝會受到影響,需要被 inject 的 member 都要是 public 的才行。
  2. Code is difficult to trace
    這其實不太算是 DI framework 的缺點,當系統高度抽象就會有這個問題。也就是實作和抽象的分離,原本 jump to definition 可以馬上跳到實作,但是現在只會跳到定義抽象的地方。
  3. Errors are pushed to run-time
    facebookgo/inject 是基於 reflection 機制的 DI framework,所以有一些錯誤會被延遲到 run-time 的時候才被察覺。

這些問題其實一定程度上是可以被改善的:

  1. DI framework dependent
    既然要享受便利,自然要承擔一些限制。若是開發一個目的很單純的 lib 那應該是不需要使用 DI framework。對外的物件,也提供 New 方法讓使用方可以不依賴於 DI framework。
  2. Code is difficult to trace
    好的風格可以改善這點。facebookgo/inject 其實可以用類型推導的方式 (implicit) 作 dependency binding,不過我還是 prefer 明確的 identifier binding,並採用比較明確的命名方式 <pkg>-<struct_name>-<interface_name> (ex: example.Master.Food)。這樣在 trace 某個抽象的實作是由誰提供的就比較清晰一些。
  3. Errors are pushed to run-time
    這部份沒想到什麼妙招,目前我是有寫 dependency binding 的測試,盡量讓一些物件初始化的錯誤及早被發現。

最後總結一下,這篇文章整理了 DIP、IoC 和 DI 三者之間的關聯,介紹了一個 DI framework,最後 review 了一下他所帶來的缺點以及改善的方式。如果對這個議題還想更深入了解的話,推薦大家一篇文章 Dependency Injection is EVIL

Like what you read? Give Browny Lin a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.