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

如何用 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 這類物件處理狀態的保存和更新,那麼 ControllerModel 因為職責的差異自然形成了邊界。

邊界會因為處理的方式有不一樣的耦合程度,例如:我們需要直接知道某個物件如何使用,耦合程度就會比較高。

 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 來保存資料。

我們先用比較簡單的方式區分成 ControllerRepositoryEntity 三種物件,分別的實作(部分)如下:

 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 依賴 ControllerEntity

Controller 來說,他並不在意是怎樣的物件提供 Find()Save() 方法,在定義介面時會由 Controller 來描述他的「需求」至於怎麼滿足則是依賴的物件要去思考的,實作 Repository 時我們可以引用 Controller 套件來檢查是否有滿足依賴。

除了 Application 的依賴之外,我們還需要滿足 AWS 上的依賴,在 aws-sdk-go-v2 的情境,會有 dynamodb.Client 依賴 aws.Config 的需要。

setupAws() 串起來,就會需要一系列如下的依賴注入:

  • 注入 DynamoDbWalletController 滿足 WalletRepository 介面
  • 注入 dynamodb.ClientDynamoDbWallet
  • 注入 aws.Configdynamodb.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 的原則,無法呼叫其他套件的私有方法。