Go 專案依賴注入wire工具最佳實踐介紹與使用

贾维斯Echo發表於2024-04-07

目錄
  • 一、引入
  • 二、控制反轉與依賴注入
  • 三、為什麼需要依賴注入工具
    • 3.1 示例
    • 3.2 依賴注入寫法與非依賴注入寫法
  • 四、wire 工具介紹與安裝
    • 4.1 wire 基本介紹
    • 4.2 安裝
  • 五、Wire 的基本使用
    • 5.1 前置程式碼準備
    • 5.2 使用 Wire 工具生成程式碼
  • 六、Wire 核心技術
    • 5.1 抽象語法樹分析
    • 5.2 模板程式設計
  • 七、Wire 的核心概念
    • 7.1 兩個核心概念
    • 7.2 Wire 提供者(providers)
    • 7.3 Wire 注入器(injectors)
  • 八、Wire 的高階用法
    • 8.1 繫結介面
    • 8.2 結構體提供者(Struct Providers)
    • 8.3 繫結值
    • 8.4 使用結構體欄位作為提供者(providers)
    • 8.5 清理函式
    • 8.6 備用注入器語法
  • 九、參考文件

一、引入

在Go語言的專案開發中,為了提高程式碼的可測試性和可維護性,我們通常會採用依賴注入(Dependency Injection,簡稱DI)的設計模式。依賴注入可以讓高層模組不依賴底層模組的具體實現,而是透過抽象來互相依賴,從而使得模組之間的耦合度降低,系統的靈活性和可擴充套件性增強。

二、控制反轉與依賴注入

控制反轉(Inversion of Control,縮寫為IoC),是物件導向程式設計中的一種設計原則,可以用來減低計算機程式碼之間的耦合度。其中最常見的方式叫做依賴注入(Dependency Injection,簡稱DI)。依賴注入是生成靈活和鬆散耦合程式碼的標準技術,透過明確地向元件提供它們所需要的所有依賴關係。在 Go 中通常採用將依賴項作為引數傳遞給建構函式的形式:

建構函式NewUserRepository在建立UserRepository時需要從外部將依賴項db作為引數傳入,我們在UserRepository中無需關注db的建立邏輯,實現了程式碼解耦。

// NewUserRepository 建立BookRepo的建構函式
func NewUserRepository(db *gorm.DB) *UserRepository {
	return &UserRepository{db: db}
}

區別於控制反轉,如果在NewUserRepository函式中自行建立相關依賴,這將導致程式碼高度耦合並且難以維護和除錯。

// NewUserRepository 建立UserRepository的建構函式
func NewUserRepository() *UserRepository {
  db, _ := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{})
	return &UserRepository{db: db}
}

三、為什麼需要依賴注入工具

3.1 示例

如果上面示例程式碼不夠清晰的話,我們來看這兩段程式碼:

// NewUserRepositoryV1非依賴注入的寫法
func NewUserRepositoryV1(dbCfg DBConfig, c CacheConfig)*UserRepository{
    db, err := gorm.Open(mysql.Open(dbcfg.DSN))
    if err != nil {
        panic(err)
    }
    ud = dao.NewUserDAO(db)
    uc = cache.NewUserCache(redis.NewClient(&redis.Options{
            Addr: c.Addr,
    }))
    return &UserRepository{
        dao: ud,
        cache: uc,
    }
}

// NewUserRepository 依賴注入的寫法
func NewUserRepository(d *dao.UserDAO, c *cache.UserCache)*UserRepository{
    return &UserRepository{
        dao: d,
        cache: c,
    }
}

可以清楚地看到,這兩段程式碼展示了在Go語言中實現依賴注入的兩種不同方式。
第一段程式碼 NewUserRepositoryV1 是非依賴注入的寫法。在這個函式中,UserRepository 的依賴(dbcache)是在函式內部建立的。這種方式的問題在於,它違反了單一職責原則,因為 NewUserRepositoryV1 不僅負責建立 UserRepository 例項,還負責建立其依賴的資料庫和快取客戶端。這樣做會導致程式碼耦合度較高,難以測試和維護。
第二段程式碼 NewUserRepository 是依賴注入的寫法。這個函式接受 UserRepository 的依賴(*dao.UserDAO*cache.UserCache)作為引數,而不是在函式內部建立它們。這種方式使得 UserRepository 的建立與它的依賴解耦,更容易測試,因為你可以輕鬆地為 UserRepository 提供模擬的依賴項。此外,這種寫法也更符合依賴注入的原則,因為它將控制反轉給了呼叫者,由呼叫者來決定 UserRepository 例項化時使用哪些依賴項。

3.2 依賴注入寫法與非依賴注入寫法

依賴注入寫法:不關心依賴是如何構造的。

非依賴注入寫法:必須自己初始化依賴,比如說 Repository 需要知道如何初始化 DAOCache。由此帶來的缺點是:

  • 深度耦合依賴的初始化過程。
  • 往往需要定義額外的 Config 型別來傳遞依賴所需的配置資訊。
  • 一旦依賴增加新的配置,或者更改了初始化過程,都要跟著修改。
  • 缺乏擴充套件性。
  • 測試不友好。
  • 難以複用公共元件,例如 DB 或 Redis 之類的客戶端。

四、wire 工具介紹與安裝

4.1 wire 基本介紹

  • Wire 是一個的 Google 開源專為依賴注入(Dependency Injection)設計的程式碼生成工具,透過自動生成程式碼的方式在初始編譯過程中完成依賴注入。它可以自動生成用於化各種依賴關係的程式碼,從而幫助我們更輕鬆地管理和注入依賴關係。

  • Wire 分成兩部分,一個是在專案中使用的依賴, 一個是命令列工具。

4.2 安裝

go install github.com/google/wire/cmd/wire@latest

五、Wire 的基本使用

5.1 前置程式碼準備

目錄結構如下:

wire
├── db.go                          # 資料庫相關程式碼
├── go.mod                         # Go模組依賴配置檔案
├── go.sum                         # Go模組依賴校驗檔案
├── main.go                        # 程式入口檔案
├── repository                     # 存放資料訪問層程式碼的目錄
│   ├── dao                        # 資料訪問物件(DAO)目錄
│   │   └── user.go                # 使用者相關的DAO實現
│   └── user.go                    # 使用者倉庫實現
├── wire.go                        # Wire依賴注入配置檔案

repository/dao/user.go檔案:

// repository/dao/user.go
package dao

import "gorm.io/gorm"

type UserDAO struct {
	db *gorm.DB
}

func NewUserDAO(db *gorm.DB) *UserDAO {
	return &UserDAO{
		db: db,
	}
}

repository/user.go 檔案:

// repository/user.go
package repository

import "wire/repository/dao"

type UserRepository struct {
	dao *dao.UserDAO
}

func NewUserRepository(dao *dao.UserDAO) *UserRepository {
	return &UserRepository{
		dao: dao,
	}
}

db.go 檔案:

// db.go
package wire

import (
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

func InitDB() *gorm.DB {
	db, err := gorm.Open(mysql.Open("dsn"))
	if err != nil {
		panic(err)
	}
	return db
}

main.go 檔案:

package wire

import (
	"fmt"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"wire/repository"
	"wire/repository/dao"
)

func main() {
	// 非依賴注入
	db, err := gorm.Open(mysql.Open("dsn"))
	if err != nil {
		panic(err)
	}
	ud := dao.NewUserDAO(db)
	repo := repository.NewUserRepository(ud)
	fmt.Println(repo)
}

5.2 使用 Wire 工具生成程式碼

現在我們已經有了基本的程式碼結構,接下來我們將使用 wire 工具來生成依賴注入的程式碼。

首先,確保你已經安裝了 wire 工具。如果沒有安裝,可以使用以下命令安裝:

go get github.com/google/wire/cmd/wire

接下來,我們需要建立一個 wire 的配置檔案,通常命名為 wire.go。在這個檔案中,我們將使用 wire 的語法來指定如何構建 UserRepository 例項。

wire.go 檔案:

//go:build wireinject

// 讓 wire 來注入這裡的程式碼
package wire

import (
	"github.com/google/wire"
	"wire/repository"
	"wire/repository/dao"
)

func InitRepository() *repository.UserRepository {
	// 我只在這裡宣告我要用的各種東西,但是具體怎麼構造,怎麼編排順序
	// 這個方法裡面傳入各個元件的初始化方法
	wire.Build(InitDB, repository.NewUserRepository, dao.NewUserDAO)
	return new(repository.UserRepository)
}

這段程式碼是使用 wire 工具進行依賴注入的配置檔案。在這個檔案中,我們定義了一個函式 InitRepository,這個函式的目的是為了生成一個 *repository.UserRepository 的例項。但是,這個函式本身並不包含具體的實現程式碼,而是依賴於 wire 工具來注入依賴。
讓我們逐步解釋這段程式碼:

  1. 構建約束指令:

    //go:build wireinject
    

    這行註釋是一個構建約束,它告訴 go build 只有在滿足條件 wireinject 的情況下才應該構建這個檔案。wireinject 是一個特殊的標籤,用於指示 wire 工具處理這個檔案。

  2. 匯入包:

    import (
        "github.com/google/wire"
        "wire/repository"
        "wire/repository/dao"
    )
    

    這部分匯入了必要的包,包括 wire 工具庫,以及專案中的 repositorydao 包,這些包包含了我們需要注入的依賴。

  3. InitRepository 函式:

    func InitRepository() *repository.UserRepository {
        // 我只在這裡宣告我要用的各種東西,但是具體怎麼構造,怎麼編排順序
        // 這個方法裡面傳入各個元件的初始化方法
        wire.Build(InitDB, repository.NewUserRepository, dao.NewUserDAO)
        return new(repository.UserRepository)
    }
    

    這個函式是 wire 注入的目標。它宣告瞭一個返回 *repository.UserRepository 的函式,但是函式體內部沒有具體的實現程式碼。wire.Build 函式呼叫是關鍵, 主要是連線或繫結我們之前定義的所有初始化函式。當我們執行 wire 工具來生成程式碼時,它就會根據這些依賴關係來自動建立和注入所需的例項。,這些函式按照依賴關係被呼叫,以正確地構造和注入 UserRepository 例項所需的依賴。

    • InitDB 是初始化資料庫連線的函式。
    • repository.NewUserRepository 是建立 UserRepository 例項的函式。
    • dao.NewUserDAO 是建立 UserDAO 例項的函式。
      wire 工具會自動生成這些函式呼叫的程式碼,並確保依賴關係得到滿足。
  4. 返回語句:

    return new(repository.UserRepository)
    

    這個返回語句是必須的,儘管它實際上並不會被執行。wire 工具會生成一個替換這個函式體的程式碼,其中包括所有必要的依賴注入邏輯。
    在編寫完 wire.go 檔案後,你需要執行 wire 命令來生成實際的依賴注入程式碼。生成的程式碼將被放在一個名為 wire_gen.go 的檔案中,這個檔案應該被提交到你的版本控制系統中。

現在,我們可以執行 wire 命令來生成依賴注入的程式碼:

wire

這個命令會掃描 wire.go 檔案,並生成一個新的 Go 檔案 wire_gen.go,其中包含了 InitializeUserRepository 函式的實現,這個函式會建立並返回一個 UserRepository 例項,其依賴項已經自動注入。

生成 wire_gen.go 檔案,內容如下所示:

// Code generated by Wire. DO NOT EDIT.

//go:generate go run -mod=mod github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject

package wire

import (
	"wire/repository"
	"wire/repository/dao"
)

// Injectors from wire.go:

func InitRepository() *repository.UserRepository {
	db := InitDB()
	userDAO := dao.NewUserDAO(db)
	userRepository := repository.NewUserRepository(userDAO)
	return userRepository
}

最後,我們需要修改 main.go 檔案,使用 wire 生成的程式碼來獲取 UserRepository 例項:

package wire

func main() {
	InitRepository()
}

現在,當我們執行 main.go 時,它將使用 wire 工具生成的程式碼來初始化 UserRepository,包括其依賴的 UserDAO 和資料庫連線。這樣,我們就實現了依賴注入,並且程式碼更加簡潔、易於維護。

六、Wire 核心技術

5.1 抽象語法樹分析

wire 工具的工作原理是基於對Go程式碼的抽象語法樹(Abstract Syntax Tree,簡稱AST)的分析。AST是原始碼的抽象語法結構的樹狀表示,它以樹的形式表現程式語言的語法結構。wire 工具透過分析AST來理解程式碼中的依賴關係。
在Go中,go/ast 包提供瞭解析Go原始檔並構建AST的功能。wire 工具利用這個包來遍歷和分析專案的Go程式碼,識別出所有的依賴項,並構建出依賴關係圖。這個依賴關係圖隨後被用來生成注入依賴的程式碼。

5.2 模板程式設計

wire 工具生成程式碼的過程也涉及到模板程式設計。模板程式設計是一種程式設計正規化,它允許開發者定義一個模板,然後使用具體的資料來填充這個模板,生成最終的程式碼或文字。
wire中,雖然不直接使用Go語言的模板引擎(如text/templatehtml/template),但它的工作原理與模板程式設計類似。wire定義了一套自己的語法來描述依賴關係,然後根據這些描述生成具體的Go程式碼。
wire的語法主要包括以下幾個部分:

  • wire.NewSet:定義一組相關的依賴,通常包括一個或多個建構函式。
  • wire.Build:指定生成程式碼時應該使用哪些依賴集合。
  • bind 函式:用於繫結介面和實現,告訴wire如何建立介面的例項。
    wire工具透過這些語法來構建一個依賴圖,然後根據這個圖生成一個函式,該函式負責建立並返回所有必要的元件例項,同時處理它們之間的依賴關係。
    透過結合抽象語法樹分析和模板程式設計,wire 工具能夠提供一種宣告式的依賴注入方法,讓開發者能夠專注於定義依賴關係,而不是手動編寫依賴注入的程式碼。這不僅減少了重複勞動,還提高了程式碼的可維護性和降低了出錯的可能性。

七、Wire 的核心概念

7.1 兩個核心概念

wire 中,有兩個核心概念:提供者(providers)和注入器(injectors)。

7.2 Wire 提供者(providers)

提供者 是一個普通有返回值的 Go 函式,它負責建立一個物件或者提供依賴。在 wire 的上下文中,提供者可以是任何返回一個或多個值的函式。這些返回值將成為注入器函式的引數。提供者函式通常負責初始化元件,比如資料庫連線、服務例項等。並且提供者的返回值不僅限於一個,如果有需要的話,可以額外新增一個 error 的返回值。
例如,一個提供者函式可能會建立並返回一個資料庫連線:

func NewDBConnection(dsn string) (*gorm.DB, error) {
    db, err := gorm.Open(mysql.Open(dsn))
    if err != nil {
        return nil, err
    }
    return db, nil
}

提供者函式可以分組為提供者函式集(provider set)。使用wire.NewSet 函式可以將多個提供者函式新增到一個集合中。舉個例子,例如將 user 相關的 handlerservice 進行組合:

package web

var UserSet = wire.NewSet(NewUserHandler, service.NewUserService)

使用 wire.NewSet 函式將提供者進行分組,該函式返回一個 ProviderSet 結構體。不僅如此,wire.NewSet 還能對多個 ProviderSet 進行分組 wire.NewSet(UserSet, XxxSet)

package demo

import (
    // ...
    "example.com/some/other/pkg"
)

// ...

var MegaSet = wire.NewSet(UserSet, pkg.OtherSet)

7.3 Wire 注入器(injectors)

注入器(injectors)的作用是將所有的提供者(providers)連線起來,要宣告一個注入器函式只需要在函式體中呼叫wire.Build()。這個函式的返回值也無關緊要,只要它們的型別正確即可。這些值在生成的程式碼中將被忽略。回顧一下我們之前的程式碼:

//go:build wireinject

// 讓 wire 來注入這裡的程式碼
package wire

import (
	"github.com/google/wire"
	"wire/repository"
	"wire/repository/dao"
)

func InitRepository() *repository.UserRepository {
	// 我只在這裡宣告我要用的各種東西,但是具體怎麼構造,怎麼編排順序
	// 這個方法裡面傳入各個元件的初始化方法
	wire.Build(InitDB, repository.NewUserRepository, dao.NewUserDAO)
	return new(repository.UserRepository)
}

在這個例子中,InitRepository 是一個注入器,它依賴 InitDBrepository.NewUserRepository 這兩個提供者。

與提供者一樣,注入器也可以輸入引數(然後將其傳送給提供者),並且可以返回錯誤。wire.Build的引數和wire.NewSet一樣:都是提供者集合。這些就在該注入器的程式碼生成期間使用的提供者集。

八、Wire 的高階用法

8.1 繫結介面

依賴項注入通常用於繫結介面的具體實現。wire透過型別標識將輸入與輸出匹配,因此傾向於建立一個返回介面型別的提供者。然而,這也不是習慣寫法,因為Go的最佳實踐是返回具體型別。你可以在提供者集中宣告介面繫結.

我們對之前的程式碼進行改造:

首先,我們在UserRepository介面中定義一些方法。例如,我們可以定義一個GetUser方法,該方法接收一個使用者ID,並返回相應的使用者。 在repository/user.go檔案中:

package repository

import (
    "wire/repository/dao"
    "gorm.io/gorm"
)

type UserRepository interface {
    GetUser(id uint) (*User, error)
}

type UserRepositoryImpl struct {
    dao *dao.UserDAO
}

func (r *UserRepositoryImpl) GetUser(id uint) (*User, error) {
    return r.dao.GetUser(id)
}

func NewUserRepository(dao *dao.UserDAO) UserRepository {
    return &UserRepositoryImpl{
        dao: dao,
    }
}

然後,我們在UserDAO中實現這個GetUser方法。在repository/dao/user.go檔案中:

package dao

import (
    "gorm.io/gorm"
)

type User struct {
    ID uint
    // other fields...
}

type UserDAO struct {
    db *gorm.DB
}

func (dao *UserDAO) GetUser(id uint) (*User, error) {
    var user User
    result := dao.db.First(&user, id)
    if result.Error != nil {
        return nil, result.Error
    }
    return &user, nil
}

func NewUserDAO(db *gorm.DB) *UserDAO {
    return &UserDAO{
        db: db,
    }
}

最後,我們需要更新wire.go檔案中的InitRepository函式,以返回UserRepository介面,而不是具體的實現。 在wire.go檔案中:

//go:build wireinject

package wire

import (
    "github.com/google/wire"
    "wire/repository"
    "wire/repository/dao"
)

func InitRepository() repository.UserRepository {
    wire.Build(InitDB, repository.NewUserRepository, dao.NewUserDAO)
    return &repository.UserRepositoryImpl{}
}

使用 wire.Bind 來建立介面型別和具體的實現型別之間的繫結關係,這樣 Wire 工具就可以根據這個繫結關係進行型別匹配並生成程式碼。

wire.Bind 函式的第一個引數是指向所需介面型別值的指標,第二個實參是指向實現該介面的型別值的指標。

8.2 結構體提供者(Struct Providers)

Wire 庫有一個函式是 wire.Struct,它能根據現有的型別進行構造結構體,我們來看看下面的例子:

package main

import "github.com/google/wire"

type Name string

func NewName() Name {
	return "小米SU7"
}

type PublicAccount string

func NewPublicAccount() PublicAccount {
	return "新一代車神"
}

type User struct {
	MyName          Name
	MyPublicAccount PublicAccount
}

func InitializeUser() *User {
	wire.Build(
		NewName,
		NewPublicAccount,
		wire.Struct(new(User), "MyName", "MyPublicAccount"),
	)
	return &User{}
}

上述程式碼中,首先定義了自定義型別 NamePublicAccount 以及結構體型別 User,並分別提供了 NamePublicAccount 的初始化函式(providers)。然後定義一個注入器(injectorsInitializeUser,用於構造連線提供者並構造 *User 例項。

使用 wire.Struct 函式需要傳遞兩個引數,第一個引數是結構體型別的指標值,另一個引數是一個可變引數,表示需要注入的結構體欄位的名稱集。

根據上述程式碼,使用 Wire 工具生成的程式碼如下所示:

func InitializeUser() *User {
    name := NewName()
    publicAccount := NewPublicAccount()
    user := &User{
       MyName:          name,
       MyPublicAccount: publicAccount,
    }
    return user
}

如果我們不想返回指標型別,只需要修改 InitializeUser 函式的返回值為非指標即可。

8.3 繫結值

有時,將基本值(通常為nil)繫結到型別是有用的。你可以向提供程式集新增一個值表示式,而不是讓注入器依賴於一次性函式提供者(providers)。

func InjectUser() User {
    wire.Build(wire.Value(User{MyName: "小米SU7"}))
    return User{}
}

在上述程式碼中,使用 wire.Value 函式透過表示式直接指定 MyName 的值,生成的程式碼如下所示:

func InjectUser() User {
    user := _wireUserValue
    return user
}

var (
    _wireUserValue = User{MyName: "小米SU7"}
)

需要注意的是,值表示式將被複制到生成的程式碼檔案中。

對於介面型別,可以使用 InterfaceValue

func InjectPostService() service.IPostService {
    wire.Build(wire.InterfaceValue(new(service.IPostService), &service.PostService{}))
    return nil
}

8.4 使用結構體欄位作為提供者(providers)

有些時候,你可以使用結構體的某個欄位作為提供者,從而生成一個類似 GetXXX 的函式。

func GetUserName() Name {
    wire.Build(
       NewUser,
       wire.FieldsOf(new(User), "MyName"),
    )
    return ""
}

你可以使用 wire.FieldsOf 函式新增任意欄位,生成的程式碼如下所示:

func GetUserName() Name {
    user := NewUser()
    name := user.MyName
    return name
}

func NewUser() User {
    return User{MyName: Name("小米SU7"), MyPublicAccount: PublicAccount("新一代車神!")}
}

8.5 清理函式

如果一個提供者建立了一個需要清理的值(例如關閉一個檔案),那麼它可以返回一個閉包來清理資源。注入器會用它來給呼叫者返回一個聚合的清理函式,或者在注入器實現中稍後呼叫的提供商返回錯誤時清理資源。

func provideFile(log Logger, path Path) (*os.File, func(), error) {
    f, err := os.Open(string(path))
    if err != nil {
        return nil, nil, err
    }
    cleanup := func() {
        if err := f.Close(); err != nil {
            log.Log(err)
        }
    }
    return f, cleanup, nil
}

8.6 備用注入器語法

如果你不喜歡在注入器函式宣告的末尾編寫類似return Foo{}, nil的語句,那麼你可以簡單粗暴地使用panic

func InitializeGin() *gin.Engine {
    panic(wire.Build(/* ... */))
}

九、參考文件

  • 掘金依賴注入工具-wire
  • 李文周的部落格-依賴注入工具-wire

相關文章