Go工程化(三) 依赖注入框架 wire

本系列为Go 进阶训练营 笔记,预计 2021Q1 完成更新,访问博客: Go 进阶训练营 即可查看当前更新进度,部分文章篇幅较长,使用 PC 大屏浏览体验更佳
在上一篇文章当中我们讲到了项目的目录结构,大体上水平切分为了四层,然后再根据需要进行垂直切分,然后由于我们大量的使用到了接口和依赖注入的手段,所以在项目初始化的时候如果手动进行依赖关系的初始化会比较麻烦,这时候就需要用到依赖注入的框架了。
在刚开始接触 go 的那一段时间,我是比较排斥使用太多框架的,觉得保持简单更加重要,这种想法在很长一段时间(大概两年左右)都没有问题,直到正式工作一段时间之后发现,随着开发合作的同学的增多以及部门的要求增加,项目启动时的依赖越来越多,依赖之间还有先后顺序,有一些甚至是隐式的顺序,到时 main 函数的代码膨胀的非常迅速并且慢慢的变的不可维护了,这种情况下引入依赖注入框架其实可以省心很多。
Golang 的依赖注入框架有两类,一类是通过反射在运行时进行依赖注入,典型代表是 uber 开源的 dig,另外一类是通过 generate 进行代码生成,典型代表是 Google 开源的 wire。使用 dig 功能会强大一些,但是缺点就是错误只能在运行时才能发现,这样如果不小心的话可能会导致一些隐藏的 bug 出现。使用 wire 的缺点就是功能限制多一些,但是好处就是编译的时候就可以发现问题,并且生成的代码其实和我们自己手写相关代码差不太多,更符合直觉,心智负担更小,所以我更加推荐 wire,如果对 dig 感兴趣可以跳转到文章参考文献处跳转查阅。
本文分为两个部分,首先介绍 wire 的使用方法,然后是结合上一篇文章中的工程目录,我在使用 wire 过程中的一些 “最佳实践” 避免大家重复踩坑。

wire 使用教程

如果你对 wire 已经比较熟悉可以直接跳过这一部分,阅读完本文之后建议对照看一下官方文档再进行操作。

安装

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

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

Provider

正式开始前需要先了解一下 wire 当中的两个概念:provider 和 injector
Provider 是一个普通的函数,这个函数会返回构建依赖关系所需的组件。如下所示,就是一个 provider 函数,在实际使用的时候,往往是一些简单的工厂函数,这个函数不会太复杂。

// NewPostRepo 创建文章 Repo
func NewPostRepo() IPostRepo {}

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

Injector

injector 也是一个普通函数,我们常常在 wire.go
文件中定义 injector 函数签名,然后通过 wire
命令自动生成一个完整的函数

//+build wireinject

func GetBlogService() *Blog {
    panic(wire.Build(NewBlogService, NewPostUsecase, NewPostRepo))
}

第一行的 //+build wireinject
注释确保了这个文件在我们正常编译的时候不会被引用,而 wire .
生成的文件 wire_gen.go
会包含 //+build !wireinject
注释,正常编译的时候,不指定 tag 的情况下会引用这个文件

wire.Build
injector
函数中使用,用于表名这个 injector
由哪些 provider
提供依赖, injector
函数本身只是一个函数签名,所以我们直接在函数中 panic
实际生成代码的时候并不会直接调用 panic

一个完整的 :chestnut:

基本示例

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}
}

上面的是一个简单的示例, NewPostService
NewPostUsecase
这些都是 Provider
函数,下面我们在 wire.go
当中构建 Injector
函数签名

//+build wireinject

package example

import "github.com/google/wire"

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

我们在目录下执行 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
}

缺少 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

返回错误

在 go 中如果遇到错误,我们会在最后一个返回值返回 error,wire 同样也支持返回错误的情况,只需要在 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
}

清理函数

有时候我们需要打开文件,或者是链接这种需要关闭的资源,这时候 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
返回错误,会调用它依赖的所有 provider 的 cleanup 函数,如果都没有问题,就会把所有 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
}

高级方法

接口注入

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

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

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

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

▶ 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.Bind
Struct
和接口进行绑定了,表示这个结构体实现了这个接口,我们修改一下 injector
函数

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

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

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"),
        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
}

值绑定

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

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

我们修改一下 PostService
使他依赖一个 int 和 io.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), "*"),
        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
)

ProviderSet(Provider 集合)

在真实的项目当中依赖往往是一组一组的,就像我们的示例一样,只要依赖 PostService
那么 NewPostUsecase
NewPostRepo
这两个就必不可少,所以我们往往会创建一些 ProviderSet
Injector
函数中直接依赖 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,
    ))
}

wire 使用最佳实践

不要使用默认类型

之前有提到过,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,
)

这种方式在使用上会感觉有点糟心,但是就我目前的使用来看,用到基础类型的情况还是比价少,所以也还好

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,
)

项目目录结构

.
├── 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 函数
    • 例如可能存在 usecase 和 repo 都依赖 config 如果 repo 创建一个 ProviderSet 包含 NewConfig
      ,usecase 也来一个,就会导致在 wire .
      生成代码的时候报错,因为有冲突,同一个组件有两个 Provider

总结

本文详细的介绍了 wire 的使用方法和一些存在的坑,避免大家重复踩坑,同时结合上一篇文章当中的项目结构给出了一种实践方式。依赖注入这个东西如果只是一个比较简单的应用并且这个应用的开发同学比较少,可以不用引入,引入依赖注入的框架还是会带来一些复杂性和学习成本,但是如果这个项目有很多同学在协作开发,并且部门要求的依赖组件比较多的时候还是需要引入的,随着项目代码的膨胀会导致后面依赖管理的管理越来越复杂,如果想要做一点点重构会带来很多麻烦。