Wire 是啥
Wire 是一個輕巧的Golang依賴注入工具。它由Go Cloud團隊開發,通過自動生成代碼的方式在編譯期完成依賴注入。
依賴注入是保持軟件 “低耦合、易維護” 的重要設計准則之一。
此准則被廣泛應用在各種開發平台之中,有很多與之相關的優秀工具。
其中最著名的當屬 Spring,Spring IOC 作為框架的核心功能對Spring的發展到今天統治地位起了決定性作用。
事實上, 軟件開發 S.O.L.I.D 原則 中的“D”, 就專門指代這個話題。
Wire 的特點
依賴注入很重要,所以Golang社區中早已有人開發了相關工具, 比如來自Uber 的 dig 、來自Facebook 的 inject 。他們都通過反射機制實現了運行時依賴注入。
為什么Go Cloud團隊還要重造一遍輪子呢? 因為在他們看來上述類庫都不符合Go的哲學:
Clear is better than clever ,Reflection is never clear.
— Rob Pike
作為一個代碼生成工具, Wire可以生成Go源碼並在編譯期完成依賴注入。 它不需要反射機制或 Service Locators 。 后面會看到, Wire 生成的代碼與手寫無異。 這種方式帶來一系列好處:
- 方便debug,若有依賴缺失編譯時會報錯
- 因為不需要 Service Locators, 所以對命名沒有特殊要求
- 避免依賴膨脹。 生成的代碼只包含被依賴的代碼,而運行時依賴注入則無法作到這一點
- 依賴關系靜態存於源碼之中, 便於工具分析與可視化
團隊對Wire設計的仔細權衡可以參看 Go Blog 。
雖然目前Wire只發布了 v0.4 .0,但已經比較完備地達成了團隊設定的目標。 預計后面不會有什么大變化了。 從團隊傲嬌的聲明中可以看出這一點:
It works well for the tasks it was designed to perform, and we prefer to keep it as simple as possible.
We’ll not be accepting new features at this time, but will gladly accept bug reports and fixes.
上手使用
安裝很簡單,運行 go get github.com/google/wire/cmd/wire
之后, wire
命令行工具 將被安裝到 $GOPATH/bin
。只要確保 $GOPATH/bin
在 $PATH
中, wire
命令就可以在任何目錄調用了。
在進一步介紹之前, 需要先解釋 wire 中的兩個核心概念: Provider 和 Injector:
Provider: 生成組件的普通方法。這些方法接收所需依賴作為參數,創建組件並將其返回。
組件可以是對象或函數 —— 事實上它可以是任何類型,但單一類型在整個依賴圖中只能有單一provider。因此返回 int
類型的provider 不是個好主意。 對於這種情況, 可以通過定義類型別名來解決。例如先定義type Category int
,然后讓 provider 返回 Category
類型
典型provider示例如下:
// wiretest.go
// ConnectionOpt: 連接默認選項
type ConnectionOpt struct {
Drive string
DNS string
}
// User: 數據庫模型
type User struct {
Name string `json:"name"`
Age int `json:"age;string"`
}
/// DefaultConnectionOpt 提供默認的連接選項
func DefaultConnectionOpt() *ConnectionOpt {
return &ConnectionOpt{
Drive: "Mysql",
DNS: "root:123456@tcp(127.0.0.1:3306)/test?charset=utf8mb4&parseTime=True",
}
}
// NewDb 提供一個Db對象
func NewDb(opt *ConnectionOpt) (*sqlx.DB, error) {
return sqlx.Connect(opt.Drive, opt.DNS)
}
// NewUserLoadFunc 提供可以加載用戶的功能
// PS:返回的函數簽名隨意定義 但是要跟下面 `UserLoader(注入器)`一致
func NewUserLoadFunc(db *sqlx.DB) (func(id int) (*User,error), error) {
return func(id int) (*User,error) {
var res User
err := db.Get(&res, "SELECT name, age from user where id = ?", id)
return &res,err
}, nil
}
實踐中, 一組業務相關的provider時常被放在一起組織成 ProviderSet,以方便維護與切換。
var DbSet = wire.NewSet(DefaultConnectionOpt, NewDb)
Injector: 由wire
自動生成的函數。函數內部會按根據依賴順序調用相關privoder 。
為了生成此函數, 我們在 wire.go
(文件名非強制,但一般約定如此)文件中定義injector函數簽名。 然后在函數體中調用wire.Build
,並以所需provider作為參數(無須考慮順序)。
由於wire.go
中的函數並沒有真正返回值,為避免編譯器報錯, 簡單地用panic
函數包裝起來即可。不用擔心執行時報錯, 因為它不會實際運行,只是用來生成真正的代碼的依據。一個簡單的wire.go 示例
// +build wireinject
package main
// UserLoader: 加載用戶
func UserLoader() (func(int) (*User,error), error) {
panic(wire.Build(NewUserLoadFunc, DbSet))
}
var DbSet = wire.NewSet(DefaultConnectionOpt, NewDb)
有了這些代碼以后,運行 wire
命令將生成wire_gen.go文件,其中保存了injector 函數的真正實現。 wire.go 中若有非injector 的代碼將被原樣復制到 wire_gen.go 中(雖然技術上允許,但不推薦這樣作)。 生成代碼如下:
$ wire
// Code generated by Wire. DO NOT EDIT.
//go:generate wire
//+build !wireinject
package main
import (
"github.com/google/wire"
"github.com/jmoiron/sqlx"
)
import (
_ "github.com/go-sql-driver/mysql"
)
// Injectors from wiretest.go:
// UserLoader: 加載用戶
func UserLoader() (func(int) (*User, error), error) {
connectionOpt := DefaultConnectionOpt()
db, err := NewDb(connectionOpt)
if err != nil {
return nil, err
}
v, err := NewUserLoadFunc(db)
if err != nil {
return nil, err
}
return v, nil
}
// wiretest.go:
// ConnectionOpt: 連接默認選項
type ConnectionOpt struct {
Drive string
DNS string
}
// User: 數據庫模型
type User struct {
Name string `json:"name"`
Age int `json:"age;string"`
}
/// DefaultConnectionOpt 提供默認的連接選項
func DefaultConnectionOpt() *ConnectionOpt {
return &ConnectionOpt{
Drive: "mysql",
DNS: "root:123456@tcp(127.0.0.1:3306)/test?charset=utf8mb4&parseTime=True",
}
}
// NewDb 提供一個Db對象
func NewDb(opt *ConnectionOpt) (*sqlx.DB, error) {
return sqlx.Connect(opt.Drive, opt.DNS)
}
// NewUserLoadFunc 提供可以加載用戶的功能
func NewUserLoadFunc(db *sqlx.DB) (func(id int) (*User, error), error) {
return func(id int) (*User, error) {
var res User
err := db.Get(&res, "SELECT name, age from user where id = ?", id)
return &res, err
}, nil
}
var DbSet = wire.NewSet(DefaultConnectionOpt, NewDb)
上述代碼有兩點值得關注:
- wire.go 第一行
// +build wireinject
,這個 build tag 確保在常規編譯時忽略wire.go 文件(因為常規編譯時不會指定wireinject
標簽)。 與之相對的是 wire_gen.go 中的//+build !wireinject
。兩組對立的build tag保證在任意情況下, wire.go 與 wire_gen.go 只有一個文件生效, 避免了“UserLoader方法被重復定義”的編譯錯誤 - 自動生成的UserLoader 代碼包含了 error 處理。 與我們手寫代碼幾乎相同。 對於這樣一個簡單的初始化過程, 手寫也不算麻煩。 但當組件數達到幾十、上百甚至更多時, 自動生成的優勢就體現出來了。
- 注意
NewUserLoadFunc
返回的函數簽名可以隨意定義,但是要注意的是要和下面UserLoader
的函數簽名保持一致
要觸發“生成”動作有兩種方式:go generate
或 wire
。前者僅在 wire_gen.go 已存在的情況下有效(因為wire_gen.go 的第三行 //go:generate wire
),而后者在任何時候都有可以調用。 並且后者有更多參數可以對生成動作進行微調, 所以建議始終使用 wire
命令。
然后我們就可以使用真正的injector了, 例如:
package main
import "fmt"
func main() {
fn, err := UserLoader()
if err != nil {
fmt.Println(err)
return
}
user,err := fn(1)
fmt.Println(user,err)
}
如果不小心忘記了某個provider, wire
會報出具體的錯誤, 幫忙開發者迅速定位問題。 例如我們修改 wire.go
,去掉其中的NewDb
// +build wireinject
package main
import "github.com/google/wire"
func UserLoader() (func(int) (*User,error), error) {
panic(wire.Build(NewUserLoadFunc, DbSet))
}
var DbSet = wire.NewSet(DefaultConnectionOpt) //forgot add Db provider
將會報出明確的錯誤:“no provider found for *example.Db
”
wire: .../test/wireTest/wiretest.go:59:1: inject UserLoader: no provider found for *github.com/jmoiron/sqlx.DB
needed by func(int) (*.../wireTest.User, error) in provider "NewUserLoadFunc" (.../test/wireTest/wiretest.go:50:6)
wire: .../test/wireTest: generate failed
wire: at least one generate failure
同樣道理, 如果在wire.go 中寫入了未使用的provider , 也會有明確的錯誤提示。
高級功能
談過基本用法以后, 我們再看看高級功能
接口注入
有時需要自動注入一個接口, 這時有兩個選擇:
- 較直接的作法是在provider中生成具體類, 然后返回接口類型。 但這不符合Golang代碼規范。一般不采用
- 讓provider返回具體類,但在injector聲明環節作文章,將類綁定成接口,例如:
// FooInf是一個接口
// FooClass是一個類,實現了該接口
// fooClassProvider, a provider function that provider *FooClassvar
var set = wire.NewSet(
fooClassProvider,
wire.Bind(new(FooInf), new(*FooClass) // bind class to interface
)
屬性自動注入
有時我們不需什么特定的初始化工作, 只是簡單地創建一個對象實例, 為其指定屬性賦值,然后返回。當屬性多的時候,這種工作會很無聊。
// provider.gotype App struct {
Foo *Foo
Bar *Bar
}
func DefaultApp(foo *Foo, bar *Bar)*App{
return &App{Foo: foo, Bar: bar}
}
// wire.go
...
wire.Build(provideFoo, provideBar, DefaultApp)
...
wire.Struct
可以簡化此類工作, 指定屬性名來注入特定屬性:
wire.Build(provideFoo, provideBar, wire.Struct(new(App),"Foo","Bar")
如果要注入全部屬性,則有更簡化的寫法:
wire.Build(provideFoo, provideBar, wire.Struct(new(App), "*")
如果struct 中有個別屬性不想被注入,那么可以修改 struct 定義:
type App struct {
Foo *Foo
Bar *Bar
NoInject int `wire:"-"`
}
這時 NoInject
屬性會被忽略。與常規 provider相比, wire.Struct
提供一項額外的靈活性: 它能適應指針與非指針類型,根據需要自動調整生成的代碼。
大家可以看到wire.Struct
的確提供了一些便利。但它要求注入屬性可公開訪問, 這導致對象暴露本可隱藏的細節。
好在這個問題可以通過上面提到的“接口注入”來解決。用 wire.Struct
創建對象,然后將其類綁定到接口上。 至於在實踐中如何權衡便利性和封裝程度,則要具體情況具體分析了。
值綁定
雖不常見,但有時需要為基本類型的屬性綁定具體值, 這時可以使用 wire.Value
:
// provider.go
type Foo struct {
X int
}// wire.go
...
wire.Build(wire.Value(Foo{X: 42}))
...
為接口類型綁定具體值,可以使用 wire.InterfaceValue
:
wire.Build(wire.InterfaceValue(new(io.Reader), os.Stdin))
把對象屬性用作Provider
有時我們只是需要用某個對象的屬性作為Provider,例如
// provider
func provideBar(foo Foo)*Bar{
return foo.Bar
}
// injector
...
wire.Build(provideFoo, provideBar)
...
這時可以用 wire.FieldsOf
加以簡化,省掉啰嗦的 provider:
wire.Build(provideFoo, wire.FieldsOf(new(Foo), "Bar"))
與 wire.Struct
類似, wire.FieldsOf
也會自動適應指針/非指針的注入請求
清理函數
前面提到若provider 和 injector 函數有返回錯誤, 那么wire會自動處理。除此以外,wire還有另一項自動處理能力: 清理函數。
所謂清理函數是指型如 func()
的閉包, 它隨provider生成的組件一起返回, 確保組件所需資源可以得到清理。
清理函數典型的應用場景是文件資源和網絡連接資源,例如:
type App struct {
File *os.File
Conn net.Conn
}
func provideFile() (*os.File, func(), error) {
f, err := os.Open("foo.txt")
if err != nil {
return nil, nil, err
}
cleanup := func() {
if err := f.Close(); err != nil {
log.Println(err)
}
}
return f, cleanup, nil
}
func provideNetConn() (net.Conn, func(), error) {
conn, err := net.Dial("tcp", "foo.com:80")
if err != nil {
return nil, nil, err
}
cleanup := func() {
if err := conn.Close(); err != nil {
log.Println(err)
}
}
return conn, cleanup, nil
}
上述代碼定義了兩個 provider 分別提供了文件資源和網絡連接資源
wire.go
// +build wireinject
package main
import "github.com/google/wire"
func NewApp() (*App, func(), error) {
panic(wire.Build(
provideFile,
provideNetConn,
wire.Struct(new(App), "*"),
))
}
注意由於provider 返回了清理函數, 因此injector函數簽名也必須返回,否則將會報錯
wire_gen.go
// Code generated by Wire. DO NOT EDIT.
//go:generate wire
//+build !wireinject
package main
// Injectors from wire.go:
func NewApp() (*App, func(), error) {
file, cleanup, err := provideFile()
if err != nil {
return nil, nil, err
}
conn, cleanup2, err := provideNetConn()
if err != nil {
cleanup()
return nil, nil, err
}
app := &App{
File: file,
Conn: conn,
}
return app, func() {
cleanup2()
cleanup()
}, nil
}
生成代碼中有兩點值得注意:
- 當
provideNetConn
出錯時會調用cleanup()
, 這確保了即使后續處理出錯也不會影響前面已分配資源的清理。 - 最后返回的閉包自動組合了
cleanup2()
和cleanup()
。 意味着無論分配了多少資源, 只要調用過程不出錯,他們的清理工作就會被集中到統一的清理函數中。 最終的清理工作由injector的調用者負責
可以想像當幾十個清理函數的組合在一起時, 手工處理上述兩個場景是非常繁瑣且容易出錯的。 wire 的優勢再次得以體現。
然后就可以使用了:
func main() {
app, cleanup, err := NewApp()
if err != nil {
log.Fatal(err)
}
defer cleanup()
...
}
注意main函數中的 defer cleanup()
,它確保了所有資源最終得到回收