Golang 依賴注入設計哲學|12.6K 🌟 的依賴注入庫 wire

白泽talk發表於2024-07-03

一、前言

線上專案往往依賴非常多的具備特定能力的資源,如:DB、MQ、各種中介軟體,以及隨著專案業務的複雜化,單一專案內,業務模組也逐漸增多,如何高效、整潔管理各種資源十分重要。

本文從“術”層面,講述“依賴注入”的實現,帶你體會其對於整潔架構 & DDD 等設計思想的落地,起到的支撐作用。

涉及內容:

  • 最熱門的 golang 依賴注入庫,GitHub 🌟 12.5k:https://github.com/google/wire

  • GiuHub 🌟 22.5k 的 golang 微服務框架 kratos 預設使用 wire 作為依賴注入方式:https://github.com/go-kratos/kratos

  • Spring Boot 與 Golang 的依賴注入對比

  • 依賴注入的設計哲學

📺 B站賬號:白澤talk,絕大部分部落格內容都將會透過影片講解,不過文章一般是先於影片釋出

image-20240703002016429

白澤的開源 Golang 學習倉庫:https://github.com/BaiZe1998/go-learning,用於文章歸檔 & 聚合部落格程式碼案例

公眾號【白澤talk】,本期內容的 pdf 版本,可以關注公眾號,回覆【依賴注入】獲得,往期資源的獲取,都是類似的方式。

二、What

📒 本文所涉及編寫的程式碼,已收錄於 https://github.com/BaiZe1998/go-learning/di 目錄

一句話概括:例項 A 的建立,依賴於例項 B 的建立,且在例項 A 的生命週期內,持有對例項 B 的訪問許可權。

2.1 案例分析

依賴注入(Dependency Injection, DI),以 Golang 為例,左側為手動完成依賴注入,右側為不使用依賴注入

🌟 不使用依賴注入風險:

  1. 全域性變數十分不安全,存在覆寫的可能
  2. 資源散落在各處,可能重複建立,浪費記憶體,後續維護能力極差
  3. 提高迴圈依賴的風險
  4. 全域性變數的引入提高單元測試的成本

image-20240625222009500

  • 不使用依賴注入 demo
package main

var (
	mysqlUrl = "mysql://blabla"
	// 全域性資料庫例項
	db = NewMySQLClient(mysqlUrl)
)

func NewMySQLClient(url string) *MySQLClient {
	return &MySQLClient{url: url}
}

type MySQLClient struct {
	url string
}

func (c *MySQLClient) Exec(query string, args ...interface{}) string {
	return "data"
}

func NewApp() *App {
	return &App{}
}

type App struct {
}

func (a *App) GetData(query string, args ...interface{}) string {
	data := db.Exec(query, args...)
	return data
}

// 不使用依賴注入
func main() {
	app := NewApp()
	rest := app.GetData("select * from table where id = ?", "1")
	println(rest)
}
  • 手動依賴注入 demo
package main

func NewMySQLClient(url string) *MySQLClient {
	return &MySQLClient{url: url}
}

type MySQLClient struct {
	url string
}

func (c *MySQLClient) Exec(query string, args ...interface{}) string {
	return "data"
}

func NewApp(client *MySQLClient) *App {
	return &App{client: client}
}

type App struct {
	// App 持有唯一的 MySQLClient 例項
	client *MySQLClient
}

func (a *App) GetData(query string, args ...interface{}) string {
	data := a.client.Exec(query, args...)
	return data
}

// 手動依賴注入
func main() {
	client := NewMySQLClient("mysql://blabla")
	app := NewApp(client)
	rest := app.GetData("select * from table where id = ?", "1")
	println(rest)
}

三、Why

依賴注入 (Dependency Injection,縮寫為 DI),可以理解為一種程式碼的構造模式(就是寫法),按照這樣的方式來寫,能夠讓你的程式碼更加容易維護。

四、How

4.1 Golang 依賴注入

以 Golang 🌟 最多的開源庫 wire 為例講解:https://github.com/google/wire/blob/main/docs/guide.md

wire是由 google 開源的一個供 Go 語言使用的依賴注入程式碼生成工具。它能夠根據你的程式碼,生成相應的依賴注入 go 程式碼。

而與其它依靠反射實現的依賴注入工具不同的是,wire 能在編譯期(準確地說是程式碼生成時)如果依賴注入有問題,在程式碼生成時即可報出來,不會拖到執行時才報,更便於 debug。

  • Install:
go install github.com/google/wire/cmd/wire@latest
  • provider: a function that can produce a value

以上面手動實現依賴注入為基礎,wire 做的工作是幫助開發者完成如下組裝過程

client := NewMySQLClient("mysql://blabla")
app := NewApp(client)

而其中用到的 NewMySQLClient、NewApp 在 wire 定義為一個個的 provider,是需要提前由開發者實現的。

func NewMySQLClient(url string) *MySQLClient {
	return &MySQLClient{url: url}
}

func NewApp(client *MySQLClient) *App {
	return &App{client: client}
}

假設系統中的資源很多,配置很多,出現瞭如下複雜的初始化流程,人工完成依賴注入則變得複雜:

a := NewA(xxx, yyy) error
b := NewB(ctx, a) error
c := NewC(zzz, a, b) error
d := NewD(www, kkk, a) error
e := NewD(ctx, b, d) error
  • injector: a function that calls providers in dependency order

如下是名為 wire.go 的依賴注入配置檔案,是一個只會被 wire 命令列工具處理的 injector 檔案,用於宣告依賴注入流程。

wire.go:

//go:build wireinject
// +build wireinject

// The build tag makes sure the stub is not built in the final build.

package main

import "github.com/google/wire"

// wireApp init application.
func wireApp(url string) *App {
	wire.Build(NewMySQLClient, NewApp)
	return nil
}

執行 wire 命令,則在當前目錄下生成 wire_gen.go 檔案,此時的 wireApp 函式,就等價於最初手動編寫的依賴注入流程,可以在真正需要初始化的引入。

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 main

// Injectors from wire.go:

// wireApp init application.
func wireApp(url string) *App {
   mySQLClient := NewMySQLClient(url)
   app := NewApp(mySQLClient)
   return app
}

4.2 針對複雜專案的依賴注入設計哲學

這裡以 go-kratos 的模版專案為例講解,是一個 helloworld 服務,我們著重分析其藉助 wire 進行依賴注入的部分。

以下 helloworld 模板服務的 interanl 目錄的內容:

.
├── biz
│   ├── README.md
│   ├── biz.go
│   └── greeter.go
├── conf
│   ├── conf.pb.go
│   └── conf.proto
├── data
│   ├── README.md
│   ├── data.go
│   └── greeter.go
├── server
│   ├── grpc.go
│   ├── http.go
│   └── server.go
└── service
    ├── README.md
    ├── greeter.go
    └── service.go

各個目錄的關係如圖:

image-20240702235735708

  • data:業務資料訪問,包含 cache、db 等封裝,實現了 biz 的 repo 介面,data 偏重業務的含義,它所要做的是將領域物件重新拿出來。

  • biz:業務邏輯的組裝層,類似 DDD 的 domain 層,data 類似 DDD 的 repo,repo 介面在這裡定義,使用依賴倒置的原則。

  • service:實現了 api 定義的服務層,類似 DDD 的 application 層,處理 DTO 到 biz 領域實體的轉換(DTO -> DO),同時協同各類 biz 互動,但是不應處理複雜邏輯。

  • server:為http和grpc例項的建立和配置,以及註冊對應的 service 。

🌟上圖右側部分,表示了模組之間的依賴關係,可以看到,依賴的注入是逆向的,資源往往被業務模組持有,業務模組則被負責編排業務的應用持有,應用則被負責對外通訊的模組持有。

此時在服務啟動前的例項化階段,provider 的定義和注入,本質是這樣一種狀態:

func main() {
    dbClient := NewDBClient()
    dataN := NewDataN(dbClient)
    dataM := NewDataM(dbClient)
    bizA := NewBizA(dataN)
    bizB := NewBizB(dataM)
    bizC := NewBizC(dataN, dataM)
    serviceX := NewService(bizA, bizB, bizC)
    server := NewServer(serviceX)
    server.httpXXX // 提供 http 服務
    server.grpcXXX // 提供 grpc 服務
}

在 helloworld 這個 demo 當中,則是這樣定義 provider 的:

// biz 目錄
var ProviderSet = wire.NewSet(NewGreeterUsecase)

type GreeterUsecase struct {
	repo GreeterRepo
	log  *log.Helper
}

func NewGreeterUsecase(repo GreeterRepo, logger log.Logger) *GreeterUsecase {
	return &GreeterUsecase{repo: repo, log: log.NewHelper(logger)}
}

func (uc *GreeterUsecase) CreateGreeter(ctx context.Context, g *Greeter) (*Greeter, error) {
	uc.log.WithContext(ctx).Infof("CreateGreeter: %v", g.Hello)
	return uc.repo.Save(ctx, g)
}

// data 目錄
var ProviderSet = wire.NewSet(NewData, NewGreeterRepo)

type Data struct {
	// TODO wrapped database client
}

func NewData(c *conf.Data, logger log.Logger) (*Data, func(), error) {
	cleanup := func() {
		log.NewHelper(logger).Info("closing the data resources")
	}
	return &Data{}, cleanup, nil
}

type greeterRepo struct {
	data *Data
	log  *log.Helper
}

func NewGreeterRepo(data *Data, logger log.Logger) biz.GreeterRepo {
	return &greeterRepo{
		data: data,
		log:  log.NewHelper(logger),
	}
}
// service 目錄
var ProviderSet = wire.NewSet(NewGreeterService)

type GreeterService struct {
	v1.UnimplementedGreeterServer

	uc *biz.GreeterUsecase
}

func NewGreeterService(uc *biz.GreeterUsecase) *GreeterService {
	return &GreeterService{uc: uc}
}

func (s *GreeterService) SayHello(ctx context.Context, in *v1.HelloRequest) (*v1.HelloReply, error) {
	g, err := s.uc.CreateGreeter(ctx, &biz.Greeter{Hello: in.Name})
	if err != nil {
		return nil, err
	}
	return &v1.HelloReply{Message: "Hello " + g.Hello}, nil
}

// server 目錄
var ProviderSet = wire.NewSet(NewGRPCServer, NewHTTPServer)

func NewGRPCServer(c *conf.Server, greeter *service.GreeterService, logger log.Logger) *grpc.Server {
	var opts = []grpc.ServerOption{
		grpc.Middleware(
			recovery.Recovery(),
		),
	}
	if c.Grpc.Network != "" {
		opts = append(opts, grpc.Network(c.Grpc.Network))
	}
	if c.Grpc.Addr != "" {
		opts = append(opts, grpc.Address(c.Grpc.Addr))
	}
	if c.Grpc.Timeout != nil {
		opts = append(opts, grpc.Timeout(c.Grpc.Timeout.AsDuration()))
	}
	srv := grpc.NewServer(opts...)
	v1.RegisterGreeterServer(srv, greeter)
	return srv
}

在 helloworld 這個 demo 當中,則是這樣定義 injector 的:

// wire.go
func wireApp(*conf.Server, *conf.Data, log.Logger) (*kratos.App, func(), error) {
   panic(wire.Build(server.ProviderSet, data.ProviderSet, biz.ProviderSet, service.ProviderSet, newApp))
}

最後執行 wire 的到的完成注入的檔案如下:

// wire_gen.go
func wireApp(confServer *conf.Server, confData *conf.Data, logger log.Logger) (*kratos.App, func(), error) {
	dataData, cleanup, err := data.NewData(confData, logger)
	if err != nil {
		return nil, nil, err
	}
	greeterRepo := data.NewGreeterRepo(dataData, logger)
	greeterUsecase := biz.NewGreeterUsecase(greeterRepo, logger)
	greeterService := service.NewGreeterService(greeterUsecase)
	grpcServer := server.NewGRPCServer(confServer, greeterService, logger)
	httpServer := server.NewHTTPServer(confServer, greeterService, logger)
	app := newApp(logger, grpcServer, httpServer)
	return app, func() {
		cleanup()
	}, nil
}

生成程式碼之後,則可以像使用普通的 golang 函式一樣,使用這個 wire_gen.go 檔案內的 wireApp 函式例項化一個 helloworld 服務

func main() {
	flag.Parse()
	logger := log.With(log.NewStdLogger(os.Stdout),
		// ...
	)
	c := config.New(
        // ...
	)
	defer c.Close()
	// ...

	app, cleanup, err := wireApp(bc.Server, bc.Data, logger)
	if err != nil {
		panic(err)
	}
	defer cleanup()

	// start and wait for stop signal
	if err := app.Run(); err != nil {
		panic(err)
	}
}

4.3 wire 的更多用法

參見 wire 的文件,自己用幾遍就明白了,這裡舉幾個例子:

  • 定義攜帶 error 返回值的 provider
func ProvideBaz(ctx context.Context, bar Bar) (Baz, error) {
    if bar.X == 0 {
        return Baz{}, errors.New("cannot provide baz when bar is zero")
    }
    return Baz{X: bar.X}, nil
}
  • provider 集合:方便組織多個 provider
var SuperSet = wire.NewSet(ProvideFoo, ProvideBar, ProvideBaz)
  • 介面繫結:
type Fooer interface {
    Foo() string
}

type MyFooer string

func (b *MyFooer) Foo() string {
    return string(*b)
}

func provideMyFooer() *MyFooer {
    b := new(MyFooer)
    *b = "Hello, World!"
    return b
}

type Bar string

func provideBar(f Fooer) string {
    // f will be a *MyFooer.
    return f.Foo()
}

var Set = wire.NewSet(
    provideMyFooer,
    wire.Bind(new(Fooer), new(*MyFooer)),
    provideBar)

五、對比 Spring Boot 的依賴注入

Spring Boot的依賴注入(DI)和Golang開源庫Wire的依賴注入在設計思路上存在一些相同點和不同點。以下是對這些相同點和不同點的分析:

相同點

  1. 降低耦合度:兩者都透過依賴注入的方式實現了程式碼的松耦合。這意味著,一個物件不需要顯式地建立或查詢它所依賴的其他物件,這些依賴項會由外部容器(如Spring容器)或工具(如Wire)自動提供。
  2. 提高可測試性:由於依賴關係被解耦,可以更容易地替換依賴項以進行單元測試。無論是Spring Boot還是使用Wire的Golang應用,都可以輕鬆地為元件提供模擬或存根的依賴項以進行測試。
  3. 靈活性:兩者都允許在不修改元件程式碼的情況下替換依賴項。這使得應用程式在維護和擴充套件時更加靈活。

不同點

  1. 實現方式
    • Spring Boot的依賴注入是基於Java的反射機制和Spring框架的容器管理功能實現的。Spring容器負責建立和管理Bean的生命週期,並在需要時自動注入依賴項,核心在於執行時
    • Wire是一個Golang的程式碼生成工具,它透過分析程式碼中的建構函式和結構體標籤,自動生成依賴注入的程式碼(減少人工工作量),在開發階段已經透過工具生成好了依賴注入的程式碼,程式編譯時,資源之間的依賴關係已經固定。
  2. 配置方式
    • Spring Boot的依賴注入通常透過配置檔案(如application.properties或application.yml)和註解(如@Autowired)進行配置。開發者可以在配置檔案中定義Bean的屬性,並透過註解在需要注入的地方指明依賴關係。
    • Wire則透過特殊的Go檔案(通常是wire.go檔案)來定義型別之間的依賴關係。這些檔案包含了用於生成依賴注入程式碼的指令和後設資料。
  3. 執行時開銷
    • Spring Boot的依賴注入在執行時需要依賴Spring容器來管理Bean的生命週期和依賴關係。這可能會引入一些額外的執行時開銷,特別是在大型應用程式中。
    • Wire在編譯時生成依賴注入的程式碼,因此它在執行時沒有額外的開銷。這使得使用Wire的Golang應用程式通常具有更好的效能。

六、參考資料

kratos:https://go-kratos.dev/en/docs/getting-started/start/

wire:https://github.com/google/wire/blob/main/_tutorial/README.md

相關文章