基于反射的Go依赖注入工具——dig

diguber 开源的一个依赖注入组件,内部基于反射实现,适用于:

  • 为程序框架(如 Fx )提供依赖注入能力;
  • 在进程启动阶段,解决对象依赖图;

但不适用于以下场景:

  • 它只提供依赖注入功能,无法替代程序框架(如 Fx );
  • 在进程启动阶段后的依赖解决;
  • 作为服务发现组件,暴露给其他用户代码;

安装

开始使用 dig 解决依赖问题前,我们需要先安装,推荐安装主版本 1

1
2
3
$ glide get 'go.uber.org/dig#^1'
$ dep ensure -add "go.uber.org/dig@v1"
$ go get 'go.uber.org/dig@v1'

Container

dig 提供了一容器( container ),来解决有向无环图式依赖。调用 New 函数即可创建一个容器:

1
c := dig.New()

程序组件间的依赖关系是一个有向图,边指向被依赖组件。通常程序不允许循环依赖,这意味着图不能有环,因此是一个有向无环图。

Provide

dig 容器负责管理依赖关系,并接管了对象创建工作。因此,我们必须告诉 container 每个数据类型分别应该如何创建。这通过 Container 提供的 Provide 方法来进行。

Provide 方法接收一个构造器函数作为参数,该函数负责具体数据类型的创建和初始化,并将其返回。如果待创建的数据类型依赖其他类型,可以将依赖作为构造函数参数来声明。

所有依赖的构造器函数也必须注册进 Container ,但顺序没有严格要求,在当前类型前后均可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
err := c.Provide(func(conn *sql.DB) (*UserGateway, error) {
  // ...
})
if err != nil {
  // ...
}

if err := c.Provide(newDBConnection); err != nil {
  // ...
}

这段代码注册了用户网关 UserGateway 的构造函数,告诉 dig 容器 UserGateway 依赖数据库连接 *sql.DB ,以及详细的创建过程。dig 接到这个构造器后,通过 reflect 反射检查构造器参数返回值,即可获悉这些信息。

最终在创建 UserGateway 时,dig 先取得 *sql.DB ,并以它为参数执行构造函数。注意到,代码在第 8 行处注册了数据库连接的构造函数,由此 dig 知道如何创建 *sql.DB

一个类型可以被多个构造器依赖,但不会被重复创建。因为 dig 容器会为每个注册的类型,维护一个单例( singleton )。单例在第一次用到时创建,而且最多只创建一次。

1
2
3
4
5
6
err := c.Provide(func(conn *sql.DB) *CommentGateway {
  // ...
})
if err != nil {
  // ...
}

构造函数可以声明任意个依赖,如有必要也可返回 error

1
2
3
4
5
6
err := c.Provide(func(u *UserGateway, c *CommentGateway) (*RequestHandler, error) {
  // ...
})
if err != nil {
  // ...
}

这段代码注册了 *RequestHandler 的构造函数,它有两个依赖,分别是 UserGatewayCommentGateway

构造函数也可以返回多个值,同时完成多个类型的初始化:

1
2
3
4
5
6
err := c.Provide(func(conn *sql.DB) (*UserGateway, *CommentGateway, error) {
  // ...
})
if err != nil {
  // ...
}

这段代码注册的构造函数,依赖数据库连接 *sql.DB ,负责 UserGatewayCommentGateway 的创建工作。

构造函数可以接收可变参数,但 dig 执行时不会为其传参:

1
func NewVoteGateway(db *sql.DB, options ...Option) *VoteGateway

因此,对 dig 来说,这个构造函数等价于:

1
func NewVoteGateway(db *sql.DB) *VoteGateway

Invoke

类型构造函数加入 dig 容器后,可以通过 Invoke 方法来发起构建请求。跟 Provide 一样,Invoke 方法也接收一个函数作为参数。函数通过参数指定要请求的数据类型,dig 负责创建这些类型,并执行该函数:

1
2
3
4
5
6
err := c.Invoke(func(l *log.Logger) {
  // ...
})
if err != nil {
  // ...
}

这段代码调用 Invoke 方法请求日志对象 *log.Loggerdig 先完成 *log.Logger 的创建工作,并将其传给该函数。匿名函数里可以执行任何需要用到 *log.Logger 的处理逻辑,或者将其记下后再其他地方使用。

请求函数如有必要,也可以返回 errorerror 则被 Invoke 传给调用者:

1
2
3
4
5
6
err := c.Invoke(func(server *http.Server) error {
  // ...
})
if err != nil {
  // ...
}

如果请求的类型,或者依赖类型在容器中不可用,Invoke 方法也将报错。

参数对象

构造函数通过参数声明依赖,依赖一多很快就会影响可读性:

1
2
3
func NewHandler(users *UserGateway, comments *CommentGateway, posts *PostGateway, votes *VoteGateway, authz *AuthZGateway) *Handler {
  // ...
}

有个编程模式可以提高这种场景的可读性:创建一个结构体,将函数参数作为结构体字段,修改原函数以该结构体为参数。这个结构体就是所谓的 参数对象parameter object )。

dig 对参数对象支持到位:任何结构体只要嵌入 dig.In 即视为参数对象。上一构造器可以这样改写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type HandlerParams struct {
  dig.In

  Users    *UserGateway
  Comments *CommentGateway
  Posts    *PostGateway
  Votes    *VoteGateway
  AuthZ    *AuthZGateway
}

func NewHandler(p HandlerParams) *Handler {
  // ...
}

构造函数支持同时接受参数对象和普通参数,可以任意组合:

1
2
3
func NewHandler(p HandlerParams, l *log.Logger) *Handler {
  // ...
}

结果对象

跟参数对象相对的是 结果对象result object ),用来描述构造函数返回的多个值。结果对象也是一个结构体,每个字段表示构造函数创建的一个值。任何结构体只要嵌入 dig.Out ,即视为结果对象。

1
2
3
func SetupGateways(conn *sql.DB) (*UserGateway, *CommentGateway, *PostGateway, error) {
  // ...
}

这个构造器负责创建 *UserGateway*CommentGateway*PostGateway ,可以使用结果对象改写成这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type Gateways struct {
  dig.Out

  Users    *UserGateway
  Comments *CommentGateway
  Posts    *PostGateway
}

func SetupGateways(conn *sql.DB) (Gateways, error) {
  // ...
}

可选依赖

在某些场景下,就算依赖缺失,组件也可降级工作。因此,组件构造函数不用强制依赖其他类型。dig 支持声明可选依赖,只要在参数对象相关字段上打上 optional:"true" 标签即可:

1
2
3
4
5
6
type UserGatewayParams struct {
  dig.In

  Conn  *sql.DB
  Cache *redis.Client `optional:"true"`
}

如果可选依赖字段在容器中不存在,构造函数会收到该字段的零值:

1
2
3
4
5
6
func NewUserGateway(p UserGatewayParams, log *log.Logger) (*UserGateway, error) {
  if p.Cache == nil {
    log.Print("Logging disabled")
  }
  // ...
}

构造函数声明可选依赖后,必须处理依赖缺失的情况。有了可选依赖后,我们还能在不破坏当前代码结构的前提下,添加新的依赖项。

命名依赖

某些使用场景可能会为同个数据类型初始化多个实例,例如数据库连接可分为只读和读写:

1
2
func NewReadOnlyConnection(...) (*sql.DB, error)
func NewReadWriteConnection(...) (*sql.DB, error)

dig 支持对实例值进行命名,将同个类型的多个不同实例加入容器。注册构造函数时可以传 dig.Name 参数,告诉 dig 对构造函数返回的实例值进行命名。如果构造函数返回多个值,每个值都会被命名。

我们将上述两个数据库连接构造函数注册到 dig 容器,并通过可选参数 dig.Name 将它们分别命名为 rorw

1
2
c.Provide(NewReadOnlyConnection, dig.Name("ro"))
c.Provide(NewReadWriteConnection, dig.Name("rw"))

另一种方式,在结果对象中为字段打上命名标签:name:"xxxx" ,从而为实例值命名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type ConnectionResult struct {
  dig.Out

  ReadWrite *sql.DB `name:"rw"`
  ReadOnly  *sql.DB `name:"ro"`
}

func ConnectToDatabase(...) (ConnectionResult, error) {
  // ...
  return ConnectionResult{ReadWrite: rw, ReadOnly:  ro}, nil
}

无论依赖用哪种方式命名,其他构建函数都可以通过参数对象准确获取。参数对象字段必须打上命名标签,指定要获取的依赖实例名,dig 据此注入同名且同类型的实例对象:

1
2
3
4
5
6
type GatewayParams struct {
  dig.In

  WriteToConn  *sql.DB `name:"rw"`
  ReadFromConn *sql.DB `name:"ro"`
}

命名标签和可选依赖标签可以组合使用,声明可选的命名依赖:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type GatewayParams struct {
  dig.In

  WriteToConn  *sql.DB `name:"rw"`
  ReadFromConn *sql.DB `name:"ro" optional:"true"`
}

func NewCommentGateway(p GatewayParams, log *log.Logger) (*CommentGateway, error) {
  if p.ReadFromConn == nil {
    log.Print("Warning: Using RW connection for reads")
    p.ReadFromConn = p.WriteToConn
  }
  // ...
}

CommentGateway 的依赖由参数对象 GatewayParams 声明,它依赖两个数据库连接对象,一个是可读可写,另一个是只读的。注意到,只读连接是可选的,只读连接缺失则直接使用读写连接。

实例组

为实现同一类型的多实例构建或消费,dig 引入了实例组( value group )的概念。实例组是容器内部的一个乱序命名集合,构造函数可以往该集合添加实例值,而其他构造函数可以请求集合中的所有实例,结果以 切片slice )的形式返回。

构造函数只要返回打上 group:"xxxx" 标签的 dig.Out 结果对象,相关字段就会被添加到实例组:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type HandlerResult struct {
  dig.Out

  Handler Handler `group:"server"`
}

func NewHelloHandler() HandlerResult {
  ..
}

func NewEchoHandler() HandlerResult {
  ..
}

这段代码中两个构造函数注册后,dig 会将它们创建的 Handler 实例值添加到名为 server 的实例组。可能有任意多的构造函数往该命名组添加实例,而且其他构造函数借助同样打上 group:"xxxx" 标签的切片字段,即可请求全部实例值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type ServerParams struct {
  dig.In

  Handlers []Handler `group:"server"`
}

func NewServer(p ServerParams) *Server {
  server := newServer()
  for _, h := range p.Handlers {
    server.Register(h)
  }
  return server
}

*Server 构造函数参数对象通过 Handler 字段请求 server 组中的所有 Handler 实例。dig 会先执行所有向该实例组添加实例值的构造函数,但执行顺序没有明确规定。

实例组中的实例值是无序的,因为这些实例值的构建函数以什么顺序执行,dig 不作任何保证。

在某些场景,我们可能需要在 dig.Out 中添加切片字段,以便同时向实例值添加多个值。然而,考虑到实例组必须通过切片来请求的原则,我们只能通过切片的切片来获取这些实例值。

好在 v1.9.0 版本后,dig 支持将切片中的元素逐个加入实例值,而不是将切片本身。想要实现这种效果,只需要为 group 标签打上 flatten 修饰语即可:

1
2
3
4
5
6
type IntResult struct {
  dig.Out

  Handler []int `group:"server"`         // [][]int from dig.In
  Handler []int `group:"server,flatten"` // []int from dig.In
}
  • 4 行将切片本身添加进实例组,必须通过切片的切片( [][]int )来获取;
  • 5 行将切片中的元素逐一加入实例组,通过实例切片( []int )获取即可;

【小菜学Go语言】系列文章首发于公众号【小菜学编程】,敬请关注:

【小菜学Go语言】系列文章首发于公众号【小菜学编程】,敬请关注: