之前其实已经学习过 Go 生态中的一个依赖注入库 fx,这篇文章我们将继续学习另一个依赖注入库 Wire。
Wire 简介
Wire 是 Google 开发的一个依赖注入(Dependency Injection)库,它通过代码生成的方式实现依赖注入,而不是使用反射机制。Wire 是一个代码生成工具,它通过依赖注入的方式自动连接组件,组件间的依赖通过 函数参数 的方式声明,鼓励使用显式初始化而不是全局变量。使用 Wire 有一个好处,因为 Wire 不依赖于运行时状态/反射,因此使用 Wire 时写的代码照样可以用于手写初始化的场景。
Wire 解决什么问题
依赖注入是一种标准技术,通过显式地为组件提供其工作所需的所有依赖关系,来生成灵活且松散耦合的代码。在 Go 中,依赖 通常是以 constructor 参数的形式出现:
1 | // NewUserStore returns a UserStore that uses cfg and db as dependencies. |
这种通过参数来传递依赖项的方式,在小型应用程序中工作良好。但是大型应用通常具有复杂的依赖关系图,这就需要编写大量的初始化代码,而且顺序也很重要。这导致难以干净地分解代码,尤其是某些依赖需要多次使用。实现服务间的替换也会很痛苦,因为需要更改依赖图:删除旧的依赖,添加一系列新的依赖。在实践中,在具有复杂依赖的应用程序中更改初始化代码既繁琐又缓慢。
依赖注入工具 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 有两个基本概念:provider 和 injector。
provider 是普通的 Go 函数,根据 依赖关系 提供 value,而依赖则是简单地通过 函数参数 描述。如下定义了 3 个 provider:
1 | // NewUserStore is the same function we saw above; it is a provider for UserStore, |
通常一起使用的 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 | func initUserStore(info *ConnectionInfo) (*UserStore, error) { |
之后通过 wire 的命令行工具 wire,就可以自动生成初始化代码,例如:
1 | func initUserStore(info *ConnectionInfo) (*UserStore, error) { |
使用 Wire 时完全没有运行时依赖,自动生成的初始化代码就和普通的 Go 代码一样。
Wire 示例
接下来以 Wire 官方的 Tutorial 为例,完整地介绍 Wire 的使用方法。如下是一个简单的 Go 程序,它没有使用依赖注入:
1 | package main |
可以看到,在没有使用依赖注入的情况,为了生成最终的 Event 对象,需要手动依次创建 Message, Greeter, Event 对象。接下来使用 Wire 来改造这个过程,定义一个 wire.go 文件:
1 | //+build wireinject |
然后 main() 函数修改为如下代码:
1 | func main() { |
在 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 | # wire |
生成的代码和我们自己写的初始化代码是一样的,只不过这次我们不需要手动编写这部分逻辑了。这个示例只是展示了最简单的情况,实际上 Wire 还有很多特性:
- 即使某些 provider 会返回 error,Wire 生成的初始化代码也能正确处理:它会检查 error 并尽早返回。
- injector 也可以接收参数,并将它们传递给 provider
- 当代码中出现错误时,wire 也会提供有用的编译错误信息(例如缺少或多余 provider)
当使用 Wire 时,wire.go 和 wire_gen.go 都会提交到源码控制系统中。如果想了解 Wire 在大规模程序中的使用,可以参考这个 Go Cloud 中的 guestbook 示例。
Wire 高级特性
上文已经介绍了 Wire 的基本用法,也简单介绍了 Wire 的两个核心概念:provider 和 injector。这里再详细介绍一下 provider 和 injector:
- 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 | type Fooer interface { |
- wire.Bind 的第一个参数是指向
interface的指针,第二个参数是指向实现该 interface 的具体类型的指针 - 包含
interface binding的provider set也必须包含提供实现类型的 provider
struct provider
可以通过 provided types 来构造结构体,使用 wire.Struct() 来创建一个 struct 类型并告诉 injector 应该注入到哪个字段,injector 会根据 field 的 type 调用 provider 来填充这些字段。wire.Struct() 可以生成 S 或 *S 类型。
如下是个例子:
1 | type FooBar struct { |
wire.Struct() 的第一个参数是指向期望 struct 的指针,之后的参数则是需要注入的字段名称,* 可以表示所有字段。如下代码则只注入了一个字段:
1 | var Set = wire.NewSet( |
使用 ``wire:“-”` 结构体标签可以让 Wire 忽略某些字段:
1 | type Foo struct { |
Binding values
有时候会想给某个类型绑定一个基本值,可以在 provider set 中使用 值表达式(value expression)来实现:
1 | type Foo struct { |
生成的 injector 代码如下:
1 | func injectFoo() Foo { |
对于接口类型的值,则使用 InterfaceValue:
1 | func injectReader() io.Reader { |
使用结构体的字段作为 provider
有时候 provider 提供的是某个结构体中的某个字段,例如如下代码中的 getS:
1 | type Foo struct { |
此时可以使用 wire.FieldsOf 来实现,而不用编写 getS:
1 | func injectedMessage() string { |
可以在 wire.FieldsOf 中添加任意个数的字段名称,对于给定的字段类型 T,FieldsOf 至少提供 T 类型,如果结构体参数是指向结构体的指针,则 FieldsOf 也提供 *T。
清理函数
如果 provider 创建的值需要被清理(例如关闭文件),它可以返回返回一个闭包来清理资源。injector 可以使用这个返回的闭包函数。如下是个例子:
1 | func provideFile(log Logger, path Path) (*os.File, func(), error) { |
另一种 injector 语法
在 injector 中,还可以有一种简单写法:
1 | func injectFoo() Foo { |
这样就不用编写类似于 return foobarbaz.Foo{}, nil 的返回语句了。
最佳实践
接下来再列举一些 Wire 官方推荐的最佳实践。
如果需要注入 string 这种公共类型,可以创建一个新的类型,这样可以避免和其他 provider 产生冲突:
1 | type MySQLConnectionString string |
包含许多依赖的 provider,可以和 options 结构体配合使用
1 | type Options struct { |
对于库中提供的 provider set,为了不破坏兼容性,需要遵守如下原则:
- 只要 provider 不对
provider set引入新的输入(可以移除输入),可以更新provider set中的某个 provider - 只有该类型本身是新添加的类型,才可以在
provider set中引入新的输出类型。因为如果该类型不是新类型,有可能某些 injector 已经包含了该类型,这会导致冲突
以下修改都可能是不可全的:
- 在
provider set中引入新的输入类型 - 在
provider set中移除输入类型 - 在
provider set中添加已存在的输出类型
为了避免破坏兼容性,可以创建一个新的 provider set 来解决这些问题。
例如假设有如下代码:
1 | var GreeterSet = wire.NewSet(NewStdoutGreeter) |
- 可以在
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 依赖 来创建一个依赖注入应用,有两种方式,具体可以参考官方提供的示例。