蒼時弦也
蒼時弦也
資深軟體工程師
發表於

wire 的依賴注入 - Clean Architecture in Go

這篇文章是 Clean Architecture in Go 系列的一部分,你可以透過 Leanpub 提前閱讀內容。

在實踐 Clean Architecture 的過程中,如果有依賴注入工具的協助會容易很多,初期的效果並不明顯,然而隨著系統成長,要建立的物件逐漸變多後,就會發現能夠自動處理依賴、生成物件是非常重要的,在 Golang 可以使用 google/wire 來協助我們。

wire 的特性

跟常見的依賴注入框架不同的地方在於,wire 選擇在編譯階段就決定好所有依賴,因此我們不會需要在運行時透過反射(Reflect)機制來推斷某個物件所依賴的物件型別,並且動態的生成這個物件。

因為這樣的特性,大多數時候我們能夠在運行測試之前就注意到依賴的問題盡快修正。然而更大的優點則是,我們會發現在大多數情境中,除了領域模型(Domain Model)之外的物件,幾乎是很少需要重新產生的,那麼就能在初始化階段決定好,在記憶體的使用上也會相對的有效率不少,也不需要額外耗費 CPU 來處理反射相關的行為。

當然,這樣的機制會失去一部分的談行,然而在大多數情境已經非常足夠。

基本使用

使用 wire 並不複雜,最常見的是對建構物件的方法進行注入,假設我們希望運行一個 HTTP 伺服器,可以定義一個 wire.go 來描述 http.Handler 如何被生成。

 1package main
 2// ...
 3
 4func initialize() http.Handler {
 5	wire.Build(
 6	    usecase.DefaultSet,
 7	    api.DefaultSet
 8	)
 9
10	return nil
11}

呼叫 wire . 找到 main 套件下的 wire.go 後,會自動產生 wire_gen.go 這個檔案,此時內容可能會像這樣

 1package main
 2// ...
 3
 4func initialize() http.Handler {
 5	helloUseCase := usecase.NewHello()
 6	// ...
 7
 8	return &api.Handler{
 9	    HelloUseCase: helloUseCase,
10	    // ...
11	}
12}

那麼,在 main.go 中,我們就能直接用 initialize() 來產生我們預期的 http.Handler 而不需要知道 api.Handler 這個實作需要哪些物件。

1package main
2// ...
3
4func main() {
5    handler := initialize()
6    http.ListenAndServe(":8080", handler)
7}

那麼,在 api 套件中,我們會用 wire.NewSet 定義 api.Handlerhttp.Handler 的關係。

1package api
2// ...
3
4var DefaultSet = wire.NewSet(
5    wire.Struct(new(Handler), "**")), // 對 Handler 所有 Public Field 注入
6    wire.Bind(new(http.Handler), new(*Handler)), // 表示 api.Handler 是 http.Handler 的實作
7)
8
9// ...

至於 usecase 套件,則是使用 Provider 的方式定義。

1package usecase
2// ...
3
4var DefaultSet = wire.NewSet(
5    NewHello, // 定義 Hello{} 由 NewHello() 產生
6    // ...
7)

剩下的處理 wire 會自動根據定義產生對應的實作,在經驗上通常只會在 main 套件會有 wire.go 的存在,其他被引用的套件最多只是提供這個套件會怎麼提供物件給依賴他的物件。

測試環境

上述我們只討論到生產環境的情境,然而當我們要提供測試替身(Test Double)的時候,就可以利用不同的 Set(組合)來實現這樣的變化。

舉例來說,我們有一個 usecase.OrderRepository 的介面存在,用於描述 Use Case 中如何存取訂單。

1package usecase
2// ...
3
4type OrderRepository interface {
5    Find(ctx context.Context, id string) (*entity.Order, error)
6    // ...
7}

為了滿足這個需求,我們會在 repository 套件中進行對應的實作,像是 PostgreSQL 的版本。

 1package repository
 2// ...
 3
 4var DefaultSet = wire.NewSet(
 5    NewPostgresOrders,
 6    // ...
 7)
 8
 9type PostgresOrders struct {
10    // ...
11}
12
13// ...

那麼,假設我們希望在測試環境使用 SQLite 來簡化測試,此時只需要定義一個新的 Set 來處理即可。

1package repository
2// ...
3
4var SQLiteSet = wire.NewSet(
5	NewSQLiteOrders,
6	// ...
7)

在原本的 wire.go 中,我們可以另外定義一個 initializeTest() 方法,來參考不同的依賴。

 1package main
 2// ...
 3
 4func initializeTest() {
 5	wire.Build(
 6		repository.SQLiteSet,
 7		usecase.DefaultSet,
 8		api.DefaultSet
 9	)
10
11	return nil
12}

這樣產生的物件就會跟 repository.DefaultSet 不同,那麼只需要在測試時使用 initializeTest() 來取得 api.Handler 即可。

在 wire 官方的範例中,這個技巧也可以搭配 Command Line Flags 來做到切換不同雲端實作的機制。

 1package main
 2// ...
 3
 4func main() {
 5	// ...
 6	var cloud string
 7	flag.StringVar(&cloud, "cloud", "aws", "the cloud provider")
 8
 9	var handler http.Handler
10	switch cloud {
11	    case "aws":
12		    handler = initializeAws()
13		case "gcp":
14			handler = initializeGcp()
15		case "azure":
16			handler = initializeAzure()
17		default:
18			// ...
19	}
20
21	http.ListenAndServe(":8080", handler)
22}

後續的文章不會特別提到 wire 的使用,然而大多數新定義的物件都會需要加入到對應套件的 Set 中來確保 wire 能夠正常運作。