0%

go 库学习之 Wire

之前其实已经学习过 Go 生态中的一个依赖注入库 fx,这篇文章我们将继续学习另一个依赖注入库 Wire

Wire 简介

Wire 是 Google 开发的一个依赖注入(Dependency Injection)库,它通过代码生成的方式实现依赖注入,而不是使用反射机制。Wire 是一个代码生成工具,它通过依赖注入的方式自动连接组件,组件间的依赖通过 函数参数 的方式声明,鼓励使用显式初始化而不是全局变量。使用 Wire 有一个好处,因为 Wire 不依赖于运行时状态/反射,因此使用 Wire 时写的代码照样可以用于手写初始化的场景。

Wire 解决什么问题

依赖注入是一种标准技术,通过显式地为组件提供其工作所需的所有依赖关系,来生成灵活且松散耦合的代码。在 Go 中,依赖 通常是以 constructor 参数的形式出现:

1
2
// NewUserStore returns a UserStore that uses cfg and db as dependencies.
func NewUserStore(cfg *Config, db *mysql.DB) (*UserStore, error) {...}

这种通过参数来传递依赖项的方式,在小型应用程序中工作良好。但是大型应用通常具有复杂的依赖关系图,这就需要编写大量的初始化代码,而且顺序也很重要。这导致难以干净地分解代码,尤其是某些依赖需要多次使用。实现服务间的替换也会很痛苦,因为需要更改依赖图:删除旧的依赖,添加一系列新的依赖。在实践中,在具有复杂依赖的应用程序中更改初始化代码既繁琐又缓慢。

依赖注入工具 Wire 则是为了简化 初始化代码 的管理。可以在代码或配置中描述服务及其依赖,然后由 Wire 来梳理组件间的依赖关系、依赖顺序,并为组件传递所需的依赖。当应用程序需要修改依赖时,只需要修改函数签名或者添加/删除 initializer(Wire 中也称为 provider),Wire 会自动完成根据依赖图 生成初始化代码 的繁琐工作。

Wire 最初是为 Go Cloud 服务的,go-cloud 的目标是为不同的云服务提供一致的 Go API,这样更容易编写可移植的云应用。举个例子:blob.Bucket 是云存储服务的同一接口,它底层即可以使用 AWS S3,也可以使用 GCS(Google Cloud Storage)。当使用 blob.Bucket API 时,即使底层存储服务发生变化,应用程序逻辑也无需修改。但是有个问题,初始化代码还是 provider 特定的,例如:

  • 构造 GCS blob.Bucket 需要 gcp.HTTPClient,最终需要 google.Credentials
  • 构造 S3 对象则需要 aws.Config,最终需要 AWS Credentials

这样应用程序如果需要替换 blob.Bucket 的底层实现,就需要大量修改初始化代码。而为了解决这个问题,通用的依赖注入工具 Wire 应运而生。

与其他依赖注入工具的比较

Go 已经有了不少依赖注入工具,例如 Uber 的 dig、Facebook 的 inject,这两个工具都是通过反射来实现运行时依赖注入,而 Wire 是一个代码生成工具,它在编译时实现依赖注入,不需要依赖反射或者 service locator。这种实现方式有以下好处:

  • 当依赖图变得复杂时,运行时依赖注入可能很难追踪、调试。而代码生成的方式则意味着 初始化代码 在运行时和普通的 Go 代码没有任何区别,更容易理解和调试。而且如果遗漏某个依赖,会导致编译期错误,而不是运行期错误
  • 不像 service locator 那样,不需要为注册服务而提供名称或 key,Wire 使用 Go 类型来连接组件及其依赖
  • 避免依赖膨胀,Wire 生成的代码只会 import 你需要的依赖,所以生成的二进制文件不会包含没有使用的组件。而运行时依赖注入工具只有到运行时才能发现 无用的依赖
  • Wire 的依赖图是静态可知的,方便工具使用,例如可视化工具

Wire 的基本概念

Wire 有两个基本概念:providerinjector

provider 是普通的 Go 函数,根据 依赖关系 提供 value,而依赖则是简单地通过 函数参数 描述。如下定义了 3 个 provider

1
2
3
4
5
6
7
8
9
// NewUserStore is the same function we saw above; it is a provider for UserStore,
// with dependencies on *Config and *mysql.DB.
func NewUserStore(cfg *Config, db *mysql.DB) (*UserStore, error) {...}

// NewDefaultConfig is a provider for *Config, with no dependencies.
func NewDefaultConfig() *Config {...}

// NewDB is a provider for *mysql.DB based on some connection info.
func NewDB(info *ConnectionInfo) (*mysql.DB, error) {...}

通常一起使用的 provider 可以分组为 ProviderSet,例如当使用 *UserStore 时,通常需要默认的 *Config,因此将如下 provider 分组到一个 ProviderSet

1
var UserStoreSet = wire.NewSet(NewUserStore, NewDefaultConfig)

injector 则是 Wire 生成的函数,它根据依赖关系调用 provider 来初始化组件。你需要编写 injector 的签名:将任何需要的输入作为参数,并插入一个 wire.Build 调用,同时将构建最终结果所需要的一系列 provider 或者 ProviderSet 作为 wire.Build 的参数。如下是一个 injector 的例子:

1
2
3
4
func initUserStore(info *ConnectionInfo) (*UserStore, error) {
wire.Build(UserStoreSet, NewDB)
return nil, nil // These return values are ignored.
}

之后通过 wire 的命令行工具 wire,就可以自动生成初始化代码,例如:

1
2
3
4
5
6
7
8
9
10
11
12
func initUserStore(info *ConnectionInfo) (*UserStore, error) {
config := NewDefaultConfig()
db, err := NewDB(info)
if err != nil {
return nil, err
}
userStore, err := NewUserStore(config, db)
if err != nil {
return nil, err
}
return userStore, nil
}

使用 Wire 时完全没有运行时依赖,自动生成的初始化代码就和普通的 Go 代码一样。

Wire 示例

接下来以 Wire 官方的 Tutorial 为例,完整地介绍 Wire 的使用方法。如下是一个简单的 Go 程序,它没有使用依赖注入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package main

import "fmt"

type Message string

type Greeter struct {
Message
}

type Event struct {
Greeter
}

func NewMessage() Message {
return Message("Hi")
}

func NewGreeter(m Message) Greeter {
return Greeter{Message: m}

}

func (g Greeter) Greet() Message {
return g.Message
}

func NewEvent(g Greeter) Event {
return Event{Greeter: g}
}

func (e Event) Start() {
fmt.Println(e.Greet())
}

func main() {
message := NewMessage()
greeter := NewGreeter(message)
event := NewEvent(greeter)

event.Start()
}

可以看到,在没有使用依赖注入的情况,为了生成最终的 Event 对象,需要手动依次创建 Message, Greeter, Event 对象。接下来使用 Wire 来改造这个过程,定义一个 wire.go 文件:

1
2
3
4
5
6
7
8
9
10
//+build wireinject

package main

import "github.com/google/wire"

func InitializeEvent() Event {
wire.Build(NewEvent, NewGreeter, NewMessage)
return Event{}
}

然后 main() 函数修改为如下代码:

1
2
3
4
5
func main() {
event := InitializeEvent()

event.Start()
}

InitializeEvent() 这个 injector 中,我们返回了一个 Event 的零值,这样做是可以的。因为 Wire 本身只是需要根据 injector 中提供的 provider 来生成初始化代码(这里即构造最终的 Event 对象)。因此 wire.go 本身只是在构建时使用,不需要包含在最终的二进制文件中。因此在 wire.go 中包含了一个 build tag

1
//+build wireinject

接下来就可以通过 wire 来生成初始化代码,首先需要安装 wire 命令行工具

1
go install github.com/google/wire/cmd/wire@latest

然后在当前目录下执行 wire 命令,它会找到 InitializeEvent injector 并生成一个对应的函数,包含初始化代码。生成的代码报文在文件 wire_gen.go 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# wire

# cat 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:

func InitializeEvent() Event {
message := NewMessage()
greeter := NewGreeter(message)
event := NewEvent(greeter)
return event
}

生成的代码和我们自己写的初始化代码是一样的,只不过这次我们不需要手动编写这部分逻辑了。这个示例只是展示了最简单的情况,实际上 Wire 还有很多特性:

  • 即使某些 provider 会返回 error,Wire 生成的初始化代码也能正确处理:它会检查 error 并尽早返回。
  • injector 也可以接收参数,并将它们传递给 provider
  • 当代码中出现错误时,wire 也会提供有用的编译错误信息(例如缺少或多余 provider)

当使用 Wire 时,wire.gowire_gen.go 都会提交到源码控制系统中。如果想了解 Wire 在大规模程序中的使用,可以参考这个 Go Cloud 中的 guestbook 示例

Wire 高级特性

上文已经介绍了 Wire 的基本用法,也简单介绍了 Wire 的两个核心概念:providerinjector。这里再详细介绍一下 providerinjector

  • Provider 函数必须是导出函数,这样才可以在其他 package 中使用这些函数
  • Provider 通过函数参数指定依赖
  • Provider 可以返回 errors
  • Provider 可以分组为 provider set。当几个 provider 通常一起使用时,将它们分组到一个 provider set,这样可以方便使用。通过 wire.NewSet 创建 provider set
  • 可以将其他 provider set 继续添加到 provider set
  • 应用程序在 injector 中将多个 provider 连接起来
  • injector 是一个函数,我们需要编写它的函数签名,同时在函数体内需要调用 wire.Build,函数的返回值 值本身 并不重要,只要保证类型正确即可。injector 也可以接收参数并返回错误
  • wire 会根据 injector 生成真正的 初始化代码,文件中的非 injector 代码则会原样复制到输出文件中
  • 通过 wire 命令生成 初始化代码,生成的 初始化代码 和普通的 Go 代码基本没有区别,这些生成的代码可以在没有 Wire 的环境中使用
  • 一旦 wire_gen.go 创建之后,可以通过 go generate 命令重新生成代码

接下来再来介绍 Wire 的一些高级特性。

interface binding

依赖注入通常用于为接口类型绑定一个具体的实现,Wire 通过 类型标识 来将输入映射到输出,因此你可以会倾向于创建一个返回接口类型的 provider。但这不符合 Go 语言的惯例,因为 Go 的最佳实践是返回具体类型。为了解决这个问题,你可以在 provider set 中声明一个 interface binding,如下是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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)
  • wire.Bind 的第一个参数是指向 interface 的指针,第二个参数是指向 实现该 interface 的具体类型 的指针
  • 包含 interface bindingprovider set 也必须包含提供 实现类型 的 provider

struct provider

可以通过 provided types 来构造结构体,使用 wire.Struct() 来创建一个 struct 类型并告诉 injector 应该注入到哪个字段,injector 会根据 field 的 type 调用 provider 来填充这些字段。wire.Struct() 可以生成 S*S 类型。

如下是个例子:

1
2
3
4
5
6
7
8
9
10
type FooBar struct {
MyFoo Foo
MyBar Bar
}

var Set = wire.NewSet(
ProvideFoo,
ProvideBar,
wire.Struct(new(FooBar), "MyFoo", "MyBar")
)

wire.Struct() 的第一个参数是指向期望 struct 的指针,之后的参数则是需要注入的字段名称,* 可以表示所有字段。如下代码则只注入了一个字段:

1
2
3
4
var Set = wire.NewSet(
ProvideFoo,
wire.Struct(new(FooBar), "MyFoo")
)

使用 ``wire:“-”` 结构体标签可以让 Wire 忽略某些字段:

1
2
3
4
type Foo struct {
mu sync.Mutex `wire:"-"`
Bar Bar
}

Binding values

有时候会想给某个类型绑定一个基本值,可以在 provider set 中使用 值表达式(value expression)来实现:

1
2
3
4
5
6
7
8
type Foo struct {
X int
}

func injectFoo() Foo {
wire.Build(wire.Value(Foo{X: 42}))
return Foo{}
}

生成的 injector 代码如下:

1
2
3
4
5
6
7
8
func injectFoo() Foo {
foo := _wireFooValue
return foo
}

var (
_wireFooValue = Foo{X: 42}
)

对于接口类型的值,则使用 InterfaceValue

1
2
3
4
func injectReader() io.Reader {
wire.Build(wire.InterfaceValue(new(io.Reader), os.Stdin))
return nil
}

使用结构体的字段作为 provider

有时候 provider 提供的是某个结构体中的某个字段,例如如下代码中的 getS:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Foo struct {
S string
N int
F float64
}

func getS(foo Foo) string {
// Bad! Use wire.FieldsOf instead.
return foo.S
}

func provideFoo() Foo {
return Foo{ S: "Hello, World!", N: 1, F: 3.14 }
}

func injectedMessage() string {
wire.Build(
provideFoo,
getS)
return ""
}

此时可以使用 wire.FieldsOf 来实现,而不用编写 getS

1
2
3
4
5
6
func injectedMessage() string {
wire.Build(
provideFoo,
wire.FieldsOf(new(Foo), "S"))
return ""
}

可以在 wire.FieldsOf 中添加任意个数的字段名称,对于给定的字段类型 T,FieldsOf 至少提供 T 类型,如果结构体参数是指向结构体的指针,则 FieldsOf 也提供 *T

清理函数

如果 provider 创建的值需要被清理(例如关闭文件),它可以返回返回一个闭包来清理资源。injector 可以使用这个返回的闭包函数。如下是个例子:

1
2
3
4
5
6
7
8
9
10
11
12
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
}

另一种 injector 语法

在 injector 中,还可以有一种简单写法:

1
2
3
func injectFoo() Foo {
panic(wire.Build(/* ... */))
}

这样就不用编写类似于 return foobarbaz.Foo{}, nil 的返回语句了。

最佳实践

接下来再列举一些 Wire 官方推荐的最佳实践。

如果需要注入 string 这种公共类型,可以创建一个新的类型,这样可以避免和其他 provider 产生冲突:

1
type MySQLConnectionString string

包含许多依赖的 provider,可以和 options 结构体配合使用

1
2
3
4
5
6
7
8
9
10
11
12
type Options struct {
// Messages is the set of recommended greetings.
Messages []Message
// Writer is the location to send greetings. nil goes to stdout.
Writer io.Writer
}

func NewGreeter(ctx context.Context, opts *Options) (*Greeter, error) {
// ...
}

var GreeterSet = wire.NewSet(wire.Struct(new(Options), "*"), NewGreeter)

对于库中提供的 provider set,为了不破坏兼容性,需要遵守如下原则:

  • 只要 provider 不对 provider set 引入新的输入(可以移除输入),可以更新 provider set 中的某个 provider
  • 只有该类型本身是新添加的类型,才可以在 provider set 中引入新的输出类型。因为如果该类型不是新类型,有可能某些 injector 已经包含了该类型,这会导致冲突

以下修改都可能是不可全的:

  • provider set 中引入新的输入类型
  • provider set 中移除输入类型
  • provider set 中添加已存在的输出类型

为了避免破坏兼容性,可以创建一个新的 provider set 来解决这些问题。

例如假设有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
var GreeterSet = wire.NewSet(NewStdoutGreeter)

func DefaultGreeter(ctx context.Context) *Greeter {
// ...
}

func NewStdoutGreeter(ctx context.Context, msgs []Message) *Greeter {
// ...
}

func NewGreeter(ctx context.Context, w io.Writer, msgs []Message) (*Greeter, error) {
// ...
}
  • 可以在 GreeterSet 中使用 DefaultGreeter,替换 NewStdoutGreeter
  • 如果新创建了一个类型 T,可以在 GreeterSet 中添加一个 T 类型的 provider(在同一个 commit 中进行修改)
  • 不可以在 GreeterSet 中添加 NewGreeter 来替换 NewStdoutGreeter,因为这会导致 injector 引入新的类型、同时增加了一个输出类型 error
  • 不可以从 GreeterSet 中移除 NewStdoutGreeter,因为这会导致依赖 *Greeter 的 injector 的无法工作
  • 也不能在 GreeterSet 中为 io.Writer 引入 provider,因为 injector 可能已经为 io.Writer 提供了 provider,这可能导致冲突

因此尽可能保持库中的 provider set 最小化,可以降低冲突的可能。

如果想使用 mocked 依赖 来创建一个依赖注入应用,有两种方式,具体可以参考官方提供的示例

Reference