原文。
本文叙述了如何使用 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
静态属性中声明依赖。可以使用 Injector
的 injectClass
方法实例化一个类,任何构造器参数或者 inject
属性中的错误都会引起编译错误。
很好奇原理吧?这就对了。
挑战
为了让编译器给出编译错误,有三个挑战:
- 如何静态声明依赖?
- 在构造函数的参数中,怎么关联上依赖的类型?
- 如何实现一个
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']
,属性名就是 0
和 1
。实际上,对于元组类型和映射类型的搭配支持,已经在最近单独的 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);
如上所示,Injector
有 provideXXX
方法,每个 provide 方法都会向 TContext
泛型中添加键,我们需要另外一个 TypeScript 特性来实现这个效果。
介绍:交叉类型
在 TypeScript 中,可以很轻松地用 &
组合两种类型,因此 Foo & Bar
是一种同时拥有 Foo
和 Bar
属性的类型,这种类型被称为交叉类型。这有点像 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 的类型系统很强大,在本文中我们结合了:
- 字面量类型
- 元组类型的剩余参数
- 查询类型
- 泛型
- 条件映射元组类型
- 交叉类型
来创建类型安全的依赖注入框架。
虽然,你不会总是遇到这些特性,但是对这些特性保持关注是值得的,毕竟它们为更好地编码提供了可能性。