DI 原理解析 並實現一個簡易版 DI 容器

語言: CN / TW / HK
本文基於自身理解進行輸出,目的在於交流學習,如有不對,還望各位看官指出。

DI

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

使用方式

首先看一下常用依賴注入 (DI)的方式:

function Inject(target: any, key: string){
    target[key] = new (Reflect.getMetadata('design:type',target,key))()
}

class A {
    sayHello(){
        console.log('hello')
    }
}

class B {
    @Inject   // 編譯後等同於執行了 @Reflect.metadata("design:type", A)
    a: A

    say(){
       this.a.sayHello()  // 不需要再對class A進行例項化
    }
}

new B().say() // hello

原理分析

TS在編譯裝飾器的時候,會通過執行__metadata函式多返回一個屬性裝飾器@Reflect.metadata,它的目的是將需要例項化的service以元資料'design:type'存入reflect.metadata,以便我們在需要依賴注入時,通過Reflect.getMetadata獲取到對應的service, 並進行例項化賦值給需要的屬性。

@Inject編譯後代碼:

var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};

// 由於__decorate是從右到左執行,因此, defineMetaData 會優先執行。
__decorate([
    Inject,
    __metadata("design:type", A)  //  作用等同於 Reflect.metadata("design:type", A)
], B.prototype, "a", void 0);

即預設執行了以下程式碼:

Reflect.defineMetadata("design:type", A, B.prototype, 'a');

Inject函式需要做的就是從metadata中獲取對應的建構函式並構造例項物件賦值給當前裝飾的屬性

function Inject(target: any, key: string){
    target[key] = new (Reflect.getMetadata('design:type',target,key))()
}

不過該依賴注入方式存在一個問題:

  • 由於Inject函式在程式碼編譯階段便會執行,將導致B.prototype在程式碼編譯階段被修改,這違反了六大設計原則之開閉原則(避免直接修改類,而應該在類上進行擴充套件) 那麼該如何解決這個問題呢,我們可以借鑑一下TypeDI的思想。

typedi

typedi 是一款支援TypeScript和JavaScript依賴注入工具 typedi 的依賴注入思想是類似的,不過多維護了一個container

1. metadata

在瞭解其container前,我們需要先了解 typedi 中定義的metadata,這裡重點講述一下我所瞭解的比較重要的幾個屬性。

  • id: service的唯一標識
  • type: 儲存service建構函式
  • value: 快取service對應的例項化物件
const newMetadata: ServiceMetadata<T> = {
      id: ((serviceOptions as any).id || (serviceOptions as any).type) as ServiceIdentifier,    // service的唯一標識
      type: (serviceOptions as ServiceMetadata<T>).type || null,  // service 建構函式
      value: (serviceOptions as ServiceMetadata<T>).value || EMPTY_VALUE,  // 快取service對應的例項化物件
};

2. container 作用

function ContainerInstance() {
        this.metadataMap = new Map();  //儲存metadata對映關係,作用類似於Refect.metadata
        this.handlers = []; // 事件待處理佇列
        get(){};  // 獲取依賴注入後的例項化物件
         ...
}
  • this. metadataMap - @service會將service建構函式以metadata形式儲存到this.metadataMap中。
    • 快取例項化物件,保證單例;
  • this.handlers - @inject會將依賴注入操作的物件目標行為以 object 形式 push 進 handlers 待處理陣列。
    • 儲存建構函式靜態型別屬性間的對映關係。
{
        object: target,  // 當前等待掛載的類的原型物件
        propertyName: propertyName,  // 目標屬性值
        index: index, 
        value: function (containerInstance) {   // 行為
            var identifier = Reflect.getMetadata('design:type', target, propertyName)
            return containerInstance.get(identifier);
        }
}

@inject將該物件 push 進一個等待執行的 handlers 待處理數組裡,當需要用到對應 service 時執行 value函式 並修改 propertyName。

if (handler.propertyName) {
     instance[handler.propertyName] = handler.value(this);
}
  • get - 物件例項化操作及依賴注入操作
    • 避免直接修改類,而是對其例項化物件的屬性進行拓展;

相關結論

  • typedi中的例項化操作不會立即執行, 而是在一個handlers待處理陣列,等待Container.get(B),先對B進行例項化,然後從handlers待處理陣列取出對應的value函式並執行修改例項化物件的屬性值,這樣不會影響Class B 自身
  • 例項的屬性值被修改後,將被快取到metadata.value(typedi 的單例服務特性)。

相關資料可檢視:

https://stackoverflow.com/questions/55684776/typedi-inject-doesnt-work-but-container-get-does

new B().say()  // 將會輸出sayHello is undefined

Container.get(B).say()  // hello word

實現一個簡易版 DI Container

此處程式碼依賴TS,不支援JS環境

interface Handles {
    target: any
    key: string,
    value: any
}

interface Con {
    handles: Handles []   // handlers待處理陣列
    services: any[]  // service陣列,儲存已例項化的物件
    get<T>(service: new () => T) : T   // 依賴注入並返回例項化物件
    findService<T>(service: new () => T) : T  // 檢查快取
    has<T>(service: new () => T) : boolean  // 判斷服務是否已經註冊
}

var container: Con = {
    handles: [],  // handlers待處理陣列
    services: [], // service陣列,儲存已例項化的物件
    get(service){
        let res: any = this.findService(service)
        if(res){
            return  res
        }

        res = new service()
        this.services.push(res)
        this.handles.forEach(handle=>{
            if(handle.target !== service.prototype){
                return
            }
            res[handle.key] = handle.value
        })
        return res
    },

    findService(service){
        return this.services.find(instance => instance instanceof service)
    },

   // service是否已被註冊
    has(service){
        return !!this.findService(service)
    }
}

function Inject(target: any, key: string){
    const service = Reflect.getMetadata('design:type',target,key)
    
    // 將例項化賦值操作快取到handles陣列
    container.handles.push({
        target,
        key,
        value: new service()
    })

    // target[key] = new (Reflect.getMetadata('design:type',target,key))()
}

class A {
    sayA(name: string){
        console.log('i am '+ name)
    }
}

class B {
    @Inject
    a: A

    sayB(name: string){
       this.a.sayA(name)
    }
}

class C{
    @Inject
    c: A

    sayC(name: string){
       this.c.sayA(name)
    }
}

// new B().sayB(). // Cannot read property 'sayA' of undefined
container.get(B).sayB('B')
container.get(C).sayC('C')

· 往期精彩 ·

【不懂物理的前端不是好的遊戲開發者(一)—— 物理引擎基礎】

【3D效能優化 | 說一說glTF檔案壓縮】

【京東購物小程式 | Taro3 專案分包實踐】

歡迎關注凹凸實驗室部落格:aotu.io

或者關注凹凸實驗室公眾號(AOTULabs),不定時推送文章。