TypeScript 進階:類型安全的依賴注入

原文。

本文敘述了如何使用 TypeScript 從頭創(chuàng)建一個 100% 類型安全的依賴注入框架。

在我作為專業(yè) TypeScript 講師的日子里,開發(fā)者們經(jīng)常問我:“為什么我們需要這么復(fù)雜的高級類型系統(tǒng)?”他們在實際項目中并沒有感受到對常量類型交叉類型、條件類型元組式的剩余參數(shù)的需求。這是一個很好的問題,如果沒有一個合適的場景,是很難回答的。

這就促使我去尋找一個合適的場景。幸運的是,我確實找到了一個場景:依賴注入,或者簡稱為 DI。

本文,我將帶著你一起探索。首先我會解釋類型安全的依賴注入是什么意思。接下來我會展示最終代碼形態(tài),這樣你就知道具體要達(dá)到什么目標(biāo)了。然后,我們逐一解決靜態(tài)類型的依賴注入框架所遇到的挑戰(zhàn)。

閱讀本文的前提是你已經(jīng)具備了 TypeScript 基礎(chǔ)知識。

目標(biāo)

我的目標(biāo)是在 TypeScript 中創(chuàng)建 100% 類型安全的依賴注入(DI)框架。如果你還不知道 DI,建議先閱讀 samueleresca 寫的這篇文章,文章介紹了什么是 DI,以及為什么要使用 DI。同時文章中也介紹了 InversifyJS,它是目前最流行的 TypeScript DI 框架,借助 TypeScript 的裝飾器reflect-metadata在運行時解析依賴。

InversifyJS 確實實現(xiàn)了依賴注入……但是,卻不是類型安全的。以下面代碼為例:

@injectable()
class Foo {
    constructor(@inject('bar') bar: string) {
        console.log(bar.substr(2));
    }
}

const context = new Context();
context.bind('bar').toConstantValue(42);
context.bind(Foo).toSelf();
context.get(Foo); // Error: bar.substr is not a function

在上述示例中,可以看到 bar 被聲明為 string 類型,但是在運行時它卻是一個 number 類型。實際上,在 DI 配置中很容易犯類似這樣的錯誤。由于 DI 的緣故而失去類型安全性,這太糟糕了。

我的目標(biāo)就是調(diào)研“是否能讓編譯器知道依賴及其類型”。如果你的代碼有編譯過程,那么這會很有用:字符串就是字符串,數(shù)字就是數(shù)字,F(xiàn)oo 就是 Foo,不會出現(xiàn)任何其它可能性。

最終結(jié)果

如果你對最終結(jié)果感興趣,那么我可以告訴你:我成功了!你可以看看 GitHub 上的這個項目。下面是從 README 中提取出來的一段最簡化代碼:

import { rootInjector, tokens } from 'typed-inject';

class Logger {
    info(message: string) {
        console.log(message);
    }
}

class HttpClient {
    constructor(private log: Logger) { }
    public static inject = tokens('logger');
}

class MyService {
    constructor(private http: HttpClient, private log: Logger) { }
    public static inject = tokens('httpClient', 'logger');
}

const appInjector = rootInjector
  .provideValue('logger', new Logger())
  .provideClass('httpClient', HttpClient);

const myService = appInjector.injectClass(MyService);
// Dependencies for MyService validated and injected

在類的 inject 靜態(tài)屬性中聲明依賴??梢允褂?InjectorinjectClass 方法實例化一個類,任何構(gòu)造器參數(shù)或者 inject 屬性中的錯誤都會引起編譯錯誤。

很好奇原理吧?這就對了。

挑戰(zhàn)

為了讓編譯器給出編譯錯誤,有三個挑戰(zhàn):

  1. 如何靜態(tài)聲明依賴?
  2. 在構(gòu)造函數(shù)的參數(shù)中,怎么關(guān)聯(lián)上依賴的類型?
  3. 如何實現(xiàn)一個 Injector,用于根據(jù)類型生成實例?

我們逐一解決上述挑戰(zhàn)。

挑戰(zhàn)1:聲明依賴

我們從靜態(tài)聲明依賴開始。InversifyJS 使用裝飾器,比如:@inject('bar') 用于尋找一個叫做 bar 的依賴并將其注入,由于裝飾器動態(tài)運行方式(裝飾器僅僅是一個運行時執(zhí)行的函數(shù)),沒辦法在編譯階段確定 bar 依賴存在。

所以我們不能使用裝飾器,我們找找其他方式來聲明依賴。

在 Angular 仍叫 AngularJS 的時代,我們在類(當(dāng)時我們稱之為構(gòu)造函數(shù))上面的 $inject 靜態(tài)屬性上聲明依賴。在 $inject 屬性上的值,我們稱之為“tokens”,$inject 數(shù)組中聲明的 tokens 順序與構(gòu)造函數(shù)中參數(shù)的順序保持一一對應(yīng)關(guān)系。我們用 MyService 舉個相似的例子:

class MyService {
    constructor(private http: HttpClient, private log: Logger) { }
    public static inject = ['httpClient', 'logger'];
}

這是一個好的開始,但是我們還沒達(dá)到目標(biāo)。通過字符串?dāng)?shù)組的方式初始化 inject 屬性,編譯器只會將其解析為普通的字符串?dāng)?shù)組類型,編譯器沒辦法將 bar token 與 Bar 類型關(guān)聯(lián)起來。

介紹:字面量類型

當(dāng)寫錯代碼的時候,我們期望編譯器會報錯。為了在編譯時能知道 token 數(shù)組的值,我們需要將其類型聲明為字符串字面量

class MyService {
    constructor(private http: HttpClient, private log: Logger) { }
    public static inject: ['httpClient', 'logger'] = ['httpClient', 'logger'];
}

我們告訴了 TypeScript 數(shù)組的類型是一個值為 ['httpClient', 'logger'] 的 元組,現(xiàn)在我們有了一絲進展。但是,我們是懶惰的開發(fā)者,我們不想寫重復(fù)的代碼。讓我們使其更加符合 DRY 原則。

介紹:結(jié)合元組類型和剩余參數(shù)

我們可以創(chuàng)建一個簡單的輔助方法,它接收任意數(shù)量的字面量字符串參數(shù),返回相應(yīng)的字面量元組值,看起來大致這樣:

function tokens<Tokens extends string[]>(...theTokens: Tokens): Tokens {
    return theTokens;
}

如上所示,theTokens 參數(shù)聲明為剩余參數(shù),它能匹配到函數(shù)的所有參數(shù),同時類型被定義為 Tokens,繼承自 string[],因此能匹配到任何字符串類型。返回值是 theTokens,其類型是字面量字符串元組。這樣一來,我們就能避免之前例子中的重復(fù)編碼:

class MyService {
    constructor(private http: HttpClient, private log: Logger) { }
    public static inject = tokens('httpClient', 'logger');
}

如上所示,只需要列舉 tokens 一次就行,inject 的類型就會是 ['httpClient','logger']。變得更棒了,你覺得呢?

TypeScript 中有望引入顯式的元組語法,因此以后我們不再需要額外的 tokens 輔助函數(shù)。

挑戰(zhàn)2:關(guān)聯(lián)依賴

說到了有趣的部分:確??勺⑷腩惖臉?gòu)造函數(shù)的參數(shù)與聲明的 tokens 相匹配。

首先,我們聲明 MyService 類(或者任何可注入的類)的靜態(tài)接口:

interface Injectable {
    new(...args: any): any;
    inject: string[];
}

Injectable 接口描述了一種類:有一個接收任意數(shù)量參數(shù)的構(gòu)造函數(shù);有一個靜態(tài) inject 數(shù)組屬性,包含了注入 tokens,類型為 string[]。這僅僅是個開始,實際上用處不大,不能夠?qū)?tokens 值與構(gòu)造函數(shù)參數(shù)的類型關(guān)聯(lián)起來。

介紹:查詢類型

因此,我們需要告訴 TypeScript 編譯器,哪個 token 對應(yīng)哪種類型。幸運的是,TypeScript 支持查詢類型:它是一種不必直接作為類型使用的簡單 interface,我們將其用作查詢類型的字典。聲明一個 Context 查詢類型,其值可用于注入:

interface Context {
    httpClient: HttpClient;
    logger: Logger;
}

任何時候你想聲明一個 Logger 實例,都可以使用 Context 查詢類型,例如 let log: Context['logger']。有了這個接口,我們可以指定 MyService 類的 inject 屬性必須是 Context 的鍵:

interface Injectable {
    new(...arg: (Context[keyof Context])[]): any;
    inject: (keyof Context)[];
}

這更加接近目標(biāo)了。我們收窄了 inject 的有效值到一個 keyof Context 數(shù)組,因此只能使用 'logger' 或者 'httpClient' 作為 token。構(gòu)造函數(shù)中的每一個參數(shù)的類型都是 Context[keyof Context],因此要么是 Logger,要么是 HttpClient。

但是,并沒有達(dá)到目的。我們?nèi)匀恍枰_關(guān)聯(lián)值,這就要用到泛型了。

介紹:泛型

展示一個泛型魔法:

interface Injectable<Token extends keyof Context, R> {
    new(arg: Context[Token]): R;
    inject: [Token];
}

現(xiàn)在我們有了新的進展!我們聲明了一個泛型變量 Token,限定了取值只能是 Context 中的鍵。我們也在構(gòu)造函數(shù)中用 Context[Token] 關(guān)聯(lián)了確定的類型。同時,我們也添加了一個類型參數(shù) R,指代 Injectable(例如 MyService 實例)實例類型。

仍然存在一個問題,如果我們想讓構(gòu)造函數(shù)支持更多的參數(shù),我們就需要為每一種參數(shù)數(shù)量聲明一個類型:

interface Injectable2<Token extends keyof Context, Token2 extends keyof Context, R> {
    new(arg: Context[Token], arg2: Context[Token2]): R;
    inject: [Token, Token2];
}

這是不可持續(xù)的。理想情況下,對于不同數(shù)量的構(gòu)造函數(shù)參數(shù),我們只需要定義一種類型就行了。

我們已經(jīng)知道了如何實現(xiàn)!直接使用元組類型的剩余參數(shù):

interface Injectable<Tokens extends (keyof Context)[], R> {
    new(...args: CorrespondingTypes<Tokens>): R;
    inject: Tokens;
}

我們先仔細(xì)看一下 Tokens。通過將 Tokens 聲明為 keyof Context 數(shù)組,我們能夠靜態(tài)地將 inject 屬性定義為一種元組類型,TypeScript 編譯器會保持跟蹤每一個 token。舉個例子,對于 inject = tokens('httpClient', 'logger'),Tokens 類型會被解析為 ['httpClient', 'logger']。

構(gòu)造函數(shù)的剩余參數(shù)使用 CorrespondingTypes<Tokens> 映射類型,在下面一節(jié)中我們詳細(xì)介紹這塊。

介紹:條件映射元組類型

CorrespondingTypes 被實現(xiàn)為條件映射類型,代碼實現(xiàn)如下:

type CorrespondingTypes<Tokens extends (keyof Context)[]> = {
    [I in keyof Tokens]: Tokens[I] extends keyof Context ? Context[Tokens[I]] : never;
}

上述代碼“一言難盡”,我們逐層分析。

首先,我們需要知道 CorrespondingTypes映射類型:新類型的屬性名與源類型一致,但是是一種不同的類型。在上面代碼中,我們映射了 Tokens 的屬性。Tokens 是一個泛型元組類型(extends (keyof Context)[])。

但是,元組類型的屬性名是什么呢?好吧,你可以認(rèn)為就是它的索引。因此,對于 ['foo', 'bar'],屬性名就是 01。實際上,對于元組類型和映射類型的搭配支持,已經(jīng)在最近單獨的 PR 中支持了。一個超棒的特性。

現(xiàn)在,看下關(guān)聯(lián)屬性值,我們使用了類型判斷:Tokens[I] extends keyof Context? Context[Tokens[I]] : never。因此,如果 token 是 Context 的一個鍵,就會返回對應(yīng)鍵的類型;否則,返回 nerver 類型,意思就是告知 TypeScript 不會出現(xiàn)這種情況。

挑戰(zhàn)3:注入

既然我們有了 Injectable 接口,是時候用起來了。先創(chuàng)建核心類:Injector。

class Injector {
    injectClass<Tokens extends (keyof Context)[]>(Injectable: Injectable<Tokens, R>): R {
        const args = /* resolve inject tokens */;
        return new Injectable(...args);
    }
}

Injector 類有一個 injectClass 方法,接收一個 Injectable 類作為參數(shù),創(chuàng)建并返回需要的實例。該方法的具體實現(xiàn)已經(jīng)超出了本文的范疇,但是你可以思考一下:通過迭代 inject 屬性配置的 tokens 來查詢需要注入的值。

動態(tài)上下文

到目前為止,我們靜態(tài)聲明了 Context 類型,它是一個查詢類型,用于關(guān)聯(lián) token 和其它類型。如果你在項目中需要這樣寫,會不怎么光彩。因為這意味著整個 DI 上下文需要一次性初始化,后續(xù)再也不能配置,一點都不實用。

為了使 Context 動態(tài)化,我們將其作為另外一個泛型傳入(我保證這會是最后一個泛型)。新的類型聲明如下:

interface Injectable<TContext, Tokens extends (keyof TContext)[], R> {
    new(...args: CorrespondingTypes<TContext, Tokens>): R;
    inject: Tokens;
}
type CorrespondingTypes<TContext, Tokens extends (keyof TContext)[]> = {
    [Token in keyof Tokens]: Tokens[Token] extends keyof TContext ? TContext[Tokens[Token]] : never;
}
class Injector<TContext> {
    inject<Tokens extends (keyof TContext)[]>(injectable: Injectable<TContext, Tokens, R>): R {
        /* out of scope */
    }
}

好了,所有的內(nèi)容看起來都還是比較熟悉的。我們引入了 TContext,用于表示 DI 上下文的查詢接口。

現(xiàn)在,還剩最后一個問題,我們想要通過動態(tài)添加值的方式來配置 Injector??聪逻@塊的示例代碼:

const appInjector = rootInjector
  .provideValue('logger', logger)
  .provideClass('httpClient', HttpClient);

如上所示,InjectorprovideXXX 方法,每個 provide 方法都會向 TContext 泛型中添加鍵,我們需要另外一個 TypeScript 特性來實現(xiàn)這個效果。

介紹:交叉類型

在 TypeScript 中,可以很輕松地用 & 組合兩種類型,因此 Foo & Bar 是一種同時擁有 FooBar 屬性的類型,這種類型被稱為交叉類型。這有點像 C++ 的多重繼承或者 Scala 中的 traits。我們將 TContext 與使用字符串字面量 token 的映射類型關(guān)聯(lián)起來:

class Injector<TContext> {
  provideValue<Token extends string, R>(token: Token, value: R)
  : Injector<{ [K in Token]: R } & TContext> {
      /* out of scope */
  }
}

如上所示,provideValue 有兩個泛型參數(shù):一個是 token 常量類型(Token),一個是注入的值的類型(R)。該方法返回了一個新的 Injector 實例,其上下文為 { [K in Token]: R } & TContext。也就是說,可以注入任何當(dāng)前注入器支持的值,也可以是新提供的 token。

你可能想知道為什么新的 TContext 要和 { [k in Token]: R } 做交叉而不是簡單地用 { [Token]: R }。這是因為 Token 本身可以表示一個字符串字面量聯(lián)合類型,舉個例子,'foo'| 'bar'。雖然從 TypeScript 角度來看沒什么問題,但是如果在調(diào)用 provideValue 的時候顯示地傳入一個聯(lián)合類型(provideValue<'foo' | 'bar', _>('foo', 42))將會破壞類型安全,它會在編譯時同時注冊 'foo''bar' 作為 token,并關(guān)聯(lián)同一個數(shù)字,但是在運行時僅僅注冊了 'foo'。所以,在實際項目中不要這么做。

其它 provideXXX 方法也是類似的道理,它們返回新的 Injector 實例,提供新的 token,同時合并進了所有舊的 token。

結(jié)論

TypeScript 的類型系統(tǒng)很強大,在本文中我們結(jié)合了:

  • 字面量類型
  • 元組類型的剩余參數(shù)
  • 查詢類型
  • 泛型
  • 條件映射元組類型
  • 交叉類型

來創(chuàng)建類型安全的依賴注入框架。

雖然,你不會總是遇到這些特性,但是對這些特性保持關(guān)注是值得的,畢竟它們?yōu)楦玫鼐幋a提供了可能性。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容