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(...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 {
    new(arg: Context[Token]): R;
    inject: [Token];
}

现在我们有了新的进展!我们声明了一个泛型变量 Token,限定了取值只能是 Context 中的键。我们也在构造函数中用 Context[Token] 关联了确定的类型。同时,我们也添加了一个类型参数 R,指代 Injectable(例如 MyService 实例)实例类型。

仍然存在一个问题,如果我们想让构造函数支持更多的参数,我们就需要为每一种参数数量声明一个类型:

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

这是不可持续的。理想情况下,对于不同数量的构造函数参数,我们只需要定义一种类型就行了。

我们已经知道了如何实现!直接使用元组类型的剩余参数:

interface Injectable {
    new(...args: CorrespondingTypes): R;
    inject: Tokens;
}

我们先仔细看一下 Tokens。通过将 Tokens 声明为 keyof Context 数组,我们能够静态地将 inject 属性定义为一种元组类型,TypeScript 编译器会保持跟踪每一个 token。举个例子,对于 inject = tokens('httpClient', 'logger')Tokens 类型会被解析为 ['httpClient', 'logger']

构造函数的剩余参数使用 CorrespondingTypes 映射类型,在下面一节中我们详细介绍这块。

介绍:条件映射元组类型

CorrespondingTypes 被实现为条件映射类型,代码实现如下:

type CorrespondingTypes = {
    [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(Injectable: Injectable): R {
        const args = /* resolve inject tokens */;
        return new Injectable(...args);
    }
}

Injector 类有一个 injectClass 方法,接收一个 Injectable 类作为参数,创建并返回需要的实例。该方法的具体实现已经超出了本文的范畴,但是你可以思考一下:通过迭代 inject 属性配置的 tokens 来查询需要注入的值。

动态上下文

到目前为止,我们静态声明了 Context 类型,它是一个查询类型,用于关联 token 和其它类型。如果你在项目中需要这样写,会不怎么光彩。因为这意味着整个 DI 上下文需要一次性初始化,后续再也不能配置,一点都不实用。

为了使 Context 动态化,我们将其作为另外一个泛型传入(我保证这会是最后一个泛型)。新的类型声明如下:

interface Injectable {
    new(...args: CorrespondingTypes): R;
    inject: Tokens;
}
type CorrespondingTypes = {
    [Token in keyof Tokens]: Tokens[Token] extends keyof TContext ? TContext[Tokens[Token]] : never;
}
class Injector {
    inject(injectable: Injectable): 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 {
  provideValue(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 的类型系统很强大,在本文中我们结合了:

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

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

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

你可能感兴趣的:(TypeScript 进阶:类型安全的依赖注入)