如何利用 IOC 改善工程設計
控制反轉 (Inversion of Control) 及其背後的 SOLID 設計原則已經非常成熟,並且在傳統軟件開發領域得到了驗證。
本文從 JavaScript 生態出發,結合領域內流行的基礎設施和成功的項目樣例,對這套這套方法論進行重新審視。
緒論
什麼是 IOC 控制反轉
一個來自 React 的例子:Context – React[1]
Avatar 雖然被諸多底層組件依賴,但是它卻不是被底層組件引入並初始化的,這樣就實現了底層組件與 Avatar 的解耦。
-
底層組件不再關注 Avatar 的具體實現
-
僅關注一個抽象的承諾:上層組件會傳入一個可渲染的片段
-
Avatar 的初始化由多個地點集中到了一起
完成了複雜度的收束,並且沒有影響代碼的能力。
這個例子僅說明了 IOC 的核心,完整的 IOC 實踐與 SOLID 設計原則 緊密相關
維基百科: SOLID (面向對象設計)
實際上這也是 IOC 難以被講透的主要原因:IOC 不是一種單獨的技術,而是一整套方法論。
這套方法論試圖解決從項目架構設計,開發協作流程,再到後期項目迭代直到代碼老化等多個環節中的多個問題。
很難說某些優勢是不是 IOC 直接帶來的,但是 IOC 確實和這套方法論配合良好,後面可以看到例子。
兩個關鍵點:
-
單一功能原則 確保了功能單元的可複用性,同時帶來了一些好處。
-
邊界清晰,關注點集中;
-
代碼即文檔,降低命名難度,有助於提升可讀性。
-
單元間的調用基於 interface 的共識。也被稱爲 Interface Driven。
-
在設計之初,自頂向下地拆分功能模塊,並明確各單元間的接口(在依賴單元被實現之前,不阻塞當前單元被開發,有利於團隊協作與並行);
-
對其他模塊的認知僅限於 interface,而不應依賴其特定的實現方式(可替換性:便於 Mock 和 重構)。
模塊與 IOC
在社區中,也有一些聲音認爲藉助模塊系統的能力,JavaScript 可以獲取與 IOC 類似的優勢。
舉個例子:
// my-class.ts
Class MyClass {}
// 單例
export const myClass = new MyClass();
// 工廠函數
export const makeMyClass() {
return new Myclass();
}
// foo.ts
import { myClass } from 'my-class.ts';
// bar.ts
import { makeMyClass } from 'my-class.ts';
這裏的 myClass 可以是單例的,並且在它自己的模塊中被初始化,其它模塊不需要知道細節。
在小型項目中,這樣處理是足夠好的,簡單且符合直覺,但是:
-
實際上發生了耦合,對 my-class.ts 的引用就是這種耦合的體現。
-
在一個非常大的項目 Repo 中,需要關注 my-class.ts 的文件位置,它甚至可能位於另一個 Package。
-
依賴了一個具體的實現而非接口。
-
因此在你編寫這段代碼的時候,MyClass 需要存在,並且實現了你需要的接口。
-
這種依賴缺乏某種預先設計,非常不利於協作。
-
潛在的循環依賴問題。
-
Modules: CommonJS modules | Node.js v19.4.0 Documentation[2]
-
ES6 Modules and Circular Dependency[3]
-
myClass 單例的生命週期是不可控的,被實例化的時機是不明確的。
-
工廠函數需要專門編寫。
InversifyJS:JavaScript 生態內最流行的 IOC 框架
InversifyJS 是一個輕量的 (4KB) IOC 容器 ,可用於編寫 TypeScript 和 JavaScript 應用。
主要目標:
-
允許 JavaScript 開發人員編寫遵循 SOLID 原則的代碼。
-
促進並鼓勵遵守最佳的面向對象編程和依賴注入實踐。
-
儘可能少的運行時開銷。
-
提供藝術編程體驗和生態。
一分鐘認識 InversifyJS
-
提供一個容器的基礎設施,各模塊都被註冊到 Container 容器中。
-
容器可以簡單理解爲一個 Map:
container = new Container()
-
容器中的每個單元擁有自己的 標識符(Service Identifier) 和預先定義的 interface。
1、標識符是集中聲明的常量,其值一般是一個 Symbol 對象,例如下文中的TYPES.FOO
2、interface 使用 TS 聲明,例如下文中的Foo
3、標識符 和 interface 共同構成了在設計階段的 模塊抽象 -
模塊註冊到容器是集中完成的
1、FooImpl 實現了 Foo 接口,以下代碼將其實現與抽象綁定
2、container.bind<Foo>(TYPES.FOO).to(FooImpl)
-
當其它模塊需要與模塊 Foo 交互,通過 @inject(標識符) 聲明對 Foo 的依賴,Foo 的實例會被自動注入。
-
@inject(TYPES.FOO)
標識符 (Service Identifier) 也可以使用 string 或者其它類型,只要意義清晰即可。其 TS 聲明如下:
InversifyJS 實戰
本小節基於官方文檔改編
步驟 1: 聲明接口和類型
目標是編寫遵循依賴倒置原則的代碼,這意味着我們應該 “依賴於抽象而不依賴於具體實現”。先聲明一些 interface:
// file interfaces.ts
interface Warrior {
fight(): string;
sneak(): string;
}
interface Weapon {
hit(): string;
}
interface ThrowableWeapon {
throw(): string;
}
Inversifyjs 需要在運行時使用類型標記作爲標識符。接下來將使用 Symbol
作爲標識符:
// file types.ts
const TYPES = {
Warrior: Symbol.for("Warrior"),
Weapon: Symbol.for("Weapon"),
ThrowableWeapon: Symbol.for("ThrowableWeapon")
};
export { TYPES };
這一步完成了整個應用的 模塊抽象 設計。
步驟 2: 使用 @injectable
和 @inject
裝飾器聲明依賴
編寫一些類,來實現上一步聲明的 interface。
希望使用依賴注入的類需要添加 @injectable
裝飾器來激活這個特性,然後就可以使用@inject
聲明依賴。
// file entities.ts
import { injectable, inject } from "inversify";
import "reflect-metadata";
import { Weapon, ThrowableWeapon, Warrior } from "./interfaces"
import { TYPES } from "./types";
@injectable()
class Katana implements Weapon {
public hit() {
return "cut!";
}
}
@injectable()
class Shuriken implements ThrowableWeapon {
public throw() {
return "hit!";
}
}
@injectable()
class Ninja implements Warrior {
private _katana: Weapon;
private _shuriken: ThrowableWeapon;
public constructor(
@inject(TYPES.Weapon) katana: Weapon,
@inject(TYPES.ThrowableWeapon) shuriken: ThrowableWeapon
) {
this._katana = katana;
this._shuriken = shuriken;
}
public fight() { return this._katana.hit(); }
public sneak() { return this._shuriken.throw(); }
}
export { Ninja, Katana, Shuriken };
可選地,也支持使用屬性注入來代替構造函數注入,更加簡潔:
@injectable()
class Ninja implements Warrior {
@inject(TYPES.Weapon) private _katana: Weapon;
@inject(TYPES.ThrowableWeapon) private _shuriken: ThrowableWeapon;
public fight() { return this._katana.hit(); }
public sneak() { return this._shuriken.throw(); }
}
步驟 3: 創建和配置容器
這一步驟我們真正將 實現 綁定到各自的 抽象 上。
推薦在命名爲 inversify.config.ts
的文件中創建和配置容器。
這是唯一有耦合的地方,項目的其它地方,不應該包含對其他類的引用。
// file inversify.config.ts
import { Container } from "inversify";
import { TYPES } from "./types";
import { Warrior, Weapon, ThrowableWeapon } from "./interfaces";
import { Ninja, Katana, Shuriken } from "./entities";
const myContainer = new Container();
myContainer.bind<Warrior>(TYPES.Warrior).to(Ninja);
myContainer.bind<Weapon>(TYPES.Weapon).to(Katana);
myContainer.bind<ThrowableWeapon>(TYPES.ThrowableWeapon).to(Shuriken);
export { myContainer };
步驟 4: 解析依賴
您可以使用方法 get<T>
從 Container 中獲得依賴。
應該在根結構 (儘可能靠近應用程序的入口點的位置) 去解析依賴(指引入 inversify.config),避免反模式的服務定位器問題。
譯文:服務定位器 Service Locator 是一種反模式的設計 [4]
import { myContainer } from "./inversify.config";
import { TYPES } from "./types";
import { Warrior } from "./interfaces";
const ninja = myContainer.get<Warrior>(TYPES.Warrior);
expect(ninja.fight()).eql("cut!"); // true
expect(ninja.sneak()).eql("hit!"); // true
InversifyJS 的優勢
本小節基於官方文檔改編
解耦與依賴抽象
InversifyJS 賦予你真正解耦的能力。
在上一小節的實戰中,Ninja
類永遠不會直接持有 Katana
或者 Shuriken
類。但是,它會指向接口(在設計時)或者符號(在運行時)。
由於這是抽象的所以這是可接受的。畢竟 依賴抽象 正是依賴反轉所要做的。InversifyJS 容器是應用中唯一清楚生命週期和依賴關係的元素。
應用中所有的耦合關係發生在唯一一處:inversify.config.ts
文件中。這非常重要,想象我們正在更改一個遊戲的難度級別,只需要去 inversify.config.ts
文件中並且修改 Katana 的綁定即可:
import { Katana } from "./entitites/SharpKatana";
if(difficulty === "hard") {
container.bind<Katana>(TYPES.KATANA).to(SharpKatana);
} else {
container.bind<Katana>(TYPES.KATANA).to(Katana);
}
你根本不需要修改 Ninja 文件!
想象一下,如果你在 inversify.config
當中實現一些小機制,理論上可以在運行時對應用的所有功能單元進行動態替換,然後你得到了一個所有內部單元都可以做 AB 測試 / 灰度發佈應用!
在下一小節 Theia 的架構中,可以看到此機制是如何提供了魔法般的高度的可定製性與靈活性。
需要付出的代價是符號或者字符串字面量的使用,但是隻要你在一個文件中定義所有的字符串字面量,那麼這個代價將有所緩和 (Redux 中的 actions 就是這麼做的)。
好消息是未來這些符號或者字符串字面量能夠由 TS 編譯器自動生成,但是目前這還在 TC39 委員會的手中。
解決對象組合的痛點
一個常見的模式:
var svc = new ShippingService(
new Productlocator(),
new PricingService(),
new InventoryService(),
new TrackingRepository(new ConfigProvider()),
new Logger(new EmailLogger(new ConfigProvider()))
);
單元之間層層嵌套的依賴關係是 OOP 的一個痛點,並且這種嵌套關係會很快增長到無法有效維護。即使使用工廠函數,你所編寫的額外代碼仍然是不划算的。
類型安全
支持 TypeScript ,被注入的模塊有完整的類型聲明
高級特性
-
解決複雜依賴關係
-
可選依賴:
@optional()
裝飾器聲明一個可選依賴 -
層次化的容器
1、可以將多個 Container 使用類似原型鏈的方式嵌套連接,其尋址方式也類似原型鏈
2、childContainer.parent = parentContainer
-
多重注入
1、當有兩個或者多個具體實現被綁定到同一個標識符,可以使用多重注入
2、@multiInject
裝飾器會將多個實現以數組方式注入 -
解決循環依賴
1、@lazyInject
裝飾器將對依賴項的注入延遲到了真正要使用它們的那一刻,這發生在類實例被創建之後
2、有能力識別循環依賴,並且會給出提示信息 -
中間件與攔截器:Logger
-
容器內容的生命週期管理:單元被綁定時可以聲明其生命週期
-
TransientScope 默認值,每次從容器中獲取時都初始化新實例
-
SingletonScope 單例,每次獲取返回同一實例
-
RequestScope 前兩者的混合,在同一個依賴樹上總是返回同一實例
-
開發者工具
Dive Into Theia
Eclipse Theia [5] 是一個使用現代 Web 技術構建自定義雲和桌面 IDE 和工具的平臺。
Theia 本身並不是一個工具,Theia 是一個開發 IDE 的框架,可以基於 Theia 創建自己的 IDE。Theia 使用 Typescript 編寫,整體技術體系和 Visual Studio Code 類似。
Theia 爲什麼是一個好例子
-
出身名門
-
高完成度
-
足夠複雜
-
挑戰大
-
代碼量多
-
以開源項目方式維護
-
足夠新
-
使用現代技術棧
-
基於 TypeScript 的 IOC & SOLID 實踐
Theia 的目標與挑戰
-
多平臺:整個應用可運行於 B/S 模式,也可運行於 Electron 中
-
對標 VS Code 的現代 IDE 架構,兼容 VS Code 插件
-
高可維護性的模塊化架構
-
儘可能複用基礎功能
-
使用標準組件,不重複造輪子
-
高擴展性與靈活性
-
本質上是個框架,設計出來就是爲了二次開發
-
用戶可以輕鬆改變、擴展內置模塊的行爲
-
用戶可以按照規約添加新的模塊和功能
這幾個目標對於應用架構設計提出了極高的要求。
Theia 的架構設計
Theia 整體上分爲前端和後端兩個子應用,中間使用 JSON-RPC 通信。
前端
負責顯示 UI,處理交互,運行在瀏覽器 (或 Electron 窗口) 中。前端進程啓動時,將首先加載所有 Extension 貢獻的 DI 模塊,然後獲取 FrontendApplication 的實例並在其上調用 start()。
後端
運行在 Node.js 中,是一個基於 Express.js 的服務。後端應用程序的啓動會首先加載所有所有 Extension 貢獻的 DI 模塊,然後它會獲取一個 BackendApplication 實例並在其上調用 start(portNumber)。
依賴注入
前後端都使用 DI(具體來說就是 Inversify.js) 來組合邏輯,稍後我們會詳細討論。
Extension
Extension 是 Theia 中的功能模塊(npm package),Theia 就是由無數個 Extension 組成的。
編寫一個 Extension 是用戶定製 Theia 的主要方式。用戶提供的 Extension 會和 Theia 內置的 Extension 一起經歷編譯過程,併產出一個可運行的應用。
用戶 Extension 和內置 Extension 地位相同,其權限和能力幾乎不受限制。
注意這與 VS Code 定義的插件 (VS Code Extension) 是不同的。
插件是運行時可動態加載的,在 Theia 中被稱爲 Plugin。
因爲 Theia 由前後端兩個子應用組成,所以 Extension 一般也由前後端兩部分組成,其典型目錄結構爲:
-
common 目錄
-
包含不依賴於任何運行時的代碼
-
一般包含前後端 RPC 接口的定義,常量,通用的工具函數等
-
browser 目錄
-
包含需要現代瀏覽器作爲平臺 (DOM API) 的代碼。
-
electron-browser 目錄
-
包含需要 DOM API 以及 Electron renderer-process 特定 API 的前端代碼。
-
node 目錄
-
包含需要 Node.js 作爲平臺的(後端)代碼。
-
node-electron 目錄
-
包含專用於 Electron 的(後端)代碼。
可以通過 Theia 的內置模塊 [6],來一窺其是怎麼進行模塊劃分的。
扁平且清晰。
構建 Theia 應用
Theia 可以基於 Package.json 聲明構建:
{
"private": true,
"dependencies": {
"@theia/callhierarchy": "latest",
"@theia/console": "latest",
"@theia/core": "latest",
"@theia/debug": "latest",
"@theia/editor": "latest",
"@theia/editor-preview": "latest",
"@theia/file-search": "latest",
"@theia/filesystem": "latest",
"@theia/getting-started": "latest",
// 以下省略
},
"devDependencies": {
"@theia/cli": "latest"
},
"scripts": {
"preinstall": "node-gyp install"
}
}
通過編輯 dependencies, 可以挑選本次構建包含哪些功能模塊。
拆解一個 Theia Extension
此處以內置 Package file-search
爲例,探索一下其內部實現,這個模塊實現了彈出式文件選擇彈窗:
其目錄結構如下:
Common
common/file-search-service.ts
-
前後端 JSON-RPC 接口定義
-
標識符 Symbol 定義
-
本模塊的 inerface
-
其它常量定義
import { CancellationToken } from '@theia/core';
export const fileSearchServicePath = '/services/search';
/**
* The JSON-RPC file search service interface.
*/
export interface FileSearchService {
/**
* finds files by a given search pattern.
* @return the matching file uris
*/
find(searchPattern: string, options: FileSearchService.Options, cancellationToken?: CancellationToken): Promise<string[]>;
}
export const FileSearchService = Symbol('FileSearchService');
export namespace FileSearchService {
export interface BaseOptions {
useGitIgnore?: boolean
includePatterns?: string[]
excludePatterns?: string[]
}
export interface RootOptions {
[rootUri: string]: BaseOptions
}
export interface Options extends BaseOptions {
rootUris?: string[]
rootOptions?: RootOptions
fuzzyMatch?: boolean
limit?: number
}
}
export const WHITESPACE_QUERY_SEPARATOR = /\s+/;
後端
node/file-search-service-impl.ts
這裏實現了功能的後端服務,依賴的模塊使用 @inject 注入:
import * as cp from 'child_process';
import * as readline from 'readline';
import { injectable, inject } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { FileUri } from '@theia/core/lib/node/file-uri';
import { RawProcessFactory } from '@theia/process/lib/node';
import { FileSearchService, WHITESPACE_QUERY_SEPARATOR } from '../common/file-search-service';
import * as path from 'path';
@injectable()
export class FileSearchServiceImpl implements FileSearchService {
constructor(
@inject(ILogger) protected readonly logger: ILogger,
/** @deprecated since 1.7.0 */
@inject(RawProcessFactory) protected readonly rawProcessFactory: RawProcessFactory,
) { }
async find(searchPattern: string, options: FileSearchService.Options, clientToken?: CancellationToken): Promise<string[]> {
// 略去具體實現
}
private doFind(rootUri: URI, options: FileSearchService.BaseOptions, accept: (fileUri: string) => void, token: CancellationToken): Promise<void> {
// 略去具體實現
}
private getSearchArgs(options: FileSearchService.BaseOptions): string[] {
// 略去具體實現
}
}
node/file-search-backend-module.ts
類似 inversify.config.ts
的作用,將 FileSearchServiceImpl
和 ConnectionHandler
綁定到其抽象:
import { ContainerModule } from '@theia/core/shared/inversify';
import { ConnectionHandler, JsonRpcConnectionHandler } from '@theia/core/lib/common';
import { FileSearchServiceImpl } from './file-search-service-impl';
import { fileSearchServicePath, FileSearchService } from '../common/file-search-service';
export default new ContainerModule(bind => {
bind(FileSearchService).to(FileSearchServiceImpl).inSingletonScope();
bind(ConnectionHandler).toDynamicValue(ctx =>
new JsonRpcConnectionHandler(fileSearchServicePath, () =>
ctx.container.get(FileSearchService)
)
).inSingletonScope();
});
前端
browser/quick-file-open.ts
包含 UI 相關的主要業務邏輯:
import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify';
import { OpenerService, KeybindingRegistry, QuickAccessRegistry, QuickAccessProvider, CommonCommands } from '@theia/core/lib/browser';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import URI from '@theia/core/lib/common/uri';
import { FileSearchService, WHITESPACE_QUERY_SEPARATOR } from '../common/file-search-service';
import { CancellationToken, Command, nls } from '@theia/core/lib/common';
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
import { NavigationLocationService } from '@theia/editor/lib/browser/navigation/navigation-location-service';
import * as fuzzy from '@theia/core/shared/fuzzy';
import { MessageService } from '@theia/core/lib/common/message-service';
import { FileSystemPreferences } from '@theia/filesystem/lib/browser';
import { EditorOpenerOptions, EditorWidget, Position, Range } from '@theia/editor/lib/browser';
import { findMatches, QuickInputService, QuickPickItem, QuickPicks } from '@theia/core/lib/browser/quick-input/quick-input-service';
export const quickFileOpen = Command.toDefaultLocalizedCommand({
id: 'file-search.openFile',
category: CommonCommands.FILE_CATEGORY,
label: 'Open File...'
});
export interface FilterAndRange {
filter: string;
range?: Range;
}
// Supports patterns of <path><#|:><line><#|:|,><col?>
const LINE_COLON_PATTERN = /\s?[#:(](?:line "#:(")?(\d*)(?:[#:,](\d* "#:,"))?)?\s*$/;
export type FileQuickPickItem = QuickPickItem & { uri: URI };
@injectable()
export class QuickFileOpenService implements QuickAccessProvider {
static readonly PREFIX = '';
@inject(KeybindingRegistry)
protected readonly keybindingRegistry: KeybindingRegistry;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(OpenerService)
protected readonly openerService: OpenerService;
@inject(QuickInputService) @optional()
protected readonly quickInputService: QuickInputService;
@inject(QuickAccessRegistry)
protected readonly quickAccessRegistry: QuickAccessRegistry;
@inject(FileSearchService)
protected readonly fileSearchService: FileSearchService;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@inject(NavigationLocationService)
protected readonly navigationLocationService: NavigationLocationService;
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(FileSystemPreferences)
protected readonly fsPreferences: FileSystemPreferences;
registerQuickAccessProvider(): void {
this.quickAccessRegistry.registerQuickAccessProvider({
getInstance: () => this,
prefix: QuickFileOpenService.PREFIX,
placeholder: this.getPlaceHolder(),
helpEntries: [{ description: 'Open File', needsEditor: false }]
});
}
/**
* Whether to hide .gitignored (and other ignored) files.
*/
protected hideIgnoredFiles = true;
/**
* Whether the dialog is currently open.
*/
protected isOpen = false;
private updateIsOpen = true;
protected filterAndRangeDefault = { filter: '', range: undefined };
/**
* Tracks the user file search filter and location range e.g. fileFilter:line:column or fileFilter:line,column
*/
protected filterAndRange: FilterAndRange = this.filterAndRangeDefault;
/**
* The score constants when comparing file search results.
*/
private static readonly Scores = {
max: 1000, // represents the maximum score from fuzzy matching (Infinity).
exact: 500, // represents the score assigned to exact matching.
partial: 250 // represents the score assigned to partial matching.
};
@postConstruct()
protected init(): void {
// 省略
}
isEnabled(): boolean {
return this.workspaceService.opened;
}
open(): void {
// 省略
}
}
browser/quick-file-open-contribution.ts
註冊菜單,快捷鍵和 Command,實現觸發時的回調:
import { injectable, inject } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { QuickFileOpenService, quickFileOpen } from './quick-file-open';
import { CommandRegistry, CommandContribution, MenuContribution, MenuModelRegistry } from '@theia/core/lib/common';
import { KeybindingRegistry, KeybindingContribution, QuickAccessContribution } from '@theia/core/lib/browser';
import { EditorMainMenu } from '@theia/editor/lib/browser';
import { nls } from '@theia/core/lib/common/nls';
@injectable()
export class QuickFileOpenFrontendContribution implements QuickAccessContribution, CommandContribution, KeybindingContribution, MenuContribution {
@inject(QuickFileOpenService)
protected readonly quickFileOpenService: QuickFileOpenService;
registerCommands(commands: CommandRegistry): void {
commands.registerCommand(quickFileOpen, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
execute: (...args: any[]) => {
let fileURI: string | undefined;
if (args) {
[fileURI] = args;
}
if (fileURI) {
this.quickFileOpenService.openFile(new URI(fileURI));
} else {
this.quickFileOpenService.open();
}
}
});
}
registerKeybindings(keybindings: KeybindingRegistry): void {
keybindings.registerKeybinding({
command: quickFileOpen.id,
keybinding: 'ctrlcmd+p'
});
}
registerMenus(menus: MenuModelRegistry): void {
menus.registerMenuAction(EditorMainMenu.WORKSPACE_GROUP, {
commandId: quickFileOpen.id,
label: nls.localizeByDefault('Go to File...'),
order: '1',
});
}
registerQuickAccessProvider(): void {
this.quickFileOpenService.registerQuickAccessProvider();
}
}
browser/file-search-frontend-module.ts
與後端類似,完成實現到抽象的綁定。
-
通過 RPC 調用後端服務,實際上是一個透明的 Proxy;
-
上文中
QuickFileOpenFrontendContribution
分別實現了QuickAccessContribution
,CommandContribution
等多個 interface,所以這裏分別完成綁定。
import { ContainerModule, interfaces } from '@theia/core/shared/inversify';
import { CommandContribution, MenuContribution } from '@theia/core/lib/common';
import { WebSocketConnectionProvider, KeybindingContribution } from '@theia/core/lib/browser';
import { QuickFileOpenFrontendContribution } from './quick-file-open-contribution';
import { QuickFileOpenService } from './quick-file-open';
import { fileSearchServicePath, FileSearchService } from '../common/file-search-service';
import { QuickAccessContribution } from '@theia/core/lib/browser/quick-input/quick-access';
export default new ContainerModule((bind: interfaces.Bind) => {
bind(FileSearchService).toDynamicValue(ctx => {
const provider = ctx.container.get(WebSocketConnectionProvider);
return provider.createProxy<FileSearchService>(fileSearchServicePath);
}).inSingletonScope();
bind(QuickFileOpenFrontendContribution).toSelf().inSingletonScope();
[CommandContribution, KeybindingContribution, MenuContribution, QuickAccessContribution].forEach(serviceIdentifier =>
bind(serviceIdentifier).toService(QuickFileOpenFrontendContribution)
);
bind(QuickFileOpenService).toSelf().inSingletonScope();
});
注意:以下代碼引入的是 Interface 而非具體實現。此外,Interface 同時也充當了標識符。
import { KeybindingRegistry, KeybindingContribution, QuickAccessContribution } from '@theia/core/lib/browser';
在這個例子中,InversifyJS 是鏈接代碼模塊的基礎設施,即使處於同個 Package 中的不同模塊, 也是通過 DI 訪問的。
如何給正在行駛的汽車換輪子,並且不讓司機知道
因爲 IOC 的存在,只需要實現一個與原模塊接口相同的模塊,並且覆蓋其綁定,就可以便捷地改變應用的行爲。比如上文中的 QuickFileOpenService
,如果對其行爲不滿意,可以創建一個 file-search-patched 的 Extension, 在其中實現一個新的 MyQuickFileOpenService
,然後綁定到原抽象即可:
import { QuickFileOpenService } from '@theia/file-search/lib/browser/quick-file-open';
import { MyQuickFileOpenService } from './my-quick-file-open';
bind(QuickFileOpenService).to(MyQuickFileOpenService).inSingletonScope();
神奇的是,QuickFileOpenFrontendContribution
仍然可以正常工作,儘管它:
-
處於舊的 file-search 包中
-
依賴了
QuickFileOpenService
QuickFileOpenFrontendContribution
通過 @inject
獲取到我們提供的修改版 MyQuickFileOpenService
,並且和舊實現接口兼容,所以 QuickFileOpenFrontendContribution
不需要做任何事情。
如何保證所有使用 QuickFileOpenService 的地方都能獲取到的新版實現?
inject 發生於應用邏輯的運行時,而所有的 bind 都在應用入口就提前完成了。
只要 bind 的順序是確定的,那麼可供 inject 的內容就是完全確定的。
在 InversifyJS 的推薦的標準實踐中, bind 集中發生在 Inversify.config 中,順序當然是確定的。
在 Theia 中,因爲用戶通過新增 Package 的方式擴展功能,所以 bind 自然分散在各模塊中。但是 Theia 在構建時引入 Extension 的順序是確定的,然後在應用邏輯啓動前按順序先完成所有模塊的 bind,這樣也就保證了 inject 的結果是確定的。
基於 Theia 進行開發的體驗
直觀來說,Theia 的這套體系解決了:
-
如何在一個複雜系統中可靠地修改藏於深處的行爲
-
因爲單一職責和 IOC,這些實現相對扁平且便捷清晰,並不難找
-
Interface Driven 和 TS 提供了很強的約束 / 輔助
-
通過新 Package 去覆蓋內置模塊的行爲而非直接改動內置模塊
1、內置模塊和用戶擴展的功能有明確邊界,保障核心穩定
2、內置模塊就是天然的文檔
3、便於二次開發,用戶無需爲了修改核心行爲而從源頭 Fork 後修改 -
如何擴展一個複雜的系統
-
利用 IOC 實現的 Contribution 機制 [7]
對開發者來說,解決了在哪寫和怎麼寫這兩個核心問題後,出錯的可能就不多了。
筆者之前寫過幾個 Theia 的 Extension,有一些還涉及了深度的定製。在缺乏文檔的情況下,依靠 TS 和參照 Theia 官方 Package,就實現了功能。
雖然 Theia 在其它方面設計也很優秀,但是如果沒有基於 IOC 的這一套方法論,很難想象一個新手開發者經過簡單的學習後可以對這樣一個龐然大物進行二次開發,並且保證架構合理和功能可靠。
沒有銀彈: IOC 的問題
-
JavaScript 構建的應用不總是 OOP 的。
-
IOC 或者整個 SOLID 理念脫胎於 Java 等傳統技術生態,在開發習慣上存在差異。
-
高效使用 IOC 需要相當的學習成本。
-
在 Theia 中,也看到了不遵循 IOC 的實現。
如何決定哪些地方使用 IOC, 哪些地方又可以突破限制,是一個需要工程經驗和直覺的難題。 -
IOC 推崇的 interface driven 需要良好的預先設計。
-
在迭代快,需求快速變化的互聯網領域,這是一個很強的假設。
總結
涉及到設計模式的討論,總會有很多似是而非的觀點。
如何將設計模式落地到項目,真正地改善工程設計,是一個複雜的開放性問題,希望這篇文章可以給各位帶來一些啓發。
參考資料
[1]
Context – React: https://zh-hans.reactjs.org/docs/context.html#before-you-use-context
[2]
Modules: CommonJS modules | Node.js v19.4.0 Documentation: https://nodejs.org/api/modules.html#modules_cycles
[3]
ES6 Modules and Circular Dependency: https://stackoverflow.com/questions/46589957/es6-modules-and-circular-dependency
[4]
譯文:服務定位器 Service Locator 是一種反模式的設計: https://juejin.cn/post/7195850600503083066?
[5]
Eclipse Theia : https://theia-ide.org/
[6]
Theia 的內置模塊: https://github.com/eclipse-theia/theia/tree/master/packages
[7]
Contribution 機制: https://theia-ide.org/docs/frontend_application_contribution/
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/AvoFeACu4wCjQhYeLkP2bw