天天看點

教你如何建構自己的依賴注入工具

作者:閃念基因

閱讀前準備

在閱讀這篇文檔之前,你可以先了解一下這些知識,友善跟上文檔裡面的内容:

  • 概念知識:控制反轉、依賴注入、依賴倒置;
  • 技術知識:裝飾器 Decorator、反射 Reflect;
  • TSyringe 的定義:Token、Provider https://github.com/microsoft/tsyringe#injection-token。

本篇文章所有實作的實踐都寫到了 codesandbox 裡面,感興趣可以點進去看看源碼 https://codesandbox.io/s/di-playground-oz2j9。

什麼是依賴注入

簡單的例子

我們這裡來實作一個簡單的例子來解釋什麼是依賴注入:學生從家裡駕駛交通工具去上學。

class Transportation {
  drive() {
    console.log('driving by transportation')
  }
}

class Student {
  transportation = new Transportation()
  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}
           

那麼在現實的生活中,比較遠的學生會選擇開車去上學,近的的學生選擇騎自行車去上學,那麼上面的代碼我們可能會繼續抽象,寫成下面的樣子:

class Car extends Transportation {
  drive() {
    console.log('driving by car')
  }
}

class FarStudent extends Student {
  transportation = new Car()
}
           

這樣的确滿足了比較遠的學生駕車去上學的需求,但是這裡有一個問題,學生也有自己的具體選擇和偏好,有的人喜歡寶馬、有的人喜歡特斯拉。我們可以為了解決這樣的問題繼續使用繼承的方式,繼續抽象,得到喜歡寶馬的有錢學生、喜歡特斯拉的有錢學生。

大家估計也會覺得這樣寫代碼完全不可行,耦合度太高,每個類型的學生在抽象的時候都直接和一個具體的交通工具綁定在一起。學生擁有的交通工具并不是這個學生建立出來決定的,而是根據他的家庭狀況、喜好确定他使用什麼樣子的交通工具去上學;甚至家裡可能有很多的車,每天看心情開車去上學。

那麼為了降低耦合性,根據具體的狀态和條件進行依賴的建立,就要說到下面的模式了。

控制反轉

控制反轉(Inversion of Control,縮寫為 IoC)是一種設計原則,通過反轉程式邏輯來降低代碼之間的耦合性。

控制反轉容器(IoC 容器)是某一種具體的工具或者架構,用來執行從内部程式反轉出來的代碼邏輯,進而提高代碼的複用性和可讀性。我們常常用到的 DI 工具,就扮演了 IoC 容器的角色,連接配接着所有的對象和其依賴。

教你如何建構自己的依賴注入工具
參考 Martin Fowler 關于控制反轉和依賴注入的文章 https://martinfowler.com/articles/injection.html

依賴注入

依賴注入是控制反轉的一種具體的實作,通過放棄程式内部對象生命建立的控制,由外部去建立并注入依賴的對象。

依賴注入的方法主要是以下四種:

  • 基于接口。實作特定接口以供外部容器注入所依賴類型的對象。
  • 基于 set 方法。實作特定屬性的 public set 方法,來讓外部容器調用傳入所依賴類型的對象。
  • 基于構造函數。實作特定參數的構造函數,在建立對象時傳入所依賴類型的對象。
  • 基于注解,在私有變量前加類似 “@Inject” 的注解,讓工具或者架構能夠分析依賴,自動注入依賴。

前兩種方法不會在前端常用的 DI 工具中用到,這裡主要介紹後面兩種。

如果從構造函數傳遞,就可以寫成這樣:

class Student {
  constructor(transportation: Transportation) {
    this.transportation = transportation
  }

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

class Car extends Transportation {
  drive() {
    console.log('driving by car')
  }
}

const car = new Car()
const student = new Student(car)
student.gotoSchool()
           

在沒有工具的情況情況下,基于構造函數的定義方式是可以手寫出來的,隻不過這裡的寫法雖然是屬于依賴注入了,但是過多繁瑣的手動執行個體化會是研發人員的噩夢;特别是 Car 對象本身可能會依賴着不同的輪胎、不同的發動機的執行個體。

依賴注入的工具

依賴注入的的工具是 IoC 容器的一種,通過自動分析依賴,然後在工具内完成了本來手動執行的對象執行個體化的過程。

@Injectable()
class Student {
  constructor(transportation: Transportation) {
    this.transportation = transportation
  }

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

const injector = new Injector()
const student = injector.create(Student)
student.gotoSchool()
           

如果是使用注解的方式,就可以寫成這樣:

@Injectable()
class Student {
  @Inject()
  private transportation: Transportation

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

const injector = new Injector()
const student = injector.create(Student)
student.gotoSchool()
           

兩者的差別在于對工具的依賴性,從構造函數定義的 class 即使用手動建立仍然能正常運作,但是以注解的方式定義的 class 就隻能通過工具進行建立,無法通過手動建立。

依賴倒置

軟體設計模式六大原則之一,依賴倒置原則,英文縮寫 DIP,全稱 Dependence Inversion Principle。

高層子產品不應該依賴低層子產品,兩者都應該依賴其抽象;抽象不應該依賴細節,細節應該依賴抽象。

在擁有 loC 容器的場景下,對象建立的控制不在我們手上,而是在工具或者架構内部建立,一個學生在上學時開的是寶馬還是特斯拉,是由運作環境決定的。而在 JS 的實際運作環境中,遵循的更是鴨子模型,不管是不是交通工具,隻要能開,什麼車都可以。

是以我們可以把代碼改成下面這樣,依賴的是一個抽象,而不是某個具體的實作。

// src/transportations/car.ts
class Car {
   drive() {
     console.log('driving by car')
   }
}

// src/students/student.ts
interface Drivable {
   drive(): void
}

class Student {
  constructor(transportation: Drivable) {
    this.transportation = transportation
  }

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}
           

為什麼依賴抽象而不依賴實作那麼重要呢?在複雜的架構裡面,合理的抽象能夠幫助我們在保持簡潔,在領域邊界内提高内聚,不同的邊界降低耦合,指導項目進行合理的目錄結構劃分。在實作 SSR 和 CSR 的複合能力的時候,在用戶端運作的時候,需要通過 HTTP 去請求資料,而在服務端,我們隻需要直接調用 DB 或者 RPC 就能夠擷取資料。

我們可以抽象一下請求資料的對象,定義一個抽象的 Service,在用戶端和服務端分别實作同樣的函數,去請求資料:

interface IUserService {
  getUserInfo(): Promise<{ name: string }>
}

class Page {
  constructor(userService: IUserService) {
    this. userService = userService
  }

  async render() {
    const user = await this.userService. getUserInfo()
    return `<h1> My name is ${user.name}. </h1>`
  }
}
           

在網頁上這麼用:

class WebUserService implements IUserService {
  async getUserInfo() {
    return fetch('/api/users/me')
  }
}

const userService = new WebUserService()
const page = new Page(userService)

page.render().then((html) => {
  document.body.innerHTML = html
})
           

在服務端這麼用:

class ServerUserService implements IUserService {
  async getUserInfo() {
    return db.query('...')
  }
}

class HomeController {
  async renderHome() {
    const userService = new ServerUserService()
    const page = new Page(userService)
    ctx.body = await page.render()
  }
}
           

可測試性

除了實作了軟體工程裡面最重要的高内聚低耦合,依賴注入還能夠提高代碼的可測試性。

一般的測試我們可能會這樣寫:

class Car extends Transportation {
  drive() {
    console.log('driving by car')
  }
}

class Student {
  this.transportation = new Car()

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

it('goto school successfully', async () => {
  const driveStub = sinon.sub(Car.prototype, 'drive').resolve()
  const student = new Student()
  student. gotoSchool()
  expect(driveStub.callCount).toBe(1)
})
           

這樣的單元測試雖然能夠正常運作,但是由于 Stub 的函數是入侵在 prototype 上面,這是一個全局的副作用影響,會讓其他單元測試如果運作到這裡受到影響。如果在測試結束的時候,清除了 sinon 的副作用,倒是不會影響串行的單元測試,但是就無法進行并行測試了。而使用了依賴注入的方法,就不會有這些問題了。

class Student {
  constructor(transportation: Transportation) {
    this.transportation = transportation
  }

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

it('goto school successfully', async () => {
  const driveFn = sinon.fake()
  const student = new Student({
    { drive: driveFn },
  })
  student.gotoSchool()
  expect(driveFn.callCount).toBe(1)
})
           

循環依賴

在擁有循環依賴的情況下,一般我們無法将對象建立出來,比如下面的這兩個類定義。雖然從邏輯上面需要避免這樣的情況發生,但是很難說代碼寫的過程中不會完全用到這樣的情況。

export class Foo {
  constructor(public bar: Bar) {}
}

export class Bar {
  constructor(public foo: Foo) {}
}
           

有了 DI 之後,通過 IoC 的建立控制反轉,一開始建立對象的時候不會真正建立執行個體,而是給一個 proxy 對象,當這個對象真正被使用的時候才會建立執行個體,然後解決循環依賴的問題。至于為什麼這裡的 Lazy 裝飾器是一定要存在的,等到我們後面實作的時候再解釋。

@Injectable()
export class Foo {
  constructor(public @Lazy(() => Bar) bar: Bar) {}
}

@Injectable()
export class Bar {
  constructor(public @Lazy(() => Foo) foo: Foo) {}
}
           

一些缺點

當然,使用 DI 工具也不是完全沒有壞處,比較明顯的壞處包括:

  • 無法控制的生命周期,因為對象的執行個體化在 IoC 裡面,是以對象什麼時候建立出來并不完全由目前程式說了算。是以這要求我們在使用工具或者架構之前,需要非常了解其中的原理,最好是讀過裡面的源碼。
  • 當依賴出錯的時候,比較難定位到是哪個在出錯。因為依賴是注入進來的,是以當依賴出錯的時候隻能通過經驗去分析,或者現場 debug 住,一點點進行深入調試,才能知道是哪個地方的内容出錯了。這對 Debug 能力,或者項目的整體把控能力要求很高。
  • 代碼無法連貫閱讀。如果是依賴實作,你從入口一直往下,就能看到整個代碼執行樹;而如果是依賴抽象,具體的實作和實作之間的連接配接關系是分開的,通常需要文檔才能夠看到項目全貌。

社群的工具

從 github 的 DI 分類可以檢視到一些流行的 DI 工具 https://github.com/topics/dependency-injection?l=typescript。

InversifyJS(https://github.com/inversify/InversifyJS): 能力強大的,依賴注入的工具,比較嚴格的依賴抽象的執行方式;雖然嚴格的申明很好,但是寫起來很重複和啰嗦。

TSyringe(https://github.com/microsoft/tsyringe): 簡單易用,繼承自 angular 的抽象定義,比較值得學習。

實作基本的能力

為了實作基本的 DI 工具的能力,去接管對象建立,實作依賴倒置和依賴注入,我們主要實作三個能力:

  • 依賴分析:為了能夠建立對象,需要讓工具知道有那些依賴。
  • 注冊建立器:為了支援不同類型的執行個體建立方式,支援直接依賴、支援抽象依賴、支援工廠建立;不同的上下文注冊不同的實作。
  • 建立執行個體:利用建立器将執行個體建立出來,支援單例模式,多例模式。

假定我們的最終形态是這樣的執行代碼,如果要想要最終的結果的話,可以點選線上編碼的連結 https://codesandbox.io/s/di-playground-oz2j9 。

@Injectable()
class Transportation {
  drive() {
    console.log('driving by transportation')
  }
}

@Injectable()
class Student {
  constructor(
    private transportation: Transportation,
  ) {}

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

const container = new Container()
const student = container.resolve(Student)
student.gotoSchool()
           

依賴分析

為了能夠讓 DI 工具能夠進行依賴分析,需要開啟 TS 的裝飾器功能,以及裝飾器的中繼資料功能。

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
           

Decorator

那麼首先讓我們來看一下構造函數的依賴是怎麼分析出來的。開啟了裝飾器和中繼資料的功能之後,前面的代碼嘗試在 TS 的 playground 進行一次編譯,能夠看到運作的 JS 代碼是這樣的。

教你如何建構自己的依賴注入工具

能夠注意到比較關鍵的代碼定義是這樣的:

Student = __decorate([
  Injectable(),
  __metadata("design:paramtypes", [Transportation])
], Student);
           

仔細去閱讀 __decorate 函數的邏輯的話,實際上就是一個高階函數,為了倒序執行 ClassDecorator 和 Metadata 的 Decorator,翻譯一下上面的代碼,就等于:

Student = __metadata("design:paramtypes", [Transportation])(Student)
Student = Injectable()(Student)
           

然後我們再仔細閱讀 __metadata 函數的邏輯,執行的是 Reflect 的函數,就等于代碼:

Student = Reflect.metadata("design:paramtypes", [Transportation])(Student)
Student = Injectable()(Student)
           

反射

前面的代碼結果,我們暫時可以不管第一行,來閱讀一下第二行的含義,這裡正是我們需要的依賴分析的能力。Reflect.metadata 是一個高階函數,傳回的是一個 decorator 函數,執行後将資料定義在構造函數上,可以通過 getMetadata 從這個構造函數或者其繼承者都能找到定義的資料。

教你如何建構自己的依賴注入工具

比如上面的反射,我們能夠通過下面的方式拿到定義的資料:

const args = Reflect.getMetadata("design:paramtypes", Student)
expect(args).toEqual([Transportation])
           
反射中繼資料的提案:https://rbuckton.github.io/reflect-metadata/#syntax

開啟了 emitDecoratorMetadata 之後,被裝飾的地方,TS 會在編譯的時候自動填充三種中繼資料:

  • design:type 目前屬性的類型中繼資料,出現在 PropertyDecorator 和 MethodDecorator;
  • design:paramtypes 入參的中繼資料,出現在 ClassDecorator 和 MethodDecorator;
  • design:returntype 傳回類型中繼資料,出現在 MethodDecorator。
教你如何建構自己的依賴注入工具

标記依賴

為了讓 DI 工具能夠收集并存儲依賴,我們需要在 Injectable 中,将依賴的構造函數解析出來,然後也通過反射定義構造函數的方式,将資料描述通過一個 Symbol 值記錄在反射中。

const DESIGN_TYPE_NAME = {
  DesignType: "design:type",
  ParamType: "design:paramtypes",
  ReturnType: "design:returntype"
};

const DECORATOR_KEY = {
  Injectable: Symbol.for("Injectable"),
};

export function Injectable<T>() {
  return (target: new (...args: any[]) => T) => {
    const deps = Reflect.getMetadata(DESIGN_TYPE_NAME.ParamType, target) || [];
    const injectableOpts = { deps };
    Reflect.defineMetadata(DECORATOR_KEY.Injectable, injectableOpts, target);
  };
}
           

這樣做有兩個目的:

  • 通過内部的 Symbol 标記配置資料,表面這個構造函數是經過 Injectable 裝飾的,可以被 IoC 建立出來。
  • 采集并組裝配置資料,定義在構造函數中,包括依賴的資料,後面可能會用到的比如單例多例的配置資料。

定義容器

有了裝飾器在反射中定義的資料,可以就可以建立 IoC 中最重要的 Container 部分,我們就實作一個 resolve 函數,将執行個體自動建立出來:

const DECORATOR_KEY = {
  Injectable: Symbol.for("Injectable"),
};

const ERROR_MSG = {
  NO_INJECTABLE: "Constructor should be wrapped with decorator Injectable.",
}

export class ContainerV1 {
  resolve<T>(target: ConstructorOf<T>): T {
    const injectableOpts = this.parseInjectableOpts(target);
    const args = injectableOpts.deps.map((dep) => this.resolve(dep));
    return new target(...args);
  }

  private parseInjectableOpts(target: ConstructorOf<any>): InjectableOpts {
    const ret = Reflect.getOwnMetadata(DECORATOR_KEY.Injectable, target);
    if (!ret) {
      throw new Error(ERROR_MSG.NO_INJECTABLE);
    }
    return ret;
  }
}
           

主要的核心邏輯是下面幾個:

  • 從構造函數中解析 Injectable 裝飾器通過反射定義的資料,如果沒有的話,就抛出錯誤;稍微要注意一下的是,由于反射資料具備繼承性,是以這裡隻能用 getOwnMetadata 取目前目标的反射資料,保證目前目标一定是被裝飾過的。
  • 然後通過依賴再遞歸建立出依賴的執行個體,得到構造函數的入參清單。
  • 最後通過執行個體化構造函數,得到我們要的結果。

建立執行個體

到這裡最基本的建立對象的功能就實作好了,下面這樣的代碼終于能夠正常運作了。

@Injectable()
class Transportation {
  drive() {
    console.log('driving by transportation')
  }
}

@Injectable()
class Student {
  constructor(
    private transportation: Transportation,
  ) {}

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

const container = new Container()
const student = container.resolve(Student)
student.gotoSchool()
           

也可以通過通路 codesandbox,左邊選擇 ContainerV1 的模式,看到這樣的結果。

教你如何建構自己的依賴注入工具

依賴抽象

那麼基本的 IoC 我們就完成了,但是接下來我們要改變一下需求,希望能夠在運作時候将交通工具替換成想要的任何工具,而 Student 的依賴仍然應該是一個可以開的交通工具。

接下來我們分兩步實作:

  • 執行個體替換:運作時把 Transportation 替換成 Bicycle。
  • 依賴抽象:把 Transportation 從 class 變成 Interface。

在實作執行個體替換和依賴抽象的能力前,我們先得定義清楚依賴和依賴實作的關系,讓 IoC 能夠知道建立哪個執行個體去注入依賴,那就得先說一下 Token 和 Provier。

Token

作為依賴的唯一标記,可以是 String、Symbol、Constructor、或者 TokenFactory。在沒有依賴抽象的情況下,其實就是不同的 Constructor 之間直接依賴;String 和 Symbol 是我們在依賴抽象之後會使用到的依賴 ID;而 TokenFactory 是實在想要進行檔案循環引用的時候,用來進行解析依賴的方案。

我們可以先不管 TokenFactory, 其他的定義部分 Token 并不需要單獨實作,隻是一個類型定義:

export type Token<T = any> = string | symbol | ConstructorOf<T>;
           

Provider

注冊到容器裡面的和 Token 形成對應關系的執行個體建立定義,然後 IoC 在拿到 Token 之後,能夠通過 Provider 建立出正确的執行個體對象。再細分一下,Provider 又可以分成三個類型:

  • ClassProvider
  • ValueProvider
  • FactoryProvider

ClassProvider

使用構造函數來進行執行個體化的定義,一般我們前面實作的簡單版本的例子,其實就是這個模式的簡化版;再稍微改一下,就很容易實作這個版本,并且實作了 ClassProvider 之後,我們就能夠通過注冊 Provider 的方式去替換前面例子中的交通工具了。

interface ClassProvider<T = any> {
  token: Token<T>
  useClass: ConstructorOf<T>
}
           

ValueProvider

ValueProvider 在全局已經擁有一個唯一實作,但是在内部卻定義了抽象依賴的情況下非常好用。舉個簡單的例子,在進行簡潔架構的模式下,我們要求核心的代碼邏輯是和上下文無關的,那麼前端如果想要使用浏覽器環境中的全局對象的時候,需要進行抽象定義,然後把這個對象通過 ValueProvider 傳遞進去。

interface ClassProvider<T = any> {
  token: Token<T>
  useValue: T
}
           

FactoryProvider

這個 Provider 會有一個工廠函數,然後去建立執行個體,當我們需要使用工廠模式的時候,就會非常有用。

interface FactoryProvider<T = any> {
  token: Token<T>;
  useFactory(c: ContainerInterface): T;
}
           

實作注冊和建立

定義了 Token 和 Provider 之後,我們就可以通過他們實作一個注冊函數,并将 Provider 和建立連接配接起來。邏輯也比較簡單,重點就兩個:

  • 使用 Map 形成 Token 和 Provider 的映射關系,同時對 Provider 的實作進行去重,後注冊的覆寫前面的。TSyringe 可以進行多次注冊,如果構造函數依賴的是一個示例數組的話,就會依次對每次的 Provider 都建立一個執行個體;不同這種情況實際上用得很少,并且會讓 Provider 實作的複雜增加很高,感興趣的同學可以去研究它的這部分實作和定義方式。
  • 通過解析不同類型的 Provider,然後去做不同的依賴的建立。
export class ContainerV2 implements ContainerInterface {
  private providerMap = new Map<Token, Provider>();

  resolve<T>(token: Token<T>): T {
    const provider = this.providerMap.get(token);
    if (provider) {
      if (ProviderAssertion.isClassProvider(provider)) {
        return this.resolveClassProvider(provider);
      } else if (ProviderAssertion.isValueProvider(provider)) {
        return this.resolveValueProvider(provider);
      } else {
        return this.resolveFactoryProvider(provider);
      }
    }

    return this.resolveClassProvider({
      token,
      useClass: token
    });
  }

  register(...providers: Provider[]) {
    providers.forEach((p) => {
      this.providerMap.set(p.token, p);
    });
  }
 }
           

執行個體替換

實作了支援 Provider 注冊的函數之後,我們就可以通過定義 Transportation 的 Provider 的方式,去替換學生上學時候的交通工具了。

const container = new ContainerV2();
container.register({
  token: Transportation,
  useClass: Bicycle
});

const student = container.resolve(Student);
return student.gotoSchool();
           

于是我們在 codesandbox 就能夠看到下面的效果,終于可以騎車去上學了。

教你如何建構自己的依賴注入工具

工廠模式

我們實作了依賴的替換,在實作依賴抽象之前,我們先插入一個新的需求,因為平時騎車上學實在是太辛苦了,是以周末路況比較好,希望能夠開車上學。通過工廠模式,我們就能夠使用下面的方式進行實作:

const container = new ContainerV2();
container.register({
  token: Transportation,
  useFactory: (c) => {
    if (weekday > 5) {
      return c.resolve(Car);
    } else {
      return c.resolve(Bicycle);
    }
  }
});

const student = container.resolve(Student);
return student.gotoSchool();
           

這裡是簡單的工廠模式介紹,TSyringe 和 InversifyJS 都有工廠模式的建立函數,這是比較推薦的方式;同時大家也可以在其他的的 DI 工具設計裡面,有一些工具會把工廠函數的判斷放到 class 申明的地方。

這樣不是不可以,單個實作單個作用的時候寫起來會更簡單,但是這裡就要說到我們引入 DI 的目的,為了解耦。工廠函數的邏輯判斷其實是業務邏輯的一部分,本身不屬于具體的實作所歸屬的領域;并且當實作被多個工廠邏輯中使用的時候,這個地方的邏輯就會變得很奇怪。

定義抽象

那麼做完執行個體替換之後,我們來看看怎麼讓 Transportation 變成一個抽象,而不是一個具體的實作對象。那麼首先第一步,就是需要把 Student 的依賴從具體的實作邏輯,變成一個抽象邏輯。

我們需要的是一個交通工具抽象,一個可以開的交通工具,自行車、機車、小汽車都可以;隻要可以開,什麼車都可以。然後再建立一個新的學生 class 繼承一下舊的對象,用于區分和對比。

interface ITransportation {
  drive(): string
}

@Injectable({ muiltple: true })
export class StudentWithAbstraction extends Student {
  constructor(protected transportation: ITransportation) {
    super(transportation);
  }
}
           

如果這樣寫的話,會發現依賴解析出來會是錯誤的;因為在 TS 編譯的時候,interface 是一個類型,運作時就會變成類型所對應的構造對象,無法正确解析依賴。

教你如何建構自己的依賴注入工具

是以這裡除了定義一個抽象類型,同時我們還需要為這個抽象類型定義一個唯一标記,也就是 Token 裡面的 string 或者 symbol。我們一般會選擇 symbol,這樣全局唯一的值。這裡可以利用 TS 同名在值和類型的多重定義,當作值和當作類型讓 TS 自己去分析。

const ITransportation = Symbol.for('ITransportation')
interface ITransportation {
  drive(): string
}

@Injectable({ muiltple: true })
export class StudentWithAbstraction extends Student {
  constructor(
    protected @Inject(ITransportation) transportation: ITransportation,
  ) {
    super(transportation);
  }
}
           

替換抽象依賴

注意到的是,除了定義了抽象依賴的 Token 值,我們還需要加一個額外的裝飾器,讓這個标記構造函數的入參依賴,給它一個 Token 标記。

function Inject(token: Token) {
  return (target: ConstructorOf<any>, key: string | symbol, index: number) => {
    if (!Reflect.hasOwnMetadata(DECORATOR_KEY.Inject, target)) {
      const tokenMap = new Map([[key, token]]);
      Reflect.defineMetadata(DECORATOR_KEY.Inject, tokenMap, target);
    } else {
      const tokenMap: Map<number, Token> = Reflect.getOwnMetadata(
        DECORATOR_KEY.Inject,
        target
      );
      tokenMap.set(index, token);
    }
  };
}
           

同時在 Injectable 中的邏輯也需要改一下,把相應位置的依賴替換掉。

export function Injectable<T>(opts: InjectableDecoratorOpts = {}) {
  return (target: new (...args: any[]) => T) => {
    const deps = Reflect.getMetadata(DESIGN_TYPE_NAME.ParamType, target) || [];
    const tokenMap: Map<number, Token> = Reflect.getOwnMetadata(
      DECORATOR_KEY.Inject,
      target
    );
    if (tokenMap) {
      for (const [index, token] of tokenMap.entries()) {
        deps[index] = token;
      }
    }

    const injectableOpts = {
      ...opts,
      deps
    };
    Reflect.defineMetadata(DECORATOR_KEY.Injectable, injectableOpts, target);
  };
}
           

注冊抽象的 Provider

到這裡還剩下最後的一步,注入 Token 對應的 Provider 就可以使用了,我們隻需要更改一下之前的 FactoryProvider 的 Token 定義,然後就達到了我們的目标了。

const ITransportation = Symbol.for('ITransportation')
interface ITransportation {
  drive(): string
}

const container = new ContainerV2();
container.register({
  token: ITransportation,
  useFactory: (c) => {
    if (weekday > 5) {
      return c.resolve(Car);
    } else {
      return c.resolve(Bicycle);
    }
  }
});
const student = container.resolve(StudentWithAbstraction);
return student.gotoSchool();
           

實作惰性建立

前面我們已經實作了基于構造函數的依賴注入的方式,這種方式很好,不影響構造函數正常的使用。但是這樣有一個問題是,依賴樹上面所有的對象執行個體都會在根對象被建立出來的時候,全部建立出來。這樣子會有一些浪費,那些沒有被使用到的執行個體原本是可以不建立出來的。

為了保證被建立的執行個體都是被使用的,那麼我們選擇使用時建立執行個體,而不是初始化根對象的時候。

定義使用方式

在這裡我們需要更改一下 Inject 函數,使其能夠同時支援構造函數的入參裝飾和 Property 的裝飾。

const ITransportation = Symbol.for('ITransportation')
interface ITransportation {
  drive(): string
}

@Injectable()
class Student {
  @Inject(ITransportation)
  private transportation: ITransportation

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

const container = new Container()
const student = container.resolve(Student)
student.gotoSchool()
           

屬性裝飾器

我們結合 TS 編譯的結果和類型定義,來看看 ParameterDecorator 和 PropertyDecorator 的特征。

下面是 .d.ts 中的描述

教你如何建構自己的依賴注入工具

下面是編譯的結果

教你如何建構自己的依賴注入工具

可以看到的是有以下幾個差別:

  • 入參個數是不一樣的,ParameterDecorator 因為會有第幾個參數的資料。
  • 描述對象是不一樣的,構造函數的 ParameterDecorator 描述的是構造函數;而 PropertyDecorator 描述的是構造函數的 Prototype。

于是通過識别标記,然後傳回 property 的描述檔案,在 Prototype 上面添加了對應屬性的 getter 函數,實作了使用時進行對象建立的邏輯。

function decorateProperty(_1: object, _2: string | symbol, token: Token) {
  const valueKey = Symbol.for("PropertyValue");
  const ret: PropertyDescriptor = {
    get(this: any) {
      if (!this.hasOwnProperty(valueKey)) {
        const container: IContainer = this[REFLECT_KEY.Container];
        const instance = container.resolve(token);
        this[valueKey] = instance;
      }

      return this[valueKey];
    }
  };
  return ret;
}

export function Inject(token: Token): any {
  return (
    target: ConstructorOf<any> | object,
    key: string | symbol,
    index?: number
  ) => {
    if (typeof index !== "number" || typeof target === "object") {
      return decorateProperty(target, key, token);
    } else {
      return decorateConstructorParameter(target, index, token);
    }
  };
}
           

這裡可以稍微注意一點的是,TS 本身的描述的設計裡面是不推薦傳回 PropertyDescriptor 去更改屬性的定義,但是實際上在标準和 TS 的實作裡面,他其實是做了這個事情的,是以這裡未來也許會發生變化。

循環依賴

做完惰性建立,我們來說一個有一點點關系的問題,循環依賴。一般來說,我們應該從邏輯中避免循環依賴,但是如果不得不使用的時候,還是需要提供解決方案來解決循環依賴。

比如這樣一個例子:

@Injectable()
class Son {
  @Inject()
  father: Father

  name = 'Thrall'

  getDescription() {
    return `I am ${this.name}, son of ${this.father.name}.`
  }
}

@Injectable()
class Father {
 @Inject()
  son: Son

  name = 'Durotan'

  getDescription() {
    return `I am ${this.name}, my son is ${this.son.name}.`
  }
}

const container = new Container()
const father = container.resolve(Father)
console.log(father. getDescription())
           

為什麼會出問題

出問題的原因是因為裝飾器的運作時機。構造函數裝飾器的目的是描述構造函數,也就是當構造函數被申明出來之後,緊接着就會運作裝飾器的邏輯,而此時它的依賴還沒有被申明出來,取到的值還是 undefined。

教你如何建構自己的依賴注入工具

檔案循環

除了檔案内循環,還有檔案之間的循環,比如下面的這個例子。

教你如何建構自己的依賴注入工具

會發生下面的事情:

  • Father 檔案被 Node 讀取;
  • Father 檔案在 Node 會初始化一個 module 注冊在總的 modules 裡面;但是 exports 還是一個空對象,等待執行指派;
  • Father 檔案開始執行第一行,引用 Son 的結果;
  • 開始讀取 Son 檔案;
  • Son 檔案在 Node 會初始化一個 module 注冊在總的 modules 裡面;但是 exports 還是一個空對象,等待執行指派;
  • Son 檔案執行第一行,引用 Father 的結果,然後讀取到 Father 注冊的空 module;
  • Son 開始申明構造函數;然後讀取 Father 的構造函數,但是此時是 undefined,執行裝飾器邏輯;
  • Son 的 Module 指派 exports 并結束執行;
  • Father 讀取到 Son 的構造函數之後,開始申明構造函數;正确讀取 Son 的構造函數執行裝飾器邏輯。

打破循環

當發生循環依賴的時候,第一個思路應該是打破循環;讓依賴變成沒有循環的抽象邏輯,打破執行之間的先後問題。

export const IFather = Symbol.for("IFather");
export const ISon = Symbol.for("ISon");

export interface IPerson {
  name: string;
}

@Injectable()
export class FatherWithAbstraction {
  @Inject(ISon)
  son!: IPerson;

  name = "Durotan";
  getDescription() {
    return `I am ${this.name}, my son is ${this.son.name}.`;
  }
}

@Injectable()
export class SonWithAbstraction {
  @Inject(IFather)
  father!: IPerson;

  name = "Thrall";
  getDescription() {
    return `I am ${this.name}, son of ${this.father.name}.`;
  }
}

const container = new ContainerV2(
 { token: IFather, useClass: FatherWithAbstraction },
 { token: ISon, useClass: SonWithAbstraction }
);
const father = container.resolve(FatherWithAbstraction);
const son = container.resolve(SonWithAbstraction);
console.log(father.getDescription())
console.log(son.getDescription())
           

通過定義公共的 Person 抽象,讓 getDescription 函數能夠正常執行;通過提供 ISon 和 IFather 的 Provider,提供了各自依賴的具體實作,邏輯代碼就能夠正常運作了。

惰性依賴

除了依賴抽象以外,如果實在是需要進行循環依賴,我們仍然能夠通過技術手段解決這個問題,那就是讓依賴的解析在構造函數定義之後能夠執行,而不是和構造函數申明時執行。此時隻需要一個簡單的手段,使用函數執行,這就是我們前面說到的 Lazy 邏輯了。

因為 JS 的作用域内變量提升,在函數中是能持有變量引用的,隻要保證函數在執行的時候,變量已經指派過了,就能夠正确解析依賴了。

@Injectable()
class Son {
  @LazyInject(() => Father)
  father: Father

  name = 'Thrall'

  getDescription() {
    return `I am ${this.name}, son of ${this.father.name}.`
  }
}

@Injectable()
class Father {
  @LazyInject(() => Son)
  son: Son

  name = 'Durotan'

  getDescription() {
    return `I am ${this.name}, my son is ${this.son.name}.`
  }
}

const container = new Container()
const father = container.resolve(Father)
console.log(father. getDescription())
           

TokenFactory

我們需要做的,是增加一個新的 Token 解析方式,能夠使用函數動态擷取依賴。

interface TokenFactory<T = any> {
  getToken(): Token<T>;
}
           

然後增加一個 LazyInject 的裝飾器,并相容這個邏輯。

export function LazyInject(tokenFn: () => Token): any {
  return (
    target: ConstructorOf<any> | object,
    key: string | symbol,
    index?: number
  ) => {
    if (typeof index !== "number" || typeof target === "object") {
      return decorateProperty(target, key, { getToken: tokenFn });
    } else {
      return decorateConstructorParameter(target, index, { getToken: tokenFn });
    }
  };
}
           

最後在 Container 中相容一下這個邏輯,寫一個 V3 的版本 Container。

export class ContainerV3 extends ContainerV2 implements IContainer {
  resolve<T>(tokenOrFactory: Token<T> | TokenFactory<T>): T {
    const token =
      typeof tokenOrFactory === "object"
        ? tokenOrFactory.getToken()
        : tokenOrFactory;

    return super.resolve(token);
  }
}
           

最後看一下使用效果:

const container = new ContainerV3();
const father = container.resolve(FatherWithLazy);
const son = container.resolve(SonWithLazy);
father.getDescription();
son.getDescription();
           
教你如何建構自己的依賴注入工具

最後

到這裡基本上實作了一個基本可用的 DI 工具,稍微回顧一下我們的内容:

  • 使用反射和 TS 的裝飾器邏輯,我們實作了依賴的解析和對象建立;
  • 通過 Provider 定義,實作了執行個體替換、依賴抽象、工廠模式;
  • 通過使用 PropertyDecorator 定義 getter 函數,我們實作了惰性建立;
  • 通過 TokenFactory 動态擷取依賴,我們解決了循環依賴。

作者:華橋

來源:微信公衆号:位元組前端 ByteFE

出處:https://mp.weixin.qq.com/s/m45XiXL2-DVyYUUsQ4G5vQ

繼續閱讀