- 一、引入
- 二、控制反轉與依賴注入
- 三、為什麼需要依賴注入工具
- 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
的依賴(db
和 cache
)是在函式內部建立的。這種方式的問題在於,它違反了單一職責原則,因為 NewUserRepositoryV1
不僅負責建立 UserRepository
例項,還負責建立其依賴的資料庫和快取客戶端。這樣做會導致程式碼耦合度較高,難以測試和維護。
第二段程式碼 NewUserRepository
是依賴注入的寫法。這個函式接受 UserRepository
的依賴(*dao.UserDAO
和 *cache.UserCache
)作為引數,而不是在函式內部建立它們。這種方式使得 UserRepository
的建立與它的依賴解耦,更容易測試,因為你可以輕鬆地為 UserRepository
提供模擬的依賴項。此外,這種寫法也更符合依賴注入的原則,因為它將控制反轉給了呼叫者,由呼叫者來決定 UserRepository
例項化時使用哪些依賴項。
3.2 依賴注入寫法與非依賴注入寫法
依賴注入寫法:不關心依賴是如何構造的。
非依賴注入寫法:必須自己初始化依賴,比如說 Repository
需要知道如何初始化 DAO
和 Cache
。由此帶來的缺點是:
- 深度耦合依賴的初始化過程。
- 往往需要定義額外的
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
工具來注入依賴。
讓我們逐步解釋這段程式碼:
-
構建約束指令:
//go:build wireinject
這行註釋是一個構建約束,它告訴
go build
只有在滿足條件wireinject
的情況下才應該構建這個檔案。wireinject
是一個特殊的標籤,用於指示wire
工具處理這個檔案。 -
匯入包:
import ( "github.com/google/wire" "wire/repository" "wire/repository/dao" )
這部分匯入了必要的包,包括
wire
工具庫,以及專案中的repository
和dao
包,這些包包含了我們需要注入的依賴。 -
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
工具會自動生成這些函式呼叫的程式碼,並確保依賴關係得到滿足。
-
返回語句:
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/template
或html/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
相關的 handler
和 service
進行組合:
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
是一個注入器,它依賴 InitDB
和 repository.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{}
}
上述程式碼中,首先定義了自定義型別 Name
和 PublicAccount
以及結構體型別 User
,並分別提供了 Name
和 PublicAccount
的初始化函式(providers
)。然後定義一個注入器(injectors
)InitializeUser
,用於構造連線提供者並構造 *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