Go 的依賴注入
過去幾年裡我一直使用 Java。最近,用 Go 建立了一個小專案,然而 Go 生態系統中依賴注入(DI)功能缺乏讓我震驚。於是我決定嘗試使用 Uber 的ofollow,noindex" target="_blank">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 { Idint`json:"id"` Name string `json:"name"` Ageint`json:"age"` }
Person
有Id
,Name
和Age
。
接下來讓我們看看Config
。與Person
類似,它沒有依賴關係。與Person
不同的是,我們將提供建構函式。
type Config struct { Enabledbool DatabasePath string Portstring } 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 ( idint name string ageint ) 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
這是容器為我們做的很多工作。事實上,它做的更多。容器很智慧,可以構建每種型別有且僅有一個例項。這意味著如果我們在多個地方(比如多個儲存庫)使用它,我們永遠不會意外地建立第二個資料庫連線。
較好的 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
將停止並返回該錯誤。
雖然這個例子很小,但應該很容易看出這種方法的一些好處超過了“常規“的 main 。隨著應用程式變得越來越大,這些好處變得更加明顯。
最重要的好處之一是將元件的建立與其依賴的建立分離。比如說,我們PersonRepository
現在需要訪問Config
。我們所要做的就是更改NewPersonRepository
建構函式以包含Config
作為引數。程式碼其他任何內容沒有發生改變。
其他的好處是沒有全域性狀態,沒有呼叫init
(依賴關係在需要時才建立,只建立一次,不需要容易出錯的init
設定),並且易於測試單個元件。想象一下,在測試中建立容器並要求完整構建物件進行測試。或者,建立一個物件需要所有的依賴。使用 DI ,這些都更容易。
一個值得傳播的想法
我相信依賴注入有助於構建更強大和可測試的應用程式。隨著這些應用程式體量逐漸增大,尤為明顯。 Go 非常適合構建大型應用程式,並且具有很好的 DI 工具dig
。我相信 Go 社群應該接受 DI 並在更多的應用程式中使用它。