掌握框架——探索依賴注入模式

已發表: 2022-03-11

控制反轉 (IoC) 的傳統觀點似乎在兩種不同的方法之間劃清界限:服務定位器和依賴注入 (DI) 模式。

幾乎我知道的每個項目都包含一個 DI 框架。 人們之所以被它們吸引,是因為它們促進了客戶端與其依賴項之間的鬆散耦合(通常通過構造函數注入),並且使用最少或沒有樣板代碼。 雖然這對於快速開發非常有用,但有些人發現它會使代碼難以跟踪和調試。 “幕後魔術”通常是通過反思來實現的,這會帶來一整套新的問題。

在本文中,我們將探索一種非常適合 Java 8+ 和 Kotlin 代碼庫的替代模式。 它保留了 DI 框架的大部分優點,同時與服務定位器一樣簡單,無需外部工具。

動機

  • 避免外部依賴
  • 避免反射
  • 促進構造函數注入
  • 最小化運行時行為

一個例子

在下面的示例中,我們將對 TV 實現進行建模,其中可以使用不同的源來獲取內容。 我們需要構建一個可以接收來自各種來源(例如,地面、電纜、衛星等)的信號的設備。 我們將構建以下類層次結構:

實現任意信號源的電視設備的類層次結構

現在讓我們從傳統的 DI 實現開始,其中 Spring 等框架為我們連接一切:

 public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } } public interface TvSource { void tuneChannel(int channel); } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } }

我們注意到一些事情:

  • TV 類表示對 TvSource 的依賴。 外部框架將看到這一點並註入具體實現(地面或有線)的實例。
  • 構造函數注入模式允許輕鬆測試,因為您可以使用替代實現輕鬆構建 TV 實例。

我們有了一個良好的開端,但我們意識到為此引入一個 DI 框架可能有點矯枉過正。 一些開發人員報告了調試構造問題的問題(長堆棧跟踪、無法跟踪的依賴項)。 我們的客戶還表示,製造時間比預期的要長一些,我們的分析器顯示反射調用速度放緩。

另一種方法是應用服務定位器模式。 它很簡單,不使用反射,對於我們的小代碼庫來說可能已經足夠了。 另一種選擇是不理會這些類,並在它們周圍編寫依賴位置代碼。

在評估了許多備選方案後,我們選擇將其實現為提供者接口的層次結構。 每個依賴項都有一個關聯的提供者,該提供者將單獨負責定位類的依賴項並構造注入的實例。 我們還將使提供程序成為易於使用的內部接口。 我們將其稱為 Mixin 注入,因為每個提供程序都與其他提供程序混合以定位其依賴項。

詳細信息和基本原理中詳細說明了我選擇這種結構的原因,但這裡是簡短版本:

  • 它隔離了依賴位置行為。
  • 擴展接口不屬於鑽石問題。
  • 接口具有默認實現。
  • 缺少依賴項會阻止編譯(加分!)。

下圖顯示了依賴項和提供程序如何交互,實現如下圖所示。 我們還添加了一個 main 方法來演示我們如何組合我們的依賴項並構造一個 TV 對象。 此示例的更長版本也可以在此 GitHub 上找到。

提供者和依賴項之間的交互

 public interface TvSource { void tuneChannel(int channel); interface Provider { TvSource tvSource(); } } public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } interface Provider extends TvSource.Provider { default TV tv() { return new TV(tvSource()); } } } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Terrestrial(); } } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Cable(); } } } // Here compose the code above to instantiate a TV with a Cable TvSource public class Main { public static void main(String[] args) { new MainContext().tv().turnOn(); } static class MainContext implements TV.Provider, Cable.Provider { } }

關於這個例子的幾點說明:

  • TV 類依賴於 TvSource,但它不知道任何實現。
  • TV.Provider 擴展了 TvSource.Provider,因為它需要 tvSource() 方法來構建 TvSource,即使它沒有在那裡實現,它也可以使用它。
  • 電視可以互換使用地面和有線信號源。
  • Terrestrial.Provider 和 Cable.Provider 接口提供了具體的 TvSource 實現。
  • main 方法有一個 TV.Provider 的具體實現 MainContext,用於獲取 TV 實例。
  • 該程序需要在編譯時實現 TvSource.Provider 來實例化電視,因此我們以 Cable.Provider 為例。

細節和理由

我們已經看到了這種行為模式及其背後的一些原因。 你現在可能不相信你應該使用它,你是對的; 它不完全是靈丹妙藥。 就個人而言,我認為它在大多數方面都優於服務定位器模式。 但是,與 DI 框架相比,必須評估優勢是否超過添加樣板代碼的開銷。

提供者擴展其他提供者來定位他們的依賴

當一個提供者擴展另一個提供者時,依賴關係被綁定在一起。 這為防止創建無效上下文的靜態驗證提供了基本基礎。

服務定位器模式的主要痛點之一是您需要調用通用的GetService<T>()方法,該方法將以某種方式解決您的依賴關係。 在編譯時,您無法保證依賴項會在定位器中註冊,並且您的程序可能會在運行時失敗。

DI 模式也沒有解決這個問題。 依賴關係解析通常是通過外部工具的反射完成的,該工具大部分對用戶隱藏,如果不滿足依賴關係,該工具在運行時也會失敗。 諸如 IntelliJ 的 CDI 之類的工具(僅在付費版本中可用)提供了某種程度的靜態驗證,但似乎只有帶有註釋預處理器的 Dagger 通過設計解決了這個問題。

類維護 DI 模式的典型構造函數注入

這不是必需的,但開發人員社區絕對需要。 一方面,你可以只看構造函數,立即看到類的依賴關係。 另一方面,它實現了許多人堅持的那種單元測試,即通過模擬其依賴項來構建被測主題。

這並不是說不支持其他模式。 事實上,人們甚至可能會發現 Mixin 注入簡化了構建複雜依賴圖以進行測試,因為您只需要實現一個擴展主題提供者的上下文類。 上面的MainContext是一個完美的例子,所有接口都有默認實現,所以它可以有一個空的實現。 替換依賴項只需要覆蓋其提供者方法。

讓我們看看下面的電視類測試。 它需要實例化一個電視,但不是調用類構造函數,而是使用 TV.Provider 接口。 TvSource.Provider 沒有默認實現,所以我們需要自己編寫。

 public class TVTest { @Test public void testWithProvider() { TvSource source = Mockito.mock(TvSource.class); TV.Provider provider = () -> source; // lambdas FTW provider.tv().turnOn(); Mockito.verify(source, times(1)).tuneChannel(42); } }

現在讓我們向 TV 類添加另一個依賴項。 CathodeRayTube 依賴項具有使圖像出現在電視屏幕上的魔力。 它與 TV 實現分離,因為我們將來可能希望切換到 LCD 或 LED。

 public class TV { public TV(TvSource source, CathodeRayTube cathodeRayTube) { ... } public interface Provider extends TvSource.Provider, CathodeRayTube.Provider { default TV tv() { return new TV(tvSource(), cathodeRayTube()); } } } public class CathodeRayTube { public void beam() { System.out.println("Beaming electrons to produce the TV image"); } public interface Provider { default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

如果您這樣做,您會注意到我們剛剛編寫的測試仍然可以按預期編譯和通過。 我們為 TV 添加了一個新的依賴項,但我們也提供了一個默認實現。 這意味著如果我們只想使用真正的實現,我們不必模擬它,我們的測試可以創建具有我們想要的任何級別的模擬粒度的複雜對象。

當您想要模擬複雜類層次結構中的特定內容(例如,只有數據庫訪問層)時,這會派上用場。 該模式可以輕鬆設置有時比單獨測試更喜歡的那種社交測試。

無論您的偏好如何,您都可以確信您可以轉向任何形式的測試,以更好地滿足您在每種情況下的需求。

避免外部依賴

如您所見,沒有對外部組件的引用或提及。 這對於許多具有規模甚至安全限制的項目來說是關鍵。 它還有助於互操作性,因為框架不必致力於特定的 DI 框架。 在 Java 中,已經做出了一些努力,例如 JSR-330 Dependency Injection for Java Standard,可以緩解兼容性問題。

避免反射

服務定位器實現通常不依賴反射,但 DI 實現會依賴反射(Dagger 2 除外)。 這具有減慢應用程序啟動速度的主要缺點,因為框架需要掃描您的模塊,解析依賴關係圖,反射性地構造您的對像等。

Mixin 注入要求您編寫代碼來實例化您的服務,類似於服務定位器模式中的註冊步驟。 這個小小的額外工作完全消除了反射調用,使您的代碼更快更直接。

最近引起我注意並從避免反射中受益的兩個項目是 Graal 的 Substrate VM 和 Kotlin/Native。 兩者都編譯為本機字節碼,這需要編譯器提前知道您將進行的任何反射調用。 對於 Graal,它在 JSON 文件中指定,難以編寫,無法靜態檢查,無法使用您喜歡的工具輕鬆重構。 首先使用 Mixin 注入來避免反射是獲得原生編譯好處的好方法。

最小化運行時行為

通過實現和擴展所需的接口,您可以一次構建一個依賴關係圖。 每個提供者都位於具體實現旁邊,這為您的程序帶來了順序和邏輯。 如果您以前使用過 Mixin 模式或 Cake 模式,那麼這種分層方式會很熟悉。

在這一點上,可能值得討論 MainContext 類。 它是依賴圖的根,知道大局。 此類包括所有提供程序接口,並且是啟用靜態檢查的關鍵。 如果我們回到示例並從其實現列表中刪除 Cable.Provider,我們將清楚地看到這一點:

 static class MainContext implements TV.Provider { } // ^^^ // MainContext is not abstract and does not override abstract method tvSource() in TvSource.Provider

這裡發生的是應用程序沒有指定要使用的具體 TvSource,編譯器捕獲了錯誤。 使用服務定位器和基於反射的 DI,這個錯誤可能會被忽視,直到程序在運行時崩潰——即使所有的單元測試都通過了! 我相信我們展示的這些和其他好處超過了編寫使模式工作所需的樣板文件的缺點。

捕獲循環依賴

讓我們回到 CathodeRayTube 示例並添加一個循環依賴項。 假設我們希望它被注入一個 TV 實例,所以我們擴展 TV.Provider:

 public class CathodeRayTube { public interface Provider extends TV.Provider { // ^^^ // cyclic inheritance involving CathodeRayTube.Provider default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

編譯器不允許循環繼承,我們無法定義這種關係。 當這種情況發生時,大多數框架在運行時都會失敗,開發人員傾向於解決它只是為了讓程序運行。 儘管這種反模式可以在現實世界中找到,但它通常是糟糕設計的標誌。 當代碼無法編譯時,我們應該鼓勵我們在更改為時已晚之前尋找更好的解決方案。

保持對象構造的簡單性

支持 SL 而不是 DI 的論據之一是它簡單且易於調試。 從示例中可以清楚地看出,實例化依賴項將只是提供者方法調用的鏈。 追溯依賴的來源就像步入方法調用並查看最終結果一樣簡單。 調試比這兩種方法都簡單,因為您可以直接從提供者那裡準確導航依賴項的實例化位置。

使用壽命

細心的讀者可能已經註意到,這個實現並沒有解決服務生命週期問題。 所有對提供者方法的調用都會實例化新對象,這類似於 Spring 的 Prototype 範圍。

這個和其他的考慮稍微超出了本文的範圍,因為我只是想展示模式的本質而不分散細節。 但是,在產品中充分使用和實施需要考慮到具有終身支持的完整解決方案。

結論

無論您習慣於依賴注入框架還是編寫自己的服務定位器,您都可能想探索這種替代方案。 考慮使用我們剛剛看到的 mixin 模式,看看您是否可以使您的代碼更安全、更易於推理。

相關: JS 最佳實踐:使用 TypeScript 和依賴注入構建 Discord Bot