依赖注入是什么
Dependency Injection is the idea that your components (usually structs in go) should receive their dependencies when being created.
在 Golang 中,构造一个结构体常见的有两种方式:
- 在结构体初始化过程中,构建它的依赖;
- 将依赖作为构造器入参,传入进来。
所谓依赖注入就是第二种思想。不夸张的说,依赖注入是保持我们的软件系统松耦合,可维护的最重要的设计原则。
为什么?
因为当你的依赖通过入参传入,意味着从本对象的角度,你不用去关心它的生成,只用关心它的能力。更具体来讲,它能让我们更加倾向于定义好接口,以接口方法来进行交互。而不是依赖一个具体的实现。
由此而来的另一个好处在于测试。由于依赖是传入的,你的系统只管用它的能力,那么具体这个能力如何实现,其实是由上层来控制的。我们就可以很方便地进行 mock,调整各个场景下依赖的实现,来验证我们的 SUT 的表现。
开源选型
Golang 社区中实现依赖注入的框架有很多,常用的主要是 google/wire, facebook/inject, uber/dig, uber/fx 等,我们这个专栏此前就介绍过 goioc/di,大家感兴趣的话可以往前翻一下。
大体上看,分为两个派系:
- 代码生成 codegen
- 基于反射 reflect
其实不光是 DI 工具,针对 Golang 这种强类型,但泛型能力较弱的语言,包括 copier,orm 这类通用框架都会倾向于在这两个路径上二选一。
同样的,DI 也存在这两个排序,上面我们列举的选项中,facebook/inject, uber/dig, uber/fx,以及我们此前介绍的 goioc/di 都采用了基于反射的解法。这样的好处在于使用起来相对直接,不需要额外生成代码。但劣势也是相对的,失去了编译器检查的能力,如果注入有问题,只能在运行时报错,启动时会存在一些性能消耗。
google/wire 是 Google 官方提出的解决方案,也是业界目前最经典的基于 codegen 来解决依赖注入的开源库。相较于反射这种在运行时搞事情的操作,wire 需要开发者提前使用代码生成工具,触发依赖注入代码的生成,在编译器干活。相对的,会稍微麻烦点,但语义更清晰,也消除了运行时的成本。
今天我们就来看看 wire 是怎么用的。
wire
Wire is a code generation tool that automates connecting components using dependency injection. Dependencies between components are represented in Wire as function parameters, encouraging explicit initialization instead of global variables. Because Wire operates without runtime state or reflection, code written to be used with Wire is useful even for hand-written initialization.
wire 在设计上受到了 Java’s Dagger 2的启发。正如官方对它的定位,wire是一个 Compile-time Dependency Injection for Go (编译期依赖注入)的代码生成工具。wire 非常的轻量级,只会帮助开发者进行按需初始化。
你甚至可以用手写的初始化代码来替换它,wire 作为一个代码生成工具,仅仅是帮助我们减少注入依赖的繁琐工作。
一个经典的 DI 函数签名类似下面这样:
// NewUserStore returns a UserStore that uses cfg and db as dependencies.
func NewUserStore(cfg *Config, db *mysql.DB) (*UserStore, error) {...}
我们需要生成一个 UserStore,所以需要从函数入参中,获取 Config 配置,以及一个 MySQL 的 DB 连接。
思考一下,其实创建对象无非是两种情况:
- 没有额外依赖,在当前场景下可以直接创建对象;
- 存在外部依赖,我们需要先对外部依赖进行构建,然后作为参数传进来,进而构建当前对象。
所以,要调用这个 NewUserStore,我们先构建两个依赖。如果 cfg 和 db 都是第一种情况这种简单对象,其实我们手写就够了。
但在生产环境大型应用中,依赖树的构建可能是极其复杂的。A 依赖 B,B 依赖 C 和 D,C 又依赖 E,这个链路可能很长。这意味着如果手写,你的初始化代码会非常冗余,而且很可能要注意初始化顺序。
而且有的依赖可能不仅仅在某一个父对象中使用,而是在多个对象中共用。这个过程是非常痛苦的。一句话:
In practice, making changes to initialization code in applications with large dependency graphs is tedious and slow.
那 wire 干的是一件什么事呢?
wire 希望帮助我们搞清楚,到底我要构建的这些对象,存在哪些依赖,如何一步步构建出来,保证每个对象都能得到它需要的依赖。你不需要考虑这些事情了。
如果要调整一个对象的依赖,我们直接把它的构造器从 wire 模板中增加或删除,或者调整函数签名即可,让 wire 自己去搞清楚,怎么让整个 dependency graph 完整。
wire 的设计中,需要开发者理解两个概念:providers,injectors。下面我们分别来看看。
providers
Providers 就是我们常说的构造器,它们就是一些 Golang 函数,基于一些依赖参数(也可以没有),来构造出来对象。我们经常用的 NewXXX() XXX
就是经典的 Provider,下面是三个例子:
// 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,毕竟一个大型项目中涉及的依赖量级是很大的。所以 wire 提供了 ProviderSet 的概念,用来聚合一组 Provider。拿上面 UserStore 来举例,我们可以这样:
var UserStoreSet = wire.ProviderSet(NewUserStore, NewDefaultConfig)
injectors
injectors 也代表了一类函数,和 provider 提供构造器不同,它要做的事情在于实际去注入依赖。
什么?不是说好了 wire 帮我们搞么?怎么还要我们自己写 injector ?
不要慌,的确是 wire 来做,但 wire 需要我们的帮助才能做到这一点。我们总得告诉 wire 我们想要啥样的 injector 签名吧?遇见错误返回不?要用哪些 provider?
要知道,provider 可不仅仅包括那些简单的构造函数,有些对象构造的时候需要别的依赖作为参数,它们自己的构造器也是 provider。我们只有告诉 wire 有哪些 provider,它才能知道要给哪些对象进行构造。
所以,我们需要在这里做好两件事:
- 明确 injector 的函数签名,确定好入参;
- 调用 wire.Build,传入一系列 provider(或者 providerSet),wire 将会以此来构造最终结果。
func initUserStore() (*UserStore, error) {
// We're going to get an error, because NewDB requires a *ConnectionInfo
// and we didn't provide one.
wire.Build(UserStoreSet, NewDB)
return nil, nil // These return values are ignored.
}
看看示例,发现了么?
除了这两步我们什么都不用干,甚至直接 return 了两个 nil。不要慌,这个函数不是最后要用的,wire 会忽略它的返回值,只需要签名,以及 wire.Build 这个信息。最终我们使用的 injector 并不是自己写的这个。
好,下来操练一下,首先我们安装一下 wire 工具:
go install github.com/google/wire/cmd/wire@latest
安装结束后,直接在当前目录运行 wire
即可。输出如下信息:
$ wire
wire.go:2:10: inject initUserStore: no provider found for ConnectionInfo (required by provider of *mysql.DB)
wire: generate failed
这里信息很明确,上面我们的 func NewDB(info *ConnectionInfo) (*mysql.DB, error) {...}
要求传入 ConnectionInfo,但是我们调用 wire.Build 里面没有对应的 Provider,所以无法生成。
这里我们有两种方案:
- 加上 ConnectionInfo 依赖作为参数,表明我们这个构造器,就得显式传入;
- 加上 Provider。
我们试试第一种:
func initUserStore(info ConnectionInfo) (*UserStore, error) {
wire.Build(UserStoreSet, NewDB)
return nil, nil // These return values are ignored.
}
只是加了个入参,看看 wire 能不能识别出来。再次触发命令,会发现目录下多了个 wire_gen.go
// File: wire_gen.go
// Code generated by Wire. DO NOT EDIT.
//go:generate wire
//+build !wireinject
func initUserStore(info ConnectionInfo) (*UserStore, error) {
defaultConfig := NewDefaultConfig()
db, err := NewDB(info)
if err != nil {
return nil, err
}
userStore, err := NewUserStore(defaultConfig, db)
if err != nil {
return nil, err
}
return userStore, nil
}
完美,原本需要我们手动触发的流程,wire 全都搞定了。这里的签名和我们预期的也一样。
这里也能看到,wire 其实非常轻量级,只是把原本需要开发者手写的构建流程,自动生成了。依赖越多,它的作用就越大。
有了生成的代码,我们就可以继续自己的初始化流程,wire 就是个缩减大家人工的小帮手。
类型区分
wire不允许不同的组件拥有相同的类型。官方认为这是设计上的缺陷。我们可以通过类型别名来将组件的类型进行区分。例如服务会同时操作两个Redis,redisA, redisB,不要用这样,wire 无法推导出依赖关系:
func NewRedisA() *goredis.Client {...}
func NewRedisB() *goredis.Client {...}
建议用:
type RedicCliA *goredis.Client
type RedicCliB *goredis.Client
func NewRedisA() RedicCliA {...}
func NewRedisB() RedicCliB {...}