前言:
- 本文主要介紹的是Goalng中關於 DI 的部分,前一部分會先透過典型的面嚮物件語言Java引入DI這個概念
- 僅供初學者理解使用,文章如有紕漏敬請指出
- 本文涉及到的知識面較為零散,其中包含物件導向程式設計的 SOLID原則、各語言典型的DI框架等,博主都已插入連線?供讀者訪問自行查閱
- 另外本文篇幅較長,粗略閱讀全文大概需要5分鐘,希望能在看完一遍之後對讀者理解DI有所幫助,初步理解什麼是依賴注入,並在實踐時知道什麼時候使用它
正文:
什麼是DI
在理解它在程式設計中的含義之前,首先讓我們瞭解一下它的總體含義,這可以幫助我們更好地理解這個概念。
依賴是指依靠某種東西來獲得支援。比如我會說我們對手機的依賴程度過高。
在討論依賴注入之前,我們先理解程式設計中的依賴是什麼意思。
當 class A 使用 class B 的某些功能時,則表示 class A 具有 class B 依賴。
在 Java 中,在使用其他 class 的方法之前,我們首先需要建立那個 class 的物件(即 class A 需要建立一個 class B 例項)。
因此,將建立物件的任務轉移給其他 class,並直接使用依賴項的過程,被稱為“依賴項注入”。
依賴注入(Dependency Injection, DI)是一種設計模式,也是Spring框架的核心概念之一。其作用是去除Java類之間的依賴關係,實現松耦合,以便於開發測試。為了更好地理解DI,先了解DI要解決的問題。
我們先用Java程式碼理解一下普遍的情況:·
耦合太緊的問題
如果使用一個類,自然的做法是建立一個類的例項:
class Player{ Weapon weapon; Player(){ // 與 Sword類緊密耦合 this.weapon = new Sword(); } public void attack() { weapon.attack(); } }
這個方法存在耦合太緊的問題,例如,玩家的武器只能是劍Sword
,而不能把Sword
替換成槍Gun
。要把Sword
改為Gun
,所有涉及到的程式碼都要修改,當然在程式碼規模小的時候這根本就不是什麼問題,但程式碼規模很大時,就會費時費力了。
依賴注入(DI)過程
依賴注入是一種消除類之間依賴關係的設計模式。例如,A類要依賴B類,A類不再直接建立B類,而是把這種依賴關係配置在外部xml檔案(或java config檔案)中,然後由Spring容器根據配置資訊建立、管理bean類。
示例:
class Player{ Weapon weapon; // weapon 被注入進來 Player(Weapon weapon){ this.weapon = weapon; } public void attack() { weapon.attack(); } public void setWeapon(Weapon weapon){ this.weapon = weapon; } }
如上所示,Weapon
類的例項並不在程式碼中建立,而是外部透過建構函式傳入,傳入型別是父類Weapon
,所以傳入的物件型別可以是任何Weapon
子類。
傳入哪個子類,可以在外部xml檔案(或者java config檔案)中配置,Spring容器根據配置資訊建立所需子類例項,並注入Player
類中,如下所示:
<bean id="player" class="com.qikegu.demo.Player"> <construct-arg ref="weapon"/> </bean> <bean id="weapon" class="com.qikegu.demo.Gun"> </bean>
上面程式碼中<construct-arg ref="weapon"/>
ref指向id="weapon"
的bean,傳入的武器型別是Gun
,如果想改為Sword
,可以作如下修改:
<bean id="weapon" class="com.qikegu.demo.Sword">
</bean>
注意:松耦合,並不是不要耦合。A類依賴B類,A類和B類之間存在緊密耦合,如果把依賴關係變為A類依賴B的父類B0類,在A類與B0類的依賴關係下,A類可使用B0類的任意子類,A類與B0類的子類之間的依賴關係是松耦合的。
可以看到依賴注入的技術基礎是多型機制與反射機制。
有三種型別的依賴注入:
- 建構函式注入:依賴關係是透過 class 構造器提供的。
- setter 注入:注入程式用客戶端的 setter 方法注入依賴項。
- 介面注入:依賴項提供了一個注入方法,該方法將把依賴項注入到傳遞給它的任何客戶端中。客戶端必須實現一個介面,該介面的 setter 方法接收依賴。
依賴注入的作用是:
- 建立物件
- 知道哪些類需要那些物件
- 並提供所有這些物件
如果物件有任何更改,則依賴注入會對其進行調查,並且不應影響到使用這些物件的類。這樣,如果將來物件發生變化,則依賴注入負責為類提供正確的物件。
控制反轉——依賴注入背後的概念
這是指一個類不應靜態配置其依賴項,而應由其他一些類從外部進行配置。
這是 S.O.L.I.D 的第五項原則——類應該依賴於抽象,而不是依賴於具體的東西(簡單地說,就是硬編碼)。
根據這些原則,一個類應該專注於履行其職責,而不是建立履行這些職責所需的物件。 這就是依賴注入發揮作用的地方:它為類提供了必需的物件。
使用依賴注入的優勢
- 幫助進行單元測試。
- 由於依賴關係的初始化是由注入器元件完成的,因此減少了樣板程式碼。
- 擴充套件應用程式變得更加容易。
- 幫助實現松耦合,這在應用程式設計中很重要。
使用依賴注入的劣勢
- 學習起來有點複雜,如果使用過度會導致管理問題和其他問題。
- 許多編譯時錯誤被推送到執行時。
- 依賴注入框架是透過反射或動態程式設計實現的。這可能會妨礙 IDE 自動化的使用,例如“查詢引用”,“顯示呼叫層次結構”和安全重構。
你可以自己實現依賴項注入,也可以使用第三方庫或框架來實現。
實現依賴注入的庫和框架
- Spring (Java)
- Google Guice (Java)
- Dagger (Java and Android)
- Castle Windsor (.NET)
- Unity (.NET)
- Wire(Golang)
重點:Golang TDD 中理解DI
很對人使用Golang時對於依賴注入(dependency injection)存在諸多誤解。我們希望本篇會向你展示為什麼:
-
你不一定需要一個框架
-
它不會過度複雜化你的設計
-
它易於測試
- 它能讓你編寫優秀和通用的函式
func Greet(name string) { fmt.Printf("Hello, %s", name) }
那麼我們該如何測試它呢?呼叫 fmt.Printf
會列印到標準輸出,用測試框架來捕獲它會非常困難。
我們所需要做的就是注入(這只是一個等同於「傳入」的好聽的詞)列印的依賴。
fmt.Printf
的原始碼,你可以發現一種引入(hook in)的方式:
// It returns the number of bytes written and any write error encountered. func Printf(format string, a ...interface{}) (n int, err error) { return Fprintf(os.Stdout, format, a...) }
有意思!在 Printf
內部,只是傳入 os.Stdout
,並呼叫了 Fprintf
。
os.Stdout
究竟是什麼?Fprintf
期望第一個引數傳遞過來什麼?
func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) { p := newPrinter() p.doPrintf(format, a) n, err = w.Write(p.buf) p.free() return }
io.Writer
是:
type Writer interface { Write(p []byte) (n int, err error) }
io.Writer
是一個很好的通用介面,用於「將資料放在某個地方」。Writer 來把問候傳送到某處。我們現在來使用這個抽象,讓我們的程式碼可以測試,並且重用性更好。
先寫個測試
func TestGreet(t *testing.T) { buffer := bytes.Buffer{} Greet(&buffer,"Chris") got := buffer.String() want := "Hello, Chris" if got != want { t.Errorf("got '%s' want '%s'", got, want) } }
bytes
包中的 buffer
型別實現了 Writer
介面。Writer
,接著呼叫了 Greet後,我們可以用它來檢查寫入了什麼。
嘗試執行測試
./di_test.go:10:7: too many arguments in call to Greet
have (*bytes.Buffer, string)
want (string)
編寫最小化程式碼供測試執行,並檢查失敗的測試輸出
根據編譯器提示修復問題。
func Greet(writer *bytes.Buffer, name string) { fmt.Printf("Hello, %s", name) }
Hello, Chris di_test.go:16: got '' want 'Hello, Chris'
測試失敗了。注意到可以列印出 name
,不過它傳到了標準輸出
編寫足夠的程式碼使其透過
用 writer
把問候傳送到我們測試中的緩衝區。記住 fmt.Fprintf
和 fmt.Printf
一樣,只不過 fmt.Fprintf
會接收一個 Writer
引數,用於把字串傳遞過去,而 fmt.Printf
預設是標準輸出。
func Greet(writer *bytes.Buffer, name string) { fmt.Fprintf(writer, "Hello, %s", name) }
現在測試就可以透過了。
重構
bytes.Buffer
的指標。這在技術上是正確的,但卻不是很有用。Greet
函式接入到一個 Go 應用裡面,其中我們會列印到標準輸出。
func main() { Greet(os.Stdout, "Elodie") }
./di.go:14:7: cannot use os.Stdout (type *os.File) as type *bytes.Buffer in argument to Greet
我們前面討論過,fmt.Fprintf
允許傳入一個 io.Writer
介面,我們知道 os.Stdout
和 bytes.Buffer
都實現了它
我們可以修改一下程式碼,使用更為通用的介面,這樣我們現在可以在測試和應用中都使用這個函式了
package main import ( "fmt" "os" "io" ) func Greet(writer io.Writer, name string) { fmt.Fprintf(writer, "Hello, %s", name) } func main() { Greet(os.Stdout, "Elodie") }
關於 io.Writer 的更多內容
透過使用 io.Writer
,我們還可以將資料寫入哪些地方?我們的 Greet
函式的通用性怎麼樣了?
網際網路
執行下面程式碼:
package main import ( "fmt" "io" "net/http" ) func Greet(writer io.Writer, name string) { fmt.Fprintf(writer, "Hello, %s", name) } func MyGreeterHandler(w http.ResponseWriter, r *http.Request) { Greet(w, "world") } func main() { http.ListenAndServe(":5000", http.HandlerFunc(MyGreeterHandler)) }
執行程式並訪問 http://localhost:5000。你會看到你的 greeting
函式被使用了。
http.ResponseWriter
和用於建立請求的 http.Request
。在你實現伺服器時,你使用 writer
寫入了請求。http.ResponseWriter
也實現了 io.Writer
,所以我們可以重用處理器中的 Greet
函式。總結:
-
測試程式碼。如果你不能很輕鬆地測試函式,這通常是因為有依賴硬連結到了函式或全域性狀態。例如,如果某個服務層使用了全域性的資料庫連線池,這通常難以測試,並且執行速度會很慢。DI 提倡你注入一個資料庫依賴(透過介面),然後就可以在測試中控制你的模擬資料了。
-
關注點分離,解耦了資料到達的地方和如何產生資料。如果你感覺一個方法 / 函式負責太多功能了(生成資料並且寫入一個資料庫?處理 HTTP 請求並且處理業務級別的邏輯),那麼你可能就需要 DI 這個工具了。
- 在不同環境下重用程式碼。我們的程式碼所處的第一個「新」環境就是在內部進行測試。但是隨後,如果其他人想要用你的程式碼嘗試點新東西,他們只要注入他們自己的依賴就可以了。