[譯]Golang中的依賴注入
文章來源:Dependency Injection in Go
關於作者:Drew Olson
作者部落格:software is fun
譯者按:本文用於介紹DI和golang中DI庫dig的簡單使用,適合對go有一定瞭解的開發人員。
我最近使用Go建立了一個小項目,由於最近幾年一直用Java,我立刻就被Go語言生態裡依賴注入(DI)的缺失震驚了。我決定嘗試用Uber的dig庫來建立我的項目,結果非常不錯。
我發覺DI幫我解決了很多在以前的Go應用中遇到的問題——init
函數的過度使用,全域變數的濫用和複雜的應用初始化設定。
在這篇文章中,我會介紹DI,並展示一個應用在使用DI前後的區別(使用dig庫)。
DI概述
依賴注入是這樣一個概念,你的組件(在go中通常是structs
)應該在被建立時接收到它們的依賴。這與組件初始化過程中建立自己的依賴的反面模式剛好相反。讓我們來看一個例子。
假設你有一個Server
結構,要實現其功能,需要一個Config
結構。一種辦法是在Server
初始化時,構建它自己的Config
。
type Server struct { config *Config}func New() *Server { return &Server{ config: buildMyConfigSomehow(), }}
這樣看上去很方便,調用者甚至不需要知道我們的Server
需要訪問Config
。這些對函數的使用者來說都是隱藏的。
然而這樣也有些缺點。首先,如果我們想改變Config
建立的方式,我們必須修改所有調用了它的建立方法的代碼。假設,舉個例子,我們的buildMyConfigSomehow
函數現在需要一個參數,每個調用將必需訪問那個參數,且需要將它傳遞給buildMyConfigSomehow
方法。
而且,mock Config
會變得很棘手。我們必須通過某種方式去觸及到New
函數的內部,去鼓搗Config
的建立。
下面是使用DI的方式:
type Server struct { config *Config}func New(config *Config) *Server { return &Server{ config: config, }}
現在Server
的建立與Config
的建立解除了耦合,我們可以隨意選擇建立Config
的邏輯,然後將結果資料傳遞給New
方法。
而且,如果Config
是個介面,mock就會變得更容易,只要實現了介面,都可傳遞給New
方法。這使我們對Server
進行mock變得容易。
主要的負面影響是,我們在建立Server
前必須手動建立Config
,這一點比較痛苦。我們建立了一個“依賴圖”——我們必須首先建立Config
因為Server
依賴它。在現實的應用中,依賴圖可能變得非常大,這會導致建立所有你的應用需要的組件的邏輯非常複雜。
這正是DI架構能提供協助的地方。一個DI架構一般有兩個主要功用:
- 一個“提供”新組件的機制。籠統地說,這個機制告訴DI架構你需要哪些其他組件,來建立你自己(譯者按:可以理解為你的一個程式),以及有了這些組件之後如何進行建立。
- 一個用於“取用”建立好的組件的機制。
一個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"`}
一個Person
有Id
,Name
和Age
,沒別的了。
接下來,我們看一下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
依賴於Config
和PersonRepository
。它暴露一個叫做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
依賴於PersonService
和Config
。
好了,我們現在知道我們系統的所有組件了。現在怎麼他媽的初始化它們並且啟動我們的系統?
令人害怕的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”。
在這兩個例子中,我們有點囉嗦了,因為我們已經定義了NewConfig
和ConnectDatabase
函數,我們可以直接將它們作為提供者給容器使用。
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) }}
除了Invoke
的error
返回,其它的我們都見過了。如果任何被Invoke
使用的提供者返回錯誤,對Invoke
的調用就會停止,並返回error。
即使這個例子很小,應該也足以看出這種方法相對於“標準”main的一些好處了。隨著我們的應用規模的增長,好處也會更多更明顯。
其中一個最重要的好處就是解耦組件的建立和依賴的建立。比方說,我們的PersonRepository
需要訪問Config
,我們只需要修改構造器NewPersonRepository
,給它加上一個Config
參數,其它什麼都不用變。
也有一些其它的好處,減少了全域的狀態,減少了init
的調用(依賴會在被需要時延遲建立,且只會建立一次,避免了有報錯傾向的init
調用),讓獨立組件的測試更加容易。想象下建立一個容器,去請求一個建立好的對象做測試,或者mock所有的依賴並建立對象。所有的這些都會隨著DI的引入變得容易。
一個值得推廣的想法
我相信依賴注入協助我們建立更健壯,更易測試的程式。隨著應用程式規模的增長,這一點也更加顛撲不破。Go非常適合建立大型的應用程式,並且dig
是一個很好的DI工具。我認為Go社區應該接受DI並且在儘可能多的應用程式中使用它。
更新
Google最近發布了它們自己的DI容器,叫做wire。它通過代碼產生的方式避免了運行時反射。比起dig我更建議用wire。