Go語言:一文看懂什麼是DI依賴注入(dependency injection)設計模式

slowlydance2me發表於2023-03-27

前言:

  • 本文主要介紹的是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 自動化的使用,例如“查詢引用”,“顯示呼叫層次結構”和安全重構。

  你可以自己實現依賴項注入,也可以使用第三方庫或框架來實現。

實現依賴注入的庫和框架

 

重點:Golang TDD 中理解DI

  很對人使用Golang時對於依賴注入(dependency injection)存在諸多誤解。我們希望本篇會向你展示為什麼:

  • 你不一定需要一個框架
  •  它不會過度複雜化你的設計
  •  它易於測試
  •  它能讓你編寫優秀和通用的函式
  就像我們在 hello-world 做的那樣,我們想要編寫一個問候某人的函式,只不過這次我們希望測試實際的列印(actual printing)。
  這個函式應該長這個樣子:
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)
}
如果你寫過很多 Go 程式碼的話,你會發現這個介面出現的頻率很高,因為 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.Fprintffmt.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.Stdoutbytes.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 處理器(handler)時,你需要給出 http.ResponseWriter 和用於建立請求的 http.Request。在你實現伺服器時,你使用 writer 寫入了請求。
你可能已經猜到,http.ResponseWriter 也實現了 io.Writer,所以我們可以重用處理器中的 Greet函式。
 

總結:

我們第一輪迭代的程式碼不易測試,因為它把資料寫到了我們無法控制的地方。
透過測試的啟發,我們重構了程式碼。因為有了注入依賴,我們可以控制資料向哪兒寫入,它允許我們:
 
  • 測試程式碼。如果你不能很輕鬆地測試函式,這通常是因為有依賴硬連結到了函式或全域性狀態。例如,如果某個服務層使用了全域性的資料庫連線池,這通常難以測試,並且執行速度會很慢。DI 提倡你注入一個資料庫依賴(透過介面),然後就可以在測試中控制你的模擬資料了。
  •  關注點分離,解耦了資料到達的地方如何產生資料。如果你感覺一個方法 / 函式負責太多功能了(生成資料並且寫入一個資料庫?處理 HTTP 請求並且處理業務級別的邏輯),那麼你可能就需要 DI 這個工具了。
  •  在不同環境下重用程式碼。我們的程式碼所處的第一個「新」環境就是在內部進行測試。但是隨後,如果其他人想要用你的程式碼嘗試點新東西,他們只要注入他們自己的依賴就可以了。
 
 

相關文章