從 Dependency Injection 看 Go 的 Implicit Interface

fcamel
fcamel的程式開發心得
8 min readSep 6, 2019

Go 有許多有趣的設計。其中,implicit interface 是一個不直覺的取捨。摘錄 A Tour of Go 的說明:

A type implements an interface by implementing its methods. There is no explicit declaration of intent, no “implements” keyword.

Implicit interfaces decouple the definition of an interface from its implementation, which could then appear in any package without prearrangement.

對照 A Tour of Go 的範例碼比較好理解它的意思。

第一次看到時,有許多疑問?

  • 怎麼避免不小心誤套介面?
  • 怎麼避免實作方改變,造成無法套用介面?
  • 怎麼得知某個 type 到底實作那些介面?不會造成讀碼負擔嗎?

另一方面,十多年前曾聽聞 Dependency Injection,它也是不直覺的設計模式。巧妙的是,從可測性的觀點來看,兩者卻自然地搭在一起。

Dependency Injection

關於使用 Dependency Injection 的動機和幫助,Misko Hevery《How to Think About the “new” Operator with Respect to Unit Testing》是我看過最簡短又清楚的描述。這裡節錄他的結語:

Asking for your dependencies instead of constructing them withing the application logic is called “Dependency Injection” and is nothing new in the unit-testing world. But the reason why Dependency Injection is so important is that within unit-tests you want to test a small subset of your application. The requirement is that you can construct that small subset of the application independently of the whole system. If you mix application logic with graph construction (the new operator) unit-testing becomes impossible for anything but the leaf nodes in your application. Without Dependency Injection the only kind of testing you can do is scenario-testing, where you instantiate the whole application and than pretend to be the user in some automated way.

若覺得這段過於抽象,可看他原文列的程式碼。

Wikipedia 也有提供不錯的說明,以下用下圖說明。

出處: http://w3sdesign.com/?gr=u01&ugr=struct

一般來說,我們會在 Client 內部直接 new ServiceA1 和 ServiceB1,這樣讀寫程式碼都方便。但這個作法綁死了實作,想像 ServiceA1 會連網路和資料庫,於是測 Client 時需要準備網路和資料庫,導致很難測 Client。

所以解法是

  • Client 不能直接使用 ServiceA1,必須透過介面 ServiceA 使用它。
  • Client 不能自己生成 ServiceA1,讓測試碼有替換的機會。

因為 Client 不是自己生成相依的物性,而是被 Injector 注入的,所以稱這個模式為 Dependency Injection。一般來說可透過 constructor 或 setter 注入,我和 Martin Fowler 一樣偏好由 constructor 注入,比較好掌握物件的不變性。

那誰是 Injector?答案是另外一組程式。明確的區分 application logic 和 injectors 成兩組不同程式,然後在主程式裡使用 injectors 佈置好實作 application logic 的物件們。在測試的情況,測試碼兼作 Injector。

了解 DI 的效果後,可以明白 Dependency Injection 是良好可測性的必要條件之一。但使用 DI 會不易理解特定實作細節,增加處理特定 bug 或了解特定功能細節的負擔。所以也不是無腦地什麼都套 DI。要想清楚測試的目標。

Implicit Interface

大型專案適合用 strong typing language 開發。舉例來說,用 weak typing language 開發時,無法用工具輔助重構,連改個 method / member field 名稱都會怕怕的,test coverage 不夠很容易出事。反之,strong typing language 提供足夠資訊,像 Java / Go 都有輔助工具可正確地更改名稱。

使用 strong typing 時,介面是很重要的。我們都知道要相依於介面而不是實作。前面的例子裡, 若我們實作 Client,而 ServiceA1 是別的套件的類別,要怎麼辦呢?

用 Java 的話,可能的作法如下:

  • 作法 A: 到 ServiceA1 的套件新增 interface ServiceA,定義 ServiceA 的 methods,並且宣告 ServiceA1 實作 ServiceA。
  • 作法 B: 在 Client 所在的套件新增 interface ServiceA,寫個 wrapper 實作 ServiceA,然後在 wrapper 內呼叫 ServiceA1。

作法 A 需要改別的套件的程式,影響範圍較廣。若我們沒有原始碼或不方便更改原始碼,就只能用作法 B。但作法 B 需多寫 wrapper。兩者都會降低使用 DI 的意願。

Go 的 implicit interface 情況如何呢?Implicit interface 表示宣告類別時不會說明它實作了什麼介面,而是程式用介面指向物件時,編譯器會檢查是否符合資格,所以編譯時期可找出用錯的程式碼。

這樣有什麼好處?相較於 Java 的作法 A 或 B,Go 的作法只需在自己的套件裡定義 interface ServiceA,它的 methods 是 ServiceA1 的子集合 (我們需要的部份)。

由此可見,implicit interface 最小化使用 DI 的成本,使用任何第三方函式庫時,不用擔心相依別人的實作,在自己的套件定義介面為別人類別的子集合,然後用自己的介面即可。

這樣講有點抽象,看個實例。Writer 是常見的例子:

type Writer interface {
Write(p []byte) (n int, err error)
}

可以用 Writer 指向任何有實作 Write() 的類別 X 的物件,無須更動 X 的程式。例如 File 相關函式如下:

func Open(name string) (*File, error)File's methods:
func (f *File) Chdir() error
func (f *File) Chmod(mode FileMode) error
func (f *File) Chown(uid, gid int) error
...
func (f *File) Write(b []byte) (n int, err error)
func (f *File) WriteAt(b []byte, off int64) (n int, err error)
func (f *File) WriteString(s string) (n int, err error)

可以這麼寫:

var w Writer
w = os.Open("/path/to/my/file")
w.Write([]byte("hello"))

但在 Java 的情況,除了定義 Writer 外,還得改 X 的定義或自己加 wrapper 使用 X 實作 Writer 。

初看 implicit interface 會覺得很詭異,沒有限制實作介面的程式碼,萬一 ServiceA1 改了 ServiceA 用到的 method 的 signature,改 ServiceA1 的人可能不會察覺,那用到 ServiceA 的程式不就壞了?確實如此。此外,也無法防範指向剛好有同樣 methods 的物件。

然而,相較於防範這些錯誤,implicit interface 更看重減少模組之間的相依性,以及降低使用 interface 的成本來鼓勵大家多使用小介面 (一個介面只包含一兩個 methods)。如果 ServiceA1 真的改了 signature,影響範圍太廣,不方便改 ServiceA。到時再寫 wrapper 實作 ServiceA 並且改用 wrapper 也不遲。

相關文章

--

--