via:
​​​https://blog.drewolson.org/dependency-injection-in-go​​作者:Drew Olson

四哥水平有限,如有翻译或理解错误,烦请帮忙指出,感谢!

今天这篇文章前面一部分简要介绍了下什么是​​依赖注入​​(DI),接着通过示例对比演示引入 DI 框架之后给程序带来的好处,对于想提高代码设计水平的同学值得一看。文章引入的是 Uber dig 库。

原文如下:


最近我使用 Go 做一个小项目。过去几年我一直使用 Java 做项目开发,并立即被 Go 生态系统中缺乏依赖注入(DI)所震惊。我决定使用 Uber 的 dig 库构建自己的项目,这给我留下了深刻的印象。

我发现依赖注入(DI)解决了很多以前我在开发 Go 程序遇到的很多问题,比如过度使用 init 函数、全局变量的滥用以及复杂的程序配置。

在这篇文章中,我将介绍下依赖注入(DI),然后展示下使用 DI 框架之前和之后的示例程序(使用 dig 包)。

DI的简要概述

依赖注入的思想是当组件(在 Go 语言里通常是 struct )被创建的时候应当接收它们的依赖。
这与组件在初始化期间构建自己的依赖关系的相关反模式是背道而驰的。我们一起来看个例子。

假设你有一个 Server 结构体需要使用 Config 结构体去实现自己的行为。一种方法是 Server 在初始化的时候构建自己的 Config。

type Server struct {
config *Config
}

func New() *Server {
return &Server{
config: buildMyConfigSomehow(),
}
}

这样看起来很方便,函数调用者不必知道 Server 甚至需要访问 Config,这一些对于函数的使用者来说都是隐藏的。

然而,有一些不好的地方。首先,如果想改变 Config 的结构,那么所有调用 Config 的地方都需要修改。假设,举个例子,buildMyConfigSomehow() 函数现在需要一个参数,每个调用的地方都需要访问该参数,并且需要将其传递到构建函数中。

而且,测试时需要模拟 Config 的行为,这会变得很棘手。我们将不得不进入到 New() 函数中实现 Config 的创建。

下面使用依赖注入的方式解决这个问题:

type Server struct {
config *Config
}

func New(config *Config) *Server {
return &Server{
config: config,
}
}

现在 Server 和 Config 的创建是分离的,我们可以使用任何自己想要的逻辑去创建 Config 并且将结果传给 New() 函数。

此外,如果 Config 是一个接口,我们将更容易 mock。只要实现了这个接口,可以给 New() 函数传递任何我们想要传递的东西。这使得模拟实现 Config 来测试 Server 变得简单。

主要的缺点在于,在创建 Server 之前我们需要手动地创建 Config。因为 Server 依赖于 Config,所以我们需要创建这层依赖关系。在实际应用中,这类依赖关系图将会变得庞大,这导致了构建应用程序完成其工作所需的所有组件的复杂逻辑。

这就是依赖注入(DI)框架能提供帮助的地方,DI框架一般可以提供两方面的功能:

  1. 一种“提供”新组件的机制,简而言之,这告诉 DI 框架需要构建哪些其他组件(依赖项),以及拥有了这些组件之后,如何完成构建。
  2. 一种“提取”已构建组件的机制。

一个 DI 框架通常根据你提供的 “providers” 来构建依赖关系图,并确定如何构建对象。这些抽象概念很难理解,让我们一起来看个示例。

一个示例应用

我们一起来 review 一段 HTTP 服务器代码,当客服端发送 GET 请求 /people 接口时,它会提供 JSON 响应。我们将逐步地展示代码,为了简单起见,所有的代码都在 main 包里面。在实际的 Go 项目里请别这样做,完整的代码示例。

首先,我们来看下 Person 结构体,它只有一些 JSON tags。

type Person struct {
Id int `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
}

结构体里有 Id、Name 和 Age 字段。

接着,来看下 Config 结构体,与 Person 结构体相似,它没有其他依赖项。不同的是,我们将提供一个构造函数。

type Config struct {
Enabled bool
DatabasePath string
Port string
}

func NewConfig() *Config {
return &Config{
Enabled: true,
DatabasePath: "./example.db",
Port: "8000",
}
}

Enabled 告诉我们是否程序应当返回真实数据;DatabasePath 告诉我们数据库的位置(我们使用 sqlite 数据库);Port 告诉我们服务使用的端口号。

我们使用下面这个函数连接数据库,它依赖于 Config 并且返回 *sql.DB。

func ConnectDatabase(config *Config) (*sql.DB, error) {
return sql.Open("sqlite3", config.DatabasePath)
}

接下来看下 PersonRepository,这个模块负责从数据库读取数据并且将数据解析到 Person 结构体里面。

type PersonRepository struct {
database *sql.DB
}

func (repository *PersonRepository) FindAll() []*Person {
rows, _ := repository.database.Query(
`SELECT id, name, age FROM people;`
)
defer rows.Close()

people := []*Person{}

for rows.Next() {
var (
id int
name string
age int
)

rows.Scan(&id, &name, &age)

people = append(people, &Person{
Id: id,
Name: name,
Age: age,
})
}

return people
}

func NewPersonRepository(database *sql.DB) *PersonRepository {
return &PersonRepository{database: database}
}

PersonRepository 需要建立数据库连接,它对外暴露了一个方法 FindAll(),该函数可以连接数据库并且通过 Person 结构体将数据取出并返回。

为了在 HTTP 服务器与 PersonRepository 之间提供一层中间层,我们将创建一个 PersonService。

type PersonService struct {
config *Config
repository *PersonRepository
}

func (service *PersonService) FindAll() []*Person {
if service.config.Enabled {
return service.repository.FindAll()
}

return []*Person{}
}

func NewPersonService(config *Config, repository *PersonRepository)
*PersonService {
return &PersonService{config: config, repository: repository}
}

我们的 PersonService 依赖于 Config 和 PersonRepository,对外暴露了 FindAll() 函数,该函数在配置为 true 的情况下会调用 PersonRepository 里的 FindAll() 函数。

最后,我们来看下 Server,它负责运行 HTTP 服务并将请求分发给 PersonService。

type Server struct {
config *Config
personService *PersonService
}

func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()

mux.HandleFunc("/people", s.people)

return mux
}

func (s *Server) Run() {
httpServer := &http.Server{
Addr: ":" + s.config.Port,
Handler: s.Handler(),
}

httpServer.ListenAndServe()
}

func (s *Server) people(w http.ResponseWriter, r *http.Request) {
people := s.personService.FindAll()
bytes, _ := json.Marshal(people)

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(bytes)
}

func NewServer(config *Config, service *PersonService) *Server {
return &Server{
config: config,
personService: service,
}
}

Server 依赖于 PersonService 和 Config。

好了,我们已经知道整个系统的所有组件,现在该如何初始化并启动系统呢?

可怕的main()

首先,我们使用原有老的方式编写 main() 函数。

func main() {
config := NewConfig()

db, err := ConnectDatabase(config)

if err != nil {
panic(err)
}

personRepository := NewPersonRepository(db)

personService := NewPersonService(config, personRepository)

server := NewServer(config, personService)

server.Run()
}

首先,我们创建了 Config,接着使用 Config 连接数据库,有了数据库连接之后接着创建 PersonRepository,继而接着创建 PersonService,最后就可以创建 Server 并运行它。

这个过程有点复杂,更糟糕的是,随着应用程序变得越来越复杂,main() 函数将继续变得越来越复杂。每一次向任何组价添加一个新的依赖时,我们都不得不在 main() 函数中通过顺序和逻辑来反映这种依赖关系,以便能够构建组件。

你可能已经猜想到了,一个 DI 框架可以帮助我们解决这个问题。

让我们来看下怎么做。

构建一个容器

在 DI 框架中,容器的概念经常用来描述你需要添加到 “providers” 或者拿来构建对象的东西。dig 库为我们提供了添加提供者的 Provide() 函数和从容器中提取对象完成构建的 Invoke() 函数。

首先,我们创建一个新的容器:

container := dig.New()

现在我们可以通过容器提供的 Provide() 函数添加新的提供者,调用该函数需要传递一个参数,参数是一个函数,该函数可以有任意数目的参数(表示需要创建的组件依赖关系)和一个或两个返回值(表示函数提供的组件和可选的错误)

container.Provide(func() *Config {
return NewConfig()
})

上面的代码显示,“我往容器添加了 Config 类型,为了构建它,我需要要做任何额外事情”。现在,我们展示了容器是如何构建 Config 类型,我们可以用它来构建其他类型。

container.Provide(func(config *Config) (*sql.DB, error) {
return ConnectDatabase(config)
})

上面的代码显示,“我往容器添加了 *sql.DB 类型,我需要 Config 完成构建,我也可以返回错误”。

上面两个 case 中,我们展示了一些不必要的代码。因为我们已经定义了 NewConfig() 和 ConnectDatabase() 函数,可以直接将它们提供给容器。

container.Provide(NewConfig)
container.Provide(ConnectDatabase)

通过给容器提供需要的所有类型,现在我们便可以通过 container 获取到一个构建完成的组件,这可以通过调用 Invoke() 函数实现,该函数需要一个参数, 该参数是一个任意数目参数的函数,且这些参数是我们希望容器为我们构建的类型。

container.Invoke(func(database *sql.DB) {
// sql.DB is ready to use here
})

容器做了一些非常聪明的事情,看些这里面发生了什么:

  1. 容器意识到我们需要调用 *sql.DB;
  2. 接着判断出 ConnectDatabase() 函数提供了该类型;
  3. 接着判断出 ConnectDatabase() 函数依赖于 Config;
  4. 接着识别出 NewConfig() 函数可以提供 Config,NewConfig() 不依赖其组件,可以直接被调用;
  5. NewConfig() 的返回值 Config 传递给 ConnectDatabase() 函数;
  6. ConnectionDatabase() 的返回值是 *sql.DB,正好可以传递给 Invoke() 函数的调用者。

以上就是容器为我们做的所有工作,实际上,需要做的事情还远不止这些。容器足够聪明,可以为每种类型构建一个实例,并且只构建一个实例。这意味着,如果我们在多个地方使用它,我们绝不会意外地创建第二个数据库连接。

一个更好的 main()

现在我们已经知道 dig 容器的工作机制,我们就来构建一个更好的 main() 函数:

func BuildContainer() *dig.Container {
container := dig.New()

container.Provide(NewConfig)
container.Provide(ConnectDatabase)
container.Provide(NewPersonRepository)
container.Provide(NewPersonService)
container.Provide(NewServer)

return container
}

func main() {
container := BuildContainer()

err := container.Invoke(func(server *Server) {
server.Run()
})

if err != nil {
panic(err)
}
}

在此之前我们唯一没有看到的是 Invoke() 的错误返回值。如果任何被 Invoke() 函数调用的提供者返回错误,则将中断 Invoke() 函数的调用,并且将返回该错误。

尽管这个例子比较小,但也是很容易看出对于我们编写一个“标准”的 main() 函数的好处还是非常多的。这些好处将会随着我们的程序变大而更加明显。

最重要的好处之一就是将组件的创建与依赖关系的创建分离,比如说,PersonRepository 现在需要调用 Config,需要做的就是将 Config 作为参数传递给 NewPersonRepository 的构造函数,其他任何代码都不用改变。

其他好处比如说减少了全局变量、减少了 init() 函数的调用并且易于对各个组件进行测试。想象一下,在测试中创建容器并要求测试一个完全构建的对象。或者,创建具有所有依赖项的模拟实现的对象。使用 DI 框架提供的方法,使得这些都要变得容易得多。

一个值得传播的想法

我相信依赖注入有助于构建更强大且可测试的应用程序,随着这些应用程序规模的扩大,尤其如此。Go 非常适合构建大型的应用程序,并且有用出色的 DI 工具 -- dig。我相信 Go 社区应该拥护 DI,并将其用于更多的应用程序中。


如果我的文章对你有所帮助,点赞、转发都是一种支持!

Go 依赖注入 - Dig_spring

Go 依赖注入 - Dig_spring_02