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

原文html

本文敘述瞭如何使用 TypeScript 從頭建立一個 100% 類型安全的依賴注入框架。git

在我做爲專業 TypeScript 講師的日子裏,開發者們常常問我:「爲何咱們須要這麼複雜的高級類型系統?」他們在實際項目中並無感覺到對常量類型交叉類型條件類型元組式的剩餘參數的需求。這是一個很好的問題,若是沒有一個合適的場景,是很難回答的。github

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

本文,我將帶着你一塊兒探索。首先我會解釋類型安全的依賴注入是什麼意思。接下來我會展現最終代碼形態,這樣你就知道具體要達到什麼目標了。而後,咱們逐一解決靜態類型的依賴注入框架所遇到的挑戰。數組

閱讀本文的前提是你已經具有了 TypeScript 基礎知識。安全

目標

個人目標是在 TypeScript 中建立 100% 類型安全的依賴注入(DI)框架。若是你還不知道 DI,建議先閱讀 samueleresca 寫的這篇文章,文章介紹了什麼是 DI,以及爲何要使用 DI。同時文章中也介紹了 InversifyJS,它是目前最流行的 TypeScript DI 框架,藉助 TypeScript 的裝飾器reflect-metadata在運行時解析依賴。app

InversifyJS 確實實現了依賴注入……可是,卻不是類型安全的。如下面代碼爲例:框架

@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 的緣故而失去類型安全性,這太糟糕了。ide

個人目標就是調研「是否能讓編譯器知道依賴及其類型」。若是你的代碼有編譯過程,那麼這會頗有用:字符串就是字符串,數字就是數字,Foo 就是 Foo,不會出現任何其它可能性。函數

最終結果

若是你對最終結果感興趣,那麼我能夠告訴你:我成功了!你能夠看看 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 靜態屬性中聲明依賴。可使用 InjectorinjectClass 方法實例化一個類,任何構造器參數或者 inject 屬性中的錯誤都會引發編譯錯誤。

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

挑戰

爲了讓編譯器給出編譯錯誤,有三個挑戰:

  1. 如何靜態聲明依賴?
  2. 在構造函數的參數中,怎麼關聯上依賴的類型?
  3. 如何實現一個 Injector,用於根據類型生成實例?

咱們逐一解決上述挑戰。

挑戰1:聲明依賴

咱們從靜態聲明依賴開始。InversifyJS 使用裝飾器,好比:@inject('bar') 用於尋找一個叫作 bar 的依賴並將其注入,因爲裝飾器動態運行方式(裝飾器僅僅是一個運行時執行的函數),沒辦法在編譯階段肯定 bar 依賴存在。

因此咱們不能使用裝飾器,咱們找找其餘方式來聲明依賴。

在 Angular 仍叫 AngularJS 的時代,咱們在類(當時咱們稱之爲構造函數)上面的 $inject 靜態屬性上聲明依賴。在 $inject 屬性上的值,咱們稱之爲「tokens」,$inject 數組中聲明的 tokens 順序與構造函數中參數的順序保持一一對應關係。咱們用 MyService 舉個類似的例子:

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

這是一個好的開始,可是咱們還沒達到目標。經過字符串數組的方式初始化 inject 屬性,編譯器只會將其解析爲普通的字符串數組類型,編譯器沒辦法將 bar token 與 Bar 類型關聯起來。

介紹:字面量類型

當寫錯代碼的時候,咱們指望編譯器會報錯。爲了在編譯時能知道 token 數組的值,咱們須要將其類型聲明爲字符串字面量

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

咱們告訴了 TypeScript 數組的類型是一個值爲 ['httpClient', 'logger'] 的 元組,如今咱們有了一絲進展。可是,咱們是懶惰的開發者,咱們不想寫重複的代碼。讓咱們使其更加符合 DRY 原則。

介紹:結合元組類型和剩餘參數

咱們能夠建立一個簡單的輔助方法,它接收任意數量的字面量字符串參數,返回相應的字面量元組值,看起來大體這樣:

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

如上所示,theTokens 參數聲明爲剩餘參數,它能匹配到函數的全部參數,同時類型被定義爲 Tokens,繼承自 string[],所以能匹配到任何字符串類型。返回值是 theTokens,其類型是字面量字符串元組。這樣一來,咱們就能避免以前例子中的重複編碼:

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

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

TypeScript 中有望引入顯式的元組語法,所以之後咱們再也不須要額外的 tokens 輔助函數。

挑戰2:關聯依賴

說到了有趣的部分:確保可注入類的構造函數的參數與聲明的 tokens 相匹配。

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

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

Injectable 接口描述了一種類:有一個接收任意數量參數的構造函數;有一個靜態 inject 數組屬性,包含了注入 tokens,類型爲 string[]。這僅僅是個開始,實際上用處不大,不可以將 tokens 值與構造函數參數的類型關聯起來。

介紹:查詢類型

所以,咱們須要告訴 TypeScript 編譯器,哪一個 token 對應哪一種類型。幸運的是,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)[];
}

這更加接近目標了。咱們收窄了 inject 的有效值到一個 keyof Context 數組,所以只能使用 'logger' 或者 'httpClient' 做爲 token。構造函數中的每個參數的類型都是 Context[keyof Context],所以要麼是 Logger,要麼是 HttpClient

可是,並無達到目的。咱們仍然須要精確關聯值,這就要用到泛型了。

介紹:泛型

展現一個泛型魔法:

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

如今咱們有了新的進展!咱們聲明瞭一個泛型變量 Token,限定了取值只能是 Context 中的鍵。咱們也在構造函數中用 Context[Token] 關聯了肯定的類型。同時,咱們也添加了一個類型參數 R,指代 Injectable(例如 MyService 實例)實例類型。

仍然存在一個問題,若是咱們想讓構造函數支持更多的參數,咱們就須要爲每一種參數數量聲明一個類型:

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

這是不可持續的。理想狀況下,對於不一樣數量的構造函數參數,咱們只須要定義一種類型就好了。

咱們已經知道了如何實現!直接使用元組類型的剩餘參數:

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

咱們先仔細看一下 Tokens。經過將 Tokens 聲明爲 keyof Context 數組,咱們可以靜態地將 inject 屬性定義爲一種元組類型,TypeScript 編譯器會保持跟蹤每個 token。舉個例子,對於 inject = tokens('httpClient', 'logger')Tokens 類型會被解析爲 ['httpClient', 'logger']

構造函數的剩餘參數使用 CorrespondingTypes<Tokens> 映射類型,在下面一節中咱們詳細介紹這塊。

介紹:條件映射元組類型

CorrespondingTypes 被實現爲條件映射類型,代碼實現以下:

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)[])。

可是,元組類型的屬性名是什麼呢?好吧,你能夠認爲就是它的索引。所以,對於 ['foo', 'bar'],屬性名就是 01。實際上,對於元組類型和映射類型的搭配支持,已經在最近單獨的 PR 中支持了。一個超棒的特性。

如今,看下關聯屬性值,咱們使用了類型判斷:Tokens[I] extends keyof Context? Context[Tokens[I]] : never。所以,若是 token 是 Context 的一個鍵,就會返回對應鍵的類型;不然,返回 nerver 類型,意思就是告知 TypeScript 不會出現這種狀況。

挑戰3:注入

既然咱們有了 Injectable 接口,是時候用起來了。先建立核心類: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 類做爲參數,建立並返回須要的實例。該方法的具體實現已經超出了本文的範疇,可是你能夠思考一下:經過迭代 inject 屬性配置的 tokens 來查詢須要注入的值。

動態上下文

到目前爲止,咱們靜態聲明瞭 Context 類型,它是一個查詢類型,用於關聯 token 和其它類型。若是你在項目中須要這樣寫,會不怎麼光彩。由於這意味着整個 DI 上下文須要一次性初始化,後續不再能配置,一點都不實用。

爲了使 Context 動態化,咱們將其做爲另一個泛型傳入(我保證這會是最後一個泛型)。新的類型聲明以下:

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

好了,全部的內容看起來都仍是比較熟悉的。咱們引入了 TContext,用於表示 DI 上下文的查詢接口。

如今,還剩最後一個問題,咱們想要經過動態添加值的方式來配置 Injector。看下這塊的示例代碼:

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

如上所示,InjectorprovideXXX 方法,每一個 provide 方法都會向 TContext 泛型中添加鍵,咱們須要另一個 TypeScript 特性來實現這個效果。

介紹:交叉類型

在 TypeScript 中,能夠很輕鬆地用 & 組合兩種類型,所以 Foo & Bar 是一種同時擁有 FooBar 屬性的類型,這種類型被稱爲交叉類型。這有點像 C++ 的多重繼承或者 Scala 中的 traits。咱們將 TContext 與使用字符串字面量 token 的映射類型關聯起來:

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

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

你可能想知道爲何新的 TContext 要和 { [k in Token]: R } 作交叉而不是簡單地用 { [Token]: R }。這是由於 Token 自己能夠表示一個字符串字面量聯合類型,舉個例子,'foo'| 'bar'。雖然從 TypeScript 角度來看沒什麼問題,可是若是在調用 provideValue 的時候顯示地傳入一個聯合類型(provideValue<'foo' | 'bar', _>('foo', 42))將會破壞類型安全,它會在編譯時同時註冊 'foo''bar' 做爲 token,並關聯同一個數字,可是在運行時僅僅註冊了 'foo'。因此,在實際項目中不要這麼作。

其它 provideXXX 方法也是相似的道理,它們返回新的 Injector 實例,提供新的 token,同時合併進了全部舊的 token。

結論

TypeScript 的類型系統很強大,在本文中咱們結合了:

  • 字面量類型
  • 元組類型的剩餘參數
  • 查詢類型
  • 泛型
  • 條件映射元組類型
  • 交叉類型

來建立類型安全的依賴注入框架。

雖然,你不會老是遇到這些特性,可是對這些特性保持關注是值得的,畢竟它們爲更好地編碼提供了可能性。

相關文章
相關標籤/搜索