[譯] 用依賴注入解耦你的程式碼

江不知發表於2020-03-29

用依賴注入解耦你的程式碼

無需第三方框架

[Icons8 團隊](https://unsplash.com/@icons8?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) 攝於 [Unsplash](https://unsplash.com/s/photos/ingredients?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)

沒有多少元件是能夠獨立存在而不依賴於其它元件的。除了建立緊密耦合的元件,我們還可以利用依賴注入(DI)來改善 關注點的分離

這篇文章將會脫離第三方框架向你介紹依賴注入的核心概念。所有的示例程式碼都將使用 Java,但所介紹的一般原則也適用於其它任何語言。


示例:資料處理器

為了讓如何使用依賴注入更加形象化,我們將從一個簡單的型別開始:

public class DataProcessor {

    private final DbManager manager = new SqliteDbManager("db.sqlite");
    private final Calculator calculator = new HighPrecisionCalculator(5);

    public void processData() {
        this.manager.processData();
    }

    public BigDecimal calc(BigDecimal input) {
        return this.calculator.expensiveCalculation(input);
    }
}
複製程式碼

DataProcessor 有兩個依賴項:DbManagerCalculator。直接在我們的型別中建立它們有幾個明顯的缺點:

  • 呼叫建構函式時可能發生崩潰
  • 建構函式簽名可能會改變
  • 緊密繫結到顯式實現型別

是時候改進它了!


依賴注入

《敏捷開發的藝術》 的作者 James Shore 很好地指出

「依賴注入聽起來複雜,實際上它的概念卻十分簡單。」

依賴注入的概念實際上非常簡單:為元件提供完成其工作所需的一切。

通常,這意味著通過從外部提供元件的依賴關係來解耦元件,而非直接在元件內建立依賴,讓元件間過度耦合。

我們可以通過多種方式為例項提供必要的依賴關係:

  • 建構函式注入
  • 屬性注入
  • 方法注入

建構函式注入

建構函式注入,或稱基於初始化器的依賴注入,意味著在例項初始化期間提供所有必需的依賴項,將其作為建構函式的引數:

public class DataProcessor {

    private final DbManager manager;
    private final Calculator calculator;

    public DataProcessor(DbManager manager, Calculator calculator) {
        this.manager = manager;
        this.calculator = calculator;
    }

    // ...
}
複製程式碼

由於這一簡單的改變,我們可以彌補大多數最開始的缺點:

  • 易於替換:DbManagerCalculator 不再被具體的實現所束縛,現在可以模擬單元測試了。
  • 已經初始化並且「準備就緒」:我們不必擔心依賴項所需要的任何子依賴項(例如,資料庫檔名、有效數字(譯者注)等),也不必擔心它們可在初始化期間發生崩潰的可能性。
  • 強制要求:呼叫方確切地知道建立 DataProcessor 的所需內容。
  • 不變性:依賴關係始終如初。

儘管建構函式注入是許多依賴注入框架的首選方法,但它也有明顯的缺點。其中最大的缺點是:必須在初始化時提供所有依賴項。

有時,我們無法自己初始化一個元件,或者在某個時刻我們無法提供元件的所有依賴關係。或者我們需要使用另外一個建構函式。一旦設定了依賴項,我們就無法再改變它們了。

但是我們可以使用其它注入型別來緩解這些問題。

屬性注入

有時,我們無法訪問型別實際的初始化方法,只能訪問一個已經初始化的例項。或者在初始化時,所需要的依賴關係並不像之後那樣明確。

在這些情況下,我們可以使用屬性注入而不是依賴於建構函式:

public class DataProcessor {

    public DbManager manager = null;
    public Calculator calculator = null;

    // ...

    public void processData() {
        // WARNING: Possible NPE
        this.manager.processData();
    }

    public BigDecimal calc(BigDecimal input) {
        // WARNING: Possible NPE
        return this.calculator.expensiveCalculation(input);
    }
}
複製程式碼

我們不再需要建構函式了,在初始化後我們可以隨時提供依賴項。但這種注入方式也有缺點:易變性

在初始化後,我們不再保證 DataProcessor 是「隨時可用」的。能夠隨意更改依賴關係可能會給我們帶來更大的靈活性,但同時也會帶來執行時檢查過多的缺點。

現在,我們必須在訪問依賴項時處理出現 NullPointerException 的可能性。

方法注入

即使我們將依賴項與建構函式注入與/或屬性注入分離,我們也仍然只有一個選擇。如果在某些情況下我們需要另一個 Calculator 該怎麼辦呢?

我們不想為第二個 Calculator 類新增額外的屬性或建構函式引數,因為將來可能會出現第三個這樣的類。而且在每次呼叫 calc(...) 前更改屬性也不可行,並且很可能因為使用錯誤的屬性而導致 bug。

更好的方法是引數化呼叫方法本身及其依賴項:

public class DataProcessor {

    // ...

    public BigDecimal calc(Calculator calculator, BigDecimal input) {
        return calculator.expensiveCalculation(input);
    }
}
複製程式碼

現在,calc(...) 的呼叫者負責提供一個合適的 Calculator 例項,並且 DataProcessor 類與之完全分離。

通過混合使用不同的注入型別來提供一個預設的 Calculator,這樣可以獲得更大的靈活性:

public class DataProcessor {

    // ...

    private final Calculator defaultCalculator;
    
    public DataProcessor(Calculator calculator) {
        this.defaultCalculator = calculator;
    }

    // ...

    public BigDecimal calc(Calculator calculator, BigDecimal input) {
        return Optional.ofNullable(calculator)
                       .orElse(this.calculator)
                       .expensiveCalculation(input);
    }
}
複製程式碼

呼叫者可以提供另一種型別的 Calculator,但這不是必須的。我們仍然有一個解耦的、隨時可用的 DataProcessor,它能夠適應特定的場景。

選擇哪種注入方式?

每種依賴注入型別都有自己的優點,並沒有一種「正確的方法」。具體的選擇完全取決於你的實際需求和情況。

建構函式注入

建構函式注入是我的最愛,它也常受依賴注入框架的青睞。

它清楚地告訴我們建立特定元件所需的所有依賴關係,並且這些依賴不是可選的,這些依賴關係在整個元件中應該都是必需的。

屬性注入

屬性注入更適合可選引數,例如監聽或委託。又或是我們無法在初始化時提供依賴關係。

其它程式語言,例如 Swift,大量使用了帶屬性的 委託模式。因此,使用屬性注入將使其它語言的開發人員更熟悉我們的程式碼。

方法注入

如果在每次呼叫時依賴項可能不同,那麼使用方法注入最好不過了。方法注入進一步解耦元件,它使方法本身持有依賴項,而非整個元件。

請記住,這不是非此即彼。我們可以根據需要自由組合各種注入型別。

控制反轉容器

這些簡單的依賴注入實現可以覆蓋很多用例。依賴注入是很好的解耦工具,但事實上我們仍然需要在某些時候建立依賴項。

但隨著應用程式和程式碼庫的增長,我們可能還需要一個更完整的解決方案來簡化依賴注入的建立和組裝過程。

控制反轉(IoC)是 控制流 的抽象原理。依賴注入是控制反轉的具體實現之一。

控制反轉容器是一種特殊型別的物件,它知道如何例項化和配置其它物件,它也知道如何幫助你執行依賴注入。

有些容器可以通過反射來檢測關係,而另一些必須手動配置。有些容器基於執行時,而有些則在編譯時生成所需要的所有程式碼。

比較所有容器的不同之處超出了本文的討論範圍,但是讓我通過一個小示例來更好地理解這個概念。

示例: Dagger 2

Dagger 是一個輕量級、編譯時進行依賴注入的框架。我們需要建立一個 Module,它就知道如何構建我們的依賴項,稍後我們只要新增 @Inject 註釋就可以注入這個 Module

@Module
public class InjectionModule {

    @Provides
    @Singleton
    static DbManager provideManager() {
        return manager;
    }

    @Provides
    @Singleton
    static Calculator provideCalculator() {
        return new HighPrecisionCalculator(5);
    }
}
複製程式碼

@Singleton 確保只能建立一個依賴項的例項。

要注入依賴項,我們只需要將 @Inject 新增到建構函式、欄位或方法中。

public class DataProcessor {

    @Inject
    DbManager manager;
    
    @Inject
    Calculator calculator;

    // ...
}
複製程式碼

這些僅僅是一些基礎知識,乍一看不可能會給人留下深刻的印象。但是控制反轉容器和框架不僅解耦了元件,也讓建立依賴關係的靈活性得以最大化。

由於提供了高階特性,建立過程的可配置性變得更強,並且支援了使用依賴項的新方法。

高階特性

這些特性在不同型別的控制反轉容器和底層語言之間差異很大,比如:

  • 代理模式 和延遲載入。
  • 生命週期(例如:單例模式與每個執行緒一個例項)。
  • 自動繫結。
  • 單一型別的多種實現。
  • 迴圈依賴。

這些特性是控制反轉容器真正的能力。你可能會認為諸如「迴圈依賴」這樣的特性並非好的主意,確實如此。

但是,如果由於遺留程式碼或是過去不可更改的錯誤設計而需要這種奇怪的程式碼構造,那麼我們現在有能力可以這樣做。

總結

我們應該根據抽象(例如介面)而不是具體的實現來設計程式碼,這樣可以幫助我們減少程式碼耦合。

介面必須提供我們程式碼所需要的唯一資訊,我們不能對實際實現情況做任何假設。

「程式應當依賴抽象,而非具體的實現」 —— Robert C. Martin (2000), 《設計原則與設計模式》

依賴注入是通過解耦元件來實現這一點的好辦法。它使我們能夠編寫更簡潔明瞭、更易於維護和重構的程式碼。

選擇三種依賴注入型別中的哪種很大程度上取決於環境和需求,但是我們也可以混合使用三種型別使收益最大化。

控制反轉容器有時幾乎以一種神奇的方式通過簡化元件建立過程來提供另一種便利的佈局。

我們應該處處使用它嗎?當然不是。

就像其它模式和概念一樣,我們應該在適當的時候應用它們,而不是能用則用。

永遠不要把自己侷限在一種做事的方式上。也許 工廠模式 甚至是廣為厭惡的 單例模式 是能夠滿足你需求的更好的解決方案。


資料


控制反轉容器

Java

Kotlin

Swift

C#

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章