Go 語言官方依賴注入工具 Wire 使用指北

語言: CN / TW / HK

1. 前言

接觸 Golang 有一段時間了,發現 Golang 同樣需要類似 Java 中 Spring 一樣的依賴注入框架。如果專案規模比較小,是否有依賴注入框架問題不大,但當專案變大之後,有一個合適的依賴注入框架是十分必要的。通過調研,瞭解到 Golang 中常用的依賴注入工具主要有 Inject 、Dig 等。但是今天主要介紹的是 Go 團隊開發的 Wire,一個編譯期實現依賴注入的工具。

2. 依賴注入(DI)是什麼

說起依賴注入就要引出另一個名詞控制反轉( IoC )。IoC 是一種設計思想,其核心作用是降低程式碼的耦合度。依賴注入是一種實現控制反轉且用於解決依賴性問題的設計模式。

舉個例子,假設我們程式碼分層關係是 dal 層連線資料庫,負責資料庫的讀寫操作。那麼我們的 dal 層的上一層 service 負責呼叫 dal 層處理資料,在我們目前的程式碼中,它可能是這樣的:

``` // dal/user.go

func (u UserDal) Create(ctx context.Context, data UserCreateParams) error {     db := mysql.GetDB().Model(&entity.User{})     user := entity.User{       Username: data.Username,       Password: data.Password,    }

return db.Create(&user).Error }

// service/user.go func (u UserService) Register(ctx context.Context, data schema.RegisterReq) (*schema.RegisterRes, error) {    params := dal.UserCreateParams{       Username: data.Username,       Password: data.Password,    }

err := dal.GetUserDal().Create(ctx, params)    if err != nil {       return nil, err    }

registerRes := schema.RegisterRes{       Msg: "register success",    }

return &registerRes, nil } ```

在這段程式碼裡,層級依賴關係為 service -> dal -> db,上游層級通過 Getxxx例項化依賴。但在實際生產中,我們的依賴鏈比較少是垂直依賴關係,更多的是橫向依賴。即我們一個方法中,可能要多次呼叫Getxxx的方法,這樣使得我們程式碼極不簡潔。

不僅如此,我們的依賴都是寫死的,即依賴者的程式碼中寫死了被依賴者的生成關係。當被依賴者的生成方式改變,我們也需要改變依賴者的函式,這極大的增加了修改程式碼量以及出錯風險。

接下來我們用依賴注入的方式對程式碼進行改造:

``` // dal/user.go type UserDal struct{     DB *gorm.DB }

func NewUserDal(db gorm.DB) UserDal{     return &UserDal{         DB: db     } }

func (u UserDal) Create(ctx context.Context, data UserCreateParams) error {     db := u.DB.Model(&entity.User{})     user := entity.User{       Username: data.Username,       Password: data.Password,    }

return db.Create(&user).Error }

// service/user.go type UserService struct{     UserDal *dal.UserDal }

func NewUserService(userDal dal.UserDal) *UserService{     return &UserService{         UserDal: userDal     } }

func (u UserService) Register(ctx context.Context, data schema.RegisterReq) (*schema.RegisterRes, error) {    params := dal.UserCreateParams{       Username: data.Username,       Password: data.Password,    }

err := u.UserDal.Create(ctx, params)    if err != nil {       return nil, err    }

registerRes := schema.RegisterRes{       Msg: "register success",    }

return &registerRes, nil }

// main.go  db := mysql.GetDB() userDal := dal.NewUserDal(db) userService := dal.NewUserService(userDal) ```

如上編碼情況中,我們通過將 db 例項物件注入到 dal 中,再將 dal 例項物件注入到 service 中,實現了層級間的依賴注入。解耦了部分依賴關係。

在系統簡單、程式碼量少的情況下上面的實現方式確實沒什麼問題。但是專案龐大到一定程度,結構之間的關係變得非常複雜時,手動建立每個依賴,然後層層組裝起來的方式就會變得異常繁瑣,並且容易出錯。這個時候勇士 wire 出現了!

3. Wire Come

3.1 簡介

Wire 是一個輕巧的 Golang 依賴注入工具。它由 Go Cloud 團隊開發,通過自動生成程式碼的方式在編譯期完成依賴注入。它不需要反射機制,後面會看到, Wire 生成的程式碼與手寫無異。

3.2 快速使用

wire 的安裝:

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

上面的命令會在 $GOPATH/bin 中生成一個可執行程式 wire,這就是程式碼生成器。可以把$GOPATH/bin 加入系統環境變數 $PATH 中,所以可直接在命令列中執行 wire 命令。

下面我們在一個例子中看看如何使用 wire

現在我們有這樣的三個型別:

type Message string type Channel struct {     Message Message } type BroadCast struct {     Channel Channel }

三者的 init 方法:

func NewMessage() Message {     return Message("Hello Wire!") } func NewChannel(m Message) Channel {     return Channel{Message: m} } func NewBroadCast(c Channel) BroadCast {     return BroadCast{Channel: c} }

假設 Channel 有一個 GetMsg 方法,BroadCast 有一個 Start 方法:

``` func (c Channel) GetMsg() Message {     return c.Message }

func (b BroadCast) Start() {     msg := b.Channel.GetMsg()     fmt.Println(msg) } ```

如果手動寫程式碼的話,我們的寫法應該是:

``` func main() {     message := NewMessage()     channel := NewChannel(message)     broadCast := NewBroadCast(channel)

broadCast.Start() } ```

如果使用 wire,我們需要做的就變成如下的工作了:

  1. 提取一個 init 方法 InitializeBroadCast:

``` func main() {     b := demo.InitializeBroadCast()

b.Start() } ```

  1. 編寫一個 wire.go 檔案,用於 wire 工具來解析依賴,生成程式碼:

``` //+build wireinject

package demo

func InitializeBroadCast() BroadCast {     wire.Build(NewBroadCast, NewChannel, NewMessage)     return BroadCast{} } ```

注意:需要在檔案頭部增加構建約束://+build wireinject

  1. 使用 wire 工具,生成程式碼,在 wire.go 所在目錄下執行命令:wire gen wire.go。會生成如下程式碼,即在編譯程式碼時真正使用的Init函式:

``` // Code generated by Wire. DO NOT EDIT.

//go:generate wire //+build !wireinject func InitializeBroadCast() BroadCast {     message := NewMessage()     channel := NewChannel(message)     broadCast := NewBroadCast(channel)     return broadCast } ```

我們告訴 wire,我們所用到的各種元件的 init 方法(NewBroadCastNewChannelNewMessage),那麼 wire 工具會根據這些方法的函式簽名(引數型別/返回值型別/函式名)自動推導依賴關係。

wire.go 和 wire_gen.go 檔案頭部位置都有一個 +build,不過一個後面是 wireinject,另一個是 !wireinject+build 其實是 Go 語言的一個特性。類似 C/C++ 的條件編譯,在執行 go build 時可傳入一些選項,根據這個選項決定某些檔案是否編譯。wire 工具只會處理有wireinject 的檔案,所以我們的 wire.go 檔案要加上這個。生成的 wire_gen.go 是給我們來使用的,wire 不需要處理,故有 !wireinject

3.3 基礎概念

Wire 有兩個基礎概念,Provider(構造器)和 Injector(注入器)

  • Provider 實際上就是生成元件的普通方法,這些方法接收所需依賴作為引數,建立元件並將其返回。我們上面例子的 NewBroadCast 就是 Provider
  • Injector 可以理解為 Providers 的聯結器,它用來按依賴順序呼叫 Providers 並最終返回構建目標。我們上面例子的 InitializeBroadCast 就是 Injector

4. Wire使用實踐

下面簡單介紹一下 wire 在飛書問卷表單服務中的應用。

飛書問卷表單服務的 project 模組中將 handler 層、service 層和 dal 層的初始化通過引數注入的方式實現依賴反轉。通過 BuildInjector 注入器來初始化所有的外部依賴。

4.1 基礎使用

dal 虛擬碼如下:

``` func NewProjectDal(db gorm.DB) ProjectDal{     return &ProjectDal{         DB:db     } }

type ProjectDal struct {    DB *gorm.DB }

func (dal ProjectDal) Create(ctx context.Context, item entity.Project) error {    result := dal.DB.Create(item)    return errors.WithStack(result.Error) } // QuestionDal、QuestionModelDal... ```

service 虛擬碼如下:

``` func NewProjectService(projectDal dal.ProjectDal, questionDal dal.QuestionDal, questionModelDal dal.QuestionModelDal) ProjectService {    return &projectService{       ProjectDal:       projectDal,       QuestionDal:      questionDal,       QuestionModelDal: questionModelDal,    } }

type ProjectService struct {    ProjectDal       dal.ProjectDal    QuestionDal      dal.QuestionDal    QuestionModelDal *dal.QuestionModelDal }

func (s ProjectService) Create(ctx context.Context, projectBo bo.ProjectCreateBo) (int64, error) {} ```

handler 虛擬碼如下:

``` func NewProjectHandler(srv service.ProjectService) ProjectHandler{     return &ProjectHandler{         ProjectService: srv     } }

type ProjectHandler struct {    ProjectService *service.ProjectService }

func (s ProjectHandler) CreateProject(ctx context.Context, req project.CreateProjectRequest) (resp * project.CreateProjectResponse, err error) {} ```

injector.go 虛擬碼如下:

``` func NewInjector()(handler handler.ProjectHandler) Injector{     return &Injector{         ProjectHandler: handler     } }

type Injector struct {    ProjectHandler *handler.ProjectHandler    // components,others... } ```

在 wire.go 中如下定義:

``` // +build wireinject

package app

func BuildInjector() (*Injector, error) {    wire.Build(       NewInjector,

// handler       handler.NewProjectHandler,

// services       service.NewProjectService,       // 更多service...

//dal       dal.NewProjectDal,       dal.NewQuestionDal,       dal.NewQuestionModelDal,       // 更多dal...

// db       common.InitGormDB,       // other components...    )

return new(Injector), nil } ```

執行 wire gen ./internal/app/wire.go 生成 wire_gen.go

``` // Code generated by Wire. DO NOT EDIT.

//go:generate wire //+build !wireinject

func BuildInjector() (*Injector, error) {    db, err := common.InitGormDB()    if err != nil {       return nil, err    }        projectDal := dal.NewProjectDal(db)    questionDal := dal.NewQuestionDal(db)    questionModelDal := dal.NewQuestionModelDal(db)    projectService := service.NewProjectService(projectDal, questionDal, questionModelDal)    projectHandler := handler.NewProjectHandler(projectService)    injector := NewInjector(projectHandler)    return injector, nil } ```

在 main.go 中加入初始化 injector 的方法 app.BuildInjector

``` injector, err := BuildInjector() if err != nil {    return nil, err }

//project服務啟動 svr := projectservice.NewServer(injector.ProjectHandler, logOpt) svr.Run() ```

注意,如果你執行時,出現了 BuildInjector 重定義,那麼檢查一下你的 //+build wireinject 與 package app 這兩行之間是否有空行,這個空行必須要有!見http://github.com/google/wire/issues/117

4.2 高階特性

4.2.1 NewSet

NewSet 一般應用在初始化物件比較多的情況下,減少 Injector 裡面的資訊。當我們專案龐大到一定程度時,可以想象會出現非常多的 Providers。NewSet 幫我們把這些 Providers 按照業務關係進行分組,組成 ProviderSet(構造器集合),後續只需要使用這個集合即可。

``` // project.go var ProjectSet = wire.NewSet(NewProjectHandler, NewProjectService, NewProjectDal)

// wire.go func BuildInjector() (*Injector, error) {    wire.Build(InitGormDB, ProjectSet, NewInjector)

return new(Injector), nil } ```

4.2.2 Struct

上述例子的 Provider 都是函式,除函式外,結構體也可以充當 Provider 的角色。Wire 給我們提供了結構構造器(Struct Provider)。結構構造器建立某個型別的結構,然後用引數或呼叫其它構造器填充它的欄位。

``` // project_service.go // 函式provider func NewProjectService(projectDal dal.ProjectDal, questionDal dal.QuestionDal, questionModelDal dal.QuestionModelDal) ProjectService {    return &projectService{       ProjectDal:       projectDal,       QuestionDal:      questionDal,       QuestionModelDal: questionModelDal,    } }

// 等價於 wire.Struct(new(ProjectService), "") // ""代表全部欄位注入

// 也等價於 wire.Struct(new(ProjectService), "ProjectDal", "QuestionDal", "QuestionModelDal")

// 如果個別屬性不想被注入,那麼可以修改 struct 定義: type App struct {     Foo Foo     Bar Bar     NoInject int wire:"-" } ```

4.2.3 Bind

Bind 函式的作用是為了讓介面型別的依賴參與 Wire 的構建。Wire 的構建依靠引數型別,介面型別是不支援的。Bind 函式通過將介面型別和實現型別繫結,來達到依賴注入的目的。

``` // project_dal.go type IProjectDal interface {    Create(ctx context.Context, item *entity.Project) (err error)    // ... }

type ProjectDal struct {    DB *gorm.DB }

var bind = wire.Bind(new(IProjectDal), new(*ProjectDal)) ```

4.2.4 CleanUp

構造器可以提供一個清理函式(cleanup),如果後續的構造器返回失敗,前面構造器返回的清理函式都會呼叫。初始化 Injector 之後可以獲取到這個清理函式,清理函式典型的應用場景是檔案資源和網路連線資源。清理函式通常作為第二返回值,引數型別為 func()。當 Provider 中的任何一個擁有清理函式,Injector 的函式返回值中也必須包含該函式。並且 Wire 對 Provider 的返回值個數及順序有以下限制:

  1. 第一個返回值是需要生成的物件
  2. 如果有 2 個返回值,第二個返回值必須是 func() 或 error
  3. 如果有 3 個返回值,第二個返回值必須是 func(),而第三個返回值必須是 error

``` // db.go func InitGormDB()(*gorm.DB, func(), error) {     // 初始化db連結     // ...     cleanFunc := func(){         db.Close()     }

return db, cleanFunc, nil }

// wire.go func BuildInjector() (*Injector, func(), error) {    wire.Build(       common.InitGormDB,       // ...       NewInjector    )

return new(Injector), nil, nil }

// 生成的wire_gen.go func BuildInjector() (*Injector, func(), error) {    db, cleanup, err := common.InitGormDB()    // ...    return injector, func(){        // 所有provider的清理函式都會在這裡        cleanup()    }, nil }

// main.go injector, cleanFunc, err := app.BuildInjector() defer cleanFunc() ```

更多用法具體可以參考 wire官方指南:http://github.com/google/wire/blob/main/docs/guide.md

4.3 高階使用

接著我們就用上述的這些 wire 高階特性對 project 服務進行程式碼改造:

project_dal.go

``` type IProjectDal interface {    Create(ctx context.Context, item *entity.Project) (err error)    // ... }

type ProjectDal struct {    DB *gorm.DB }

// wire.Struct方法是wire提供的構造器,""代表為所有欄位注入值,在這裡可以用"DB"代替 // wire.Bind方法把介面和實現繫結起來 var ProjectSet = wire.NewSet(    wire.Struct(new(ProjectDal), ""),    wire.Bind(new(IProjectDal), new(*ProjectDal)))

func (dal ProjectDal) Create(ctx context.Context, item entity.Project) error {} ```

dal.go

// DalSet dal注入 var DalSet = wire.NewSet(    ProjectSet,    // QuestionDalSet、QuestionModelDalSet... )

project_service.go

``` type IProjectService interface {    Create(ctx context.Context, projectBo *bo.CreateProjectBo) (int64, error)    // ... }

type ProjectService struct {    ProjectDal       dal.IProjectDal    QuestionDal      dal.IQuestionDal    QuestionModelDal dal.IQuestionModelDal

} func (s ProjectService) Create(ctx context.Context, projectBo bo.ProjectCreateBo) (int64, error) {}

var ProjectSet = wire.NewSet(    wire.Struct(new(ProjectService), ""),    wire.Bind(new(IProjectService), new(ProjectService))) ```

service.go

// ServiceSet service注入 var ServiceSet = wire.NewSet(    ProjectSet,    // other service set... )

handler 虛擬碼如下:

``` var ProjectHandlerSet = wire.NewSet(wire.Struct(new(ProjectHandler), "*"))

type ProjectHandler struct {    ProjectService service.IProjectService }

func (s ProjectHandler) CreateProject(ctx context.Context, req project.CreateProjectRequest) (resp * project.CreateProjectResponse, err error) {} ```

injector.go 虛擬碼如下:

``` var InjectorSet = wire.NewSet(wire.Struct(new(Injector), "*"))

type Injector struct {    ProjectHandler *handler.ProjectHandler    // others... } ```

wire.go

```  // +build wireinject

package app

func BuildInjector() (*Injector, func(), error) {    wire.Build(       // db       common.InitGormDB,       // dal       dal.DalSet,       // services       service.ServiceSet,       // handler       handler.ProjectHandlerSet,       // injector       InjectorSet,       // other components...    )

return new(Injector), nil, nil } ```

5. 注意事項

5.1 相同型別問題

wire 不允許不同的注入物件擁有相同的型別。google 官方認為這種情況,是設計上的缺陷。這種情況下,可以通過類型別名來將物件的型別進行區分。

例如服務會同時操作兩個 Redis 例項,RedisA & RedisB

func NewRedisA() *goredis.Client {...} func NewRedisB() *goredis.Client {...}

對於這種情況,wire 無法推導依賴的關係。可以這樣進行實現:

``` type RedisCliA goredis.Client type RedisCliB goredis.Client

func NewRedisA() RedicCliA {...} func NewRedisB() RedicCliB {...} ```

5.2 單例問題

依賴注入的本質是用單例來繫結介面和實現介面物件間的對映關係。而通常實踐中不可避免的有些物件是有狀態的,同一型別的物件總是要在不同的用例場景發生變化,單例就會引起資料的錯誤,不能儲存彼此的狀態。針對這種場景我們通常設計多層的 DI 容器來實現單例隔離,亦或是脫離 DI 容器自行管理物件的生命週期。

6. 結語

Wire 是一個強大的依賴注入工具。與 Inject 、Dig 等不同的是,Wire只生成程式碼而不是使用反射在執行時注入,不用擔心會有效能損耗。專案工程化過程中,Wire 可以很好協助我們完成複雜物件的構建組裝。

更多關於 Wire 的介紹請傳送至:http://github.com/google/wire

7. 關於我們

我們來自位元組跳動飛書商業應用研發部(Lark Business Applications),目前我們在北京、深圳、上海、武漢、杭州、成都、廣州、三亞都設立了辦公區域。我們關注的產品領域主要在企業經驗管理軟體上,包括飛書 OKR、飛書績效、飛書招聘、飛書人事等 HCM 領域系統,也包括飛書審批、OA、法務、財務、採購、差旅與報銷等系統。

歡迎加入我們。掃碼發現職位&投遞簡歷(二維碼如下)官網投遞:

圖片