[譯]Golang中的依賴注入

[譯]Golang中的依賴注入

文章來源: Dependency Injection in Go
關於做者: Drew Olson
做者博客: software is fun
譯者按:本文用於介紹DI和golang中DI庫dig的簡單使用,適合對go有必定了解的開發者。

我最近使用Go建立了一個小項目,因爲最近幾年一直用Java,我馬上就被Go語言生態裏依賴注入(DI)的缺失震驚了。我決定嘗試用Uber的dig庫來建立個人項目,結果很是不錯。git

我發覺DI幫我解決了不少在之前的Go應用中遇到的問題——init函數的過分使用,全局變量的濫用和複雜的應用初始化設置。
在這篇文章中,我會介紹DI,並展現一個應用在使用DI先後的區別(使用dig庫)。github

DI概述

依賴注入是這樣一個概念,你的組件(在go中一般是structs)應該在被建立時接收到它們的依賴。這與組件初始化過程當中建立本身的依賴反面模式恰好相反。讓咱們來看一個例子。golang

假設你有一個Server結構,要實現其功能,須要一個Config結構。一種辦法是在Server初始化時,構建它本身的Configsql

type Server struct {
  config *Config
}

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

這樣看上去很方便,調用者甚至不須要知道咱們的Server須要訪問Config。這些對函數的使用者來講都是隱藏的。數據庫

然而這樣也有些缺點。首先,若是咱們想改變Config建立的方式,咱們必須修改全部調用了它的建立方法的代碼。假設,舉個例子,咱們的buildMyConfigSomehow函數如今須要一個參數,每一個調用將必需訪問那個參數,且須要將它傳遞給buildMyConfigSomehow方法。json

並且,mock Config會變得很棘手。咱們必須經過某種方式去觸及到New函數的內部,去鼓搗Config的建立。服務器

下面是使用DI的方式:app

type Server struct {
  config *Config
}

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

如今Server的建立與Config的建立解除了耦合,咱們能夠隨意選擇建立Config的邏輯,而後將結果數據傳遞給New方法。框架

並且,若是Config是個接口,mock就會變得更容易,只要實現了接口,均可傳遞給New方法。這使咱們對Server進行mock變得容易。ide

主要的負面影響是,咱們在建立Server前必須手動建立Config,這一點比較痛苦。咱們建立了一個「依賴圖」——咱們必須首先建立Config由於Server依賴它。在現實的應用中,依賴圖可能變得很是大,這會致使建立全部你的應用須要的組件的邏輯很是複雜。

這正是DI框架能提供幫助的地方。一個DI框架通常有兩個主要功用:

  1. 一個「提供」新組件的機制。籠統地說,這個機制告訴DI框架你須要哪些其餘組件,來建立你本身(譯者按:能夠理解爲你的一個程序),以及有了這些組件以後如何進行建立。
  2. 一個用於「取用」建立好的組件的機制。

一個DI框架通常基於你告訴框架的「提供者」來建立一個圖(graph),而且決定如何建立你的object。抽象的概念比較難以理解,因此讓咱們看一個合適的例子。

一個示例應用

咱們將要看一段代碼,一個在接收到客戶端GET people請求時,會發送一個JSON響應的HTTP服務器。簡單起見,咱們吧代碼都放在main包裏,不過現實中千萬別這麼幹。所有的實例代碼能夠在此處下載。

首先,讓咱們看一下Person結構,除了一些JSON tag以外,它沒有任何行爲定義。

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

一個PersonIdNameAge,沒別的了。

接下來,咱們看一下Config,相似於Person,它沒有任何依賴,不一樣於Person的是,咱們將提供一個構造器(constructor)。

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。這個結構負責從數據庫中獲取people數據並將其反序列化,保存到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 Server和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依賴於ConfigPersonRepository。它暴露一個叫作FindAll的函數,根據應用是不是enabled去調用PersonRepository

最後,咱們來到Server,負責運行一個HTTP Server,而且把請求分配到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依賴於PersonServiceConfig
好了,咱們如今知道咱們系統的全部組件了。如今怎麼他媽的初始化它們而且啓動咱們的系統?

使人懼怕的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框架中,一般表示你存放「提供者(provider)」和獲取建立好的對象的一個地方。
dig庫提供給咱們Provide函數,用於添加咱們本身的提供者;還有Invoke函數,用於從容器中獲取建立好的對象。

首先,咱們建立一個新的容器。

container := dig.New()

如今咱們能夠添加新的提供者,方法很簡單,咱們用container調用Provide函數,這個函數接收一個參數:一個函數。這個參數函數能夠有任意數量的參數(表明將要建立的組件的依賴)以及一個或兩個返回值(表明這個函數提供的組件,以及一個可選的error)。

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

上述代碼表示,「我提供一個Config給容器,爲了建立它,我並不須要其餘東西。」。如今咱們已經告訴了容器如何建立一個Config類型,咱們可使用它來建立其餘類型了。

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

這部分代碼表示,「我提供一個*sql.DB類型給容器,爲了建立它,我須要一個Config,我可能會返回一個可選的error」。
在這兩個例子中,咱們有點囉嗦了,由於咱們已經定義了NewConfigConnectDatabase函數,咱們能夠直接將它們做爲提供者給容器使用。

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

如今,咱們能夠直接向容器請求一個構造好的組件,任何咱們給出了提供者的類型均可以。咱們用Invoke函數來作這個操做。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
  • ConnectionData的結果是一個*sql.DB,被傳回給Invoke的調用者

容器爲咱們作了不少事,實際上它作的更多。容器只會爲每種類型提供一個實例,這意味着咱們永遠不會意外的建立了第二個數據庫鏈接,即便咱們在多個地方調用(好比多個repository)。

一個更好的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)
  }
}

除了Invokeerror返回,其它的咱們都見過了。若是任何被Invoke使用的提供者返回錯誤,對Invoke的調用就會中止,並返回error。
即便這個例子很小,應該也足以看出這種方法相對於「標準」main的一些好處了。隨着咱們的應用規模的增加,好處也會更多更明顯。
其中一個最重要的好處就是解耦組件的建立和依賴的建立。比方說,咱們的PersonRepository須要訪問Config,咱們只須要修改構造器NewPersonRepository,給它加上一個Config參數,其它什麼都不用變。
也有一些其它的好處,減小了全局的狀態,減小了init的調用(依賴會在被須要時延遲建立,且只會建立一次,避免了有報錯傾向的init調用),讓獨立組件的測試更加容易。想象下建立一個容器,去請求一個建立好的對象作測試,或者mock全部的依賴並建立對象。全部的這些都會隨着DI的引入變得容易。

一個值得推廣的想法

我相信依賴注入幫助咱們建立更健壯,更易測試的程序。隨着應用程序規模的增加,這一點也更加顛撲不破。Go很是適合建立大型的應用程序,而且dig是一個很好的DI工具。我認爲Go社區應該接受DI而且在儘量多的應用程序中使用它。

更新

Google最近發佈了它們本身的DI容器,叫作wire。它經過代碼生成的方式避免了運行時反射。比起dig我更建議用wire。

相關文章
相關標籤/搜索