Nestjs模組機制的概念和實現原理

子慕大詩人發表於2022-04-06

1 前言

Nest 提供了模組機制,通過在模組裝飾器中定義提供者、匯入、匯出和提供者建構函式便完成了依賴注入,通過模組樹組織整個應用程式的開發。按照框架本身的約定直接擼一個應用程式,是完全沒有問題的。可是,於我而言對於框架宣稱的依賴注入、控制反轉、模組、提供者、後設資料、相關裝飾器等等,覺得缺乏一個更清晰系統的認識。

  • 為什麼需要控制反轉?
  • 什麼是依賴注入?
  • 裝飾器做了啥?
  • 模組 (@Module) 中的提供者(providers),匯入(imports)、匯出(exports)是什麼實現原理?

好像能夠理解,能夠意會,但是讓我自己從頭說清楚,我說不清楚。於是進行了一番探索,便有了這篇文章。從現在起,我們從新出發,進入正文。

2 兩個階段

2.1 Express、Koa

一個語言和其技術社群的發展過程,一定是從底層功能逐漸往上豐富發展的,就像是樹根慢慢生長為樹枝再長滿樹葉的過程。在較早,Nodejs 出現了 Express 和 Koa 這樣的基本 Web 服務框架。能夠提供一個非常基礎的服務能力。基於這樣的框架,大量的中介軟體、外掛開始在社群誕生,為框架提供更加豐富的服務。我們需要自己去組織應用依賴,搭建應用腳手架,靈活又繁瑣,也具有一定工作量。

發展到後面,一些生產更高效、規則更統一的框架便誕生了,開啟了一個更新的階段。

2.2 EggJs、Nestjs

為了更加適應快速生產應用,統一規範,開箱即用,便發展出了 EggJs、NestJs、Midway等框架。此類框架,通過實現底層生命週期,將一個應用的實現抽象為一個通用可擴充套件的過程,我們只需要按照框架提供的配置方式,便可以更簡單的實現應用程式。框架實現了程式的過程控制,而我們只需要在合適位置組裝我們的零件就行,這看起來更像是流水線工作,每個流程被分割的很清楚,也省去了很多實現成本。

2.3 小結

上面的兩個階段只是一個鋪墊,我們可以大致瞭解到,框架的升級是提高了生產效率,而要實現框架的升級,就會引入一些設計思路和模式,Nest 中就出現了控制反轉、依賴注入、超程式設計的概念,下面我們來聊聊。

3 控制反轉和依賴注入

3.1 依賴注入

一個應用程式實際就是非常多的抽象類,通過互相呼叫實現應用的所有功能。隨著應用程式碼和功能複雜度的增加,專案一定會越來越難以維護,因為類越來越多,相互之間的關係越來越複雜。

舉個例子,假如我們使用 Koa 開發我們的應用,Koa 本身主要實現了一套基礎的 Web 服務能力,我們在實現應用的過程中,會定義很多類,這些類的例項化方式、相互依賴關係,都會由我們在程式碼邏輯自由組織和控制。每個類的例項化都是由我們手動 new,並且我們可以控制某個類是隻例項化一次然後被共享,還是每次都例項化。下面的 B 類依賴 A,每次例項化 B 的時候,A 都會被例項化一次,所以對於每個例項 B 來說,A 是不被共享的例項。

class A{}
// B
class B{
    contructor(){
        this.a = new A();
    }
}

下面的 C 是獲取的外部例項,所以多個 C 例項是共享的 app.a 這個例項。

class A{}
// C
const app = {};
app.a = new A();
class C{
    contructor(){
        this.a = app.a;
    }
}

下面的 D 是通過建構函式引數傳入,可以每次傳入一個非共享例項,也可以傳入共享的 app.a 這個例項(D 和 F 共享 app.a),並且由於現在是引數的方式傳入,我也可以傳入一個 X 類例項。

class A{}
class X{}
// D
const app = {};
app.a = new A();
class D{
    contructor(a){
        this.a = a;
    }
}
class F{
    contructor(a){
        this.a = a;
    }
}
new D(app.a)
new F(app.a)
new D(new X())

這種方式就是依賴注入,把 B 所依賴的 A,通過傳值的方式注入到 B 中。通過建構函式注入(傳值)只是一種實現方式,也可以通過實現 set 方法呼叫傳入,或者是其他任何方式,只要能把外部的一個依賴,傳入到內部就行。其實就這麼簡單。

class A{}
// D
class D{
    setDep(a){
        this.a = a;
    }
}
const d = new D()
d.setDep(new A())

3.2 All in 依賴注入?

隨著迭代進行,出現了 B 根據不同的前置條件依賴會發生變化。比如,前置條件一 this.a 需要傳入 A 的例項,前置條件二this.a需要傳入 X 的例項。這個時候,我們就會開始做實際的抽象了。我們就會改造成上面 D 這樣依賴注入的方式。

初期,我們在實現應用的時候,在滿足當時需求的情況下,就會實現出 B 和 C 類的寫法,這本身也沒有什麼問題,專案迭代了幾年之後,都不一定會動這部分程式碼。我們要是去考慮後期擴充套件什麼的,是會影響開發效率的,而且不一定派的上用場。所以大部分時候,我們都是遇到需要抽象的場景,再對部分程式碼做抽象改造。

// 改造前
class B{
    contructor(){
        this.a = new A();
    }
}
new B()

// 改造後
class D{
    contructor(a){
        this.a = a;
    }
}
new D(new A())
new D(new X())

按照目前的開發模式,CBD三種類都會存在,B 和 C有一定的機率發展成為 D,每次升級 D 的抽象過程,我們會需要重構程式碼,這是一種實現成本。

這裡舉這個例子是想說明,在一個沒有任何約束或者規定的開發模式下。我們是可以自由的寫程式碼來達到各種類與類之間依賴控制。在一個完全開放的環境裡,是非常自由的,這是一個刀耕火種的原始時代。由於沒有一個固定的程式碼開發模式,沒有一個最高行動綱領,隨著不同開發人員的介入或者說同一個開發者不同時間段寫程式碼的差別,程式碼在增長的過程中,依賴關係會變得非常不清晰,該共享的例項可能被多次例項化,浪費記憶體。從程式碼中,很難看清楚一個完整的依賴關係結構,程式碼可能會變得非常難以維護。

image.png

那我們每定義一個類,都按照依賴注入的方式來寫,都寫成 D 這樣的,那 C 和 B 的抽象過程就被提前了,這樣後期擴充套件也比較方便,減少了改造成本。所以把這叫All in 依賴注入,也就是我們所有依賴都通過依賴注入的方式實現。

可這樣前期的實現成本又變高了,很難在團隊協作中達到統一併且堅持下去,最終可能會落地失敗,這也可以被定義為是一種過度設計,因為額外的實現成本,不一定能帶來收益。

3.3 控制反轉

既然已經約定好了統一使用依賴注入的方式,那是否可以通過框架的底層封裝,實現一個底層控制器,約定一個依賴配置規則,控制器根據我們定義的依賴配置來控制例項化過程和依賴共享,幫助我們實現類管理。這樣的設計模式就叫控制反轉

控制反轉可能第一次聽說的時候會很難理解,控制指的什麼?反轉了啥?

猜測是由於開發者一開始就用此類框架,並沒有體驗過上個“Express、Koa時代”,缺乏舊社會毒打。加上這反轉的用詞,在程式中顯得非常的抽象,難以望文生義。

前文我們說的實現 Koa 應用,所有的類完全由我們自由控制的,所以可以看作是一個常規的程式控制方式,那就叫它:控制正轉。而我們使用 Nest,它底層實現一套控制器,我們只需要在實際開發過程中,按照約定寫配置程式碼,框架程式就會幫我們管理類的依賴注入,所以就把它叫作:控制反轉。

本質就是把程式的實現過程交給框架程式去統一管理,控制權從開發者,交給了框架程式。

控制正轉:開發者純手動控制程式

控制反轉:框架程式控制

舉個現實的例子,一個人本來是自己開車去上班的,他的目的就是到達公司。它自己開車,自己控制路線。而如果交出開車的控制權,就是去趕公交,他只需要選擇一個對應的班車就可以到達公司了。單從控制來說,人就是被解放出來了,只需要記住坐那趟公交就行了,犯錯的機率也小了,人也輕鬆了不少。公交系統就是控制器,公交線路就是約定配置。

通過如上的實際對比,我想應該有點能理解控制反轉了。

3.4 小結

從 Koa 到 Nest,從前端的 JQuery 到 Vue React。其實都是一步步通過框架封裝,去解決上個時代低效率的問題。

上面的 Koa 應用開發,通過非常原始的方式去控制依賴和例項化,就類似於前端中的 JQuery 操作 dom ,這種很原始的方式就把它叫控制正轉,而 Vue React 就好似 Nest 提供了一層程式控制器,他們可以都叫控制反轉。這也是個人理解,如果有問題期望大神指出。

下面再來說說 Nest 中的模組 @Module,依賴注入、控制反轉需要它作為媒介。

4 Nestjs的模組(@Module)

Nestjs實現了控制反轉,約定配置模組(@module)的 imports、exports、providers 管理提供者也就是類的依賴注入。

providers 可以理解是在當前模組註冊和例項化類,下面的 A 和 B 就在當前模組被例項化,如果B在建構函式中引用 A,就是引用的當前 ModuleD 的 A 例項。

import { Module } from '@nestjs/common';
import { ModuleX } from './moduleX';
import { A } from './A';
import { B } from './B';

@Module({
  imports: [ModuleX],
  providers: [A,B],
  exports: [A]
})
export class ModuleD {}

// B
class B{
    constructor(a:A){
        this.a = a;
    }
}

exports 就是把當前模組中的 providers 中例項化的類,作為可被外部模組共享的類。比如現在 ModuleF 的 C 類例項化的時候,想直接注入 ModuleD 的 A 類例項。就在 ModuleD 中設定匯出(exports)A,在 ModuleF 中通過 imports 匯入 ModuleD。

按照下面的寫法,控制反轉程式會自動掃描依賴,首先看自己模組的 providers 中,有沒有提供者 A,如果沒有就去尋找匯入的 ModuleD 中是否有 A 例項,發現存在,就取得 ModuleD 的 A 例項注入到 C 例項之中。

import { Module } from '@nestjs/common';
import { ModuleD} from './moduleD';
import { C } from './C';

@Module({
  imports: [ModuleD],
  providers: [C],
})
export class ModuleF {}

// C
class C {
    constructor(a:A){
        this.a = a;
    }
}

因此想要讓外部模組使用當前模組的類例項,必須先在當前模組的providers裡定義例項化類,再定義匯出這個類,否則就會報錯。

//正確
@Module({
  providers: [A],
  exports: [A]
})
//錯誤
@Module({
  providers: [],
  exports: [A]
})

這裡還是提一嘴ts的知識點

export class C {
  constructor(private a: A) {
  }
}

由於 TypeScript 支援 constructor 引數(private、protected、public、readonly)隱式自動定義為 class 屬性 (Parameter Property),因此無需使用 this.a = a。Nest 中都是這樣的寫法。

5 Nest 超程式設計

超程式設計的概念在 Nest 框架中得到了體現,它其中的控制反轉、裝飾器,就是超程式設計的實現。大概可以理解為,超程式設計本質還是程式設計,只是中間多了一些抽象的程式,這個抽象程式能夠識別後設資料(如@Module中的物件資料),其實就是一種擴充套件能力,能夠將其他程式作為資料來處理。我們在編寫這樣的抽象程式,就是在超程式設計了。

5.1 後設資料

Nest 文件中也常提到了後設資料,後設資料這個概念第一次看到的話,也會比較費解,需要隨著接觸時間增長習慣成理解,可以不用太過糾結。

後設資料的定義是:描述資料的資料,主要是描述資料屬性的資訊,也可以理解為描述程式的資料。

Nest 中 @Module 配置的exports、providers、imports、controllers都是後設資料,因為它是用來描述程式關係的資料,這個資料資訊不是展示給終端使用者的實際資料,而是給框架程式讀取識別的。

5.2 Nest 裝飾器

如果看看 Nest 中的裝飾器原始碼,會發現,幾乎每一個裝飾器本身只是通過 reflect-metadata 定義了一個後設資料。

@Injectable裝飾器

export function Injectable(options?: InjectableOptions): ClassDecorator {
  return (target: object) => {
    Reflect.defineMetadata(INJECTABLE_WATERMARK, true, target);
    Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, options, target);
  };
}

這裡存在反射的概念,反射也比較好理解,拿 @Module 裝飾器舉例,定義後設資料 providers,只是往providers陣列裡傳入了類,在程式實際執行時providers裡的類,會被框架程式自動例項化變為提供者,不需要開發者顯示的去執行例項化和依賴注入。類只有在模組中例項化了之後才變成了提供者。providers中的類被反射了成了提供者,控制反轉就是利用的反射技術。

換個例子的話,就是資料庫中的 ORM(物件關係對映),使用 ORM 只需要定義表欄位,ORM 庫會自動把物件資料轉換為 SQL 語句。

const data = TableModel.build();

data.time = 1;
data.browser = 'chrome';
    
data.save();
// SQL: INSERT INTO tableName (time,browser) [{"time":1,"browser":"chrome"}]

ORM 庫就是利用了反射技術,讓使用者只需要關注欄位資料本身,物件被 ORM 庫反射成為了 SQL 執行語句,開發者只需要關注資料欄位,而不需要去寫 SQL 了。

5.3 reflect-metadata

reflect-metadata 是一個反射庫,Nest 用它來管理後設資料。reflect-metadata 使用 WeakMap,建立一個全域性單例項,通過 set 和 get 方法設定和獲取被裝飾物件(類、方法等)的後設資料。

// 隨便看看即可
var _WeakMap = !usePolyfill && typeof WeakMap === "function" ? WeakMap : CreateWeakMapPolyfill();   

var Metadata = new _WeakMap();
function defineMetadata(){
    OrdinaryDefineOwnMetadata(){
        GetOrCreateMetadataMap(){
          var targetMetadata = Metadata.get(O);
            if (IsUndefined(targetMetadata)) {
                if (!Create)
                    return undefined;
                targetMetadata = new _Map();
                Metadata.set(O, targetMetadata);
            }
            var metadataMap = targetMetadata.get(P);
            if (IsUndefined(metadataMap)) {
                if (!Create)
                    return undefined;
                metadataMap = new _Map();
                targetMetadata.set(P, metadataMap);
            }
            return metadataMap;
        }
    }
}

reflect-metadata 把被裝飾者的後設資料存在了全域性單例物件中,進行統一管理。reflect-metadata 並不是實現具體的反射,而是提供了一個輔助反射實現的工具庫。

6 最後

現在再來看看前面的幾個疑問。

  1. 為什麼需要控制反轉?
  2. 什麼是依賴注入?
  3. 裝飾器做了啥?
  4. 模組 (@Module) 中的提供者(providers),匯入(imports)、匯出(exports)是什麼實現原理?

1 和 2 我想前面已經說清楚了,如果還有點模糊,建議再回去看一遍並查閱一些其它文章資料,通過不同作者的思維來幫助理解知識。

6.1 問題 [3 4] 總述:

Nest 利用反射技術、實現了控制反轉,提供了超程式設計能力,開發者使用 @Module 裝飾器修飾類並定義後設資料(providers\imports\exports),後設資料被儲存在全域性物件中(使用 reflect-metadata 庫)。程式執行後,Nest 框架內部的控制程式讀取和註冊模組樹,掃描後設資料並例項化類,使其成為提供者,並根據模組後設資料中的 providers\imports\exports 定義,在所有模組的提供者中尋找當前類的其它依賴類的例項(提供者),找到後通過建構函式注入。

本文概念較多,也並沒有做太詳細的解析,概念需要時間慢慢理解,如果一時理解不透徹,也不必太過著急。好吧,就到這裡,這篇文章還是花費不少精力,喜歡的朋友期望你能一鍵三連~

相關文章