golang依赖注入工具wire指南

2021年2月6日 · 829 字 · 4 分钟

wire与依赖注入

Wire 是一个的Golang依赖注入工具,通过自动生成代码的方式在编译期完成依赖注入,Java体系中最出名的Spring框架采用运行时注入,个人认为这是wire和其他依赖注入最大的不同之处。

依赖注入(Dependency Injection)也称作控制反转(Inversion of Control),个人给控制反转下的定义如下:

当前对象需要的依赖对象由外部提供(通常是IoC容器),外部负责依赖对象的构造等操作,当前对象只负责调用,而不关心依赖对象的构造。即依赖对象的控制权交给了IoC容器。

下面给出一个控制反转的示例,比如我们通过配置去创建一个数据库连接:

// 连接配置
type DatabaseConfig struct {
    Dsn string 
}

func NewDB(config *DatabaseConfig)(*sql.DB, error) {
    db,err := sql.Open("mysql", config.Dsn)
    if err != nil {
        return nil, err
    }
    // ...
}

fun NewConfig()(*DatabaseConfig,error) {
    // 读取配置文件
    fp, err := os.Open("config.json")
    if err != nil {
        return nil,err
    }
    defer fp.Close()
    // 解析为Json
    var config DatabaseConfig
    if err:=json.NewDecoder(fp).Decode(&config);err!=nil {
        return nil,err
    }
    return &config, nil
}

func InitDatabase() {
    cfg, err:=NewConfig()
    if err!=nil {
        log.Fatal(err)
    }
    db,err:=NewDB(cfg)
    if err!=nil {
        log.Fatail(err)
    }
    // db对象构造完毕
}

数据库配置怎么来的,NewDB方法并不关心(示例代码采用的是NewConfig提供的JSON配置对象),NewDB只负责创建DB对象并返回,和配置方式并没有耦合,所以即使换成配置中心或者其他方式来提供配置,NewDB代码也无需更改,这就是控制反转的魔力!

来看一个反面例子,也就是控制正转:

当前对象需要的依赖由自己创建,即依赖对象的控制权在当前对象自己手里。

type DatabaseConfig struct {
    Dsn string 
}

func NewDB()(*sql.DB, error) {
    // 读取配置文件
    fp, err := os.Open("config.json")
    if err != nil {
        return nil,err
    }
    defer fp.Close()
    // 解析为Json
    var config DatabaseConfig
    if err:=json.NewDecoder(fp).Decode(&config);err!=nil {
        return nil,err
    }
    // 初始化数据库连接
    db,err = sql.Open("mysql", config.Dsn)
    if err != nil {
        return
    }
    // ...
}

在控制正转模式下,NewDB方法需要自己实现配置对象的创建工作,在示例中需要读取Json配置文件,这是强耦合的代码,一旦配置文件的格式不是Json,NewDB方法将返回错误。

依赖注入固然好用,但是像刚才的例子中去手动管理依赖关系是相当复杂也是相当痛苦的一件事,因此在接下来的内容中会重点介绍golang的依赖注入工具——wire。

上手使用

通过go get github.com/google/wire/cmd/wire安装好wire命令行工具即可。

在正式开始之前需要介绍一下wire中的两个概念:ProviderInjector

  • Provider:负责创建对象的方法,比如上文中控制反转示例NewDB(提供DB对象)和NewConfig(提供DatabaseConfig对象)方法。
  • Injector:负责根据对象的依赖,依次构造依赖对象,最终构造目的对象的方法,比如上文中控制反转示例InitDatabase方法。

现在我们通过wire来实现一个简单的项目。项目结构如下:

|--cmd
	|--main.go
	|--wire.go
|--config
	|--app.json
|--internal
	|--config
		|--config.go
	|--db
		|--db.go

config/app.json

{
  "database": {
    "dsn": "root:root@tcp(localhost:3306)/test"
  }
}

internal/config/config.go

package config

import (
	"encoding/json"
	"github.com/google/wire"
	"os"
)

var Provider = wire.NewSet(New) // 将New方法声明为Provider,表示New方法可以创建一个被别人依赖的对象,也就是Config对象

type Config struct {
	Database database `json:"database"`
}

type database struct {
	Dsn string `json:"dsn"`
}

func New() (*Config, error) {
	fp, err := os.Open("config/app.json")
	if err != nil {
		return nil, err
	}
	defer fp.Close()
	var cfg Config
	if err := json.NewDecoder(fp).Decode(&cfg); err != nil {
		return nil, err
	}
	return &cfg, nil
}

internal/db/db.go

package db

import (
	"database/sql"
	_ "github.com/go-sql-driver/mysql"
	"github.com/google/wire"
	"wire-example2/internal/config"
)

var Provider = wire.NewSet(New) // 同理

func New(cfg *config.Config) (db *sql.DB, err error) {
	db, err = sql.Open("mysql", cfg.Database.Dsn)
	if err != nil {
		return
	}
	if err = db.Ping(); err != nil {
		return
	}
	return db, nil
}

cmd/main.go

package main

import (
	"database/sql"
	"log"
)

type App struct { // 最终需要的对象
	db *sql.DB
}

func NewApp(db *sql.DB) *App {
	return &App{db: db}
}

func main() {
	app, err := InitApp() // 使用wire生成的injector方法获取app对象
	if err != nil {
		log.Fatal(err)
	}
	var version string
	row := app.db.QueryRow("SELECT VERSION()")
	if err := row.Scan(&version); err != nil {
		log.Fatal(err)
	}
	log.Println(version)
}

cmd/wire.go

重点文件,也就是实现Injector的核心所在:

// +build wireinject

package main

import (
	"github.com/google/wire"
	"wire-example2/internal/config"
	"wire-example2/internal/db"
)

func InitApp() (*App, error) {
	panic(wire.Build(config.Provider, db.Provider, NewApp)) // 调用wire.Build方法传入所有的依赖对象以及构建最终对象的函数得到目标对象
}

文件编写完毕,进入cmd目录执行wire命令会得到以下输出:

C:\Users\Administrator\GolandProjects\wire-example2\cmd>wire
wire: wire-example2/cmd: wrote C:\Users\Administrator\GolandProjects\wire-example2\cmd\wire_gen.go

表明成功生成wire_gen.go文件,文件内容如下:

// Code generated by Wire. DO NOT EDIT.

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

package main

import (
	"wire-example2/internal/config"
	"wire-example2/internal/db"
)

// Injectors from wire.go:

func InitApp() (*App, error) {
	configConfig, err := config.New()
	if err != nil {
		return nil, err
	}
	sqlDB, err := db.New(configConfig)
	if err != nil {
		return nil, err
	}
	app := NewApp(sqlDB)
	return app, nil
}

可以看到生成App对象的代码已经自动生成了。

Provider说明

通过NewSet方法将本包内创建对象的方法声明为Provider以供其他对象使用。NewSet可以接收多个参数,比如我们db包内可以创建Mysql和Redis连接对象,则可以如下声明:

var Provider = wire.NewSet(NewDB, NewRedis)

func NewDB(config *Config)(*sql.DB,error) { // 创建数据库对象
    
}

func NewRedis(config *Config)(*redis.Client,error) { // 创建Redis对象
}

wire.go文件说明

wire.go文件需要放在创建目标对象的地方,比如我们ConfigDB对象最终是为App服务的,因此wire.go文件需要放在App所在的包内。

wire.go文件名不是固定的,不过大家习惯叫这个文件名。

wire.go的第一行// +build wireinject是必须的,含义如下:

只有添加了名称为"wireinject"的build tag,本文件才会编译,而我们go build main.go的时候通常不会加。因此,该文件不会参与最终编译。

wire.Build(config.Provider, db.Provider, NewApp)通过传入config以及db对象来创建最终需要的App对象

wire_gen.go文件说明

该文件由wire自动生成,无需手工编辑!!!

//+build !wireinject标签和wire.go文件的标签相对应,含义如下:

编译时只有未添加“wireinject"的build tag,本文件才参与编译。

因此,任意时刻下,wire.gowire_gen.go只会有一个参与编译。

高级玩法

cleanup函数

在创建依赖资源时,如果由某个资源创建失败,那么其他资源需要关闭的情况下,可以使用cleanup函数来关闭资源。比如咱们给db.New方法返回一个cleanup函数来关闭数据库连接,相关代码修改如下(未列出的代码不修改):

internal/db/db.go

func New(cfg *config.Config) (db *sql.DB, cleanup func(), err error) { // 声明第二个返回值
	db, err = sql.Open("mysql", cfg.Database.Dsn)
	if err != nil {
		return
	}
	if err = db.Ping(); err != nil {
		return
	}
	cleanup = func() { // cleanup函数中关闭数据库连接
		db.Close()
	}
	return db, cleanup, nil
}

cmd/wire.go

func InitApp() (*App, func(), error) { // 声明第二个返回值
	panic(wire.Build(config.Provider, db.Provider, NewApp))
}

cmd/main.go

func main() {
	app, cleanup, err := InitApp() // 添加第二个参数
	if err != nil {
		log.Fatal(err)
	}
	defer cleanup() // 延迟调用cleanup关闭资源
	var version string
	row := app.db.QueryRow("SELECT VERSION()")
	if err := row.Scan(&version); err != nil {
		log.Fatal(err)
	}
	log.Println(version)
}

重新在cmd目录执行wire命令,生成的wire_gen.go如下:

func InitApp() (*App, func(), error) {
	configConfig, err := config.New()
	if err != nil {
		return nil, nil, err
	}
	sqlDB, cleanup, err := db.New(configConfig)
	if err != nil {
		return nil, nil, err
	}
	app := NewApp(sqlDB)
	return app, func() { // 返回了清理函数
		cleanup()
	}, nil
}

接口绑定

在面向接口编程中,代码依赖的往往是接口,而不是具体的struct,此时依赖注入相关代码需要做一点小小的修改,继续刚才的例子,示例修改如下:

新增internal/db/dao.go

package db

import "database/sql"

type Dao interface { // 接口声明
	Version() (string, error)
}

type dao struct { // 默认实现
	db *sql.DB
}

func (d dao) Version() (string, error) {
	var version string
	row := d.db.QueryRow("SELECT VERSION()")
	if err := row.Scan(&version); err != nil {
		return "", err
	}
	return version, nil
}

func NewDao(db *sql.DB) *dao { // 生成dao对象的方法
	return &dao{db: db}
}

internal/db/db.go也需要修改Provider,增加NewDao声明:

var Provider = wire.NewSet(New, NewDao)

cmd/main.go文件修改:

package main

import (
	"log"
	"wire-example2/internal/db"
)

type App struct {
	dao db.Dao // 依赖Dao接口
}

func NewApp(dao db.Dao) *App { // 依赖Dao接口
	return &App{dao: dao}
}

func main() {
	app, cleanup, err := InitApp()
	if err != nil {
		log.Fatal(err)
	}
	defer cleanup()
	version, err := app.dao.Version() // 调用Dao接口方法
	if err != nil {
		log.Fatal(err)
	}
	log.Println(version)
}

进入cmd目录执行wire命令,此时会出现报错:

C:\Users\Administrator\GolandProjects\wire-example2\cmd>wire
wire: C:\Users\Administrator\GolandProjects\wire-example2\cmd\wire.go:11:1: inject InitApp: no provider found for wire-example2/internal/db.Dao
        needed by *wire-example2/cmd.App in provider "NewApp" (C:\Users\Administrator\GolandProjects\wire-example2\cmd\main.go:12:6)
wire: wire-example2/cmd: generate failed
wire: at least one generate failure

wire提示inject InitApp: no provider found for wire-example2/internal/db.Dao,也就是没找到能提供db.Dao对象的Provider,咱们不是提供了默认的db.dao实现也注册了Provider吗?这也是go的OOP设计奇特之处。

咱们修改一下internal/db/db.goProvider声明,增加db.*daodb.Dao的接口绑定关系:

var Provider = wire.NewSet(New, NewDao, wire.Bind(new(Dao), new(*dao)))

wire.Bind()方法第一个参数为interface{},第二个参数为实现

此时再执行wire命令就可以成功了!

结尾

wire工具还有很多玩法,但是就笔者个人工作经验而言,掌握本文介绍到的知识已经能够胜任绝大部分场景了!