一、前言
線上專案往往依賴非常多的具備特定能力的資源,如: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,絕大部分部落格內容都將會透過影片講解,不過文章一般是先於影片釋出
白澤的開源 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 為例,左側為手動完成依賴注入,右側為不使用依賴注入:
🌟 不使用依賴注入風險:
- 全域性變數十分不安全,存在覆寫的可能
- 資源散落在各處,可能重複建立,浪費記憶體,後續維護能力極差
- 提高迴圈依賴的風險
- 全域性變數的引入提高單元測試的成本
- 不使用依賴注入 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
各個目錄的關係如圖:
-
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的依賴注入在設計思路上存在一些相同點和不同點。以下是對這些相同點和不同點的分析:
相同點
- 降低耦合度:兩者都透過依賴注入的方式實現了程式碼的松耦合。這意味著,一個物件不需要顯式地建立或查詢它所依賴的其他物件,這些依賴項會由外部容器(如Spring容器)或工具(如Wire)自動提供。
- 提高可測試性:由於依賴關係被解耦,可以更容易地替換依賴項以進行單元測試。無論是Spring Boot還是使用Wire的Golang應用,都可以輕鬆地為元件提供模擬或存根的依賴項以進行測試。
- 靈活性:兩者都允許在不修改元件程式碼的情況下替換依賴項。這使得應用程式在維護和擴充套件時更加靈活。
不同點
- 實現方式:
- Spring Boot的依賴注入是基於Java的反射機制和Spring框架的容器管理功能實現的。Spring容器負責建立和管理Bean的生命週期,並在需要時自動注入依賴項,核心在於執行時。
- Wire是一個Golang的程式碼生成工具,它透過分析程式碼中的建構函式和結構體標籤,自動生成依賴注入的程式碼(減少人工工作量),在開發階段已經透過工具生成好了依賴注入的程式碼,程式編譯時,資源之間的依賴關係已經固定。
- 配置方式:
- Spring Boot的依賴注入通常透過配置檔案(如application.properties或application.yml)和註解(如@Autowired)進行配置。開發者可以在配置檔案中定義Bean的屬性,並透過註解在需要注入的地方指明依賴關係。
- Wire則透過特殊的Go檔案(通常是wire.go檔案)來定義型別之間的依賴關係。這些檔案包含了用於生成依賴注入程式碼的指令和後設資料。
- 執行時開銷:
- 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