Go 語言的依賴注入
依賴注入(DI)是一種解耦組件之間依賴關系的設計模式。在需要的時候,不同組件之間可以通過一個統一的界面獲取其它組件中的對象和狀態。Go語言的接口設計,避免了很多需要使用第三方依賴注入框架的情況(比如Java,等等)。我們的注入方案只提供非常少的類似Dager或Guice中的注入方案,而專注于盡量避免手動去配置對象和組件之間的依賴關系。因為,我們認為如果在Go代碼庫中,注入能夠更加容易理解,就根本沒有必要那樣。
在Go中實現注入只需要這幾個簡單的步驟:
全局變量
先從一個一致的、崇高的目標開始,我們需要一些如Mongo、Memcache等服務的全局連接對象。大致是這樣的:
var MongoService mongo.Service
func InitMongoService(url string) {
MongoService = ...
}
func GetApp(id uint64) *App {
a := new(App)
MongoService.Session().Find(..).One(a)
return a
}
通常 main() 函數會調用配置在flags或configuration文件中如 InitMongoService 這樣的各種初始化函數。這時,像 GetApp 這樣的函數就可以使用這些服務和連接了。當然,有時候我們會忘記初始化全局變量,被 nil 引發panic。
雖然在創建全局變量的時候共享資源讓它們(至少)有兩個缺點:
首先,因為組件的依賴關系不明確,所以代碼是很難寫的;
其次,你很難去測試你寫的代碼,在并行條件下更是幾乎不可能。
盡管測試是非常快的(我們希望確保一直很快),但是能夠在并行環境下測試才是最重要的。使用全局連接對象時,后臺服務無法在并發條件下測試出相同的數據。
清除全局變量
為了清除全局變量,我們先從一個通用模式開始。我們的組件現在顯示依賴,我們將,一個Mongo服務,或者一個緩存服務。大致來講,我們上面那個幼稚的例子現在看起來應當是這樣的:
type AppLoader struct {
MongoService mongo.Service
}
func (l *AppLoader) Get(id uint64) *App {
a := new(App)
l.MongoService.Session().Find(..).One(a)
return a
}
許多引用全局變量的函數現在變成了結構體中存儲了它們的依賴。
新的問題
真棒!在main()方法中,我們用一系列的構造代替了全局變量和函數,解決了我們之前遇到的問題。但是... 一看main()函數就知道了,太雜亂無章了。
一開始就這么亂了:
func main() {
mongoURL := flag.String(...)
mongoService := mongo.NewService(mongoURL)
cacheService := cache.NewService(...)
appLoader := &AppLoader{
MongoService: mongoService,
}
handlerOne := &HandlerOne{
AppLoader: appLoader,
}
handlerTwo := &HandlerTwo{
AppLoader: appLoader,
CacheService: cacheService,
}
rootHandler := &RootHandler{
HandlerOne: handlerOne,
HandlerTwo: handlerTwo,
}
...
}
如果一直這樣寫下去,main()函數的方法體將會被被大量的代碼占據。而這些代碼僅僅只是做了兩件很普通的事情:分配內存空間、裝配對象和組件關系。如果我們有非常多的二進制代碼和庫需要引用,我們就需要一遍又一遍的寫這些無聊的代碼。這里特別需要注意的是,不要被nil引發panic。比如我們忘記把CacheService傳遞給HandlerTwo,然后就引發了一個運行時panic。我們試圖構造一個方法,但是卻變得有些失控。還需要寫一大堆的代碼手動檢查nil。因為必須手動裝配對象并確保運行正常,我們的開發對此非常惱火。測試人員甚至還需要自己裝配對象、構建關系,顯然他們不會在main()函數中共用這些代碼。所以測試代碼也變得越來越繁雜、冗余,卻還是經常找不出實際問題。簡而言之,我們解決了一個問題,卻產生了另一種問題。
標識 Mundane
我們中的一些人對DI系統比較有經驗,并且我們都不認為這僅僅是純娛樂性的經驗。因此,當我們第一次討論用 DI系統解決這個新問題時,就已經有大量的push back(我理解為經驗儲備...高手求解)。
根據這些規則,當我們需要一些東西的時候,我們決定需要確保避免已知的復雜性并制定了一些基本準則:
1. 沒有代碼生成。我們的開發編譯步驟僅僅用 go install,我們不想引入額外的步驟。與這條規則相關的是無文件掃描,我們不想把項目變成一個O(大量文件)系統,同時也要防止增加編譯時間。
2. 沒有子圖。子圖的概念是以每個請求為基準(a per-request basis)允許注入發生,簡單來說,一個子圖必須能夠徹底地區分"global"生命周期和"per-request"(每個請求)生命周期的對象,并且確保在所有請求中不混淆這些"per-request"對象。我們決定僅僅允許"global"生命周期對象的注入,因為這正是我們現在面臨的問題。
3. 避免代碼執行。DI本質上使代碼很難理解,我們想避免定制化的代碼執行/鉤子,使它更容易理解。
根據這些準則,我們的目標變得比較清晰了:
1. 注入應該分配對象。
2. 注入應該將對象圖連接起來。
3. 注入應該在程序啟動時僅僅運行一次。
我們也討論了supporting constructor(支持構造函數)功能,但現在避免對他們增加支持。
注入庫是這項工作的成果和我們的解決方案。它使用結構標簽(struct tags)來實現注入功能,可為具體的類型注入,也支持對接口類型注入,只要明確接口類型的具體類型,它還有些不太常用的功能,比如按名稱注入(named injection)。前面的簡單示例現在看起來是這樣:type AppLoader struct {
MongoService mongo.Service inject:""
}
func (l AppLoader) Get(id uint64) App {
a := new(App)
l.MongoService.Session().Find(..).One(a)
return a
}</pre>
沒有任何改變,除了在 MongoService 字段上增加了注入標簽。有幾種不同的方式使用注入標簽,但這是最常見用法,它簡潔地表明了期望注入一個 mongo.Service 實例。同樣地,可以想象 HandlerOne,HandlerTwo 和 RootHandler 字段上也有注入標簽。
我們的main()現在看起來這樣:
func main() {
mongoURL := flag.String(...)
mongoService := mongo.NewService(mongoURL)
cacheService := cache.NewService(...)
var app RootHandler
err := inject.Populate(mongoService, cacheService, &app)
if err != nil {
panic(err)
}
...
}
更短!注入的整個流程大概是這樣:
1. 查看每個已經提供的實例,最終遇到RootHandler類型的app實例.
2. 查看RootHandler字段,尋找帶 inject 標簽的*HandlerOne,發現沒有*HandlerOne實例存在,于是就創建一個并將它賦值給這個字段.
3. 對剛剛創建的HandlerOne實例繼續進行與步驟2類似的查找,找到AppLoader字段,簡單地創建它.
4. 對于AppLoader實例,它需要一個mongo.Service實例,它發現當我們調用Populate時已經創建過一個實例,于是它將那個實例賦值到這里.
5. 當它對HandlerTwo進行同樣的查找時,它使用已經創建的AppLoader實例,因此這兩個Handlers共享這個AppLoader實例.
注入分配對象并為我們將graph連接起來。調用Populate后,注入不再做任何事情,剩下的跟之前沒有注入時的行為都一樣了.
勝利啦
我們的main()函數更易管控了。現在,手動新建一個僅有兩個case的實例:如果實例需要在main中得到配置信息,或者如果其需要請求一個接口類型。即使如此,我們往往新建一些不完整的實例,讓依賴注入為我們補充完整。測試代碼大幅度的精簡,并且現在可以在不需要知道對象圖表的情況下為測試提供執行。這使得測試更具彈性,可以改變相當大。重構同樣變得簡單起來,就像抽出邏輯而不需要手動調整我們在各類main()中新建的對象圖表。
總體來說,我們對結果和自從介紹了依賴注入,我們的代碼庫的演化感到非常高興。
資源
你可以在Github上找到該庫的資源:
https://github.com/非死bookgo/inject
我們同時提供文檔,盡管最好的學習方式是實際“玩”一下:
https://godoc.org/github.com/非死bookgo/inject
我們非常喜愛能得到貢獻,所以在貢獻時請確保下面的測試可以通過: