依赖注入 && Wire

462 阅读8分钟

依赖注入

要想用好Wire,首选我们需要什么是依赖注入。简单的说,依赖注入就是通过注入方式,实现代码低耦合高内聚的一种技术手段。举个例子简单说明下,已经了解的同学可以直接区下一节:

假设有Pen这样一个结构体类型(类),有DrawPoint的方法,我们需要使用它实现DrawPicture的功能。

// Point 代表点
type Point struct { 
    X int
    Y int
}
​
// Pen 用于画图的笔
type Pen struct {
    Color string
}
​
// NewPoint 工厂函数 用于实例化一直笔
func NewPen(color string) Pen {
    return Pen{Color: color}
}
​
// DrawPoint 笔所实现的画点方法
func (pen Pen) DrawPoint(point Point) {
    fmt.Printf("draw a point(%d, %d)\n", point.X, point.Y)
}

一个简单粗暴的办法:

// DrawPicture 画图
func DrawPicture() {
    pen := NewPen("red") // 实例化一直红笔
    pen.DrawPoint(Point{0, 0}) // 画0,0点
    // 画其他点...
}

通过依赖注入方式实现

// DrawPictureByPen 最朴素依赖注入
func DrawPictureByPen(pen Pen) {
    pen.DrawPoint(Point{0, 0})
    // 画其他点...
}
​
// 配合接口注入// PainTool 绘图工具接口
type PainTool interface {
    DrawPoint(Point)
}
​
// DrawPictureByTool 通过参数注入接口实现依赖注入
func DrawPictureByTool(tool PainTool) {
    tool.DrawPoint(Point{0, 0})
    // 画其他点...
}

依赖注入方式有什么好处呢?

  • 朴素方式的注入

有一说一,没有啥实在的好处。硬要说的话,就是Pen结构的变化,不会影响DrawPicture内部的代码结构。

  • 配合接口进行注入的话

    • 实现了依赖倒置,降低了代码的耦合度。

如果后续要使用Pencil,Brusher等进行画图,只需要实现DrawPoint方法(即实现PainTool接口),就可以直接通过参数将依赖注入。而不用修改其他地方。

  • 提高代码的可测试性。

这个主要是说单元测试,如果要进行DrawPictureByPen函数,PenDrawPoint方法的变化可能导致测试失败(极端的情况下DrawPointpanic),此时单元测试的对象DrawPictureByPen并不是引起测试异常的原有。而如果对DrawPictureByTool函数做单元测试,则只需要完成对PainTool接口的Mock操作,就可以确保单元测试失败,一定是DrawPictureByTool内部代码引起的。

  • 代码的易读性

通过参数作为依赖注入入口,可以方便我们确认哪些是外部依赖,哪些是代码行为逻辑。一定程度上可以降低当前层级代码理解的难度。这一点理解起来不是很容易。

Wire简介

Wire是Google推出的,使用依赖注入方式自动连接组键的开源代码生成工具。在编译阶段完成注入,不使用反射、也不需要服务定位。

快速开始

举例

此处直接使用官方的例子。定义号相互依赖的几个结构体类型(类)

type Message stringtype Greeter struct {
    Message Message
}
​
type Event struct {
    Greeter Greeter
}
​
func NewMessage() Message {
    return Message("Hi there!")
}
​
func NewGreeter(m Message) Greeter {
    return Greeter{Message: m}
}
​
func (g Greeter) Greet() Message {
    return g.Message
}
​
func NewEvent(g Greeter) Event {
    return Event{Greeter: g}
}
​
func (e Event) Start() {
    msg := e.Greeter.Greet()
    fmt.Println(msg)
}

手写情况下,我们要使用EventStart方法需要使用一下代码。

message := NewMessage()
greeter := NewGreeter(message)
event := NewEvent(greeter)
​
event.Start()

而使用wire,可以直接为我们生成event的实例化代码,我们的调用可以简化为。

e := InitializeEvent()
 
e.Start()

使用方法

安装Wire工具

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

需要确保GOPAH/bin已经被添加到环境变量($PATH)中。

准备wire文件

在一个单独的文件中完成如下代码。注意buildpackage之间的空行不能省略,一些插件会自动为我们处理(甚至会加上一行//go:build wireinject),可以不用在意,继续流程即可。

//+build wireinjectpackage packageName
​
func InitializeEvent() Event {
    wire.Build(NewEvent, NewGreeter, NewMessage)
    return Event{}
}

执行wire命令

在wire文件所在目录下执行wire命令,命令将生成 wire_gen.go 文件,主要内容如下。

// Code generated by Wire. DO NOT EDIT.//go:generate wire
//+build !wireinjectpackage packageName
​
import (
   "github.com/google/wire"
)
​
func InitializeEvent() Event {
    message := NewMessage()
    greeter := NewGreeter(message)
    event := NewEvent(greeter)
    return event
}

其中

  • go:generate wire,指明以后调用go generate命令即可执行wire命令。
  • //+build !wireinject,是build标签,指明在非wireinject环境下忽略此文件。

高级特性

重要概念

高级特性都构建在ProvidersInjectors的概念之上,者是wire的两个核心概念。

  • Provider

Provider是wire中的主要机制,是用于产生实例的工厂函数。前文官方示例中的NewMessage,NewGreeter,NewEvent都是ProviderProvider是可导出的Go函数,可以通过参数指定依赖,同时也可以返回错误信息。

type Baz struct {
    X int
}
​
// ProvideBaz returns a value if Bar is not zero.
func ProvideBaz(ctx context.Context, bar Bar) (Baz, error) {
    if bar.X == 0 {
        return Baz{}, errors.New("cannot provide baz when bar is zero")
    }
    return Baz{X: bar.X}, nil
}

Providers可以通过providers set进行组织,我们通常把常用的一组providers放在一个超集(wire.Set)中。此外,超集也可以组织进入更高级的超集。

var SuperSet = wire.NewSet(ProvideFoo, ProvideBar, ProvideBaz)
​
var MegaSet = wire.NewSet(SuperSet, pkg.OtherSet)

事实上,当依赖关系比较复杂时,亦可以通过这个方式进行分组管理,是的依赖关系更有层次,以提高代码的可读性。如:

// FruitSet 水果的Provider超集
var FruitSet = wire.NewSet(ProvideApple, ProvideBanana, ProvidePear)
​
// VegetableSet 蔬菜的Provider超集
var VegetableSet = wire.NewSet(ProvideCabbage, ProvideCucumber, ProvideCarrot)
​
// MeatSet 肉类的Provider超集
var MeatSet = wire.NewSet(ProvidePork, ProvideBeef, ProvideChicken)
​
// FoodSet 食物的Porvider超集 有水果、蔬菜、肉类共同构成
var FoodSet = wire.NewSet(FruitSet, VegetableSet, MeatSet)
  • Injector

我们通过Injector串联相互依赖的Provider。我们定义好Injector的函数签名(包括函数名,参数,返回值的定义),并通过wire.Build说明需要使用到的Provider,wire就会自动为我们生成按照依赖顺序执行PorviderInjector函数体。wire.Build的参数和wire.Set要求一致,同样不需要考虑顺序。

func initializeBaz(ctx context.Context) (foobarbaz.Baz, error) {
    wire.Build(foobarbaz.MegaSet)
    return foobarbaz.Baz{}, nil
}

Injector的描述代码时常规的go代码,为了保证编译通过,我们可以同上方代码一样提供一个标准的返回值。wire通过读取wire.Build的参数用以生成Injector的函数提,返回值是什么并不关心。。此外,injector声明文件中其他未使用到的代码,会原封不动的生成到新文件中。

补充:

  • injector声明文件名并未有特殊要求。但往往习惯使用wire.go或使用带有wire标记的文件名。
  • injector函数签名对函数名也没有明确要求,但还是推荐使用Initialize标记开头。
  • 首次生成是必须使用wire名,之后的生成还可以使用go generate完成,具体情况可以根据需求而定。
  • 如果觉得使用return方式结束不方便,可以尝试使用panic
func initializeBaz(ctx context.Context) (foobarbaz.Baz, error) {
    panic(wire.Build(foobarbaz.MegaSet))
}

接口绑定

当一个注入点为接口时,由于wire时通过类型判别来完成注入的。所以最直接的办法就是提供一个返回接口类型的provider。然而这是不符合Go代码规范的。解决方案时,使用wire.Bind对接口所需传入的示例类型进行声明。

wire.Bind的第一个参数是接口类型的值的指针,一般用new([interface])方式传入;第二个参数是所需绑定类型的实例的指针,一般用new([type])方式传入。

wire.Bind(new(Fooer), new(*MyFooer)),

每一个包含接口类型入参的Provider都需要进行接口与实例类型的绑定。

type Fooer interface {
    Foo() string
}
​
type MyFooer stringfunc (b *MyFooer) Foo() string {
    return string(*b)
}
​
func provideMyFooer() *MyFooer {
    b := new(MyFooer)
    *b = "Hello, World!"
    return b
}
​
type Bar stringfunc provideBar(f Fooer) string {
    // f will be a *MyFooer.
    return f.Foo()
}
​
var Set = wire.NewSet(
    provideMyFooer,
    wire.Bind(new(Fooer), new(*MyFooer)),
    provideBar)

结构体Providers

当我们需要对一个结构体进行注入时,可以使用wire.Struct。它的第一个参数是所需要注入的结构体类型,一般用new([type])。后续参数为所需注入的字段名称。

type Foo int
type Bar intfunc ProvideFoo() Foo {/* ... */}
​
func ProvideBar() Bar {/* ... */}
​
type FooBar struct {
    MyFoo Foo
    MyBar Bar
}
​
var Set = wire.NewSet(
    ProvideFoo,
    ProvideBar,
    wire.Struct(new(FooBar), "MyFoo", "MyBar"))
​
func InitializeFooBar() FooBar { // 此处可以替换为*FooBar
    wire.Build(Set)
    return &FooBar{}
}

生成代码如下

func injectFooBar() FooBar {
    foo := ProvideFoo()
    bar := ProvideBar()
    fooBar := FooBar{
        MyFoo: foo,
        MyBar: bar,
    }
    return fooBar
}

补充:

  • 可以使用"*",表示所有字段都需要注入。
  • 当使用"*"使,也可以通过结构体Tag标记不需要注入的字段。
type Foo struct {
    mu sync.Mutex `wire:"-"`
    Bar Bar
}
  • 可以通过直接修改InitializeFooBar的返回值类型,实现对指针类型的注入。其他地方无须调整。

值绑定

有时需要为基本类型的属性绑定具体值,比如说避免nil, 这时可以使用 wire.Value进行值绑定。这可以避免掉一些无用的Provider

type Foo struct {
    X int
}
​
func injectFoo() Foo {
    wire.Build(wire.Value(Foo{X: 42}))
    return Foo{}
}

生成代码如下:

func injectFoo() Foo {
    foo := _wireFooValue
    return foo
}
​
var (
    _wireFooValue = Foo{X: 42}
)

注意:

  • 每次使用绑定的值都会被拷贝,需要确保这样是没有副作用的。
  • 如果绑定的值来自于一个函数或者一个管道,wire会抛出错误。
  • 对于接口类型的值,使用wire.Interface进行处理
func injectReader() io.Reader {
    wire.Build(wire.InterfaceValue(new(io.Reader), os.Stdin))
    return nil
}

结构体字段Provider

有时我们希望结构体字段作为依赖进行注入。这时我们可以使用wire.FieldOf而不用单独取写一个Get函数。

func injectedMessage() string {
    wire.Build(
        provideFoo,
        wire.FieldsOf(new(Foo), "S"))
    return ""
}

生成代码如下:

func injectedMessage() string {
    foo := provideFoo()
    string2 := foo.S
    return string2
}

补充:

  • wire.FieldOf可以声明多个字段
  • wire.FieldOf同时支持T*T类型

清理函数

如果一个Provider创建了一个需要清理的资源,如Flie需要CloseInjector可以通过闭包的方式实现当构造出现错误时,一次清理掉所有需要清理的资源。这个清理函数需要使用func()标签。

func provideFile(log Logger, path Path) (*os.File, func(), error) {
    f, err := os.Open(string(path))
    if err != nil {
        return nil, nil, err
    }
    cleanup := func() {
        if err := f.Close(); err != nil {
            log.Log(err)
        }
    }
    return f, cleanup, nil
}

\

参考资料

Wire Github

一文读懂 Go官方的 Wire

Go 每日一库之 wire