如何用 Golang 的 wire 做依賴注入
google/wire 是一個依賴注入(Dependency Injection)的工具,透過程式碼生成(Code Generate)來幫助我們解決 Golang 中一個物件對另一個物件有依賴關係時,需要事先產生的問題。
在開始這篇之前,也建議閱讀從 wire 學到依賴注入沒有講的事了解一些基本的概念。
釐清依賴
所謂的依賴,表示一個類別(Class)需要明確知道另一個類別的存在才能夠順利運作。不過,我們可以透過一些抽象化的方式來處理,例如定義介面(Interface)來解除這樣的直接依賴關係。
舉例來說,我們有一個錢包物件需要知道金錢物件的存在才能夠運行,那麼就構成物件之間的依賴。
1type Wallet struct {
2 id string
3 balance Money
4}
5
6type Money struct {
7 currency string
8 amount int
9}
在 Clean Architecture 這本書中對這類情況的描述,指的是需要一起打包(Package)的情況,無法透過替換套件來抽換元件(Component)
在 Golang 中是以套件(Package)來進行元件的區分,因此當我們從一個套件引用另一個套件時,就構成了這種依賴。
1// package controller
2import (
3 // ...
4 "example/entity"
5)
6
7func GetWallet(res http.ResponseWriter, req *http.Request) {
8 // ...
9 wallet := entity.NewWallet("demo")
10 // ...
11}
12
13// package entity
14type Wallet struct {}
上述的範例中,套件 controller
對套件 entity
有依賴關係,表示我們需要在編譯(Compile)時,有 entity
套件的存在才能夠順利編譯。
切分成套件已經有一定程度的元件化,然而這種依賴關係仍然非常緊密仍有較高的耦合度。
要處理乾淨 wire 的依賴注入,就需要區分出哪些套件需要「可以抽換」或者「容易擴充」然後再做進一步的處理,並不一定要將所有依賴關係切割到非常乾淨。
確立邊界
邊界(Boundary)在這邊指的是將物件之間的職責(Responsibility)分組後,自然形成的界線。舉例來說,我們透過 Controller
這類物件處理 HTTP 請求,並且利用 Model
這類物件處理狀態的保存和更新,那麼 Controller
和 Model
因為職責的差異自然形成了邊界。
邊界會因為處理的方式有不一樣的耦合程度,例如:我們需要直接知道某個物件如何使用,耦合程度就會比較高。
1// package controller
2import (
3 // ...
4 "example/entity"
5)
6
7func GetWallet(res http.ResponseWriter, req *http.Request) {
8 // ...
9 wallet := entity.NewWallet("demo")
10
11 res.Write([]byte(wallet.GetCurrency())
12}
13
14// package entity
15type Wallet struct {
16 id string
17 balance Money
18}
19
20func(w *Wallet) GetCurrency() string {
21 return w.balance.currency
22}
然而,我們可以透過介面(Interface)的方式,來將兩個物件耦合關係降低變得鬆散。
1package controller
2
3type Wallet interface {
4 GetCurrency() string
5}
6
7type WalletLoader interface {
8 Find(id string) Wallet
9}
10
11func GetWallet(res http.ResponseWriter, req *http.Request) {
12 // ...
13 wallet := loader.Find(id)
14
15 res.Write([]byte(wallet.GetCurrency())
16}
上述範例定義了 WalletLoader
介面,要求一個 GetCurrency()
方法回傳 Wallet
物件,此時我們就不需要明確 import "example/entity"
去依賴 entity
這個套件,只要符合條件的物件傳入,就能夠運行。
分組
會需要使用 wire 的情境,依賴關係通常相對複雜。需要區分出一個「抽換」的單位,將不同的元件根據我們的需要劃分成幾個不同的模組。
以 wire 的範例 guestbook 為例子,除了能運行在 Google Cloud Platform 之外,也能在 Amazon Web Service 或者 Azure 上運行,那麼至少就會分成兩個組別。
- Application
- (Cloud) Infrastructure
一個是 Gustbook 的實作,這些實作並不會知道背後的資料庫、檔案是怎麼保存的,根據專案的需求會需要區分這些資訊出來,通常還會再更細一些。
使用 wire 的最終目標,是要讓我們在運行應用時可以很簡單的用 initApp()
方式來初始化整個應用,而不需要依照不同環境、需求去撰寫相對應的程式碼。
也就是說,要支援不同雲端的實作,目標是最後能得到類似這樣的 main()
函式:
1func main() {
2 cloudType := os.Getenv("CLOUD_TYPE")
3 ctx := context.Background()
4
5 var srv *server.Server
6 switch cloudType {
7 case "aws":
8 srv = setupAws(ctx)
9 case "gcp":
10 srv = setupGcp(ctx)
11 case "azure":
12 srv = setupAzure(ctx)
13 default:
14 panic("unsupport cloud")
15 }
16
17 srv.Run()
18}
實作
接下來我們用 setupAws()
這個函式來作為例子,一步一步分解需要實作的部分。假設我們有一個以 ID 來查詢錢包的服務,在 AWS 上我們會用 DynamoDB 來保存資料。
我們先用比較簡單的方式區分成 Controller
、Repository
、Entity
三種物件,分別的實作(部分)如下:
1package controller
2
3import (
4 // ...
5 "example/entity"
6)
7
8type WalletRepository struct {
9 Find(ctx context.Context, id string) (*entity.Wallet, error)
10 Save(ctx context.Context, *entity.Wallet) error
11}
12
13type Wallet struct {
14 wallets WalletRepository
15}
16
17func (ctrl *Wallet) Get(res http.ResponseWriter, req *http.Request) {
18 // ...
19 wallet := ctrl.wallets.Find(id)
20 // ...
21}
22
23func (ctrl *Wallet) Update(res http.ResponseWriter, req *http.Request) {
24 // ...
25}
1package entity
2
3type Wallet struct {
4 // ...
5}
6
7// ...
1package repository
2
3import (
4 // ...
5 "example/entity"
6 "example/controller"
7)
8
9var _ controller.WalletRepository = &DynamoDbWallet{}
10
11type DynamoDbWallet struct {
12 db *dynamodb.Client
13}
14
15func NewDynamoDbWallet(client *dynamodb.Client) *DynamoDbWallet {
16 return &DynamoDbWallet {
17 db: client,
18 }
19}
20
21func (r *DynamoDbWallet) Find(id string) (*entity.Wallet, error) {
22 // ...
23}
24
25func (r *DynamoDbWallet) Save(wallet *entity.Wallet) error {
26 // ...
27}
在上述的例子中,依賴關係大致上如下:
Controller
依賴Entity
Repository
依賴Controller
和Entity
對 Controller
來說,他並不在意是怎樣的物件提供 Find()
和 Save()
方法,在定義介面時會由 Controller
來描述他的「需求」至於怎麼滿足則是依賴的物件要去思考的,實作 Repository
時我們可以引用 Controller
套件來檢查是否有滿足依賴。
除了 Application 的依賴之外,我們還需要滿足 AWS 上的依賴,在 aws-sdk-go-v2 的情境,會有 dynamodb.Client
依賴 aws.Config
的需要。
將 setupAws()
串起來,就會需要一系列如下的依賴注入:
- 注入
DynamoDbWallet
給Controller
滿足WalletRepository
介面 - 注入
dynamodb.Client
給DynamoDbWallet
- 注入
aws.Config
給dynamodb.Client
在 wire 用於 Code Generate 的檔案(通常會叫 wire.go
但沒有硬性限制)需要實作以下內容:
1package main
2
3import (
4 // ...
5)
6
7func setupAws(ctx context.Context) (*server.Server, error) {
8 wire.Build(
9 awsConfig,
10 awsDynamoDb
11 awsRepositorySet,
12 server.New,
13 )
14
15 return nil, nil
16}
17
18func awsConfig(ctx context.Context) (aws.Config, error) {
19 return config.LoadDefaultConfig(ctx)
20}
21
22func awsDymamoDb(cfg aws.Config) *dynamodb.Client {
23 return dynamodb.NewFromConfig(cfg)
24}
25
26var awsRepositorySet = wire.NewSet(
27 repository.NewDynamoDbWallet,
28 wire.Bind(new(controller.WalletRepository), new(*repository.DynamoDbWallet))
29)
上面的例子,我們可以直接將 config.LoadDefaultConfig
放到 wire.Build
中也能夠順利運作,然而 dynamodb.NewFromConfig
就無法這樣使用,因此使用自訂的函式來處理會更加適合。
這是 wire 在處理注入時,會檢查所有傳入的參數(Parameter)都有被提供,在 dynamodb.NewFromConfig
這個方法還有額外的 Rest Parameter,可以提供 dynamodb.Option
選項來調整 DynamoDB 的設定,如果直接放到 wire.Build
就會出現找不到 []dynamodb.Option
的錯誤。
同時,我們要提供的 Repository 都是依賴於 AWS 的,可以直接製作一個 awsRepositorySet
來提供相關的設定。另外,對 wire 來說介面的綁定是無法自己判斷符合哪一個介面,因此需要使用 wire.Bind
來做關係的定義。
我們也可以將
awsRepositorySet
放到 Repository 裡面,以repository.AwsRepositorySet
的方式提供,也更好將相關邏輯統整在一起
除此之外,我們還可以做一些變化,假設我們想要對 Repository 增加一層 Cache 支援時,可以將 awsRepositorySet
改為這樣的設計。
1var awsRepositorySet = wire.NewSet(
2 cacheableAwsWalletRepository,
3)
4
5func cacheableAwsWalletRepository(client *dynamodb.Client, cfg *config.Config) controller.WalletRepository {
6 repo := repository.NewDynamoDbWallet(client)
7
8 if cfg.Cache.Enabled {
9 return repository.NewCachedWallet(repo)
10 }
11
12 return repo
13}
因為明確的定義回傳的是符合 controller.WalletRepository
的介面,就不需要透過 wire.Bind
額外處理,使用獨立的函式也能夠讓我們根據設定微調產生的 Repository 來決定是否提供快取功能。
透過這樣的方式,我們就可以讓 wire 協助我們將所有運行所需的物件一口氣初始化,而不需要每個物件都手動建立,也能確保在編譯階段就能發現是否有依賴缺少或者被改變的狀況。
如果要將
cacheableAwsWalletRepository
放到 Repository 套件,就需要改為CacheableAwsWalletRepository
的公開方法,使用上還是會遵照 Golang 的原則,無法呼叫其他套件的私有方法。