内容简介:本文叙述了如何使用 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 的类型系统很强大,在本文中我们结合了:
- 字面量类型
- 元组类型的剩余参数
- 查询类型
- 泛型
- 条件映射元组类型
- 交叉类型
来创建类型安全的依赖注入框架。
虽然,你不会总是遇到这些特性,但是对这些特性保持关注是值得的,毕竟它们为更好地编码提供了可能性。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Writing Windows VxDs and Device Drivers, Second Edition
Karen Hazzah / CMP / 1996-01-12 / USD 54.95
Software developer and author Karen Hazzah expands her original treatise on device drivers in the second edition of "Writing Windows VxDs and Device Drivers." The book and companion disk include the a......一起来看看 《Writing Windows VxDs and Device Drivers, Second Edition》 这本书的介绍吧!