了不起的 IoC 與 DI

阿寶哥發表於2020-08-17

本文阿寶哥將從六個方面入手,全方位帶你一起探索麵向物件程式設計中 IoC(控制反轉)和 DI(依賴注入) 的設計思想。閱讀完本文,你將瞭解以下內容:

  • IoC 是什麼、IoC 能解決什麼問題;
  • IoC 與 DI 之間的關係、未使用 DI 框架和使用 DI 框架之間的區別;
  • DI 在 AngularJS/Angular 和 NestJS 中的應用;
  • 瞭解如何使用 TypeScript 實現一個 IoC 容器,並瞭解 裝飾器、反射 的相關知識。

一、背景概述

在介紹什麼是 IoC 容器之前,阿寶哥來舉一個日常工作中很常見的場景,即建立指定類的例項。最簡單的情形是該類沒有依賴其他類,但現實往往是殘酷的,我們在建立某個類的例項時,需要依賴不同類對應的例項。為了讓小夥伴們能夠更好地理解上述的內容,阿寶哥來舉一個例子。

一輛小汽車 ? 通常由 發動機、底盤、車身和電氣裝置 四大部分組成。汽車電氣裝置的內部構造很複雜,簡單起見,我們只考慮三個部分:發動機、底盤和車身。

(圖片來源:https://www.newkidscar.com/ve...

在現實生活中,要造輛車還是很困難的。而在軟體的世界中,這可難不倒我們。?是阿寶哥要造的車子,有木有很酷。

(圖片來源:https://pixabay.com/zh/illust...

在開始造車前,我們得先看一下 “圖紙”:

看完上面的 “圖紙”,我們馬上來開啟造車之旅。第一步我們先來定義車身類:

1.定義車身類

export default class Body { }

2.定義底盤類

export default class Chassis { }

3.定義引擎類

export default class Engine {
  start() {
    console.log("引擎發動了");
  }
}

4.定義汽車類

import Engine from './engine';
import Chassis from './chassis';
import Body from './body';

export default class Car {
    engine: Engine;
    chassis: Chassis;
    body: Body;

    constructor() {
      this.engine = new Engine();
      this.body = new Body();
      this.chassis = new Chassis();
    }

    run() {
      this.engine.start();
    }
}

一切已準備就緒,我們馬上來造一輛車:

const car = new Car(); // 阿寶哥造輛新車
car.run(); // 控制檯輸出:引擎發動了

現在雖然車已經可以啟動了,但卻存在以下問題:

  • 問題一:在造車的時候,你不能選擇配置。比如你想更換汽車引擎的話,按照目前的方案,是實現不了的。
  • 問題二:在汽車類內部,你需要在建構函式中手動去建立汽車的各個部件。

為了解決第一個問題,提供更靈活的方案,我們可以重構一下已定義的汽車類,具體如下:

export default class Car {
    body: Body;
    engine: Engine;
    chassis: Chassis;
  
    constructor(engine, body, chassis) {
      this.engine = engine;
      this.body = body;
      this.chassis = chassis;
    }

    run() {
      this.engine.start();
    }
}

重構完汽車類,我們來重新造輛新車:

const engine = new NewEngine();
const body = new Body();
const chassis = new Chassis();

const newCar = new Car(engine, body, chassis);
newCar.run();

此時我們已經解決了上面提到的第一個問題,要解決第二個問題我們要來了解一下 IoC(控制反轉)的概念。

二、IoC 是什麼

IoC(Inversion of Control),即 “控制反轉”。在開發中, IoC 意味著你設計好的物件交給容器控制,而不是使用傳統的方式,在物件內部直接控制。  

如何理解好 IoC 呢?理解好 IoC 的關鍵是要明確 “誰控制誰,控制什麼,為何是反轉,哪些方面反轉了”,我們來深入分析一下。  

  • 誰控制誰,控制什麼:在傳統的程式設計中,我們直接在物件內部通過 new 的方式建立物件,是程式主動建立依賴物件; 而 IoC 是有專門一個容器來建立這些物件,即由 IoC 容器控制物件的建立

    誰控制誰?當然是 IoC 容器控制了物件;控制什麼?主要是控制外部資源(依賴物件)獲取。

  • 為何是反轉了,哪些方面反轉了:有反轉就有正轉,傳統應用程式是由我們自己在程式中主動控制去獲取依賴物件,也就是正轉; 而反轉則是由容器來幫忙建立及注入依賴物件

    為何是反轉?因為由容器幫我們查詢及注入依賴物件,物件只是被動的接受依賴物件,所以是反轉了;哪些方面反轉了?依賴物件的獲取被反轉了。

三、IoC 能做什麼

IoC 不是一種技術,只是一種思想,是物件導向程式設計中的一種設計原則,可以用來減低計算機程式碼之間的耦合度。

傳統應用程式都是由我們在類內部主動建立依賴物件,從而導致類與類之間高耦合,難於測試; 有了 IoC 容器後,把建立和查詢依賴物件的控制權交給了容器,由容器注入組合物件,所以物件之間是鬆散耦合。 這樣也便於測試,利於功能複用,更重要的是使得程式的整個體系結構變得非常靈活。  

其實 IoC 對程式設計帶來的最大改變不是從程式碼上,而是思想上,發生了 “主從換位” 的變化。應用程式本來是老大,要獲取什麼資源都是主動出擊,但在 IoC 思想中,應用程式就變成被動了,被動的等待 IoC 容器來建立並注入它所需的資源了。    

四、IoC 與 DI 之間的關係

對於控制反轉來說,其中最常見的方式叫做 依賴注入,簡稱為 DI(Dependency Injection)。

元件之間的依賴關係由容器在執行期決定,形象的說,即由容器動態的將某個依賴關係注入到元件之中。 依賴注入的目的並非為軟體系統帶來更多功能,而是為了提升元件重用的頻率,併為系統搭建一個靈活、可擴充套件的平臺。

通過依賴注入機制,我們只需要通過簡單的配置,而無需任何程式碼就可指定目標需要的資源,完成自身的業務邏輯,而不需要關心具體的資源來自何處,由誰實現。

理解 DI 的關鍵是 “誰依賴了誰,為什麼需要依賴,誰注入了誰,注入了什麼”: 

  • 誰依賴了誰:當然是應用程式依賴 IoC 容器;
  • 為什麼需要依賴:應用程式需要 IoC 容器來提供物件需要的外部資源(包括物件、資源、常量資料);
  • 誰注入誰:很明顯是 IoC 容器注入應用程式依賴的物件;
  • 注入了什麼:注入某個物件所需的外部資源(包括物件、資源、常量資料)。

那麼 IoC 和 DI 有什麼關係?其實它們是同一個概念的不同角度描述,由於控制反轉的概念比較含糊(可能只是理解為容器控制物件這一個層面,很難讓人想到誰來維護依賴關係),所以 2004 年大師級人物 Martin Fowler 又給出了一個新的名字:“依賴注入”,相對 IoC 而言,“依賴注入” 明確描述了被注入物件依賴 IoC 容器配置依賴物件

總的來說, 控制反轉(Inversion of Control)是說建立物件的控制權發生轉移,以前建立物件的主動權和建立時機由應用程式把控,而現在這種權利轉交給 IoC 容器,它就是一個專門用來建立物件的工廠,你需要什麼物件,它就給你什麼物件。 有了 IoC 容器,依賴關係就改變了,原先的依賴關係就沒了,它們都依賴 IoC 容器了,通過 IoC 容器來建立它們之間的關係。 

前面介紹了那麼多的概念,現在我們來看一下未使用依賴注入框架和使用依賴注入框架之間有什麼明顯的區別。

4.1 未使用依賴注入框架

假設我們的服務 A 依賴於服務 B,即要使用服務 A 前,我們需要先建立服務 B。具體的流程如下圖所示:

從上圖可知,未使用依賴注入框架時,服務的使用者需要關心服務本身和其依賴的物件是如何建立的,且需要手動維護依賴關係。若服務本身需要依賴多個物件,這樣就會增加使用難度和後期的維護成本。對於上述的問題,我們可以考慮引入依賴注入框架。下面我們來看一下引入依賴注入框架,整體流程會發生什麼變化。

4.2 使用依賴注入框架

使用依賴注入框架之後,系統中的服務會統一註冊到 IoC 容器中,如果服務有依賴其他服務時,也需要對依賴進行宣告。當使用者需要使用特定的服務時,IoC 容器會負責該服務及其依賴物件的建立與管理工作。具體的流程如下圖所示:

到這裡我們已經介紹了 IoC 與 DI 的概念及特點,接下來我們來介紹 DI 的應用。

五、DI 的應用

DI 在前端和服務端都有相應的應用,比如在前端領域的代表是 AngularJSAngular,而在服務端領域是 Node.js 生態中比較出名的 NestJS。接下來阿寶哥將簡單介紹一下 DI 在 AngularJS/Angular 和 NestJS 中的應用。

5.1 DI 在 AngularJS 中的應用

在 AngularJS 中,依賴注入是其核心的特性之一。在 AngularJS 中宣告依賴項有 3 種方式:

// 方式一: 使用 $inject annotation 方式
let fn = function (a, b) {};
fn.$inject = ['a', 'b'];

// 方式二: 使用 array-style annotations 方式
let fn = ['a', 'b', function (a, b) {}];

// 方式三: 使用隱式宣告方式 
let fn = function (a, b) {}; // 不推薦

對於以上的程式碼,相信使用過 AngularJS 的小夥們都不會陌生。作為 AngularJS 核心功能特性的 DI 還是蠻強大的,但隨著 AngularJS 的普及和應用的複雜度不斷提高,AngularJS DI 系統的問題就暴露出來了。

這裡阿寶哥簡單介紹一下 AngularJS DI 系統存在的幾個問題:

  • 內部快取: AngularJS 應用程式中所有的依賴項都是單例,我們不能控制是否使用新的例項;
  • 名稱空間衝突: 在系統中我們使用字串來標識服務的名稱,假設我們在專案中已有一個 CarService,然而第三方庫中也引入了同樣的服務,這樣的話就容易出現混淆。

由於 AngularJS DI 存在以上的問題,所以在後續的 Angular 重新設計了新的 DI 系統。

5.2 DI 在 Angular 中的應用

以前面汽車的例子為例,我們可以把汽車、發動機、底盤和車身這些認為是一種 “服務”,所以它們會以服務提供者的形式註冊到 DI 系統中。為了能區分不同服務,我們需要使用不同的令牌(Token)來標識它們。接著我們會基於已註冊的服務提供者建立注入器物件。

之後,當我們需要獲取指定服務時,我們就可以通過該服務對應的令牌,從注入器物件中獲取令牌對應的依賴物件。上述的流程的具體如下圖所示:

好的,瞭解完上述的流程。下面我們來看一下如何使用 Angular 內建的 DI 系統來 “造車”。

5.2.1 car.ts
// car.ts
import { Injectable, ReflectiveInjector } from '@angular/core';

// 配置Provider
@Injectable({
  providedIn: 'root',
})
export class Body {}

@Injectable({
  providedIn: 'root',
})
export class Chassis {}

@Injectable({
  providedIn: 'root',
})
export class Engine {
  start() {
    console.log('引擎發動了');
  }
}

@Injectable()
export default class Car {
  // 使用構造注入方式注入依賴物件
  constructor(
    private engine: Engine,
    private body: Body,
    private chassis: Chassis
  ) {}

  run() {
    this.engine.start();
  }
}

const injector = ReflectiveInjector.resolveAndCreate([
  Car,
  Engine,
  Chassis,
  Body,
]);

const car = injector.get(Car);
car.run();

在以上程式碼中我們呼叫 ReflectiveInjector 物件的 resolveAndCreate 方法手動建立注入器,然後根據車輛對應的 Token 來獲取對應的依賴物件。通過觀察上述程式碼,你可以發現,我們已經不需要手動地管理和維護依賴物件了,這些 “髒活”、“累活” 已經交給注入器來處理了。

此外,如果要能正常獲取汽車物件,我們還需要在 app.module.ts 檔案中宣告 Car 對應 Provider,具體如下所示:

5.2.2 app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import Car, { Body, Chassis, Engine } from './car';

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule],
  providers: [{ provide: Car, deps: [Engine, Body, Chassis] }],
  bootstrap: [AppComponent],
})
export class AppModule {}

5.3 DI 在 NestJS 中的應用

NestJS 是構建高效,可擴充套件的 Node.js Web 應用程式的框架。 它使用現代的 JavaScript 或 TypeScript(保留與純 JavaScript 的相容性),並結合 OOP(物件導向程式設計),FP(函數語言程式設計)和FRP(函式響應式程式設計)的元素。

在底層,Nest 使用了 Express,但也提供了與其他各種庫的相容,例如 Fastify,可以方便地使用各種可用的第三方外掛。

近幾年,由於 Node.js,JavaScript 已經成為 Web 前端和後端應用程式的「通用語言」,從而產生了像 AngularReactVue 等令人耳目一新的專案,這些專案提高了開發人員的生產力,使得可以快速構建可測試的且可擴充套件的前端應用程式。 然而,在伺服器端,雖然有很多優秀的庫、helper 和 Node 工具,但是它們都沒有有效地解決主要問題 —— 架構。

NestJS 旨在提供一個開箱即用的應用程式體系結構,允許輕鬆建立高度可測試,可擴充套件,鬆散耦合且易於維護的應用程式。 在 NestJS 中也為我們開發者提供了依賴注入的功能,這裡我們以官網的示例來演示一下依賴注入的功能。

5.3.1 app.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}
5.3.2 app.controller.ts
import { Get, Controller, Render } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  @Render('index')
  render() {
    const message = this.appService.getHello();
    return { message };
  }
}

在 AppController 中,我們通過構造注入的方式注入了 AppService 物件,當使用者訪問首頁的時候,我們會呼叫 AppService 物件的 getHello 方法來獲取 'Hello World!' 訊息,並把訊息返回給使用者。當然為了保證依賴注入可以正常工作,我們還需要在 AppModule 中宣告 providers 和 controllers,具體操作如下:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

其實 DI 並不是 AngularJS/Angular 和 NestJS 所特有的,如果你想在其他專案中使用 DI/IoC 的功能特性,阿寶哥推薦你使用 InversifyJS,它是一個可用於 JavaScript 和 Node.js 應用,功能強大、輕量的 IoC 容器。

InversifyJS 感興趣的小夥伴可以自行了解一下,阿寶哥就不繼續展開介紹了。接下來,我們將進入本文的重點,即介紹如何使用 TypeScript 實現一個簡單的 IoC 容器,該容器實現的功能如下圖所示:

六、手寫 IoC 容器

為了讓大家能更好地理解 IoC 容器的實現程式碼,阿寶哥來介紹一些相關的前置知識。

6.1 裝飾器

如果你有使用過 Angular 或 NestJS,相信你對以下的程式碼不會陌生。

@Injectable()
export class HttpService {
  constructor(
    private httpClient: HttpClient
  ) {}
}

在以上程式碼中,我們使用了 Injectable 裝飾器。該裝飾器用於表示此類可以自動注入其依賴項。其中 @Injectable() 中的 @ 符號屬於語法糖。

裝飾器是一個包裝類,函式或方法併為其新增行為的函式。這對於定義與物件關聯的後設資料很有用。裝飾器有以下四種分類:

  • 類裝飾器(Class decorators)
  • 屬性裝飾器(Property decorators)
  • 方法裝飾器(Method decorators)
  • 引數裝飾器(Parameter decorators)

前面示例中使用的 @Injectable() 裝飾器,屬於類裝飾器。在該類裝飾器修飾的 HttpService 類中,我們通過構造注入的方式注入了用於處理 HTTP 請求的 HttpClient 依賴物件。

6.2 反射

@Injectable()
export class HttpService {
  constructor(
    private httpClient: HttpClient
  ) {}
}

以上程式碼若設定編譯的目標為 ES5,則會生成以下程式碼:

// 忽略__decorate函式等程式碼
var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") 
      return Reflect.metadata(k, v);
};

var HttpService = /** @class */ (function () {
    function HttpService(httpClient) {
      this.httpClient = httpClient;
    }
    var _a;
    HttpService = __decorate([
        Injectable(),
        __metadata("design:paramtypes", [typeof (_a = typeof HttpClient !== "undefined" && HttpClient)
           === "function" ? _a : Object])
    ], HttpService);
    return HttpService;
}());

通過觀察上述程式碼,你會發現 HttpService 建構函式中 httpClient 引數的型別被擦除了,這是因為 JavaScript 是弱型別語言。那麼如何在執行時,保證注入正確型別的依賴物件呢?這裡 TypeScript 使用 reflect-metadata 這個第三方庫來儲存額外的型別資訊。

reflect-metadata 這個庫提供了很多 API 用於操作元資訊,這裡我們只簡單介紹幾個常用的 API:

// define metadata on an object or property
Reflect.defineMetadata(metadataKey, metadataValue, target);
Reflect.defineMetadata(metadataKey, metadataValue, target, propertyKey);

// check for presence of a metadata key on the prototype chain of an object or property
let result = Reflect.hasMetadata(metadataKey, target);
let result = Reflect.hasMetadata(metadataKey, target, propertyKey);

// get metadata value of a metadata key on the prototype chain of an object or property
let result = Reflect.getMetadata(metadataKey, target);
let result = Reflect.getMetadata(metadataKey, target, propertyKey);

// delete metadata from an object or property
let result = Reflect.deleteMetadata(metadataKey, target);
let result = Reflect.deleteMetadata(metadataKey, target, propertyKey);

// apply metadata via a decorator to a constructor
@Reflect.metadata(metadataKey, metadataValue)
class C {
  // apply metadata via a decorator to a method (property)
  @Reflect.metadata(metadataKey, metadataValue)
  method() {
  }
}

對於上述的 API 只需簡單瞭解一下即可。在後續的內容中,我們將介紹具體如何使用。這裡我們需要注意以下兩個問題:

  • 對於類或函式,我們需要使用裝飾器來修飾它們,這樣才能儲存後設資料。
  • 只有類、列舉或原始資料型別能被記錄。介面和聯合型別作為 “物件” 出現。這是因為這些型別在編譯後完全消失,而類卻一直存在。

6.3 定義 Token 和 Provider

瞭解完裝飾器與反射相關的基礎知識,接下來我們來開始實現 IoC 容器。我們的 IoC 容器將使用兩個主要的概念:令牌(Token)和提供者(Provider)。令牌是 IoC 容器所要建立物件的識別符號,而提供者用於描述如何建立這些物件。

IoC 容器最小的公共介面如下所示:

export class Container {
  addProvider<T>(provider: Provider<T>) {} // TODO
  inject<T>(type: Token<T>): T {} // TODO
}

接下來我們先來定義 Token:

// type.ts
interface Type<T> extends Function {
  new (...args: any[]): T;
}

// provider.ts
class InjectionToken {
  constructor(public injectionIdentifier: string) {}
}

type Token<T> = Type<T> | InjectionToken;

Token 型別是一個聯合型別,既可以是一個函式型別也可以是 InjectionToken 型別。AngularJS 中使用字串作為 Token,在某些情況下,可能會導致衝突。因此,為了解決這個問題,我們定義了 InjectionToken 類,來避免出現命名衝突問題。

定義完 Token 型別,接下來我們來定義三種不同型別的 Provider:

  • ClassProvider:提供一個類,用於建立依賴物件;
  • ValueProvider:提供一個已存在的值,作為依賴物件;
  • FactoryProvider:提供一個工廠方法,用於建立依賴物件。
// provider.ts
export type Factory<T> = () => T;

export interface BaseProvider<T> {
  provide: Token<T>;
}

export interface ClassProvider<T> extends BaseProvider<T> {
  provide: Token<T>;
  useClass: Type<T>;
}

export interface ValueProvider<T> extends BaseProvider<T> {
  provide: Token<T>;
  useValue: T;
}

export interface FactoryProvider<T> extends BaseProvider<T> {
  provide: Token<T>;
  useFactory: Factory<T>;
}

export type Provider<T> =
  | ClassProvider<T>
  | ValueProvider<T>
  | FactoryProvider<T>;

為了更方便的區分這三種不同型別的 Provider,我們自定義了三個型別守衛函式:

// provider.ts
export function isClassProvider<T>(
  provider: BaseProvider<T>
): provider is ClassProvider<T> {
  return (provider as any).useClass !== undefined;
}

export function isValueProvider<T>(
  provider: BaseProvider<T>
): provider is ValueProvider<T> {
  return (provider as any).useValue !== undefined;
}

export function isFactoryProvider<T>(
  provider: BaseProvider<T>
): provider is FactoryProvider<T> {
  return (provider as any).useFactory !== undefined;
}

6.4 定義裝飾器

在前面我們已經提過了,對於類或函式,我們需要使用裝飾器來修飾它們,這樣才能儲存後設資料。因此,接下來我們來分別建立 InjectableInject 裝飾器。

6.4.1 Injectable 裝飾器

Injectable 裝飾器用於表示此類可以自動注入其依賴項,該裝飾器屬於類裝飾器。在 TypeScript 中,類裝飾器的宣告如下:

declare type ClassDecorator = <TFunction extends Function>(target: TFunction) 
  => TFunction | void;

類裝飾器顧名思義,就是用來裝飾類的。它接收一個引數:target: TFunction,表示被裝飾的類。下面我們來看一下 Injectable 裝飾器的具體實現:

// Injectable.ts
import { Type } from "./type";
import "reflect-metadata";

const INJECTABLE_METADATA_KEY = Symbol("INJECTABLE_KEY");

export function Injectable() {
  return function(target: any) {
    Reflect.defineMetadata(INJECTABLE_METADATA_KEY, true, target);
    return target;
  };
}

在以上程式碼中,當呼叫完 Injectable 函式之後,會返回一個新的函式。在新的函式中,我們使用 reflect-metadata 這個庫提供的 defineMetadata API 來儲存元資訊,其中 defineMetadata API 的使用方式如下所示:

// define metadata on an object or property
Reflect.defineMetadata(metadataKey, metadataValue, target);
Reflect.defineMetadata(metadataKey, metadataValue, target, propertyKey);

Injectable 類裝飾器使用方式也簡單,只需要在被裝飾類的上方使用 @Injectable() 語法糖就可以應用該裝飾器:

@Injectable()
export class HttpService {
  constructor(
    private httpClient: HttpClient
  ) {}
}

在以上示例中,我們注入的是 Type 型別的 HttpClient 物件。但在實際的專案中,往往會比較複雜。除了需要注入 Type 型別的依賴物件之外,我們還可能會注入其他型別的依賴物件,比如我們希望在 HttpService 服務中注入遠端伺服器的 API 地址。針對這種情形,我們需要使用 Inject 裝飾器。

6.4.2 Inject 裝飾器

接下來我們來建立 Inject 裝飾器,該裝飾器屬於引數裝飾器。在 TypeScript 中,引數裝飾器的宣告如下:

declare type ParameterDecorator = (target: Object, 
  propertyKey: string | symbol, parameterIndex: number ) => void

引數裝飾器顧名思義,是用來裝飾函式引數,它接收三個引數:

  • target: Object —— 被裝飾的類;
  • propertyKey: string | symbol —— 方法名;
  • parameterIndex: number —— 方法中引數的索引值。

下面我們來看一下 Inject 裝飾器的具體實現:

// Inject.ts
import { Token } from './provider';
import 'reflect-metadata';

const INJECT_METADATA_KEY = Symbol('INJECT_KEY');

export function Inject(token: Token<any>) {
  return function(target: any, _: string | symbol, index: number) {
    Reflect.defineMetadata(INJECT_METADATA_KEY, token, target, `index-${index}`);
    return target;
  };
}

在以上程式碼中,當呼叫完 Inject 函式之後,會返回一個新的函式。在新的函式中,我們使用 reflect-metadata 這個庫提供的 defineMetadata API 來儲存引數相關的元資訊。這裡是儲存 index 索引資訊和 Token 資訊。

定義完 Inject 裝飾器,我們就可以利用它來注入我們前面所提到的遠端伺服器的 API 地址,具體的使用方式如下:

const API_URL = new InjectionToken('apiUrl');

@Injectable()
export class HttpService {
  constructor(
    private httpClient: HttpClient,
    @Inject(API_URL) private apiUrl: string
  ) {}
}

6.5 實現 IoC 容器

目前為止,我們已經定義了 Token、Provider、Injectable 和 Inject 裝飾器。接下來我們來實現前面所提到的 IoC 容器的 API:

export class Container {
  addProvider<T>(provider: Provider<T>) {} // TODO
  inject<T>(type: Token<T>): T {} // TODO
}
6.5.1 實現 addProvider 方法

addProvider() 方法的實現很簡單,我們使用 Map 來儲存 Token 與 Provider 之間的關係:

export class Container {
  private providers = new Map<Token<any>, Provider<any>>();

  addProvider<T>(provider: Provider<T>) {
    this.assertInjectableIfClassProvider(provider);
    this.providers.set(provider.provide, provider);
  }
}

在 addProvider() 方法內部除了把 Token 與 Provider 的對應資訊儲存到 providers 物件中之外,我們定義了一個 assertInjectableIfClassProvider 方法,用於確保新增的 ClassProvider 是可注入的。該方法的具體實現如下:

private assertInjectableIfClassProvider<T>(provider: Provider<T>) {
  if (isClassProvider(provider) && !isInjectable(provider.useClass)) {
    throw new Error(
        `Cannot provide ${this.getTokenName(
          provider.provide
     )} using class ${this.getTokenName(
          provider.useClass
     )}, ${this.getTokenName(provider.useClass)} isn't injectable`
   );
  }
}

在 assertInjectableIfClassProvider 方法體中,我們使用了前面已經介紹的 isClassProvider 型別守衛函式來判斷是否為 ClassProvider,如果是的話,會判斷該 ClassProvider 是否為可注入的,具體使用的是 isInjectable 函式,該函式的定義如下:

export function isInjectable<T>(target: Type<T>) {
  return Reflect.getMetadata(INJECTABLE_METADATA_KEY, target) === true;
}

在 isInjectable 函式中,我們使用 reflect-metadata 這個庫提供的 getMetadata API 來獲取儲存在類中的元資訊。為了更好地理解以上程式碼,我們來回顧一下前面 Injectable 裝飾器:

const INJECTABLE_METADATA_KEY = Symbol("INJECTABLE_KEY");

export function Injectable() {
  return function(target: any) {
    Reflect.defineMetadata(INJECTABLE_METADATA_KEY, true, target);
    return target;
  };
}

如果新增的 Provider 是 ClassProvider,但 Provider 對應的類是不可注入的,則會丟擲異常。為了讓異常訊息更加友好,也更加直觀。我們定義了一個 getTokenName 方法來獲取 Token 對應的名稱:

private getTokenName<T>(token: Token<T>) {
  return token instanceof InjectionToken
    ? token.injectionIdentifier
    : token.name;
}

現在我們已經實現了 Container 類的 addProvider 方法,這時我們就可以使用它來新增三種不同型別的 Provider:

const container = new Container();
const input = { x: 200 };

class BasicClass {}
// 註冊ClassProvider
container.addProvider({ provide: BasicClass, useClass:  BasicClass});
// 註冊ValueProvider
container.addProvider({ provide: BasicClass, useValue: input });
// 註冊FactoryProvider
container.addProvider({ provide: BasicClass, useFactory: () => input });

需要注意的是,以上示例中註冊三種不同型別的 Provider 使用的是同一個 Token 僅是為了演示而已。下面我們來實現 Container 類中核心的 inject 方法。

6.5.2 實現 inject 方法

在看 inject 方法的具體實現之前,我們先來看一下該方法所實現的功能:

const container = new Container();
const input = { x: 200 };

container.addProvider({ provide: BasicClass, useValue: input });
const output = container.inject(BasicClass);
expect(input).toBe(output); // true

觀察以上的測試用例可知,Container 類中 inject 方法所實現的功能就是根據 Token 獲取與之對應的物件。在前面實現的 addProvider 方法中,我們把 Token 和該 Token 對應的 Provider 儲存在 providers Map 物件中。所以在 inject 方法中,我們可以先從 providers 物件中獲取該 Token 對應的 Provider 物件,然後在根據不同型別的 Provider 來獲取其對應的物件。

好的,下面我們來看一下 inject 方法的具體實現:

inject<T>(type: Token<T>): T {
  let provider = this.providers.get(type);
  // 處理使用Injectable裝飾器修飾的類
  if (provider === undefined && !(type instanceof InjectionToken)) {
    provider = { provide: type, useClass: type };
    this.assertInjectableIfClassProvider(provider);
  }
  return this.injectWithProvider(type, provider);
}

在以上程式碼中,除了處理正常的流程之外。我們還處理一個特殊的場景,即沒有使用 addProvider 方法註冊 Provider,而是使用 Injectable 裝飾器來裝飾某個類。對於這個特殊場景,我們會根據傳入的 type 引數來建立一個 provider 物件,然後進一步呼叫 injectWithProvider 方法來建立物件,該方法的具體實現如下:

private injectWithProvider<T>(type: Token<T>, provider?: Provider<T>): T {
  if (provider === undefined) {
    throw new Error(`No provider for type ${this.getTokenName(type)}`);
  }
  if (isClassProvider(provider)) {
    return this.injectClass(provider as ClassProvider<T>);
  } else if (isValueProvider(provider)) {
    return this.injectValue(provider as ValueProvider<T>);
  } else {
    return this.injectFactory(provider as FactoryProvider<T>);
  }
 }

injectWithProvider 方法內部,我們會使用前面定義的用於區分三種不同型別 Provider 的型別守衛函式來處理不同的 Provider。這裡我們先來看一下最簡單 ValueProvider,當發現注入的是 ValueProvider 型別時,則會呼叫 injectValue 方法來獲取其對應的物件:

// { provide: API_URL, useValue: 'https://www.semlinker.com/' }
private injectValue<T>(valueProvider: ValueProvider<T>): T {
  return valueProvider.useValue;
}

接著我們來看如何處理 FactoryProvider 型別的 Provider,如果發現是 FactoryProvider 型別時,則會呼叫 injectFactory 方法來獲取其對應的物件,該方法的實現也很簡單:

// const input = { x: 200 };
// container.addProvider({ provide: BasicClass, useFactory: () => input });
private injectFactory<T>(valueProvider: FactoryProvider<T>): T {
  return valueProvider.useFactory();
}

最後我們來分析一下如何處理 ClassProvider,對於 ClassProvider 類說,通過 Provider 物件的 useClass 屬性,我們就可以直接獲取到類對應的建構函式。最簡單的情形是該類沒有依賴其他物件,但在大多數場景下,即將例項化的服務類是會依賴其他的物件的。所以在例項化服務類前,我們需要構造其依賴的物件。

那麼現在問題來了,怎麼獲取類所依賴的物件呢?我們先來分析一下以下程式碼:

const API_URL = new InjectionToken('apiUrl');

@Injectable()
export class HttpService {
  constructor(
    private httpClient: HttpClient,
    @Inject(API_URL) private apiUrl: string
  ) {}
}

以上程式碼若設定編譯的目標為 ES5,則會生成以下程式碼:

// 已省略__decorate函式的定義
var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};

var __param = (this && this.__param) || function (paramIndex, decorator) {
    return function (target, key) { decorator(target, key, paramIndex); }
};

var HttpService = /** @class */ (function () {
    function HttpService(httpClient, apiUrl) {
        this.httpClient = httpClient;
        this.apiUrl = apiUrl;
    }
    var _a;
    HttpService = __decorate([
        Injectable(),
        __param(1, Inject(API_URL)),
        __metadata("design:paramtypes", [typeof (_a = typeof HttpClient !== "undefined" && HttpClient) 
          === "function" ? _a : Object, String])
    ], HttpService);
    return HttpService;
}());

觀察以上的程式碼會不會覺得有點暈?不要著急,阿寶哥會逐一分析 HttpService 中的兩個引數。首先我們先來分析 apiUrl 引數:

在圖中我們可以很清楚地看到,API_URL 對應的 Token 最終會通過 Reflect.defineMetadata API 進行儲存,所使用的 Key 是 Symbol('INJECT_KEY')。而對於另一個引數即 httpClient,它使用的 Key 是 "design:paramtypes",它用於修飾目標物件方法的引數型別。

除了 "design:paramtypes" 之外,還有其他的 metadataKey,比如 design:typedesign:returntype,它們分別用於修飾目標物件的型別和修飾目標物件方法返回值的型別。

由上圖可知,HttpService 建構函式的引數型別最終會使用 Reflect.metadata API 進行儲存。瞭解完上述的知識,接下來我們來定義一個 getInjectedParams 方法,用於獲取類建構函式中宣告的依賴物件,該方法的具體實現如下:

type InjectableParam = Type<any>;
const REFLECT_PARAMS = "design:paramtypes";

private getInjectedParams<T>(target: Type<T>) {
  // 獲取引數的型別
  const argTypes = Reflect.getMetadata(REFLECT_PARAMS, target) as (
      | InjectableParam
      | undefined
  )[];
  if (argTypes === undefined) {
      return [];
  }
  return argTypes.map((argType, index) => {
    // The reflect-metadata API fails on circular dependencies, and will return undefined
    // for the argument instead.
    if (argType === undefined) {
      throw new Error(
        `Injection error. Recursive dependency detected in constructor for type ${target.name} 
           with parameter at index ${index}`
      );
    }
    const overrideToken = getInjectionToken(target, index);
    const actualToken = overrideToken === undefined ? argType : overrideToken;
    let provider = this.providers.get(actualToken);
    return this.injectWithProvider(actualToken, provider);
  });
}

因為我們的 Token 的型別是 Type<T> | InjectionToken 聯合型別,所以在 getInjectedParams 方法中我們也要考慮 InjectionToken 的情形,因此我們定義了一個 getInjectionToken 方法來獲取使用 @Inject 裝飾器註冊的 Token,該方法的實現很簡單:

export function getInjectionToken(target: any, index: number) {
  return Reflect.getMetadata(INJECT_METADATA_KEY, target, `index-${index}`) as Token<any> | undefined;
}

現在我們已經可以獲取類建構函式中所依賴的物件,基於前面定義的 getInjectedParams 方法,我們就來定義一個 injectClass 方法,用來例項化 ClassProvider 所註冊的類。

// { provide: HttpClient, useClass: HttpClient }
private injectClass<T>(classProvider: ClassProvider<T>): T {
  const target = classProvider.useClass;
  const params = this.getInjectedParams(target);
  return Reflect.construct(target, params);
}

這時 IoC 容器中定義的兩個方法都已經實現了,我們來看一下 IoC 容器的完整程式碼:

// container.ts
type InjectableParam = Type<any>;

const REFLECT_PARAMS = "design:paramtypes";

export class Container {
  private providers = new Map<Token<any>, Provider<any>>();

  addProvider<T>(provider: Provider<T>) {
    this.assertInjectableIfClassProvider(provider);
    this.providers.set(provider.provide, provider);
  }

  inject<T>(type: Token<T>): T {
    let provider = this.providers.get(type);
    if (provider === undefined && !(type instanceof InjectionToken)) {
      provider = { provide: type, useClass: type };
      this.assertInjectableIfClassProvider(provider);
    }
    return this.injectWithProvider(type, provider);
  }

  private injectWithProvider<T>(type: Token<T>, provider?: Provider<T>): T {
    if (provider === undefined) {
      throw new Error(`No provider for type ${this.getTokenName(type)}`);
    }
    if (isClassProvider(provider)) {
      return this.injectClass(provider as ClassProvider<T>);
    } else if (isValueProvider(provider)) {
      return this.injectValue(provider as ValueProvider<T>);
    } else {
      // Factory provider by process of elimination
      return this.injectFactory(provider as FactoryProvider<T>);
    }
  }

  private assertInjectableIfClassProvider<T>(provider: Provider<T>) {
    if (isClassProvider(provider) && !isInjectable(provider.useClass)) {
      throw new Error(
        `Cannot provide ${this.getTokenName(
          provider.provide
        )} using class ${this.getTokenName(
          provider.useClass
        )}, ${this.getTokenName(provider.useClass)} isn't injectable`
      );
    }
  }

  private injectClass<T>(classProvider: ClassProvider<T>): T {
    const target = classProvider.useClass;
    const params = this.getInjectedParams(target);
    return Reflect.construct(target, params);
  }

  private injectValue<T>(valueProvider: ValueProvider<T>): T {
    return valueProvider.useValue;
  }

  private injectFactory<T>(valueProvider: FactoryProvider<T>): T {
    return valueProvider.useFactory();
  }

  private getInjectedParams<T>(target: Type<T>) {
    const argTypes = Reflect.getMetadata(REFLECT_PARAMS, target) as (
      | InjectableParam
      | undefined
    )[];
    if (argTypes === undefined) {
      return [];
    }
    return argTypes.map((argType, index) => {
      // The reflect-metadata API fails on circular dependencies, and will return undefined
      // for the argument instead.
      if (argType === undefined) {
        throw new Error(
          `Injection error. Recursive dependency detected in constructor for type ${target.name} 
             with parameter at index ${index}`
        );
      }
      const overrideToken = getInjectionToken(target, index);
      const actualToken = overrideToken === undefined ? argType : overrideToken;
      let provider = this.providers.get(actualToken);
      return this.injectWithProvider(actualToken, provider);
    });
  }

  private getTokenName<T>(token: Token<T>) {
    return token instanceof InjectionToken
      ? token.injectionIdentifier
      : token.name;
  }
}

最後我們來簡單測試一下我們前面開發的 IoC 容器,具體的測試程式碼如下所示:

// container.test.ts
import { Container } from "./container";
import { Injectable } from "./injectable";
import { Inject } from "./inject";
import { InjectionToken } from "./provider";

const API_URL = new InjectionToken("apiUrl");

@Injectable()
class HttpClient {}

@Injectable()
class HttpService {
  constructor(
    private httpClient: HttpClient,
    @Inject(API_URL) private apiUrl: string
  ) {}
}

const container = new Container();

container.addProvider({
  provide: API_URL,
  useValue: "https://www.semlinker.com/",
});

container.addProvider({ provide: HttpClient, useClass: HttpClient });
container.addProvider({ provide: HttpService, useClass: HttpService });

const httpService = container.inject(HttpService);
console.dir(httpService);

以上程式碼成功執行後,控制檯會輸出以下結果:

HttpService {
  httpClient: HttpClient {},
  apiUrl: 'https://www.semlinker.com/' }

很明顯該結果正是我們所期望的,這表示我們 IoC 容器已經可以正常工作了。當然在實際專案中,一個成熟的 IoC 容器還要考慮很多東西,如果小夥伴想在專案中使用的話,阿寶哥建議可以考慮使用 InversifyJS 這個庫。

若需要獲取完整 IoC 容器原始碼的話,可在 全棧修仙之路 公眾號回覆 ioc 關鍵字,即可獲取。

七、參考資源

八、推薦閱讀

相關文章