内容简介:本文叙述了如何使用 TypeScript 从头创建一个 100% 类型安全的依赖注入框架。在我作为专业 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
静态属性中声明依赖。可以使用 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 的类型系统很强大,在本文中我们结合了:
- 字面量类型
- 元组类型的剩余参数
- 查询类型
- 泛型
- 条件映射元组类型
- 交叉类型
来创建类型安全的依赖注入框架。
虽然,你不会总是遇到这些特性,但是对这些特性保持关注是值得的,毕竟它们为更好地编码提供了可能性。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Thinking Recursively
Eric S. Roberts / Wiley / 1986-1-17 / USD 85.67
The process of solving large problems by breaking them down into smaller, more simple problems that have identical forms. Thinking Recursively: A small text to solve large problems. Concentrating on t......一起来看看 《Thinking Recursively》 这本书的介绍吧!