Wire:Go最优雅的依赖注入工具

导语

“成熟的工具,要学会自己写代码”。本文介绍了 Go 依赖注入工具 [[Wire]] 及其使用方法,以及在实践中积累的各种运用技巧。当代码达到一定规模后,[[Wire]] 在组件解耦、开发效率、可维护性上都能发挥很大的作用,尤其在大仓场景。

依赖注入

当项目变得越来越大,代码中的组件也越来越多:各种数据库、中间件的客户端连接,分层设计中的各种库表 repositories 实例、services 实例……

这时为了代码的可维护性,应该避免组件之间的耦合。具体的做法可以遵守一个重要的设计准则:所有依赖应该在组件初始化时传递给它,这就是依赖注入(Dependency injection)。

Dependency injection is a standard technique for producing flexible and loosely coupled code, by explicitly providing components with all of the dependencies they need to work.

– Go 官方博客

下面是个简单的例子,所有组件 MessageGreeterEvent 自身的依赖都在初始化的时候获得。

1
2
3
4
5
6
7
func main() {  
    message := NewMessage()  
    greeter := NewGreeter(message)  
    event := NewEvent(greeter)  

    event.Start()  
}

Wire 介绍

当项目中实例依赖(组件)的数量越来越多,如果还是人工手动编写初始化代码和维护组件之间依赖关系的话,会是一件非常繁琐的事情,而且在大仓中尤其明显。因此,社区里已经有了不少的依赖注入框架。

除了来自 Google 的 Wire 以外,还有 Dig(Uber) 、Inject(Facebook)。其中 Dig 和 Inject 都是基于 Golang 的 Reflection 来实现的。这不仅对性能产生影响,而且依赖注入的机制对使用者不透明,非常的“黑盒”。

Clear is better than clever ,Reflection is never clear.

— Rob Pike

相比之下,Wire 完全基于代码生成。在开发阶段,wire 会自动生成组件的初始化代码,生成代码人类可读,可以提交仓库,也可以正常编译。因此 Wire 的依赖注入非常透明,也不会带来运行阶段的任何性能损耗。

上手介绍

这里快速介绍一下 Wire 的使用方法

第一步:下载安装 Wire

下载安装 wire 命令行工具

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

第二步:创建 wire.go 文件

在生成代码之前,我们先声明各个组件的依赖关系和初始化顺序。在应用入口创建一个 wire.go 文件。

cmd/web/wire.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// +build wireinject

package main

import "..."  // 简化示例

var ProviderSet = wire.NewSet(
	configs.Get,
	databases.New,
	repositories.NewUser,
	services.NewUser,
	NewApp,
)

func CreateApp() (*App, error) {
	wire.Build(ProviderSet)
	return nil, nil
}

这个文件不会参与编译,只是为了告诉 Wire 各个组件的依赖关系,以及期望的生成结果。在这个文件:我们期望 Wire 生成一个返回 App 实例或 errorCreateApp 函数,App 实例初始化所需要的全部依赖都由 ProviderSet 这个组件列表提供,而 ProviderSet 声明了所有可能需要的组件的获取/初始化方法,也暗示组件之间的依赖顺序。

组件的获取/初始化方法,在 Wire 中叫做“组件的 provider”

还有几点需要注意:

  • 文件开头必须带上 // +build wireinject 和随后的空行,否则会影响编译
  • 在这个文件中,编辑器和 IDE 可能无法提供代码提示,但没关系,稍后会介绍如何解决这个问题
  • 其中 CreateApp 的返回(两个 nil)没有任何意义,只是为了兼容 Go 语法。

第三步:生成初始化代码

命令行执行 wire ./...,然后就能得到下面这个自动生成的代码文件。

cmd/web/wire_gen.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
// Code generated by Wire. DO NOT EDIT.

//go:generate go run github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject

package main

import "..."  // 简化示例

func CreateApp() (*App, error) {
	conf, err := configs.Get()
	if err != nil {
		return nil, err
	}
	db, err := databases.New(conf)
	if err != nil {
		return nil, err
	}
	userRepo, err := repositories.NewUser(db)
	if err != nil {
		return nil, err
	}
	userSvc, err := services.NewUser(userRepo)
	if err != nil {
		return nil, err
	}
	app, err := NewApp(userSvc)
	if err != nil {
		return nil, err
	}
	return app, nil
}

第四步:使用初始化代码

Wire 已经帮我们生成了真正的 CreateApp 初始化方法,现在可以直接使用它。

cmd/web/main.go

1
2
3
4
5
// main.go
func main() {
	app := CreateApp()
	app.Run()
}

使用技巧

组件按需加载

Wire 有个优雅的特点,不管在 wire.Build 中传入了多少个组件的 provider,Wire 始终只会按照实际需要来初始化组件,所有不需要的组件都不会生成相应的初始化代码。

因此,我们在使用时可以尽可能地提供更多的 provider,把挑选组件的工作交给 Wire。这样我们在开发时不管引用新组件、还是弃用老组件,都不需要修改初始化步骤的代码 wire.go。

比如,可以把 services 层中所有的实例构造器都提供出去。

pkg/services/wire.go

1
2
3
4
package services

// 提供了所有 service 的实例构造器
var ProviderSet = wire.NewSet(NewUserService, NewFeedService, NewSearchService, NewBannerService)

在初始化中,尽可能地引用所有可能需要的组件 provider。

cmd/web/wire.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var ProviderSet = wire.NewSet(
	configs.ProviderSet,
	databases.ProviderSet,
	repositories.ProviderSet,
	services.ProviderSet,  // 引用了所有 service 的实例构造器
	NewApp,
)

func CreateApp() (*App, error) {
	wire.Build(ProviderSet)  // wire 会按照实际需要,选择性地进行初始化
	return nil, nil
}

在后续开发中,如果需要引用新组件,只需要加到参数里即可。Wire 会任劳任怨地按照实际需要,生成需要的组件的初始化代码。

1
2
func NewApp(user *UserService, banner *BannerService) {
}

即使 Wire 找不到组件的 provider,也会提前在编译阶段报错,不会在线上运行阶段出现问题。

wire: cmd/api/wire.go:23:1: inject CreateApp: no provider found for *io.WriteCloser

编辑器与 IDE 的辅助配置

因为 wire.go 文件中加了这行注释,Go 在编译时会跳过这个文件,但也因此会影响编辑器和 IDE 的代码提示。当你在编辑 wire.go 文件时,常见的编辑器和 IDE 都无法正常地提供代码补全和错误提示功能。

1
// +build wireinject

但这个问题很容易解决。找到 IDE/编辑器的 Go 环境配置,在 Go Build Flags 中添加这个参数 -tags=wireinject 就可以了。

这个配置可以让编辑器和 IDE 正常地为 wire.go 文件提供代码补全和错误提示功能,开发体验提高不只一个数量级~

多个同类型组件的冲突问题

这个问题比较少见,但项目大了总是容易遇到。

Wire 通过 provider 的参数与返回类型,来判断组件的依赖关系。有时候,依赖网络中可能出现同类型的不同组件,这时 Wire 无法正确判断依赖关系,会直接报错。

provider has multiple parameters of type ...

比如下面这个 provider,依赖的 MySQL 和 PostgreSQL 客户端实例的类型是完全相同的(都是 *gorm.DB),这时 Wire 无法根据类型正确地判断依赖关系,生成代码时会直接报错。

1
2
3
// 这个 service 同时使用了 mysql 和 pg 中的数据,但是两个组件的类型是相同的
func NewService(mysql *gorm.DB, pg *gorm.DB) *Service {
}

解决的方法也比较简单,只需要做一层类型的包装。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type Mysql gorm.DB
type Pg    gorm.DB

// 在参数中用类型别名进行区分
func ProviderSerivce(mysql *Mysql, pg *Pg) *Service {
	// 函数内再转回原来的类型
	r1 := (*gorm.DB)(mysql)
	r2 := (*gorm.DB)(pg)
	return NewService(r1, r2)
}

然后用 ProviderSerivce 代替 NewService 即可。

1
2
3
4
5
wire.Build(
	ProviderMysql,   // func() *Mysql
	ProviderPg,      // func() *Pg
	ProviderSerivce, // func(mysql *Mysql, pg *Pg) *Service
)

自动生成构造函数

当项目中充当抽象类的结构体越来越多,手动编写和维护结构体的构造函数,也是一件非常繁琐的事情。如果结构体中新增了一个指针类型的成员、却忘记更新构造函数,甚至还会引起线上 panic。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type Service struct {
	repo   *Repository
	logger *zap.Logger  // 添加这个成员后,忘记更新构造函数了
}

func NewService(repo *Repository) *Service {
	// 缺失 logger,可能在线上出现空指针错误
	return &Service {
		repo:   repo,
	}
}

像这种繁琐、重复、容易出错的工作,就应该交给自动工具来完成。这里我毛遂自荐一个自动工具 newc(意为 “New Construtor”),它可以自动生成与更新结构体的构造函数代码。

使用方法非常简单,只需要给结构体添加这行注释。

//go:generate go run github.com/Bin-Huang/newc@v0.8.3

比如这样:

1
2
3
4
5
6
7
// My User Service
//go:generate go run github.com/Bin-Huang/newc@v0.8.3
type UserService struct {
	baseService
	userRepository *repositories.UserRepository
	proRepository  *repositories.ProRepository
}

然后命令行执行 go generate ./... 即可获得构造函数代码:

constructor_gen.go

1
2
3
4
5
6
7
8
// NewUserService Create a new UserService
func NewUserService(baseService baseService, userRepository *repositories.UserRepository, proRepository *repositories.ProRepository) *UserService {
	return &UserService{
		baseService:    baseService,
		userRepository: userRepository,
		proRepository:  proRepository,
	}
}

这个工具和 Wire 搭配使用,开发体验非常好。要使用新组件时,直接在结构体中添加成员就好了,不需要手动更新构造函数,也不需要考虑初始化的问题,所有重复的工作都交给自动工具(Wire 和 Newc)来完成。线下推荐过的同学,用过都说好。

当然这个工具也一定有考虑不周的情况,很期待大家的反馈和建议。

Don’t repeat yourself “DRY”

总结

Wire 可以完美地解决依赖注入的问题,但它不是一个框架,它没有”魔法“,也不是黑盒。它只是一个命令行工具,它根据实际需要,自动生成了各个组件的初始化代码。然后问题就解决了,没有额外的复杂性,没有运行的性能损耗。

Wire 和 [[Golang]] 的气质如出一辙,简单、直接、实用主义,不愧是 Go 最优雅的依赖注入工具!

Keep it simple stupid “K.I.S.S”

comments powered by Disqus