关于后端:Go工程化-手摸手带你理解依赖注入

43次阅读

共计 9262 个字符,预计需要花费 24 分钟才能阅读完成。

咱们在微服务框架 kratos v2[1] 的默认我的项目模板中 kratos-layout[2] 中应用了 google/wire[3]进行依赖注入,也倡议开发者在保护我的项目时应用该工具。

wire 乍看起来比拟违反直觉,导致很多同学不了解为什么要用或不分明如何用(也包含已经的我),因而本文来帮忙大家了解 wire 的应用。

What

wire[4] 是由 google 开源的一个供 Go 语言应用的依赖注入代码生成工具。它可能依据你的代码,生成相应的依赖注入 go 代码。

而与其它依附反射实现的依赖注入工具不同的是,wire 能在编译期(精确地说是代码生成时)如果依赖注入有问题,在代码生成时即可报进去,不会拖到运行时才报,更便于 debug。

Why

了解依赖注入

什么是依赖注入?为什么要依赖注入?依赖注入就是 Java 遗毒(不是)

依赖注入[5]  (Dependency Injection,缩写为 DI),能够了解为一种代码的结构模式(就是写法),依照这样的形式来写,可能让你的代码更加容易保护。

对于很多软件设计模式和架构的理念,咱们都无奈了解他们要绕好大一圈做简单的体操、用奇怪的形式进行实现的意义。他们通常都只是丢进去一段样例,说这样写就很好很优雅,因为省略掉了这种模式是如何倒退进去的推导过程,咱们只看到了后果,导致了解起来很艰难。那么接下来咱们来尝试推导还原一下整个过程,看看代码是如何和为什么演进到依赖注入模式的,以便可能更好了解应用依赖注入的意义。

依赖是什么?

这里的依赖是个名词,不是指软件包的依赖(比方那坨塞在 node\_modules 外面的货色),而是指软件中某一个模块(对象 / 实例)所依赖的其它内部模块(对象 / 实例)。

注入到哪里?

被依赖的模块,在创立模块时,被注入到(即当作参数传入)模块的外面。

不 DI 是啥样?DI 了又是啥样子?

上面用 go 伪代码来做例子,体会精力即可。

假如个场景,你在打工搞一个 web 利用,它有一个简略接口。最开始的我的项目代码可能长这个样子:

# 上面为伪代码,疏忽了很多与主题无关的细节

type App struct {
}

# 假如这个办法将会匹配并解决 GET /biu/<id> 这样的申请
func (a *App) GetData(id string) string {
    # todo: write your data query
    return "some data"
}

func NewApp() *App {return &App{}
}

app := App()
app.Run()

你要做的是接一个 mysql,从外面把数据依照 id 查出来,返回。要连 mysql 的话,假如咱们曾经有了个 NewMySQLClient 的办法返回 client 给你,初始化时传个地址进去就能拿到数据库连贯,并假如它有个 Exec 的办法给你执行参数。

不必 DI,通过全局变量传递依赖实例

一种写法是,在里面全局初始化好 client,而后 App 间接拿来调用。


var mysqlUrl = "mysql://blabla"
var db = NewMySQLClient(mysqlUrl)


type App struct {

}

func (a *App) GetData(id string) string {data := db.Exec("select data from biu where id = ? limit 1", id)
    return data
}


func NewApp() *App {return &App{}
}
func main() {app := App()
    app.Run()}

这就是没用依赖注入,app 依赖了全局变量 db,这是比拟蹩脚的一种做法。db 这个对象游离在全局作用域,裸露给包下的其余模块,比拟危险。(构想如果这个包里其余代码在运行时轻轻把你的这个 db 变量替换掉会产生啥)

不必 DI,在 App 的初始化办法里创立依赖实例

另一种形式是这样的:

type App struct {db *MySQLClient}

func (a *App) GetData(id string) string {data := a.db.Exec("select data from biu where id = ? limit 1", id)
    return data
}


func NewApp() *App {return &App{db: NewMySQLClient(mysqlUrl)}
}
func main() {app := NewApp("mysql://blabla")
    app.Run()}

这种办法略微好一些,db 被塞到 app 外面了,不会有 app 之外的无关代码碰它,比拟平安,但这仍然不是依赖注入,而是在外部创立了依赖,接下来你会看到它带来的问题。

老板:咱们的数据要换个中央存(须要变更实现)

你的老板不晓得从哪据说——Redis 贼特么快,要不咱们的数据改从 Redis 里读吧。这个时候你的心田有点解体,但毕竟要恰饭的,就硬着头皮改下面的代码。

type App struct {ds *RedisClient}

func (a *App) GetData(id string) string {data := a.ds.Do("GET", "biu_"+id)
    return data
}


func NewApp() *App {return &App{ds: NewRedisClient(redisAddr)}
}

func main() {app := NewApp("redis://ooo")
    app.Run()}

下面根本进行了 3 处批改:

  1. App 初始化办法里改成了初始化 RedisClient
  2. get\_data 里取数据时改用 run 办法,并且查问语句也换了
  3. App 实例化时传入的参数改成了 redis 地址
老板:要不,咱们再换个中央存?/ 咱们要加测试,须要 Mock

老板的思路总是很广的,又过了两天他又想换成 Postgres 存了;或者让你们给 App 写点测试代码,只测接口外面的逻辑,通常咱们不太违心在旁边再起一个数据库,那么就须要 mock 掉数据源这块货色,让它间接返回数据给申请的 handler 用,来进行针对性的测试。

这种状况怎么办?再改外面的代码?这不迷信。

面向接口编程

一个很重要的思路就是要 面向接口 (interface) 编程,而不是面向具体实现编程。

什么叫面向具体实现编程呢?比方上述的例子里改变的局部:调 mysqlclient 的 exec\_sql 执行一条 sql,被改成了:调 redisclient 的 do 执行一句 get 指令。因为每种 client 的接口设计不同,每换一个实现,就得改一遍。

而面向接口编程的思路,则齐全不同。咱们不要听老板想用啥就马上写代码。首先就得预料到,这个数据源的实现很有可能被更换,因而在一开始就应该做好筹备(设计)。

设计接口

Python 外面有个概念叫鸭子类型(duck-typing),就是如果你叫起来像鸭子,走路像鸭子,游泳像鸭子,那么你就是一只鸭子。这里的叫、走路、游泳就是咱们约定的鸭子接口,而你如果残缺实现了这些接口,咱们能够像看待一个鸭子一样看待你。

在咱们下面的例子中,不论是 Mysql 实现还是 Redis 实现,他们都有个独特的性能:用一个 id,查一个数据进去,那么这就是独特的接口。

咱们能够约定一个叫 DataSource 的接口,它必须有一个办法叫 GetById,性能是要接管一个 id,返回一个字符串

type DataSource interface {GetById(id string) string
}

而后咱们就能够把各个数据源别离进行封装,依照这个 interface 定义实现接口,这样咱们的 App 里解决申请的局部就能够稳固地调用 GetById 这个办法,而底层数据实现只有实现了 DataSource 这个 interface 就能花式替换,不必改 App 外部的代码了。

// 封装个 redis
type redis struct {r *RedisClient}

func NewRedis(addr string) *redis {return &redis{db: NewRedisClient(addr)}
}

func (r *redis) GetById(id string) string {return r.r.Do("GET", "biu_"+id)
}


// 再封装个 mysql
type mysql struct {m *MySQLClient}

func NewMySQL(addr string) *redis {return &mysql{db: NewMySQLClient(addr)}
}

func (m *mysql) GetById(id string) string {return r.m.Exec("select data from biu where id = ? limit 1", id)
}


type App struct {ds DataSource}

func NewApp(addr string) *App {
    // 须要用 Mysql 的时候
    return &App{ds: NewMySQLClient(addr)}

    // 须要用 Redis 的时候
    return &App{ds: NewRedisClient(addr)}
}

因为两种数据源都实现了 DataSource 接口,因而能够间接创立一个塞到 App 外面了,想用哪个用哪个,看着还不错?

等一等,如同少了些什么

addr 作为参数,是不是有点简略?通常初始化一个数据库连贯,可能有一堆参数,配在一个 yaml 文件里,须要解析到一个 struct 外面,而后再传给对应的 New 办法。

配置文件可能是这样的:

redis:
    addr: 127.0.0.1:6379
    read_timeout: 0.2s
    write_timeout: 0.2s

解析构造体是这样的:

type RedisConfig struct {
 Network      string             `json:"network,omitempty"`
 Addr         string             `json:"addr,omitempty"`
 ReadTimeout  *duration.Duration `json:"read_timeout,omitempty"`
 WriteTimeout *duration.Duration `json:"write_timeout,omitempty"`
}

后果你的 NewApp 办法可能就变成了这个德性:

func NewApp() *App {
    var conf *RedisConfig
    yamlFile, err := ioutil.ReadFile("redis_conf.yaml")
    if err != nil {panic(err)
    }
    err = yaml.Unmarshal(yamlFile, &conf)
    if err != nil {panic(err)
    }
    return &App{ds: NewRedisClient(conf)}
}

NewApp 说,停停,你们年轻人不讲武德,我的责任就是创立一个 App 实例,我只须要一个 DataSource 注册进去,至于这个 DataSource 是怎么来的我不想管,这么一坨解决 conf 的代码凭什么要放在我这里,我也不想关怀你这配置文件是通过网络申请拿来的还是从本地磁盘读的,我只想把 App 组装好扔出去间接上班。

依赖注入终于能够退场了

还记得后面是怎么说依赖注入的吗?被依赖的模块,在创立模块时,被注入到(即当作参数传入)初始化函数外面。通过这种模式,正好能够让 NewApp 早点上班。咱们在里面初始化好 NewRedis 或者 NewMysql,失去的 DataSource 间接扔给 NewApp。

也就是这样

func NewApp(ds DataSource) *App {return &App{ds: ds}
}

那一坨读配置文件初始化 redis 的代码扔到初始化 DataSource 的办法里去

func NewRedis() DataSource {
    var conf *RedisConfig
    yamlFile, err := ioutil.ReadFile("redis_conf.yaml")
    if err != nil {panic(err)
    }
    err = yaml.Unmarshal(yamlFile, &conf)
    if err != nil {panic(err)
    }
    return &redis{r: NewRedisClient(conf)}
}

更进一步,NewRedis 这个办法甚至也不须要关怀文件是怎么读的,它的责任只是通过 conf 初始化一个 DataSource 进去,因而你能够持续把读 config 的代码往外抽,把 NewRedis 做成接管一个 conf,输入一个 DataSource

func GetRedisConf() *RedisConfig
func NewRedis(conf *RedisConfig) DataSource

因为之前整个组装过程是散放在 main 函数上面的,咱们把它抽出来搞成一个独立的 initApp 办法。最初你的 App 初始化逻辑就变成了这样

func initApp() *App {c := GetRedisConf()
    r := NewRedis(c)
    app := NewApp(r)
    return app
}

func main() {app := initApp()
    app.Run()}

而后你能够通过实现 DataSource 的接口,更换后面的读取配置文件的办法,和更换创立 DataSource 的办法,来任意批改你的底层实现(读配置文件的实现,和用哪种 DataSource 来查数据),而不必每次都改一大堆代码。这使得你的代码档次划分得更加分明,更容易保护了。

这就是依赖注入。

手工依赖注入的问题

上文这一坨代码,把各个实例初始化好,再依照各个初始化办法的需要塞进去,最终结构出 app 的这坨代码,就是注入依赖的过程。

c := GetRedisConf()
r := NewRedis(c)
app := NewApp(r)

目前只有一个 DataSource,这样手写注入过程还能够,一旦你要保护的货色多了,比方你的 NewApp 是这样的 NewApp(r *Redis, es *ES, us *UserSerivce, db *MySQL) *App 而后其中 UserService 是这样的UserService(pg *Postgres, mm *Memcached),这样造成了多层次的一堆依赖须要注入,徒手去写十分麻烦。

而这部分,就是 wire 这样的依赖注入工具可能起作用的中央了——他的性能只是通过生成代码 帮你注入依赖,而理论的依赖实例须要你本人创立(初始化)。

How

wire 的次要问题是,看文档学不会。反正我最后看完文档之后是一头雾水——这是啥,这要干啥?但通过咱们方才的推导过程,应该大略了解了为什么要用依赖注入,以及 wire 在这其中起到什么作用——通过生成代码 帮你注入依赖,而理论的依赖实例须要你本人创立(初始化)。

接下来就比较清楚了。

首先要实现一个 wire.go 的文件,外面定义好 Injector。

// +build wireinject

func initApp() (*App) {panic(wire.Build(GetRedisConf, NewRedis, SomeProviderSet, NewApp))
}

而后别离实现好 Provider。

执行 wire 命令后 他会扫描整个我的项目,并帮你生成一个 wire_gen.go 文件,如果你有什么没有实现好,它会报错进去。

你学会了吗?

从新了解

等一等,先别放弃医治,让咱们用神奇的中文编程来解释一下要怎么做。

谁参加编译?

下面那个 initApp 办法,官网文档叫它 Injector,因为文件里首行 // +build wireinject 这句正文,这个 wire.go 文件只会由 wire 读取,在 go 编译器在编译代码时不会去管它,理论会读的是生成的 wire\_gen.go 文件。

而 Provider 就是你代码的一部分,必定会参加到编译过程。

Injector 是什么鬼货色?

Injector 就是你最终想要的后果——最终的 App 对象的初始化函数,也就是后面那个例子里的 initApp 办法。

把它了解为你去吃金拱门,进门看到点餐机,噼里啪啦点了一堆,最初打出一张单子。

// +build wireinject

func 来一袋垃圾食品() 一袋垃圾食品 {panic(wire.Build(来一份巨无霸套餐, 来一份双层鳕鱼堡套餐, 来一盒麦乐鸡, 垃圾食品打包))
}

这就是你点的单子,它不参加编译,理论参加编译的代码是由 wire 帮你生成的。

Provider 是什么鬼货色?

Provider 就是创立各个依赖的办法,比方后面例子里的 NewRedis 和 NewApp 等。

你能够了解为,这些是金拱门的服务员和后厨要干的事件:金拱门后厨须要提供这些食品的制作服务——实现这些实例初始化办法。

func 来一盒麦乐鸡() 一盒麦乐鸡 {}
func 垃圾食品打包(一份巨无霸套餐, 一份双层鳕鱼堡套餐, 一盒麦乐鸡) 一袋垃圾食品 {}

wire 外面还有个 ProviderSet 的概念,就是把一组 Provider 打包,因为通常你点单的时候很懒,不想这样点你的巨无霸套餐:我要一杯可乐,一包薯条,一个巨无霸汉堡;你想间接戳一下就好了,来一份巨无霸套餐。这个套餐就是 ProviderSet,一组约定好的配方,不然你的点单列表(injector 里的 Build)就会变得超级长,这样你很麻烦,服务员看着也很累。

用其中一个套餐举例

// 先定义套餐内容
var 巨无霸套餐 = wire.NewSet(来一杯可乐,来一包薯条,来一个巨无霸汉堡)

// 而后实现各个食品的做法
func 来一杯可乐() 一杯可乐 {}
func 来一包薯条() 一包薯条 {}
func 来一个巨无霸汉堡() 一个巨无霸汉堡 {}

wire 工具做了啥?

重要的事件说三遍,通过生成代码 帮你注入依赖

在金拱门的例子里就是,wire 就是个服务员,它依照你的订单,去叫做相应的共事把各个食物 / 套餐做好,而后最终按需要打包给你。这个两头协调构建的过程,就是注入依赖。

这样的益处就是,对于金拱门,假如他们忽然换可乐供应商了,间接把 来一杯可乐 替换掉就行,返回一种新的可乐,而对于顾客不须要有啥改变。对于顾客来说,点单内容能够变换,比方我明天不想要麦乐鸡了,或者想加点别的,只有改变我的点单(只有金拱门能做得进去),而后通过 wire 从新去生成即可,不须要关注这个服务员是如何去做这个订单的。

当初你应该大略了解 wire 的用途和益处了。

总结

让咱们从金拱门回来,从新总结一下用 wire 做依赖注入的过程。

1. 定义 Injector

创立 wire.go 文件,定义下你最终想用的实例初始化函数例如 initApp(即 Injector),定好它返回的货色*App,在办法里用panic(wire.Build(NewRedis, SomeProviderSet, NewApp)) 列举出它依赖哪些实例的初始化办法(即 Provider)/ 或者哪些组初始化办法(ProviderSet)

2. 定义 ProviderSet(如果有的话)

ProviderSet 就是一组初始化函数,是为了少写一些代码,可能更清晰的组织各个模块的依赖才呈现的。也能够不必,但 Injector 外面的货色就须要写一堆。像这样 var SomeProviderSet = wire.NewSet(NewES,NewDB)定义 ProviderSet 外面蕴含哪些 Provider

3. 实现各个 Provider

Provider 就是初始化办法,你须要本人实现,比方 NewApp,NewRedis,NewMySQL,GetConfig 等,留神他们们各自的输入输出

4. 生成代码

执行 wire 命令生成代码,工具会扫描你的代码,按照你的 Injector 定义来组织各个 Provider 的执行程序,并主动依照 Provider 们的类型需要来依照程序执行和安顿参数传递,如果有哪些 Provider 的要求没有满足,会在终端报进去,继续修复执行 wire,直到胜利生成 wire_gen.go 文件。接下来就能够失常应用 initApp 来写你后续的代码了。

如果须要替换实现,对 Injector 进行相应的批改,实现必须的 Provider,从新生成即可。

它生成的代码其实就是相似咱们之前须要手写的这个

func initApp() *App {  // injector
    c := GetRedisConf() // provider
    r := NewRedis(c)  // provider
    app := NewApp(r) // provider
    return app
}

因为咱们的例子比较简单,通过 wire 生成体现不出劣势,但如果咱们的软件简单,有很多层级的依赖,应用 wire 主动生成注入逻辑,无疑更加不便和精确。

5. 高级用法

wire 还有更多功能,比方 cleanup, bind 等等,请参考官网文档来应用。

最初,其实多折腾几次,就会应用了,心愿本文能对您起到肯定水平上的帮忙。

参考资料

[1]

kratos v2: https://github.com/go-kratos/…

[2]

kratos-layout: https://github.com/go-kratos/…

[3]

google/wire: https://github.com/google/wire

[4]

wire: https://github.com/google/wire

[5]

依赖注入: https://zh.wikipedia.org/wiki…

– END –

Go 语言进阶

Go 语言进阶,手把手带你实现能力进阶!专一于微服务架构、中间件,以及 Go 工程化 最佳实际。

7 篇原创内容

公众号

正文完
 0