前言

golang 依赖注入wire

  • 有人认为 依赖注入 不应该出现在 golang 中,是毒药
  • 而也有人认为 依赖注入 是非常好的设计思路,是依赖管理的解药
依赖注入

故,本文建议推荐给下述人群:

  1. 还在纠结 golang 要不要使用依赖注入的人
  2. 依赖注入 理解还有所疑惑的人
为了简述,下文有以下简称
依赖注入 简称为:DI
面向对象 简称为:OOP

个人观点

我不喜欢浪费时间,也避免标题党嫌疑,所以为了节约时间,对于已经知道 DI 概念的朋友,直接先给出我自己的观点:

  1. 如果你现在做的项目不大,或是个人项目,并且还在尝试阶段。完全的面向过程编程,在 go 中是可行的。
  2. 但如果你的项目比较大,又是多人协作,我真心建议你使用 DI,OOP 是有它存在的意义的。
  3. 如果你没接触过 DI,那么你一定要尝试去理解它一次,至少给他一次机会,不要盲目听取网络上的声音,实践是检验真理的唯一标准。

或许听上去,我这个观点好像有点矛盾,如果你愿意,请听从我一个过来人,下面几个方面去阐述一下。

为什么我说是过来人呢? 因为我一开始也是使用的 java 做了很久,spring 也是 YYDS,然后转而到 golang 并且一开始也没有使用依赖注入,然后慢慢在学习过程中有了转变,希望从这个路径能给你一些思路

Golang 完全面向过程编程可行吗?

可行!非常明确的告诉你。

我之前有幸参与过前公司一个比较大型的开发项目,由于那个时候刚接触 golang,当时大家都还比较陌生,还在摸索的阶段,当时就是完全使用了面向过程的方式去编程。项目本身包含前端和后台,有 WEB,也有业务,也有各种 SDK 和接口。或许你还对面向过程的方式不太理解,我举几个详细代码例子🌰:

service.GetUserByID(userID)dao.GetUserByID(userID)db.Engine.Find()

你是否有熟悉的感觉呢?如果有,那可能你和我们当时差不多。当时所有的函数都是直接使用包名调用的,不需要初始化,也不需要 new 对象,function 内容 就是 过程。

也有几个显而易见的特征:

包名+方法名
PS:其实,当初这个项目还有一个 1.0 的版本,在 1.0 的版本中虽然没有使用DI,但是当时是 OOP 的思想在做的,我们当时的开发也一致觉得麻烦,所以没有采用。

整个项目现在都还在正常运行,除了 bug 没有问题。

开发完成的感受

  1. 快速
  2. 好理解
  3. 无扩展

整个项目自始至终我们就没有定义过几个 interface 去实现,并且我们当初感觉良好,甚至多拿了点奖金,哈哈。没有意识到任何问题。直到我不断的做项目,换了公司才发现,原来挖坑了。

面向过程开发当时的想法

那时,我对依赖注入的想法可以和某些现在的同学是一模一样的,那时我看到 DI 这个东西就是反感,没有任何去了解的欲望,当时的想法就是下面这样:

  1. DI == java 的 Spring (当时我看过 spring 的源码,厌恶八股文的同时,也对它有了厌恶)
  2. 既然我都用 Go 了为啥还要像 Spring 那样非要 New 对象呢?
  3. Go 为什么还有公司会出 DI 的工具?还会出 Spring 那样类似的框架?
less is more

那用了依赖注入之后呢?

直到前两年,我参与了一个新的项目之后,才渐渐的明白,为什么会需要 OOP,为什么会需要 DI。以至于之后的各种项目都有着 DI 的身影。

新的项目

wirewire
wire

为什么需要 OOP

理由1: 调用方法前保证初始化

包名+方法名
daoNew

当然你会说,我早就在 main 函数(或者初始化函数)中初始化过数据库连接了,我一开始也是这样想的,但是后来我发现,你只能说从人为的角度保证了先初始化数据库再使用,而从代码的角度,我其实可以在任意地方调用这个方法

理由2: 减少全局变量

之前面向过程的时候几乎全部都是全局变量,数据库 ORM 的引擎是全局变量,配置文件的实体结构也是,过多的全局变量会导致的问题和上面一样,在使用时,你从代码层面无法保证使用者是在初始化之后进行使用的。
那么也就是意味着,使用可能会导致空指针,也就是没有初始化好,就已经在使用了。虽然你一样可以说人为的将所有初始化放在 main 中完成

理由3: 抽象接口,随意切换实现

dao.GetUserFromDBdao.GetUserFromCache

但是当你使用 OOP 的时候,你可以将原来的依赖改为依赖接口,并创建对象来实现这个接口。当你需要修改实现的时候,上层无需做任何改动,只需要修改实现的对象就可以了。

为什么需要 DI

那么问题来了,OOP 确实有好的地方,但是这与我们讨论的 DI 有什么关系,DI 到底解决了什么问题呢?

既然有了 OOP 就有了对象,有了对象就需要 new,就需要管理对象之间的依赖关系。那么 DI 就是为了解决你不需要 new 对象,不需要手动管理依赖关系的问题。

DI:Dependency Injection 依赖注入。如果你是第一次见到这个概念,或许还对这个概念比较陌生。
我也是从 java 过来的,在 java 中 spring 框架中就有这个概念,当时我在学习 java 的时候就有所了解,但其实当我在 golang 中实践了之后有了更深刻的认识。

如图,我随便画了一下可能存在的普遍依赖关系,那么就会遇到下面几个问题:

先有鸡才能有蛋

userRepouserRepo

先有母鸡才能有小鸡

userRepoEnginenew Enginenew userRepo

鸡的亲戚关系难管理

最后,由于对象很多,依赖会越来越复杂,如果我们手动去管理这些依赖,那么就会非常麻烦,并且依赖的先后顺序很难被理清楚,特别是当新的依赖被添加的时候。这也就是第三个问题,鸡的亲戚关系难管理。

为了解决这些问题,于是依赖注入就出现了。有了它,最大的特点就是,你不需要 new,也不需要主动去管理依赖关系。

@Autowired

使用 wire 实现 DI

digwirewirewire

base code

首先,我们定义一些结构来模拟我们经常做的 web 项目的初始化过程。

type DB struct {  
}  
  
type Cache struct {  
}  
  
type UserRepo struct {  
   DB    *DB  
   Cache *Cache  
}  
  
type UserService struct {  
   UserRepo *UserRepo  
}  
  
type App struct {  
   UserService *UserService  
}  
  
func (app *App) Start() {  
   fmt.Println("server starting")  
}  
  
func NewDB() (*DB, func(), error) {  
   db := &DB{}  
   cleanup := func() {  
      fmt.Println("close db connection")  
   }  
   return db, cleanup, nil  
}  
  
func NewCache() *Cache {  
   return &Cache{}  
}  
  
func NewUserRepo(db *DB, cache *Cache) *UserRepo {  
   return &UserRepo{DB: db, Cache: cache}  
}  
  
func NewUserService(userRepo *UserRepo) *UserService {  
   return &UserService{UserRepo: userRepo}  
}  
  
func NewApp(userService *UserService) *App {  
   return &App{UserService: userService}  
}

不使用 wire

如果不使用 wire, 我们可以通过手动 new 的方式初始化

func main() {  
   db, cleanup, err := NewDB()  
   if err != nil {  
      panic(err)  
   }  
   defer cleanup()  
   cache := NewCache()  
   userRepo := NewUserRepo(db, cache)  
   userService := NewUserService(userRepo)  
   app := NewApp(userService)  
   app.Start()  
}

使用 DI

wire.go
//go:build wireinject
// +build wireinject

package main

import (
    "github.com/google/wire"
)

// InitializeApplication
func InitializeApplication() (*App, func(), error) {
    panic(wire.Build(NewDB, NewCache, NewUserRepo, NewUserService, NewApp))
}

然后我们只需要在使用的地方调用对应的初始化方法获得产物即可,不需要关心其中的依赖关系。

func main() {  
   app, cleanup, err := InitializeApplication()  
   if err != nil {  
      panic(err)  
   }  
   defer cleanup()  
   app.Start()  
}
wire .
// Code generated by Wire. DO NOT EDIT.  
  
//go:generate go run github.com/google/wire/cmd/wire//go:build !wireinject  
// +build !wireinject  
  
package main  
  
// Injectors from wire.go:  
  
// InitializeApplication  
func InitializeApplication() (*App, func(), error) {  
   db, cleanup, err := NewDB()  
   if err != nil {  
      return nil, nil, err  
   }  
   cache := NewCache()  
   userRepo := NewUserRepo(db, cache)  
   userService := NewUserService(userRepo)  
   app := NewApp(userService)  
   return app, func() {  
      cleanup()  
   }, nil  
}

其实,我们可以看到生成的代码和我们手动写初始化的代码几乎一模一样。到这里你可能会觉得,那么我自己写不是也可以吗?没错,在项目小的时候几乎看不出来优势,但是当项目大了,有许许多多资源的时候初始化就会变得非常复杂。

并且,如果你需要做优雅关闭的时候,你需要顺序将依赖一层层的停止:
比如你是先初始化数据库,再初始化缓存,最后启动 http 服务;那么相对应的停止的时候,你应该先停止 http 服务,再关闭缓存,最后关闭数据库连接。 如果你先关闭数据库连接,http 服务依旧存在,访问就会出错。

cleanupcleanup
所以 wire 做的事情就是根据你 new 方法的入参和出参,识别了他们之间的依赖关系,生成了对应的初始化代码。

项目表现

最后当我们使用了依赖注入之后,体现在项目中的使用情况具体表现:

  1. 我们再也没有关心过对象依赖关系初始化顺序和 new
  2. 由于我们依赖的是接口,实现的切换几乎是无痛的,在上层也感知不到数据的来源变化
  3. 全局变量说拜拜,再也没有出现说用某个东西空指针,"哦,不对还没有初始化" 的尴尬

对比

那么问题来了,就如标题所说的,到底 DI 是解药还是毒药?在网络上搜索 golang 依赖注入,或者搜 wire,许许多多的人会在下面评论,golang 不需要 DI,把 DI 认为是毒药。golang 就应该简单。DI 完全是徒增代码复杂,并且还多了概念需要让人理解。

其实,我在一开始写 java 的时候就问过这个问题,为什么 java 里面不将所有的方法都声明成 static 这样都不需要 new 直接调用就可以了。

但是当我磨砺了很多项目之后,我就有了更加深刻的理解,为什么之前的人会想要这样去设计,所以我觉得这个问题可以从两个方向上来看:

为什么我之前的项目完全面向过程没有问题

  1. 所有依赖在一开始就完成了初始化,并且依赖只有配置文件、数据库和缓存
  2. 项目本身功能几乎没有二次周期的迭代,功能非常直接,已有功能没有调整,只有新功能的添加

用了 DI 带来了什么收益

  1. 减少了全局变量
  2. 理清楚了初始化的依赖关系,并且从代码层面保证你使用时,相关依赖已经初始化完毕
  3. 可以依赖接口,按需切换实现

结论

回过头来看我一开始说的观点其实就不矛盾了,就拿我自己举例来说,如果是一些小项目,并且很多时候 go 并不是做 web 开发,更多的是做工具那么 DI 有时候并不一定需要。
但是对于一些大项目来说,我觉得为了以后的考虑,还是别挖坑了,无论是从消除全局变量还是扩展性来说,DI 或者说 OOP 都是非常有必要的。

然后,有两点非常重要:

code is law

当然,也有两点值得提醒:

sync.Once

最后,我觉得,如果你从来没有用过 DI 或者没有理解过它的思想,那么请你用一次,至少明白它的设计思路,或许在别的设计方向上可以给你启发。任何语言都不应该有刻板印象,java 的 spring 并非在 golang 看来一无是处。

我们编码是为了实现功能,不用管网络的评论或者是别人的说法,实践最重要,只要你用了之后觉得舒服觉得爽,就是你认为的可以。

其实我觉得就如同 DDD 类似,很多人觉得 DDD 是银弹,很多人认为 DDD 就是繁琐堆砌了一堆概念。而真正能评价的是深入使用过他们的人们。

其他参考

兼听则明,偏信则暗

本文参与了思否技术征文,欢迎正在阅读的你也加入。