在大型項目的管理中,控制反轉的思想是非常重要的。它可以幫助我們解耦代碼,提高代碼的可維護性。同時避免了不必要的重複實例化,降低內存泄漏的可能性。
而在 JS/TS 技術棧中,我們通常會使用依賴注入框架來幫助我們管理服務。這其中最佳的選擇當然是 Angular 這種大而全的大型工程開發框架。而對於使用了其他 UI 框架的項目來説,我們同樣可以額外引入一個輕量化的依賴注入框架。而 InversifyJS 就是其中的佼佼者。我們可以通過使用它,來見微知著地瞭解依賴注入的原理與設計哲學。
但最近在使用 Inversify 進行項目重構時,遇到了一個問題:眾所周知依賴注入框架天生適合管理單例服務。它的設計哲學是 Everything as Service。但是在某些場景下,單例模式並不能解決一切問題,我們同樣需要進行多實例的管理。那麼我們該如何解決這個問題呢?
這並不是 Inversify 框架的問題,而其實是一個依賴注入框架下常見的設計疑惑,但是網上對此的解析資料卻很少。
我看了很多使用了 InversifyJS 的項目,他們對此的方式就是直接在需要處實例化,不將其註冊到容器中。這實際上是沒有真正理解到依賴注入框架的內核。這樣做的好處是簡單,但是有很多弊端。由於我們無法在容器中統一管理這些實例,那麼這些服務的生命週期將不受控制,在 dispose 時無法在容器中統一銷燬這些實例。與不引入依賴注入框架一樣,這樣同樣會帶來內存泄漏的可能性。
那麼該如何正確地處理這種情況呢?
構造器注入
一個最簡便的改造方式是,我們將類的構造函數綁定到容器中。需要的時候從容器中獲取類的構造器,再進行實例化。這樣我們就可以在容器中統一管理這些實例了。
// 將 InstanceClass 的構造函數綁定到容器中
container
.bind<interfaces.Newable<InstanceClass>>("Newable<InstanceClass>")
.toConstructor<InstanceClass>(InstanceClass);
// 獲取構造器
public constructor(
@inject("Newable<InstanceClass>") InstanceClass: Newable<InstanceClass>,
) {
this.instance1 = new InstanceClass();
this.instance2 = new InstanceClass();
}
實例會跟隨類的生命週期而存在,且該類能納入容器中進行管理。但是這樣做,實際上仍然無法在容器中統一管理這些實例的生命週期。如果我們需要在 dispose 時銷燬這些實例,那麼我們需要在類中手動實現 dispose 方法,並在 dispose 時手動銷燬這些實例。
這樣改造的好處是簡單,但是很多時候並不是一個最優解,因為我們希望該實例本身能在注入框架的管理下,避免我們去手動的控制與銷燬。
工廠注入
依賴注入框架天生不太好管理多實例的服務,但是如果利用工廠模式的設計思想,將這些服務的實例化過程封裝到工廠中,而這樣的工廠類一定是單例的。那麼我們就可以通過管理工廠類來管理這些多實例服務的生命週期了。
在需要多實例服務實例化時,我們不直接 import 類進行實例化,而是通過 import 工廠類來獲取實例。這樣我們就可以在工廠中控制多實例服務的生命週期了。
在 InversifyJS 中,提供了工廠注入的方法:
// 設置工廠函數
const instanceFactory = () => {
return context.container.get<InstanceClass>("Instance");
};
// 工廠創建器,這裏設置高階函數的目的是將 context 傳遞給工廠函數,方便獲取容器
const instanceFactoryCreator = (context: interfaces.Context) => {
return instanceFactory;
};
// 綁定工廠
container
.bind<interfaces.Factory<InstanceClass>>("Factory<InstanceClass>")
.toFactory<InstanceClass>(instanceFactoryCreator);
// 獲取構造器
public constructor(
@inject("Factory<InstanceClass>") private instanceFactory: () => InstanceClass,
) {
this.instance1 = this.instanceFactory();
this.instance2 = this.instanceFactory();
}
這樣的實現非常優雅,也是 Inversify 推薦的多實例管理方式。
當然,你也可以通過高階函數的方式,生成不同的的工廠函數,以實現不同的實例化邏輯。
// 設置工廠函數
const instanceFactory = (name: string) => () => {
if (name === "Instance") {
return context.container.get<InstanceClass>("Instance");
}
return context.container.get<DefaultClass>("Default");
};
// 工廠創建器,這裏設置高階函數的目的是將 context 傳遞給工廠函數,方便獲取容器
const instanceFactoryCreator = (context: interfaces.Context) => {
return instanceFactory;
};
// 綁定工廠
container
.bind<interfaces.Factory<InstanceClass>>("Factory<InstanceClass>")
.toFactory<InstanceClass>(instanceFactoryCreator);
在大多數情況下,它就是最標準的依賴注入框架下多實例管理方式了,也推薦能使用此方式的類儘量如此改造。
帶參數實例化的工廠注入
現在重點來了,依賴注入框架完美解決了在類實例化時需要傳入的依賴實例,避免了我們需要在類的構造函數中獲取或新建依賴實例。那麼,對於那些依賴於傳入外部上下文變量的類,我們該如何處理呢?
這是我們將已有的項目重構的過程中,經常會遇到的一種情況,這些類的構造函數執行過程依賴於外部上下文變量。
InversifyJS 的工廠注入在這中情形下的推薦實現方式比較奇怪,是在獲取實例後為實例進行屬性注入。我大致轉寫一下主要實現:
// 設置工廠函數
const instanceFactory = (payload: Record<string, any>) => {
const instance = context.container.get<InstanceClass>("Instance");
instance.payload = payload;
return instance;
};
// 工廠創建器,這裏設置高階函數的目的是將 context 傳遞給工廠函數,方便獲取容器
const instanceFactoryCreator = (context: interfaces.Context) => {
return instanceFactory;
};
// 綁定工廠
container
.bind<interfaces.Factory<InstanceClass>>("Factory<InstanceClass>")
.toFactory<InstanceClass>(instanceFactoryCreator);
在實例化後的運行時改變實例的屬性,從而使實例中對屬性的依賴得以滿足。但這樣的實現方式,會使得我們原有類的實現方式發生改變,也會改變類中屬性的訪問方式,例如原來時 readonly 或是 private 的屬性,我們都無法在運行時對其進行賦值。
當這個類繼承於外部需要傳入參數的類,或者是需要在首次實例化時根據傳入的變量依賴執行部分操作時,這種實例化的方式是行不通的。
那麼如果我們的改造類具有以上特性,在不改變原有實現方式的情況下,應當如何做呢?
我們可以注意到,通過構造器注入的方式並不會將實例化時的行為交給容器,因此我們可以在這裏進行手動的實例化並傳入參數。那這樣的實例化方式同樣可以與工廠模式相結合,實現帶參數實例化的工廠注入。
// 設置工廠函數
const instanceFactory = (payload: Record<string, any>) => {
const InstanceClass = context.container.get<Newable<InstanceClass>>(
"Newable<InstanceClass>"
);
const instance = new InstanceClass(payload);
return instance;
};
注意,這裏的 new InstanceClass 並不是引用原有類,而是引用了類的構造函數,而構造函數處於框架的管轄下,因此某種程度上該實例也是由框架進行了實例化得來的。因此,原有類甚至都不需要通過 @injectable 標註與註冊。只需註冊其構造器即可。
但始終,對於帶參數實例化的工廠注入,它的實現方式並不優雅,也不符合依賴注入的思想。因此,本質上來説,類似於類繼承的方式並不是一個好的code smell,我們推薦使用對象組合來代替類繼承,從而規避掉需要在構造函數中為 super() 傳入變量的尷尬局面。
結語
以上就是我在使用依賴注入框架重構項目時,對於多實例服務管理的一些思考與實踐。它成功地幫我完成了整個項目的重構,也讓我對於依賴注入框架有了更深的理解。
但於此同時,我也在實踐中發現了許多依賴注入框架的侷限性。但這並不説明依賴注入框架不夠完善,而是説明了依賴注入作為一種設計模式與思想,它有其匹配的設計哲學。例如在上述的例子中,真正按照框架的最佳實踐來説,我們應當只為服務注入行為抽象,而不是某些具體的變量數據,這對代碼可測性來説非常重要。
因此,我更推薦在使用依賴注入框架前,先學習依賴注入的設計思想,再去使用框架。而不是嘗試魔改某個依賴注入框架來迎合固有的編碼風格。這不一定對設計與性能有正向的收益。