一統江湖的大前端(10)——inversify.js控制反轉

大史不說話發表於2021-02-06

《大史住在大前端》前端技術博文集可在下列地址訪問:

【github總基地】【部落格園】【華為雲社群】【掘金】

位元組跳動幸福裡大前端團隊邀請各路高手前來玩耍,團隊和諧有愛,技術硬核,位元組範兒正,覆蓋前端各個方向技術棧,總有位置適合你,Base北京,社招實習都有HC,不要猶豫,內推簡歷請直接瞄準shiwenqiang@bytedance.com~

Angular是由Google推出的前端框架,曾經與React和Vue一起被開發者稱為“前端三駕馬車”,但從隨著技術的迭代發展,它在國內前端技術圈中的存在感變得越來越低,通常只有Java技術棧的後端工程師在考慮轉型全棧工程師時才會優先考慮使用。Angular沒落的原因並不是因為它不夠好,反而是因為它過於優秀,還有點高冷,忽略了國內前端開發者的學習意願和接受能力,就好像一個學霸,明明成績已經很好了,但還是不斷尋求挑戰來實現自我突破,儘管他從不吝嗇分享自己的所思所想,但他所接觸的領域令廣大學渣望塵莫及,而學渣們感興趣的事物在他看來又有些無聊,最終的結果通常都只能是大家各玩各的。

瞭解過前端框架發展歷史的讀者可能會知道在2014年時Angular1.x版本有多火,儘管它並不是第一個將MVC思想引入前端的框架,但的確可以算作第一個真正撼動jQuery江湖地位的黑馬,由於在升級Angular2.0版本的過程中強制使用Typescript作為開發語言,使它失去了大量使用者,Vue和React也開始趁勢崛起,很快便形成“三足鼎立”之勢。但Angular似乎並沒有回頭的意思,而是保持著半年一個大版本的迭代速度將更多的新概念帶給前端,從而推動前端領域的技術演進,也推動著前端向正規的軟體工程方向逐步靠攏。我常說Angular是一個孤傲的變革者,它喜歡引入和傳播思想層面的概念,將那些被公認為正確優雅且有助於工程實踐的事物帶給前端,它似乎總是在說“這個是好的,那我們就在Angular裡實現它吧”,從早期的模組化和雙向資料繫結的引入,到後來的元件化、Typescript、Cli、RxJS、DI、AOT等等,一個個特性的引入都引導著開發者從不同的角度去思考,擴充套件著前端領域的邊界,也對團隊的整體素養提出更高的要求。如果你看看今天Typescript在前端開發領域的江湖地位,回顧一下早期的Vue和Angular1.x之間的差異性,看看RxJS和React Hooks出現的時間差,就不難明白Angular的思想有多前衛。

“如果一件事情是軟體工程師應該懂的,那麼你就應該弄懂它”,這在筆者看來是Angular帶給前端開發者最有價值的思想,精細化分工對企業而言是非常有利的,但卻非常容易限制技術人員本身的視野和職業發展,就好像流水線上從事體力勞動的工人,就算對自己負責的環節再熟悉,也無法僅僅憑此來保障整個零件加工最終的質量。我們應該在協作中對自己的產出負責,但只有摘掉職位頭銜帶來的思維枷鎖,你才能成為一個更全面的軟體工程師,它並不是關於技能的,而是關於思維方式的,那些源於內心深處的認知和定位會決定一個人未來所能達到的高度。

無論你是否會在自己的專案中使用Angular,都希望你能夠花一點時間瞭解它的理念,它能夠擴充套件你對於程式設計的認知,領略軟體技術思想層面的美。本章中我們就一起來學習Angular框架中最具特色的技術——DI(依賴注入),瞭解相關的IOC設計模式、AOP程式設計思想以及實現層面的裝飾器語法,最後再看看如何使用Inversify.js來在自己的程式碼中實現“依賴注入”。如果你對此感興趣,可以通過Java的Spring框架進行更深入的研究。

依賴為什麼需要注入

依賴注入(Dependency Injection,簡稱DI)並不算一個複雜的概念,但想要真正理解它背後的原理卻不是一件容易的事情,它的上游有更加抽象的IOC設計思想,下游有更加具體的AOP程式設計思想和裝飾器語法,只有搞清楚整個知識脈絡中各個術語之間的聯絡,才能夠建立相對完整的認知,從而在適合的場景使用它,核心概念的關係如下圖所示:

物件導向的程式設計是基於“類”和“例項”來運作的,當你希望使用一個類的功能時,通常需要先對它進行例項化,然後才能呼叫相關的例項方法。由於遵循“單一職責”的設計原則,開發者在實現複雜的功能時並不會將程式碼都寫在一起,而是依賴於多個子模組協作來實現相應的功能,如何將這些模組組合在一起對於物件導向程式設計而言是非常關鍵的,這也是設計模式相關的知識需要解決的主要問題,程式碼結構設計不僅影響團隊協作的開發效率,也關係著程式碼在未來的維護和擴充套件成本。畢竟在真實的開發中,不同的模組可能由不同的團隊來開發維護,如果模組之間耦合度太高,那麼偏底層的模組一旦發生變更,整個程式在程式碼層面的修改可能會非常多,這對於軟體可靠性的影響是非常嚴重的。

在普通的程式設計模式中,開發者需要引入自己所依賴的類或者相關類的工廠方法(工廠方法是指執行後會得到例項的方法)並手動完成子模組的例項化和繫結,如下所示:

import B from ‘../def/B’;
import createC from ‘../def/C’;

class A{
    constructor(paramB, paramC){
       this.b = new B(paramB);
       this.c = createC(paramC);
}
actionA(){
  this.b.actionB();
}
}

從功能實現的角度而言,這樣做並沒有什麼問題,但從程式碼結構設計的角度來看卻存在著一些潛在的風險。首先,在生成A的例項時所接受的構造引數實際上並不是由A自身來消費的,而是將其透傳分發給它所依賴的B類和C類,換句話說,A除了需要承擔其本身的職責之外,還額外承擔了B和C的例項化任務,這與物件導向程式設計中的SOLID基本設計原則中的“單一職責”原則是相悖的;其次,A類的例項a僅僅依賴於B類例項的actionB方法,如果對actionA方法進行單元測試,理論上只要actionB方法執行正確,那麼單元測試就應該能夠通過,但在前文的示例程式碼中,這樣的單元測試實際上已經變成了包含B例項化過程、C例項化過程以及actionB方法呼叫的小範圍整合測試,任何一個環節發生異常都會導致單元測試無法通過;最後,對於C模組而言,它對外暴露的工廠方法createC可以對例項化的過程進行控制,例如維護一個全域性單例物件,但對於直接匯出類定義的B模組而言,每個依賴它的模組都需要自己完成對它的例項化,如果未來B類的構造方法發生了變化,恐怕開發者只能利用IDE全域性搜尋所有對B類進行例項化的程式碼然後手動進行修改。

“依賴注入”的模式就是為了解決以上的問題而出現的,在這種程式設計模式中,我們不再接收構造引數然後手動完成子模組的例項化,而是直接在建構函式中接受一個已經完成例項化的物件,在程式碼層面的基本實現形式變成了下面的樣子:

class A{
    constructor(bInstance, cInstance){
       this.b = bInstance;
       this.c = cInstance;
}
actionA(){
  this.b.actionB();
}
}

對於A類而言,它所依賴的b例項和c例項都是在構造時從外部注入進來的,這意味著它不再需要關心子模組例項化的過程,而只需要以形參的方式宣告對這個例項的依賴,然後專注於實現自己所負責的功能即可,對子模組例項化的工作交給A類外部的其他模組來完成,這個外部模組通常被稱為“IOC容器”,它本質上就是“類登錄檔+工廠方法”,開發者通過“key-value”的形式將各個類註冊到IOC容器中,然後由IOC容器來控制類的例項化過程,當建構函式需要使用其他類的例項時,IOC容器會自動完成對依賴的分析,生成需要的例項並將它們注入到建構函式中,當然需要以單例模式來使用的例項都會儲存在快取中。

另一方面,在“依賴注入”的模式下,上層的A類對下層模組B和C的強制依賴已經消失了,它和你在JavaScript基礎中瞭解到的“鴨式辨形”機制非常類似,只要實際傳入的bInstance引數也實現一個actionB方法,且在函式簽名(或者說型別宣告)上和B類的actionB方法保持一致,對於A模組而言它們就是一樣的,這可以極大地降低對A模組進行單元測試的難度,而且方便開發者在開發環境、測試環境和生產環境等不同的場景中對特定的模組提供完全不同的實現,而不是被B模組的具體實現所限制,如果你瞭解過物件導向程式設計的SOLID設計原則就會明白,“依賴注入”實際上就是對“依賴倒置原則”的一種體現。

依賴倒置原則(Dependency Inversion):

  1. 上層模組不應該依賴底層模組,它們應該依賴於共同的抽象。

  2. 抽象不應該依賴於細節,細節應該依賴於抽象。

這就是“依賴注入”和“控制反轉”的基本知識,依賴的例項由原本手動生成的方式轉變為由IOC容器自動分析並以注入的方式提供,原本由上層模組控制的例項化過程被轉移給IOC容器來完成,本質上它們都是對物件導向基本設計原則的實現手段,目的就是為了降低模組之間的耦合性,隱藏更多細節。很多時候,設計模式的應用的確會讓本來直觀清晰的程式碼變得晦澀難懂,但換來的卻是整個軟體對於需求不確定性的抵禦能力。初級開發者在程式設計時千萬不要只滿足於實現眼前的需求,而是應該多思考如何降低需求變動可能給自己造成的影響,甚至直接“控制反轉”將細節定製的環節以配置檔案的形式提供給產品人員。請時刻記得,軟體工程師的任務是設計軟體,讓軟體和可複用的模組去幫助自己實現需求,而不是把自己變成一個擅長搬磚的工具。

IOC容器的實現

基於前文的分析,你可以先自己來思考一下基本的IOC容器應該實現的功能,然後再繼續下面的內容。IOC容器的主要職責是接管所有例項化的過程,那麼它肯定能夠訪問到所有的類定義,並且知道每個類的依賴,但類的定義可能編寫在多個不同的檔案中,IOC容器要如何來完成依賴收集呢?比較容易想到的方法就是為IOC容器實現一個註冊方法,開發者在每個類定義完成後呼叫註冊方法將自己的建構函式和依賴模組的名稱註冊到IOC容器中,IOC容器以閉包的形式維護一個私有的類登錄檔,其中以鍵值對的形式記錄了每個類的相關資訊,例如工廠方法、依賴列表、是否使用單例以及指向單例的指標屬性等等,你可以根據實際需要去新增更多的配置資訊,這樣一來IOC容器就擁有了訪問所有類並進行例項化的能力;除了收集資訊外,IOC容器還需要實現一個獲取所需例項的呼叫方法,當呼叫方法執行時,它可以根據傳入的鍵值去找到對應的配置物件,根據配置資訊返回正確的例項給呼叫者。這樣一來,IOC容器就可以完成例項化的基本職能。

IOC容器的使用對於模組之間耦合關係的影響是非常明顯的,在原來的手動例項化模型中,模組之間的關係時相互耦合的,模組的變動很容易直接導致依賴它的模組發生修改,因為上層模組對底層模組本身產生了依賴;在引入IOC容器後,每個類只需要呼叫容器的註冊方法將自己的資訊登記進去,其他模組如果對它有依賴,通過呼叫IOC容器提供的方法就可以獲取到所需要的例項,這樣一來,子模組例項化的過程和主模組之間就不再是強依賴關係,子模組發生變化時也不需要再去修改主模組,這樣的處理模式對於保障大型應用的穩定性非常有幫助。現在我們再回過頭看看那張經典的控制反轉示意圖,就比較容易理解其背後完成的工作了:

IOC的機制其實和招聘是非常類似的,公司的專案要落地實施,需要專案經理、產品、設計、研發、測試等多個不同崗位的員工協作來完成,對公司而言,更加關注每個崗位需要多少人,低中高不同級別的人員比例大概是多少,從而可以從巨集觀的角度來評估人力配置是否足以保障專案落地,至於具體招聘到的人是誰,公司並不需要在意;而HR的角色就像是IOC容器,只需要按照公司的標準去市場上搜尋符合條件的候選人,並用面試來檢驗他是否符合用人要求就可以了。

手動實現IOC容器

下面我們使用Typescript來手動實現一個簡單的IOC容器類,你可以先體會一下它的基本用法,因為強型別的特點,它更容易幫助你在抽象層面瞭解自己所寫的程式碼,另外它的物件導向特性也更加完備,語法特徵和Java非常相似,是學習收益率很高的選擇。相比於JavaScript的靈活,Typescript的程式碼增加了非常多的限制,最開始你可能會被型別系統搞的暈頭轉向,但當你熟悉後,就會慢慢開始享受這種程式碼層面的約束和自律帶來的工程層面的清晰。我們先來編寫基本的結構和必要的型別限制:

// IOC成員屬性
interface iIOCMember {
  factory: Function;
  singleton: boolean;
  instance?: {}
}

// 定義IOC容器
Class IOC {
  private container: Map<PropertyKey, iIOCMember>;

  constructor() {
    this.container = new Map<string, iIOCMember>();
  }
}

在上面的程式碼中我們定義了2個介面和1個類,IOC容器類中有一個私有的map例項,它的鍵是PropertyKey型別,這是Typescript中預設的型別,指string | number | symbol的聯合型別,也就我們平時用作鍵的型別,而值的型別是iIOCMember,從介面的定義中可以看到,它需要一個工廠方法、一個標記是否為單例的屬性以及指向單例的指標,接下來我們在IOC容器類上新增用於註冊建構函式的方法bind:

// 建構函式泛型
interface iClass<T> {
  new(...args: any[]): T
}

// 定義IOC容器
class IOC {

  private container: Map<PropertyKey, iIOCMember>;

  constructor() {
    this.container = new Map<string, iIOCMember>();
  }

  bind<T>(key: string, Fn: iClass<T>) {
    const factory = () => new Fn();
    this.container.set(key, { factory, singleton: true });
  }
}

bind方法的邏輯並不難理解,初學者可能會對iClass介面的宣告比較陌生,它是指實現了這個介面的實體在被new時需要返回預設型別T的例項,換句話說就是這裡接收的是一個建構函式,new( )作為介面的屬性時也被稱為“構造器字面量”。但IOC容器是延遲例項化的,想要讓建構函式延遲執行,最簡單的方式就是定義一個簡單的工廠方法(如前文示例中的factory方法所做的那樣)並將它儲存起來,等需要時在進行例項化。最後我們再來實現一個呼叫方法use:

  use(namespace: string) {
    let item = this.container.get(namespace);
    if (item !== undefined) {
      if (item.singleton && !item.instance) {
        item.instance = item.factory();
      }
      return item.singleton ? item.instance : item.factory();
    } else {
      throw new Error('未找到構造方法');
    }
  }

use方法接收一個字串並根據它從容器中找出對應的值,這裡的值就會符合iIOCMember介面定義的結構,為了方便演示,如果沒有找到對應的記錄就直接報錯,如果需要單例且還沒有生成過相應的物件,就呼叫工廠方法來生成單例,最終根據配置資訊來判斷是返回單例還是建立新的例項。現在我們就可以來使用這個IOC容器了:

class UserService {
  constructor() {}
  test(name: string) {
    console.log(`my name is ${name}`);
  }
}

const container = new IOC();
container.bind<UserService>('UserService', UserService);
const userService = container.use('UserService');
userService.test('大史不說話');

使用ts-node直接執行Typescript程式碼後,就可以在控制檯看到列印的資訊。前文的IOC容器僅僅實現了最核心的流程,它還不具備依賴管理和載入的功能,希望你可以自己嘗試來進行實現,需要做的工作就是在註冊資訊時提供依賴模組鍵的列表,然後在例項化時通過遞迴的方式將依賴模組都對映為對應的例項,當你學習webpack模組載入原理時也會接觸到類似的模式,下一小節中我們來看看Angular1.x版本如何完成對依賴的自動分析和注入。

AngularJS中的依賴注入

AngularJS在業內特指Angular2以前的版本(更高的版本中統一稱為Angular),它提倡使用模組化的方式來分解程式碼,將不同層面的邏輯拆分為Controller、Service、Directive、Filter等型別的模組,從而提高整個程式碼的結構性,其中Controller模組是用來連線頁面和資料模型的,通常每個頁面會對應一個Controller,典型的程式碼片段如下所示:

var app = angular.module(“myApp”, []);  

//編寫頁面控制器
app.controller(“mainPageCtrl”,function($scope,userService) {  
     // 控制器函式操作部分 ,主要進行資料的初始化操作和事件函式的定義 
    $scope.title = ‘大史住在大前端’;
userService.showUserInfo();
}); 

// 編寫自定義服務
app.service(‘userService’,function(){
this.showUserInfo = function(){
    Console.log(‘call the method to show user information’);
}
})

示例程式碼中先通過module方法定義了一個全域性的模組例項,接著在例項上定義了一個控制器模組(Controller)和一個服務模組(Service),$scope物件用於和頁面之間產生關聯,通過模板語法繫結的變數或事件處理函式都需要掛載在頁面的$scope物件上才能夠被訪問,上面這段簡單的程式碼在執行時,AngularJS就會將頁面模板上帶有ng-bind=“title”標記的元素內容替換為自定義的內容,並執行userService服務上的showUserInfo方法。

如果你仔細觀察上面的程式碼,很容易就會發現依賴注入的痕跡,Controller在定義時接收了一個字串key和一個函式,這個函式通過形參userService來接收外部傳入的同名服務,使用者要做的僅僅是使用AngularJS提供的方法來定義對應的模組,而框架在執行工廠方法來例項化時就會自動找到它依賴的模組例項並將其注入進來,對於Controller而言,它只需要在工廠函式的形參中宣告自己依賴的模組就可以了。有了前文中IOC相關知識的鋪墊,我們不難想象,app.controller方法的本質其實就是IOC容器中的bind方法,用於將一個工廠方法登記到登錄檔中,它僅僅是依賴收集的過程,app.service方法也是類似的。這種實現方式被稱為“推斷注入”,也就是從傳入的工廠方法形參的名稱中推斷出依賴的模組並將其注入,函式體的字串形式可以呼叫toString方法得到,接著使用正則就可以提取出形參的字元,也就是依賴模組的名稱。“推斷注入”屬於一種隱式推斷的方式,它要求形參的名稱和模組註冊時使用的鍵名保持一致,例如前文示例中的userService對應著使用app.service方法所定義的userService服務。這種方式雖然簡潔,但程式碼在利用工具進行壓縮混淆時通常會將形參使用的名稱修改為更短的名稱,這時再用形參的名稱去尋找依賴項就會導致錯誤,於是AngularJS又提供了另外兩種依賴注入的實現方式——“內聯宣告”和“宣告注入”,它們的基本語法如下所示:

// 內聯注入
app.controller(“mainPageCtrl”,[‘$scope’, ’userService’, function($scope,userService) {  
     // 控制器函式操作部分 ,主要進行資料的初始化操作和事件函式的定義 
    $scope.title = ‘大史住在大前端’;
userService.showUserInfo();
}]); 

// 宣告注入
var mainPageCtrl =  function($scope,userService) {  
     // 控制器函式操作部分 ,主要進行資料的初始化操作和事件函式的定義 
    $scope.title = ‘大史住在大前端’;
userService.showUserInfo();
}; 
mainPageCtrl.$inject = [‘$scope’,’userService’];
app.controller(“mainPageCtrl”, mainPageCtrl);

內聯注入是在原本傳入工廠方法的位置傳入一個陣列,預設陣列的最後一項為工廠方法,而前置項是依賴模組的鍵名,字串常量並不像函式定義那樣會被壓縮混淆工具影響,這樣AngularJS的依賴注入系統就能夠找到需要的模組了;宣告注入的目的也是一樣的,只不過它將依賴列表掛載在工廠函式的$inject屬性上而已(JavaScript中的函式本質上也是物件型別,可以新增屬性),在程式的實現上想要相容上述的幾種不同的依賴宣告方式並不困難,只需要判斷app.controller方法接收到的第二個引數是陣列還是函式,如果是函式的話是否有$inject屬性,然後將依賴陣列提取出來並遍歷載入模組就可以了。

AngularJS的依賴注入模組原始碼可以在官方程式碼倉的src/auto/injector.js中找到,從資料夾的命名就可以看到它是用來實現自動化依賴注入的,其中包含大量官方文件的註釋,會對閱讀理解原始碼的思路有很大幫助,你可以在其中找到annotate方法的定義,就可以看到AngularJS中對於上述幾種不同的依賴宣告方式的相容處理,感興趣的讀者可以自行完成其他部分的學習。

AOP和裝飾器

面向切面程式設計(Aspect Oriented Programming,即AOP),是程式設計中非常經典的思想,它通過預編譯或動態代理的方式來為已經編寫完成的模組新增新的功能,從而避免了對原始碼的修改,也讓開發者可以更方便地將業務邏輯功能和諸如日誌記錄、事務處理、效能統計、行為埋點等系統級的功能拆分開來,從而提升程式碼的複用性和可維護性。真實開發中的專案可能時間跨度很長,參與的人員也可能會不斷更換,如果將上述程式碼都編寫在一起,勢必會對其它協作者理解主要業務邏輯造成干擾。物件導向程式設計的關注點是梳理實體關係,它解決的問題是如何將具體的需求劃分為相對獨立且封裝良好的類,讓它們有屬於自己的行為;而面向切面程式設計的關注點是剝離通用功能,讓很多類共享一個行為,這樣當它變化時只需要修改這個行為即可,它可以讓開發者在實現類的特性時更加關注其本身的任務,而不是苦惱於將它歸屬於哪個類。

“面向切面程式設計”並不是什麼顛覆性的技術,它帶來的是一種新的程式碼組織思路。假設你在系統中使用著名的axios庫來處理網路請求,後端在使用者登入成功後返回一個token,需要你每次傳送請求時都將它新增在請求頭中以便進行鑑權,常規的思路是編寫一個通用的getToken方法,然後在每次發請求時通過自定義headers將其傳入(假設自定義頭欄位為X-Token):

import { getToken } from ‘./utils’;

axios.get(‘/api/report/get_list’,{
    headers:{
        ‘X-Token’:getToken()
    }
});

從功能實現角度而言,上面的做法是可行的,但我們不得不在每個需要傳送請求的模組中引用公共方法getToken,這樣顯得非常繁瑣,畢竟在不同的請求中新增token資訊的動作都是一樣的;相比之下,axios提供的interceptors攔截器機制就非常適合用來處理類似的場景,它就是非常典型的“面向切面”實踐:

axios.interceptors.request.use(function (config) {
    // 在config配置中新增自定義資訊
    config.headers = {
      ...config.headers,
       ‘X-Token’:getToken()
}
    return config;
  }, function (error) {
    // 請求發生錯誤時的處理函式
    return Promise.reject(error);
  });

如果你瞭解過express和koa框架中所使用的中介軟體模型,就很容易意識到這裡的攔截器機制本質上和它們是一樣的,使用者自定義的處理函式被依次新增進攔截器陣列,在請求傳送前或者響應返回後的特定“時間切面”上依次執行,這樣一來,每個具體的請求就不需要再自行處理向請求頭中新增Token之類的非業務邏輯了,功能層面的程式碼就這樣被剝離並隱藏起來,業務邏輯的程式碼自然就變得更加簡潔。

除了利用程式設計技巧,高階語言也提供了更加簡潔的語法來方便開發者實踐“面向切面程式設計”,JavaScript從ES7標準開始支援裝飾器語法,但由於當前前端工程中有Babel編譯工具的存在,所以對於開發者而言並不需要考慮瀏覽器對新語法支援度的問題,如果使用Typescript,開發者就可以通過配置tsconfig.json中的引數來啟用裝飾器(在Spring框架中被稱為annotation,也就是註解)語法來實現相關的邏輯,它的本質只是一種語法糖。常見的裝飾器包括類裝飾器、方法裝飾器、屬性裝飾器、引數裝飾器,類定義中幾乎所有的部分都可以被裝飾器包裝。以類裝飾器為例,它接收的引數是需要被修飾的類,下面的示例中使用@testable修飾符在已經定義的類的原型物件上增加一個名為_testable的屬性:

function testable(target){
    target.prototype._testable = false;
}
// 在類名上一行編寫裝飾器
@testable
Class Person{
    constructor(name){
      this.name = name;
}
}

從上面的程式碼中你會發現,即使沒有裝飾器語法,我們自己在JavaScript中執行testable函式也可以完成對類的擴充套件,它們的區別在於手動執行包裝的語句是命令式風格的,而裝飾器語法是宣告式風格的,後者通常被認為更適合在物件導向程式設計中使用,因為它可以保持業務邏輯層程式碼的簡潔,而把無關緊要的細節移交給專門的模組去處理。Angular中提供的裝飾器通常都可以接收引數,我們只需要藉助高階函式來實現一個“裝飾器工廠”,返回一個裝飾器生成函式就可以了:

// Angular中的元件定義
@Component({
    selector: ‘hero-detail’,
    templateUrl: ‘hero-detail.html’,
    styleUrls: [‘style.css’]
})
Class MyComponent{
   //......
}

//@Component裝飾器的定義大致符合如下的形式
function Component(params){
    return function(target){
       // target可以訪問到params中的內容
       target.prototype._meta = params;
}
}

這樣元件在被例項化時,就可以獲得從裝飾器工廠傳入的配置資訊,這些配置資訊通常也被稱為類的元資訊。其他型別裝飾器的基本工作原理也是一樣的,只是函式簽名中的引數不同,例如方法裝飾器被呼叫時會傳入3個引數:

  • 第1個引數裝飾靜態方法時為建構函式,裝飾類方法時為類的原型物件

  • 第2個引數是成員名

  • 第3個引數是成員屬性描述符

你可能一下子就發現了,它和JavaScript中的Object.defineProperty的函式簽名是一樣的,這也意味著方法裝飾器和它一樣屬於抽象度較高但通用性更強的方法。在方法裝飾器的函式體中,我們可以從建構函式或原型物件上獲取到需要被裝飾的方法,接著用代理模式生成一個帶有附加功能的新方法,並在恰當的時機執行原方法,最後通過直接賦值或是利用屬性描述符中的getter返回包裝後的新方法,從而完成對原方法功能的擴充套件,你可以在Vue2原始碼中資料劫持的部分學習到類似的應用。下面我們來實現一個方法裝飾器,希望在被裝飾的方法執行前後在控制檯列印出一些除錯資訊,程式碼實現大致如下:

function log(target, key, descriptor){
    const originMethod = target[key];
    const decoratedMethod = ()=>{
        console.log(‘方法執行前’);
        const result = originMethod();
        console.log(‘方法執行後’);
        return result;
}
//返回新方法
target[key] = decoratedMethod;
}

你只需要在被裝飾的方法上一行寫上@log來標記就可以了,當然也可以通過工廠方法將日誌的內容以引數的形式傳入。其他型別的裝飾器本文中不再贅述,它們的工作方式是相似的,下一節中我們來看看Inversify.js是如何使用裝飾器語法來實現依賴注入的。

用inversify.js實現依賴注入

Inversify.js提供了更加完備的依賴注入實現,它是使用Typescript編寫的。

基本使用

官方網站已經提供了基本的示例程式碼和使用方式,首先是介面定義:

// file interfaces.ts

export interface Warrior {
    fight(): string;
    sneak(): string;
}

export interface Weapon {
    hit(): string;
}

export interface ThrowableWeapon {
    throw(): string;
}

上面的程式碼中定義並匯出了戰士、武器和可投擲武器這三個介面,還記得嗎?依賴注入是“SOLID”設計原則中依賴倒置原則的一種實踐,上層模組和底層模組應該依賴於共同的抽象,當不同的類使用implements關鍵字來實現介面或者將某個識別符號的型別宣告為介面時,就需要滿足介面宣告的結構限制,於是介面就成為了它們“共同的抽象”,而且Typescript中的介面定義只用於型別約束和校驗,上線前編譯為JavaScript後就消失了。接下來是型別定義:

// file types.ts
const TYPES = {
    Warrior: Symbol.for("Warrior"),
    Weapon: Symbol.for("Weapon"),
    ThrowableWeapon: Symbol.for("ThrowableWeapon")
};

export { TYPES };

和介面宣告不同的是,這裡的型別定義是一個物件字面量,它編譯後並不會消失,Inversify.js在執行時需要使用它來作為模組的識別符號,當然也支援使用字串字面量,就像前文中我們自己實現IOC容器時所做的那樣。接下來就是類定義時的宣告環節:

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 };

可以看到最核心的兩個API是從inversify中引入的injectable和inject這兩個裝飾器,這也是在大多數依賴注入框架中使用的術語,injectable是可注入的意思,也就是告知依賴注入框架這個類需要被註冊到容器中,inject是注入的意思,它是一個裝飾器工廠,接受的引數就是前文在types中定義的型別名,如果你覺得這裡難以理解,可以將它直接當做字串來對待,其作用也就是告知框架在為這個變數注入依賴時需要按照哪個key去查詢對應的模組,如果將這種語法和AngularJS中的依賴注入進行比較就會發現,它已經不需要開發者手動來維護依賴陣列了。最後需要處理的,就是容器配置的部分:

// 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 };

不要受到Typescript複雜性的干擾,這裡和前文中自己實現的IOC容器類的使用方式是一樣的,只不過我們使用的API是ioc.bind(key, value),而這裡的實現是ioc.bind(key).to(value),最後就可以來使用這個IOC容器例項了:

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

inversify.js提供了get方法來從容器中獲取指定的類,這樣就可以在程式碼中使用Container例項來管理專案中的類了,示例程式碼可以在本章的程式碼倉庫中找到。

原始碼淺析

本節中我們深入原始碼層面來進行一些探索,很多讀者一提到原始碼就會望而卻步,但Inversify.js程式碼層面的實現可能比你想象的要簡單很多,但想要弄清楚背後的思路和框架的結構,還是需要花費不少時間和精力的。首先是injectable裝飾器的定義:

import * as ERRORS_MSGS from "../constants/error_msgs";
import * as METADATA_KEY from "../constants/metadata_keys";
function injectable() {
    return function (target) {
        if (Reflect.hasOwnMetadata(METADATA_KEY.PARAM_TYPES, target)) {
            throw new Error(ERRORS_MSGS.DUPLICATED_INJECTABLE_DECORATOR);
        }
        var types = Reflect.getMetadata(METADATA_KEY.DESIGN_PARAM_TYPES, target) || [];
        Reflect.defineMetadata(METADATA_KEY.PARAM_TYPES, types, target);
        return target;
    };
}
export { injectable };

Reflect物件是ES6標準中定義的全域性物件,用於為原本掛載在Object.prototype物件上的API提供函式化的實現,Reflect.defineMetadata方法並不是標準的API,而是由引入的reflect-metadata庫提供的擴充套件能力,metadata也被稱為“元資訊”,通常是指需要隱藏在程式內部的與業務邏輯無關的附加資訊。如果我們自己來實現,很大概率會將一個名為_metadata的屬性直接掛載在物件上,但是在reflect-metadata的幫助下,元資訊的鍵值對與實體物件或物件屬性之間以對映的形式存在,從而避免了對目標物件的汙染,其用法如下:

// 為類新增元資訊
Reflect.defineMetadata(metadataKey, metadataValue, target);
// 為類的屬性新增元資訊
Reflect.defineMetadata(metadataKey, metadataValue, target, propertyKey);

injectable原始碼中引入的METADATA_KEY物件實際上只是一些字串而已。當你把上面程式碼中的常量識別符號都替換為對應的字串後就非常容易理解了:

function injectable() {
    return function (target) {
        if (Reflect.hasOwnMetadata(‘inversify:paramtypes’, target)) {
            throw new Error(/*...*/);
        }
        var types = Reflect.getMetadata(‘design:paramtypes’, target) || [];
        Reflect.defineMetadata(‘inversify:paramtypes’, types, target);
        return target;
    };
}

可以看到injectable裝飾器所做的事情就是把與target對應的key為“design:paramtypes”的元資訊賦值給了key為“inversify:paramtypes”的元資訊。再來看看inject裝飾器工廠的原始碼:

function inject(serviceIdentifier) {
    return function (target, targetKey, index) {
        if (serviceIdentifier === undefined) {
            throw new Error(UNDEFINED_INJECT_ANNOTATION(target.name));
        }
        var metadata = new Metadata(METADATA_KEY.INJECT_TAG, serviceIdentifier);
        if (typeof index === "number") {
            tagParameter(target, targetKey, index, metadata);
        }
        else {
            tagProperty(target, targetKey, metadata);
        }
    };
}
export { inject };

inject是一個裝飾器工廠,這裡的邏輯就是根據傳入的識別符號(也就是前文中定義的types),例項化一個元資訊物件,然後根據形參的型別來呼叫不同的處理函式,當裝飾器作為引數裝飾器時,第三個引數index是該引數在函式形參中的順序索引,是數字型別的,否則將認為該裝飾器是作為屬性裝飾器使用的,tagParameter和tagProperty底層呼叫的是同一個函式,其核心邏輯是在進行了大量的容錯檢查後,將新的元資訊新增到正確的陣列中儲存起來。事實上無論是injectable還是inject,它們作為裝飾器所承擔的任務都是對於元資訊的儲存,IOC的例項管理能力都是依賴於容器類Container來實現的。

Inversify.js中的Container類將例項化的過程分解為多個自定義的階段,並增加了多容器管理、多值注入、自定義中介軟體等諸多擴充套件機制,原始碼本身閱讀起來並不困難,但理論化相對較強且英文的術語較多,對於初中級開發者的實用價值非常有限,所以筆者不打算在本文中詳細展開分析Container類的實現,社群也有很多非常詳細的原始碼結構分析的文章,足以幫助感興趣的同學繼續深入瞭解。

停下來

如果你第一次接觸依賴注入相關的知識,可能也會和筆者當初一樣,覺得這樣的理論和寫法非常“高階”,迫不及待地想要深入瞭解,事實上即使花費很多時間去瀏覽原始碼,我在實際工作中也幾乎從來沒有使用過它,但“解耦”的意識卻留在了我的意識裡。作為軟體工程師,我們需要去了解技術背後的原理和思想,以便擴充套件自己的思維,但對技術的敬畏之心不應該演變成對高階技術的盲目崇拜。“依賴注入”不過是設計模式的一種,模式總會有它適合或不適合的使用場景,常用的設計模式還有很多,經典的設計思想也有很多,只有靈活運用才能讓自己在程式碼結構組織的工作上游刃有餘,請不要讓執念限制了自己思維的廣度。

相關文章