过去几年里我一直使用Java。最近,用Go建立了一个小项目,然而 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
的构建方式,我们不得不改变所有调用构建代码的地方。例如,假设我们的 buildMyConfigSomehow
函数现在需要一个参数。每个调用处都需要访问该参数并需要将其传递给构造函数。
此外,这使得实现 Config
函数变得十分麻烦,我们得以某种方法进入 new
函数的内部,并创建Config
。
这是 DI 方式:
type Server struct {
config *Config
}
func New(config *Config) *Server {
return &Server{
config: config,
}
}
现在我们将 Server
与Config
分离 。我们可以根据自己的逻辑创造 Config
然后将结果传递给 New
函数。
此外,如果 Config
是一个接口,这为我们提供了一个简单的模拟途径 。只要 New
实现了我们的接口,就可以传递任何我们想要的东西。这使得测试实现了 Config
接口的 Server
很简单。
令人痛苦的是在创建 server
之前手动创建 config
。我们在这里创建了一个依赖关系 – 因为 server
依赖 Config,
所以需要首先创建 Config
。在真正的应用程序中,这些依赖会变得更加复杂,这会导致构建应用程序完成其工作所需的组件间的复杂逻辑 。
这是 DI 框架可以提供帮助的地方。 DI 框架通常提供两个功能:
- “提供”新组件。简而言之,这告诉 DI 框架一旦你有这些组件,还需要其他什么组件(依赖关系)以及如何去构建。
- “检索”构建组件。
DI 框架通常基于您告诉它的“ providers ”构建依赖图并确定如何构建对象。这在没有具体例子的情况下很难理解,所以让我们来看一个中等大小的例子。
示例程序
我们来看http服务器端的代码 :客户端以 GET
方式请求 /people
路径时并返回 JSON 。我们将一步一步呈现代码,为简单起见,它们都存在于同一个包中(main
)。请勿在真正的 Go 程序中执行此操作。可以在此处找到此示例的完整代码。
首先,让我们看看我们的 Person
。仅有一些被 JSON 标签标记的属性。
type Person struct {
Id int `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
}
Person
有 Id
,Name
和 Age
。
接下来让我们看看 Config
。与 Person
类似,它没有依赖关系。与 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
。
接下来看看 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
,如果启用了应用程序,则会有条件地调用 PersonRepository
。
最后,我们得到了 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
函数中的排序和逻辑来反映该依赖,以构建该组件。
您可能已经猜到,依赖注入框架可以帮助我们解决这个问题。一起来看看。
创建容器
术语“ 容器(container) ”通常用在 DI 框架中,用于描述添加“ 提供者(providers) ”的内容,并从中请求构建对象。 dig
库用 Provide
函数为我们添加“ providers ”, 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
。可以选择返回错误。“
在这两种情况下,我们没必要这样写。因为我们已经有了 NewConfig
和 ConnectDatabase
函数,我们可以直接使用他们作为容器的提供者。
container.Provide(NewConfig)
container.Provide(ConnectDatabase)
现在,我们可以从之前给容器提供的类型中创建组件。我们使用 Invoke
函数,函数采用单个参数 - 具有任意数量参数的函数。函数的参数是我们希望容器构建的类型。
container.Invoke(func(database *sql.DB) {
// sql.DB is ready to use here
})
容器做了一些非常聪明的东西,如下:
- 容器认识到我们要求的是构建
*sql.DB
- 它确定函数
ConnectDatabase
提供该类型 - 接下来它确定
ConnectDatabase
函数依赖Config
- 它找到了
Config
的提供者,也就是NewConfig
NewConfig
没有任何依赖关系,所以它被调用NewConfig
的结果是一个Config
传递给ConnectDatabase
ConnectionDatabase
的结果是*sql.DB
被传递给Invoke
这是容器为我们做的很多工作。事实上,它做的更多。容器很智能,可以构建每种类型有且仅有一个实例。这意味着如果我们在多个地方(比如多个存储库)使用它,我们永远不会意外地创建第二个数据库连接。