深度思维者

永远年轻,永远热泪盈眶

Go工程化--依赖注入(一)

1. 前言

1.1 什么是依赖注入?

为了让代码可测或者可以运行,将各个module的顺序按业务逻辑进行初始化,比如首先进行config初始化,再初始化log模块,再然后是数据库,再然后实例化客户端, 这个过程其实就是依赖注入,而通常我们会在main.go做依赖注入,这就导致main.go会越来越臃肿。

在main.go中做依赖注入,意味着在初始化代码中我们要管理:

  1. 依赖的初始化顺序
  2. 依赖之间的关系
    对于小型项目而言,依赖的数量比较少,初始化代码不会很多,不需要引入依赖注入框架。但对于依赖较多的中大型项目,初始化代码又臭又长,可读性和维护性变的很差,如下一段启动函数:
func main() {
    config := NewConfig()
    // db依赖配置
    db, err := ConnectDatabase(config) 
    if err != nil {
        panic(err)
    }
    // PersonRepository 依赖db
    personRepository := NewPersonRepository(db) 
    // PersonService 依赖配置 和 PersonRepository
    personService := NewPersonService(config, personRepository)
    // NewServer 依赖配置和PersonService
    server := NewServer(config, personService)
    server.Run()
}

1.2 为什么要做使用依赖注入框架?

随着开发合作的同学的增多以及部门的要求增加,项目启动时的依赖越来越多,依赖之间还有先后顺序,有一些甚至是隐式的顺序,到时main函数的代码膨胀的非常迅速并且慢慢的变的不可维护了,这种情况下引入依赖注入框架其实可以省心很多。

1.3 依赖注入框架分类

Golang 的依赖注入框架有两类

  • 一类是通过反射在运行时进行依赖注入,典型代表是 uber 开源的dig和Facebook的 [inject] (https://github.com/facebookarchive/inject)
  • 另外一类是通过 generate 进行代码生成,典型代表是 Google 开源的 wire

使用 dig 功能会强大一些,它们都是使用反射机制来实现运行时依赖注入(runtime dependency injection), 但是缺点就是错误只能在运行时才能发现,这样如果不小心的话可能会导致一些隐藏的 bug 出现。

wire则是采用代码生成的方式来达到编译时依赖注入(compile-time dependency injection), 使用 wire 的缺点就是功能限制多一些,但是好处就是编译的时候就可以发现问题,并且生成的代码其实和我们自己手写相关代码差不太多,更符合直觉,心智负担更小,十分容易理解和调试。所以更加推荐 wire.

2. wire

2.0 安装

安装很简单,只要安装了 Go 并且已经把 $GOPATH/bin 加入到了 PATH 当中,终端执行下面的语句即可

go get github.com/google/wire/cmd/wire

2.1 如何工作?

wire有两个基本的概念:providerinjector

2.2 provider

provider就是普通的Go函数,可以把它看作是某对象的 构造函数,通过provider告诉wire该对象的依赖情况:

// NewUserStore是*UserStore的provider,表明*UserStore依赖于*Config和 *mysql.DB.
func NewUserStore(cfg *Config, db *mysql.DB) (*UserStore, error) {...}

// NewDefaultConfig是*Config的provider,没有依赖
func NewDefaultConfig() *Config {...}

// NewDB是*mysql.DB的provider,依赖于ConnectionInfo
func NewDB(info ConnectionInfo) (*mysql.DB, error) {...}

// UserStoreSet 可选项,可以使用wire.NewSet将通常会一起使用的依赖组合起来。
var UserStoreSet = wire.NewSet(NewUserStore, NewDefaultConfig)

不过需要注意的是在wire 中不能存在两个 provider 返回相同的组件类型

2.3 Injector

injector 也是一个普通函数,常常在 wire.go 文件中定义 injector 函数签名,通过调用injector来获取我们所需的对象或值,injector会按照依赖关系,按顺序调用provider函数, 通过 wire 命令自动生成一个完整的函数:

// File: wire_gen.go
// Code generated by Wire. DO NOT EDIT.
//go:generate wire

//+build !wireinject

// initUserStore是由wire生成的injector
func initUserStore(info ConnectionInfo) (*UserStore, error) {
    
    // *Config的provider函数
    defaultConfig := NewDefaultConfig()
    
    // *mysql.DB的provider函数
    db, err := NewDB(info)
    if err != nil {
        return nil, err
    }
    
    // *UserStore的provider函数
    userStore, err := NewUserStore(defaultConfig, db)
    if err != nil {
        return nil, err
    }
    
    return userStore, nil
}
  • 第一行的 //+build wireinject 注释确保了这个文件在我们正常编译的时候不会被引用,而 wire . 生成的文件 wire_gen.go 会包含 //+build !wireinject 注释,正常编译的时候,不指定 tag 的情况下会引用这个文件

injector帮我们把按顺序初始化依赖的步骤给做了,我们在main.go中只需要调用initUserStore方法就能得到我们想要的对象了。

那么wire是怎么知道如何生成injector的呢?我们需要在wire.go文件中写一个签名函数来告诉它:

  • 定义injector的函数签名
  • 在函数中使用wire.Build方法列举生成injector所需的provider

例如签名函数initUserStore

// initUserStore用于声明injector的函数签名
func initUserStore(info ConnectionInfo) (*UserStore, error) {  
    
    // wire.Build声明要获取一个UserStore需要调用到哪些provider函数
    wire.Build(UserStoreSet, NewDB)
    
    // 这些返回值wire并不关心
    return nil, nil 
}

有了上面的函数,wire就可以得知如何生成injector了。wire生成injector的步骤描述如下:

  1. 确定所生成injector函数的函数签名:
    func initUserStore(info ConnectionInfo) (*UserStore, error)
    
  2. 感知返回值第一个参数是*UserStore
  3. 检查wire.Build列表,找到*UserStore的provider:NewUserStore
  4. 由函数签名func NewUserStore(cfg *Config, db *mysql.DB)得知NewUserStore依赖于*Config, 和*mysql.DB
  5. 检查wire.Build列表,找到*Config*mysql.DBprovider:NewDefaultConfigNewDB
  6. 由函数签名func NewDefaultConfig() *Config得知*Config没有其他依赖了。
  7. 由函数签名func NewDB(info *ConnectionInfo) (*mysql.DB, error)得知*mysql.DB依赖于ConnectionInfo
  8. 检查wire.Build列表,找不到ConnectionInfo的provider,但在injector函数签名中发现匹配的入参类型,直接使用该参数作为NewDB的入参。
  9. 感知返回值的第二个参数error
  10. ...
  11. 按依赖关系。按顺序调用privider函数, 拼装injector函数

2.4 一个完整的 🌰

  1. provider.go
// provider.go

package example

// repo

// IPostRepo IPostRepo
type IPostRepo interface{}

// NewPostRepo NewPostRepo
func NewPostRepo() IPostRepo {
	return new(IPostRepo)
}

// usecase

// IPostUsecase IPostUsecase
type IPostUsecase interface{}
type postUsecase struct {
	repo IPostRepo
}

// NewPostUsecase NewPostUsecase
func NewPostUsecase(repo IPostRepo) IPostUsecase {
	return postUsecase{repo: repo}
}

// service service

// PostService PostService
type PostService struct {
	usecase IPostUsecase
}

// NewPostService NewPostService
func NewPostService(u IPostUsecase) *PostService {
	return &PostService{usecase: u}
}

NewPostServiceNewPostUsecase 这些都是 Provider 函数,下面我们在 wire.go 当中构建 Injector 函数签名

  1. 签名文件wire.go中的签名函数
//+build wireinject

package example

import "github.com/google/wire"

func GetPostService() *PostService {
	
	panic(wire.Build(
		NewPostService,
		NewPostUsecase,
		NewPostRepo,
	))
}
  1. 当前(wire.go)执行指令 wire . 生成依赖函数,(以下代码是自动生成的)
// Code generated by Wire. DO NOT EDIT.

//go:generate wire
//+build !wireinject

package example

// Injectors from wire.go:

func GetPostService() *PostService {
	iPostRepo := NewPostRepo()
	iPostUsecase := NewPostUsecase(iPostRepo)
	postService := NewPostService(iPostUsecase)
	
	return postService
}

2.5 错误机制

缺少provider

在执行wire .的时候,如果缺少某个Provider提供依赖,wire会进行提示,帮助我们快速找到问题且修改。

还是上面的例子,我们删除掉一个Provider函数试试

func GetPostService() *PostService {
	panic(wire.Build(
		NewPostService,
		NewPostUsecase,
	))
}

再次执行 wire 命令,可以发现报错

▶ wire .
wire: /Go-000/Week04/blog/03_wire/01_example/wire.go:7:1: inject GetPostService: no provider found for github.com/mohuishou/go-training/Week04/blog/03_wire/01_example.IPostRepo
        needed by github.com/mohuishou/go-training/Week04/blog/03_wire/01_example.IPostUsecase in provider "NewPostUsecase" (/Go-000/Week04/blog/03_wire/01_example/example.go:22:6)
        needed by *github.com/mohuishou/go-training/Week04/blog/03_wire/01_example.PostService in provider "NewPostService" (/Go-000/Week04/blog/03_wire/01_example/example.go:34:6)
wire: github.com/mohuishou/go-training/Week04/blog/03_wire/01_example: generate failed
wire: at least one generate failure

2.6 返回错误

go 中如果遇到错误,会在最后一个返回值返回errorwire同样也支持返回错误的情况,只需要在 injector 的函数签名中加上 error返回值即可,还是前面的那个例子,我们让 NewPostService 返回error,并且修改 GetPostService 这个Injector 函数

// example.go
// NewPostService NewPostService
func NewPostService(u IPostUsecase) (*PostService, error) {
	return &PostService{usecase: u}, nil
}

// wire.go

func GetPostService() (*PostService, error) {
	panic(wire.Build(
		NewPostService,
		NewPostUsecase,
		NewPostRepo,
	))
}

生成的代码如下所示,可以发现会像我们自己写代码一样判断一下 if err然后返回

// wire_gen.go

func GetPostService() (*PostService, error) {
	
	iPostRepo := NewPostRepo()
	iPostUsecase := NewPostUsecase(iPostRepo)
	postService, err := NewPostService(iPostUsecase)
	
	if err != nil {
		return nil, err
	}
	
	return postService, nil
}

2.7 清理函数

有时候需要打开文件,或者是链接这种需要关闭的资源,这时候provider可以返回一个闭包函数 func()wire在进行构建的时候,会在报错的时候调用,并且会将所有的闭包函数聚合返回。这个特性一般用的不多,但是有需求的时候会十分有用。

还是之前的示例,我们修改一下 NewPostRepo NewPostUsecase 让他们返回一个清理函数


// example.go

// NewPostRepo NewPostRepo
func NewPostRepo() (IPostRepo, func(), error) {
	return new(IPostRepo), nil, nil
}

// NewPostUsecase NewPostUsecase
func NewPostUsecase(repo IPostRepo) (IPostUsecase, func(), error) {
	return postUsecase{repo: repo}, nil, nil
}

// wire.go

func GetPostService() (*PostService, func(), error) {
	panic(wire.Build(
		NewPostService,
		NewPostUsecase,
		NewPostRepo,
	))
}

执行 wire . 之后可以发现生成的函数当中,当 NewPostUsecase 出现错误的时候会自动调用 NewPostRepo返回的 cleanup 函数,而 NewPostService返回错误,会调用它依赖的所有 providercleanup 函数,如果都没有问题,就会把所有cleanup函数聚合为一个函数返回

func GetPostService() (*PostService, func(), error) {
	
	iPostRepo, cleanup, err := NewPostRepo()
	if err != nil {
		return nil, nil, err
	}
	
	iPostUsecase, cleanup2, err := NewPostUsecase(iPostRepo)
	if err != nil {
		cleanup()
		return nil, nil, err
	}
	
	postService, err := NewPostService(iPostUsecase)
	if err != nil {
		cleanup2()
		cleanup()
		return nil, nil, err
	}
	
	return postService, func() {
		cleanup2()
		cleanup()
	}, nil
}

3. 高级方法

3.1 接口注入

应该依赖接口(interface),而不是实现(struct)。返回数据的时候返回实现(struct)而不是接口(interface),这是在 Golang 中的最佳实践(当然也不是所有的都是这样),所以如果我们的 provider 返回了实现,但是我们的依赖的是接口,这时候就会报错了,先来看一个例子。

修改一下NewPostUsecase 方法,让他返回 *PostUsecase 而不是接口

// NewPostUsecase NewPostUsecase
func NewPostUsecase(repo IPostRepo) (*PostUsecase, func(), error) {
	return &PostUsecase{repo: repo}, nil, nil
}

这时候执行 wire . 生成代码会发现报错,找不到 IPostUsecaseprovider

▶ wire .
wire: /Go-000/Week04/blog/03_wire/01_example/wire.go:7:1: inject GetPostService: no provider found for github.com/mohuishou/go-training/Week04/blog/03_wire/01_example.IPostUsecase
        needed by *github.com/mohuishou/go-training/Week04/blog/03_wire/01_example.PostService in provider "NewPostService" (/Go-000/Week04/blog/03_wire/01_example/example.go:36:6)
wire: github.com/mohuishou/go-training/Week04/blog/03_wire/01_example: generate failed
wire: at least one generate failure

这时候就需要使用 wire.BindStruct 和接口进行绑定了,表示这个结构体实现了这个接口,我们修改一下 injector 函数

func GetPostService() (*PostService, func(), error) {
	
	panic(wire.Build(
		NewPostService,
		wire.Bind(new(IPostUsecase), new(*PostUsecase)), // struct 和接口进行绑定
		
		NewPostUsecase,
		NewPostRepo,
	))
}

wire.Bind 的使用方法就是 wire.Bind(new(接口), new(实现))

3.2 Struct 属性注入

在上面 NewPostService 代码,我们可以发现有很多 Struct 的初始化其实就是填充里面的属性,没有其他的逻辑,这种情况我们可以偷点懒直接使用 wire.Struct 方法直接生成 provider

// structType: 结构体类型
// fieldNames: 需要填充的字段,使用 "*" 表示所有字段都需要填充
Struct(structType interface{}, fieldNames ...string)

修改一下 Injector函数

func GetPostService() (*PostService, func(), error) {
	panic(wire.Build(
		// 这里由于只有一个字段,所以这两种是等价的 wire.Struct(new(PostService), "*"),
		wire.Struct(new(PostService), "usecase"), // struct 和接口进行绑定
		wire.Bind(new(IPostUsecase), new(*PostUsecase)),
		
		NewPostUsecase,
		NewPostRepo,
	))
}

可以看到生成的代码当中自动就生成了一个结构体并且填充数据了

func GetPostService() (*PostService, func(), error) {
	
	iPostRepo, cleanup, err := NewPostRepo()
	if err != nil {
		return nil, nil, err
	}
	
	postUsecase, cleanup2, err := NewPostUsecase(iPostRepo)
	if err != nil {
		cleanup()
		return nil, nil, err
	}
	
    // 注意这里
	postService := &PostService{
		usecase: postUsecase,
	}
	
	return postService, func() {
		cleanup2()
		cleanup()
	}, nil
}

3.3 值绑定

除了依赖某一个类型之外,有时候我们还会依赖一些具体的值,这时候我们就可以使用 wire.Value 或者是 wire.InterfaceValue ,为某个类型绑定具体的值


// wire.Value 为某个类型绑定值,但是不能为接口绑定值
Value(interface{}) ProvidedValue

// wire.InterfaceValue 为接口绑定值
InterfaceValue(typ interface{}, x interface{}) ProvidedValue

修改一下 PostService 使他依赖一个intio.Reader然后为它直接绑定 a=99 io.Reader = os.Stdin

// example.go

type PostService struct {
	usecase IPostUsecase
	a       int
	r       io.Reader
}

// wire.go

func GetPostService() (*PostService, func(), error) {
	panic(wire.Build(
		wire.Struct(new(PostService), "*"),                     // struct 属性注入
		
		wire.Value(10),                                         // 值绑定
		wire.InterfaceValue(new(io.Reader), os.Stdin),          // 接口值绑定
		wire.Bind(new(IPostUsecase), new(*PostUsecase)),        // 结构体绑定接口
		
		NewPostUsecase,
		NewPostRepo,
	))
}

可以看到生成的代码当中直接生成了两个全局变量

func GetPostService() (*PostService, func(), error) {
	
	iPostRepo, cleanup, err := NewPostRepo()
	if err != nil {
		return nil, nil, err
	}
	
	postUsecase, cleanup2, err := NewPostUsecase(iPostRepo)
	if err != nil {
		cleanup()
		return nil, nil, err
	}
	
	int2 := _wireIntValue               // 生成的代码绑定全局变量
	reader := _wireFileValue
	postService := &PostService{
		usecase: postUsecase,
		a:       int2,
		r:       reader,
	}
	
	
	return postService, func() {
		cleanup2()
		cleanup()
	}, nil
}

// 注意这里
var (
	_wireIntValue  = 10
	_wireFileValue = os.Stdin
)

3.4 ProviderSet(Provider 集合)

在真实的项目当中依赖往往是一组一组的,就像我们的示例一样,只要依赖 PostService 那么 NewPostUsecase NewPostRepo 这两个就必不可少,所以我们往往会创建一些 ProviderSetInjector 函数中直接依赖ProviderSet就可以了

// 参数是一些 provider
NewSet(...interface{}) ProviderSet

示例如下所示,生成代码和之前一样就不另外贴了

// example.go

// PostServiceSet PostServiceSet
var PostServiceSet = wire.NewSet(
	wire.Struct(new(PostService), "*"),
	
	wire.Value(10),
	wire.InterfaceValue(new(io.Reader), os.Stdin),
	wire.Bind(new(IPostUsecase), new(*PostUsecase)),
	
	NewPostUsecase,
	NewPostRepo,
)

// wire.go

func GetPostService() (*PostService, func(), error) {
	panic(wire.Build(
		PostServiceSet,
	))
}

4. wire 使用最佳实践

4.1 不要使用默认类型

wire 不支持两个提供两个相同类型的 provider,所以如果我们使用默认类型如 int string 等,只要有两个依赖就会导致报错,解决方案是使用类型别名。
先来看一个报错的示例

//错误的示例
type PostService struct {
	usecase IPostUsecase
	a       int
	b       int
	r       io.Reader
}

可以看到,wire在构建依赖关系的时候,并不知道int的值该分配给 a 还是 b 所以就会报错

▶ wire .
wire: /Go-000/Week04/blog/03_wire/01_example/example.go:40:2: provider struct has multiple fields of type int
wire: github.com/mohuishou/go-training/Week04/blog/03_wire/01_example: generate failed
wire: at least one generate failure`

自定义两个类型就好了

// 正确的做法
type A int
type B int

// PostService PostService
type PostService struct {
	usecase IPostUsecase
	a       A
	b       B
	r       io.Reader
}

// PostServiceSet PostServiceSet
var PostServiceSet = wire.NewSet(
    // 属性注入
	wire.Struct(new(PostService), "*"),
	// 值绑定
	wire.Value(A(10)),
	wire.Value(B(10)),
	wire.InterfaceValue(new(io.Reader), os.Stdin),
	// 类型绑定接口
	wire.Bind(new(IPostUsecase), new(*PostUsecase)),
	NewPostUsecase,
	NewPostRepo,
)

4.2 Option Struct

在实际的业务场景当中我们的NewXXX 函数的参数列表可能会很长,这个时候就可以直接定义一个 Option Struct 然后使用 wire.Strcut 来构建 Option Strcut的依赖


// PostUsecaseOption PostUsecaseOption
type PostUsecaseOption struct {
	a    A
	b    B
	repo IPostRepo
}

// NewPostUsecase NewPostUsecase
func NewPostUsecase(opt *PostUsecaseOption) (*PostUsecase, func(), error) {
	return &PostUsecase{repo: opt.repo}, nil, nil
}

// PostServiceSet PostServiceSet
var PostServiceSet = wire.NewSet(
	wire.Struct(new(PostService), "*"),
	wire.Value(A(10)),
	wire.Value(B(10)),
	wire.InterfaceValue(new(io.Reader), os.Stdin),

    // for usecase
    wire.Bind(new(IPostUsecase), new(*PostUsecase)),
	wire.Struct(new(PostUsecaseOption), "*"),
	NewPostUsecase,

    NewPostRepo,
)

4.3 项目目录结构

.
├── api
├── cmd
│   └── app
│       ├── main.go
│       ├── wire.go
│       └── wire_gen.go
└── internal
    ├── domain
    │   └── post.go
    ├── repo
    │   └── repo.go
    ├── service
    │   └── service.go
    ├── usecase
    │   └── usecase.go
    └── wire_set.go
  • 一般在 cmd/xxx 目录下创建 wire.go 用于构建 injector 函数签名,因为我们一般会在 main 当中构建依赖关系完成服务启动
  • internal 或者是 internal/app 目录下创建 wire_set.go 构建 ProviderSet ,这里要注意
    • 这里的 ProviderSet 中的 Provider 函数只能是当前目录下创建的 Provider 函数
    • 例如可能存在 usecaserepo 都依赖 config 如果 repo 创建一个 ProviderSet 包含NewConfigusecase 也来一个,就会导致在 wire . 生成代码的时候报错,因为有冲突,同一个组件有两个 Provider

5. 参考

  1. wire 最佳实践
  2. 搞定Go单元测试
posted @ 2021-08-12 23:24  failymao  阅读(1231)  评论(0编辑  收藏  举报