原文拣宰。
本文敘述了如何使用 TypeScript 從頭創(chuàng)建一個(gè) 100% 類型安全的依賴注入框架掏呼。
在我作為專業(yè) TypeScript 講師的日子里筷黔,開(kāi)發(fā)者們經(jīng)常問(wèn)我:“為什么我們需要這么復(fù)雜的高級(jí)類型系統(tǒng)姐仅?”他們?cè)趯?shí)際項(xiàng)目中并沒(méi)有感受到對(duì)常量類型绰播、交叉類型骄噪、條件類型和元組式的剩余參數(shù)的需求。這是一個(gè)很好的問(wèn)題蠢箩,如果沒(méi)有一個(gè)合適的場(chǎng)景链蕊,是很難回答的。
這就促使我去尋找一個(gè)合適的場(chǎng)景谬泌。幸運(yùn)的是滔韵,我確實(shí)找到了一個(gè)場(chǎng)景:依賴注入,或者簡(jiǎn)稱為 DI掌实。
本文奏属,我將帶著你一起探索。首先我會(huì)解釋類型安全的依賴注入是什么意思潮峦。接下來(lái)我會(huì)展示最終代碼形態(tài)囱皿,這樣你就知道具體要達(dá)到什么目標(biāo)了。然后忱嘹,我們逐一解決靜態(tài)類型的依賴注入框架所遇到的挑戰(zhàn)嘱腥。
閱讀本文的前提是你已經(jīng)具備了 TypeScript 基礎(chǔ)知識(shí)。
目標(biāo)
我的目標(biāo)是在 TypeScript 中創(chuàng)建 100% 類型安全的依賴注入(DI)框架拘悦。如果你還不知道 DI齿兔,建議先閱讀 samueleresca 寫(xiě)的這篇文章,文章介紹了什么是 DI础米,以及為什么要使用 DI分苇。同時(shí)文章中也介紹了 InversifyJS,它是目前最流行的 TypeScript DI 框架屁桑,借助 TypeScript 的裝飾器和reflect-metadata在運(yùn)行時(shí)解析依賴医寿。
InversifyJS 確實(shí)實(shí)現(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
類型须眷,但是在運(yùn)行時(shí)它卻是一個(gè) number
類型。實(shí)際上沟突,在 DI 配置中很容易犯類似這樣的錯(cuò)誤花颗。由于 DI 的緣故而失去類型安全性,這太糟糕了惠拭。
我的目標(biāo)就是調(diào)研“是否能讓編譯器知道依賴及其類型”扩劝。如果你的代碼有編譯過(guò)程,那么這會(huì)很有用:字符串就是字符串职辅,數(shù)字就是數(shù)字棒呛,F(xiàn)oo 就是 Foo,不會(huì)出現(xiàn)任何其它可能性罐农。
最終結(jié)果
如果你對(duì)最終結(jié)果感興趣条霜,那么我可以告訴你:我成功了!你可以看看 GitHub 上的這個(gè)項(xiàng)目涵亏。下面是從 README 中提取出來(lái)的一段最簡(jiǎn)化代碼:
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)屬性中聲明依賴宰睡。可以使用 Injector
的 injectClass
方法實(shí)例化一個(gè)類气筋,任何構(gòu)造器參數(shù)或者 inject
屬性中的錯(cuò)誤都會(huì)引起編譯錯(cuò)誤拆内。
很好奇原理吧?這就對(duì)了宠默。
挑戰(zhàn)
為了讓編譯器給出編譯錯(cuò)誤麸恍,有三個(gè)挑戰(zhàn):
- 如何靜態(tài)聲明依賴?
- 在構(gòu)造函數(shù)的參數(shù)中搀矫,怎么關(guān)聯(lián)上依賴的類型抹沪?
- 如何實(shí)現(xiàn)一個(gè)
Injector
,用于根據(jù)類型生成實(shí)例瓤球?
我們逐一解決上述挑戰(zhàn)融欧。
挑戰(zhàn)1:聲明依賴
我們從靜態(tài)聲明依賴開(kāi)始。InversifyJS 使用裝飾器卦羡,比如:@inject('bar')
用于尋找一個(gè)叫做 bar
的依賴并將其注入噪馏,由于裝飾器動(dòng)態(tài)運(yùn)行方式(裝飾器僅僅是一個(gè)運(yùn)行時(shí)執(zhí)行的函數(shù)),沒(méi)辦法在編譯階段確定 bar
依賴存在绿饵。
所以我們不能使用裝飾器欠肾,我們找找其他方式來(lái)聲明依賴。
在 Angular 仍叫 AngularJS 的時(shí)代拟赊,我們?cè)陬悾ó?dāng)時(shí)我們稱之為構(gòu)造函數(shù))上面的 $inject
靜態(tài)屬性上聲明依賴刺桃。在 $inject
屬性上的值,我們稱之為“tokens”要门,$inject
數(shù)組中聲明的 tokens 順序與構(gòu)造函數(shù)中參數(shù)的順序保持一一對(duì)應(yīng)關(guān)系虏肾。我們用 MyService
舉個(gè)相似的例子:
class MyService {
constructor(private http: HttpClient, private log: Logger) { }
public static inject = ['httpClient', 'logger'];
}
這是一個(gè)好的開(kāi)始廓啊,但是我們還沒(méi)達(dá)到目標(biāo)欢搜。通過(guò)字符串?dāng)?shù)組的方式初始化 inject
屬性封豪,編譯器只會(huì)將其解析為普通的字符串?dāng)?shù)組類型,編譯器沒(méi)辦法將 bar
token 與 Bar
類型關(guān)聯(lián)起來(lái)炒瘟。
介紹:字面量類型
當(dāng)寫(xiě)錯(cuò)代碼的時(shí)候吹埠,我們期望編譯器會(huì)報(bào)錯(cuò)。為了在編譯時(shí)能知道 token 數(shù)組的值疮装,我們需要將其類型聲明為字符串字面量:
class MyService {
constructor(private http: HttpClient, private log: Logger) { }
public static inject: ['httpClient', 'logger'] = ['httpClient', 'logger'];
}
我們告訴了 TypeScript 數(shù)組的類型是一個(gè)值為 ['httpClient', 'logger']
的 元組缘琅,現(xiàn)在我們有了一絲進(jìn)展。但是廓推,我們是懶惰的開(kāi)發(fā)者刷袍,我們不想寫(xiě)重復(fù)的代碼。讓我們使其更加符合 DRY 原則樊展。
介紹:結(jié)合元組類型和剩余參數(shù)
我們可以創(chuàng)建一個(gè)簡(jiǎn)單的輔助方法呻纹,它接收任意數(shù)量的字面量字符串參數(shù),返回相應(yīng)的字面量元組值专缠,看起來(lái)大致這樣:
function tokens<Tokens extends string[]>(...theTokens: Tokens): Tokens {
return theTokens;
}
如上所示雷酪,theTokens
參數(shù)聲明為剩余參數(shù),它能匹配到函數(shù)的所有參數(shù)涝婉,同時(shí)類型被定義為 Tokens
哥力,繼承自 string[]
,因此能匹配到任何字符串類型墩弯。返回值是 theTokens
吩跋,其類型是字面量字符串元組。這樣一來(lái)渔工,我們就能避免之前例子中的重復(fù)編碼:
class MyService {
constructor(private http: HttpClient, private log: Logger) { }
public static inject = tokens('httpClient', 'logger');
}
如上所示锌钮,只需要列舉 tokens 一次就行,inject
的類型就會(huì)是 ['httpClient'涨缚,'logger']
轧粟。變得更棒了,你覺(jué)得呢脓魏?
TypeScript 中有望引入顯式的元組語(yǔ)法兰吟,因此以后我們不再需要額外的 tokens
輔助函數(shù)。
挑戰(zhàn)2:關(guān)聯(lián)依賴
說(shuō)到了有趣的部分:確泵瑁可注入類的構(gòu)造函數(shù)的參數(shù)與聲明的 tokens 相匹配混蔼。
首先,我們聲明 MyService
類(或者任何可注入的類)的靜態(tài)接口:
interface Injectable {
new(...args: any): any;
inject: string[];
}
Injectable
接口描述了一種類:有一個(gè)接收任意數(shù)量參數(shù)的構(gòu)造函數(shù)珊燎;有一個(gè)靜態(tài) inject
數(shù)組屬性惭嚣,包含了注入 tokens遵湖,類型為 string[]
。這僅僅是個(gè)開(kāi)始晚吞,實(shí)際上用處不大延旧,不能夠?qū)?tokens 值與構(gòu)造函數(shù)參數(shù)的類型關(guān)聯(lián)起來(lái)。
介紹:查詢類型
因此槽地,我們需要告訴 TypeScript 編譯器迁沫,哪個(gè) token 對(duì)應(yīng)哪種類型。幸運(yùn)的是捌蚊,TypeScript 支持查詢類型:它是一種不必直接作為類型使用的簡(jiǎn)單 interface
集畅,我們將其用作查詢類型的字典。聲明一個(gè) Context
查詢類型缅糟,其值可用于注入:
interface Context {
httpClient: HttpClient;
logger: Logger;
}
任何時(shí)候你想聲明一個(gè) Logger
實(shí)例挺智,都可以使用 Context
查詢類型,例如 let log: Context['logger']
窗宦。有了這個(gè)接口赦颇,我們可以指定 MyService
類的 inject
屬性必須是 Context
的鍵:
interface Injectable {
new(...arg: (Context[keyof Context])[]): any;
inject: (keyof Context)[];
}
這更加接近目標(biāo)了。我們收窄了 inject
的有效值到一個(gè) keyof Context
數(shù)組迫摔,因此只能使用 'logger' 或者 'httpClient' 作為 token沐扳。構(gòu)造函數(shù)中的每一個(gè)參數(shù)的類型都是 Context[keyof Context]
,因此要么是 Logger
句占,要么是 HttpClient
沪摄。
但是,并沒(méi)有達(dá)到目的纱烘。我們?nèi)匀恍枰_關(guān)聯(lián)值杨拐,這就要用到泛型了。
介紹:泛型
展示一個(gè)泛型魔法:
interface Injectable<Token extends keyof Context, R> {
new(arg: Context[Token]): R;
inject: [Token];
}
現(xiàn)在我們有了新的進(jìn)展擂啥!我們聲明了一個(gè)泛型變量 Token
哄陶,限定了取值只能是 Context
中的鍵。我們也在構(gòu)造函數(shù)中用 Context[Token]
關(guān)聯(lián)了確定的類型哺壶。同時(shí)屋吨,我們也添加了一個(gè)類型參數(shù) R
,指代 Injectable
(例如 MyService
實(shí)例)實(shí)例類型山宾。
仍然存在一個(gè)問(wèn)題至扰,如果我們想讓構(gòu)造函數(shù)支持更多的參數(shù),我們就需要為每一種參數(shù)數(shù)量聲明一個(gè)類型:
interface Injectable2<Token extends keyof Context, Token2 extends keyof Context, R> {
new(arg: Context[Token], arg2: Context[Token2]): R;
inject: [Token, Token2];
}
這是不可持續(xù)的资锰。理想情況下敢课,對(duì)于不同數(shù)量的構(gòu)造函數(shù)參數(shù),我們只需要定義一種類型就行了。
我們已經(jīng)知道了如何實(shí)現(xiàn)直秆!直接使用元組類型的剩余參數(shù):
interface Injectable<Tokens extends (keyof Context)[], R> {
new(...args: CorrespondingTypes<Tokens>): R;
inject: Tokens;
}
我們先仔細(xì)看一下 Tokens
濒募。通過(guò)將 Tokens
聲明為 keyof Context
數(shù)組,我們能夠靜態(tài)地將 inject
屬性定義為一種元組類型圾结,TypeScript 編譯器會(huì)保持跟蹤每一個(gè) token瑰剃。舉個(gè)例子,對(duì)于 inject = tokens('httpClient', 'logger')
疫稿,Tokens
類型會(huì)被解析為 ['httpClient', 'logger']
培他。
構(gòu)造函數(shù)的剩余參數(shù)使用 CorrespondingTypes<Tokens>
映射類型鹃两,在下面一節(jié)中我們?cè)敿?xì)介紹這塊遗座。
介紹:條件映射元組類型
CorrespondingTypes
被實(shí)現(xiàn)為條件映射類型,代碼實(shí)現(xiàn)如下:
type CorrespondingTypes<Tokens extends (keyof Context)[]> = {
[I in keyof Tokens]: Tokens[I] extends keyof Context ? Context[Tokens[I]] : never;
}
上述代碼“一言難盡”俊扳,我們逐層分析途蒋。
首先,我們需要知道 CorrespondingTypes
是映射類型:新類型的屬性名與源類型一致馋记,但是是一種不同的類型号坡。在上面代碼中,我們映射了 Tokens
的屬性梯醒。Tokens
是一個(gè)泛型元組類型(extends (keyof Context)[]
)宽堆。
但是,元組類型的屬性名是什么呢茸习?好吧畜隶,你可以認(rèn)為就是它的索引。因此号胚,對(duì)于 ['foo', 'bar']
籽慢,屬性名就是 0
和 1
。實(shí)際上猫胁,對(duì)于元組類型和映射類型的搭配支持箱亿,已經(jīng)在最近單獨(dú)的 PR 中支持了。一個(gè)超棒的特性弃秆。
現(xiàn)在届惋,看下關(guān)聯(lián)屬性值,我們使用了類型判斷:Tokens[I] extends keyof Context? Context[Tokens[I]] : never
菠赚。因此脑豹,如果 token 是 Context
的一個(gè)鍵,就會(huì)返回對(duì)應(yīng)鍵的類型锈至;否則晨缴,返回 nerver
類型,意思就是告知 TypeScript 不會(huì)出現(xiàn)這種情況峡捡。
挑戰(zhàn)3:注入
既然我們有了 Injectable
接口击碗,是時(shí)候用起來(lái)了筑悴。先創(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
類有一個(gè) injectClass
方法稍途,接收一個(gè) Injectable
類作為參數(shù)阁吝,創(chuàng)建并返回需要的實(shí)例。該方法的具體實(shí)現(xiàn)已經(jīng)超出了本文的范疇械拍,但是你可以思考一下:通過(guò)迭代 inject
屬性配置的 tokens 來(lái)查詢需要注入的值突勇。
動(dòng)態(tài)上下文
到目前為止,我們靜態(tài)聲明了 Context
類型坷虑,它是一個(gè)查詢類型甲馋,用于關(guān)聯(lián) token 和其它類型。如果你在項(xiàng)目中需要這樣寫(xiě)迄损,會(huì)不怎么光彩黄锤。因?yàn)檫@意味著整個(gè) DI 上下文需要一次性初始化妈经,后續(xù)再也不能配置听哭,一點(diǎn)都不實(shí)用肩榕。
為了使 Context
動(dòng)態(tài)化,我們將其作為另外一個(gè)泛型傳入(我保證這會(huì)是最后一個(gè)泛型)氏捞。新的類型聲明如下:
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)容看起來(lái)都還是比較熟悉的。我們引入了 TContext
液茎,用于表示 DI 上下文的查詢接口逞姿。
現(xiàn)在,還剩最后一個(gè)問(wèn)題豁护,我們想要通過(guò)動(dòng)態(tài)添加值的方式來(lái)配置 Injector
哼凯。看下這塊的示例代碼:
const appInjector = rootInjector
.provideValue('logger', logger)
.provideClass('httpClient', HttpClient);
如上所示楚里,Injector
有 provideXXX
方法断部,每個(gè) provide 方法都會(huì)向 TContext
泛型中添加鍵,我們需要另外一個(gè) TypeScript 特性來(lái)實(shí)現(xiàn)這個(gè)效果班缎。
介紹:交叉類型
在 TypeScript 中蝴光,可以很輕松地用 &
組合兩種類型,因此 Foo & Bar
是一種同時(shí)擁有 Foo
和 Bar
屬性的類型达址,這種類型被稱為交叉類型蔑祟。這有點(diǎn)像 C++ 的多重繼承或者 Scala 中的 traits。我們將 TContext
與使用字符串字面量 token 的映射類型關(guān)聯(lián)起來(lái):
class Injector<TContext> {
provideValue<Token extends string, R>(token: Token, value: R)
: Injector<{ [K in Token]: R } & TContext> {
/* out of scope */
}
}
如上所示沉唠,provideValue
有兩個(gè)泛型參數(shù):一個(gè)是 token 常量類型(Token
)疆虚,一個(gè)是注入的值的類型(R
)。該方法返回了一個(gè)新的 Injector
實(shí)例,其上下文為 { [K in Token]: R } & TContext
径簿。也就是說(shuō)罢屈,可以注入任何當(dāng)前注入器支持的值,也可以是新提供的 token篇亭。
你可能想知道為什么新的 TContext
要和 { [k in Token]: R }
做交叉而不是簡(jiǎn)單地用 { [Token]: R }
缠捌。這是因?yàn)?Token
本身可以表示一個(gè)字符串字面量聯(lián)合類型,舉個(gè)例子译蒂,'foo'| 'bar'
曼月。雖然從 TypeScript 角度來(lái)看沒(méi)什么問(wèn)題,但是如果在調(diào)用 provideValue
的時(shí)候顯示地傳入一個(gè)聯(lián)合類型(provideValue<'foo' | 'bar', _>('foo', 42)
)將會(huì)破壞類型安全柔昼,它會(huì)在編譯時(shí)同時(shí)注冊(cè) 'foo'
和 'bar'
作為 token哑芹,并關(guān)聯(lián)同一個(gè)數(shù)字,但是在運(yùn)行時(shí)僅僅注冊(cè)了 'foo'
岳锁。所以绩衷,在實(shí)際項(xiàng)目中不要這么做。
其它 provideXXX
方法也是類似的道理激率,它們返回新的 Injector
實(shí)例,提供新的 token勿决,同時(shí)合并進(jìn)了所有舊的 token乒躺。
結(jié)論
TypeScript 的類型系統(tǒng)很強(qiáng)大,在本文中我們結(jié)合了:
- 字面量類型
- 元組類型的剩余參數(shù)
- 查詢類型
- 泛型
- 條件映射元組類型
- 交叉類型
來(lái)創(chuàng)建類型安全的依賴注入框架低缩。
雖然嘉冒,你不會(huì)總是遇到這些特性,但是對(duì)這些特性保持關(guān)注是值得的咆繁,畢竟它們?yōu)楦玫鼐幋a提供了可能性讳推。