TypeScript 进阶:类型安全的依赖注入

原文

本文叙述了如何使用 TypeScript 从头创建一个 100% 类型安全的依赖注入框架。

在我作为专业 TypeScript 讲师的日子里,开发者们经常问我:“为什么我们需要这么复杂的高级类型系统?”他们在实际项目中并没有感受到对常量类型交叉类型条件类型元组式的剩余参数的需求。这是一个很好的问题,如果没有一个合适的场景,是很难回答的。

这就促使我去寻找一个合适的场景。幸运的是,我确实找到了一个场景:依赖注入,或者简称为 DI。

本文,我将带着你一起探索。首先我会解释类型安全的依赖注入是什么意思。接下来我会展示最终代码形态,这样你就知道具体要达到什么目标了。然后,我们逐一解决静态类型的依赖注入框架所遇到的挑战。

阅读本文的前提是你已经具备了 TypeScript 基础知识。

目标

我的目标是在 TypeScript 中创建 100% 类型安全的依赖注入(DI)框架。如果你还不知道 DI,建议先阅读 samueleresca 写的这篇文章,文章介绍了什么是 DI,以及为什么要使用 DI。同时文章中也介绍了 InversifyJS,它是目前最流行的 TypeScript DI 框架,借助 TypeScript 的装饰器reflect-metadata在运行时解析依赖。

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 的缘故而失去类型安全性,这太糟糕了。

我的目标就是调研“是否能让编译器知道依赖及其类型”。如果你的代码有编译过程,那么这会很有用:字符串就是字符串,数字就是数字,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 的类型系统很强大,在本文中我们结合了:

  • 字面量类型
  • 元组类型的剩余参数
  • 查询类型
  • 泛型
  • 条件映射元组类型
  • 交叉类型

来创建类型安全的依赖注入框架。

虽然,你不会总是遇到这些特性,但是对这些特性保持关注是值得的,毕竟它们为更好地编码提供了可能性。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容