本文为来自飞书 aPaaS Growth 研发 团队成员的文章,已授权 ELab 发布。
aPaaS Growth 团队专注在用户可感知的、宏观的 aPaaS 应用的搭建流程,及租户、应用治理等产品路径,致力于打造 aPaaS 平台流畅的 “应用交付” 流程和体验,完善应用构建相关的生态,加强应用搭建的便捷性和可靠性,提升应用的整体性能,从而助力 aPaaS 的用户增长,与基础团队一起推进 aPaaS 在企业内外部的落地与提效。
背景
之前在技术需求中曾调研了基于 TypeScript 的数据校验方案,其中调研了一个叫 Deepkit 的第三方库,可以将 TypeScript 的类型信息保留到运行时进行消费。
TypeScript 带来的
传统开发上,Javascript 基本没有提供任何类型保护,所有的类型错误都需要在运行时才能发现,而TypeScript 为开发者提供了一套静态类型检查的方案,它提倡开发者在源码中主动声明类型信息,并与对应的变量和操作相匹配,并在编译阶段进行检查,类型相关的错误在编译时就暴露出来,一方面使代码更规范了,一方面也极大程度地规避了许多代码错误,提高了代码的健壮性。
TypeScirpt 拥有完备的类型系统。但很可惜,它在这方面的能力在运行时几乎完全不存在。TypeScript Compiler在编译源码时会删除类型信息,不对运行时造成任何开销。
但其实在许多场景下,运行时的类型信息都是极具价值的!
为什么需要运行时类型
为什么我们需要运行时的类型信息呢?让我们看看下面两个场景
数据校验
数据校验并不是局限于传统前端所关注的表单校验,需要数据校验的场景数不胜数,比如:
在编写服务的时候,若我们需要实现一个接口。对于我们来说,传入的参数是未知的,我们永远不知道业务方会给我传来什么奇奇怪怪的参数。如果我们不对参数进行校验的话,后面的代码逻辑随时可能崩溃。而参数校验自然就需要在运行时消费参数的类型定义信息。
数据库,一张表中所有字段的类型都是有严格定义的,所以在数据写入数据库时,需要校验写入的数据是否符合字段的类型定义,这也需要运行时的类型信息。
序列化与反序列化
序列化是将数据类型转换为适合传输或存储的格式的过程。反序列化是撤消此操作的过程,这个过程需要保证是无损的。对于前端开发者来说,接触的最多的应该就是 JSON.parse() 和 JSON.stringify() 这两个方法。在简单场景下,用这两个方法做序列化和反序列化可能没有问题,但是在复杂场景中就不一定了,因为这两个方法并不能保证数据是无损的。
例如下面这个场景
对于日期类型的数据,先用 JSON.stringify(date) 将其序列化成了适合传输的格式,再用JSON.parse(dateString) 反序列化,发现日期这个类型在过程中已经丢失,最后反序列化的结果为一个字符串,这显然是不符合预期的。因此,在序列化和反序列化的过程中,类型信息也十分重要。
而 DeepKit 使将 TypeScript 类型保留到运行时成为现实。
快速开始
官方文档站:https://deepkit.io/
前置
使用 DeepKit 需要安装两个包:
- @deepkit/type:提供运行时可以使用的方法
- @deepkit/type-compiler:类型编译器,介入TypeScript 编译流程,保留类型信息。可以放在package.json 的devDependencies中,因为这个类型编译器只需要编译阶段使用。
然后需要在 tsconfig.json 中配置 "reflection": true 。如果需要使用装饰器,还需要加入"experimentalDecorators": true 参数
类型信息
DeepKit 定义了两种用于描述运行时的类型信息的数据结构,分别是类型对象和反射类。
类型对象
使用 typeOf 方法可以快速获取某个类型对应的类型对象。
从上面的例子中,我们可以看到一个类型对象的基本数据结构(当然,这还不是它的全貌)。详细的类型对象定义:https://github.com/deepkit/deepkit-framework/blob/feature/autotype/packages/type/src/reflection/type.ts#L21-L452
- kind:ReflectionKind,表示传入的类型。例子中对应 Title 的类型
- typeName:string,如果用到了类型别名,会返回这个字段,标识该类型
- typeArguments:当我们用了泛型时,传递进去的类型信息也会保留到类型对象中,会返回typeArguments 字段中记录的就是对应的类型信息。
反射类
反射类多用于 类/接口/对象类型等等比较复杂的场景
对于复杂场景,我们可以通过 ReflectionClass.from 方法得到类型对应的放射类实例 ReflectionClass ,通过调用ReflectionClass中的方法可以获取更深层次的类型信息,也可以对类型信息做一些操作。
验证
需要数据验证的场景数不胜数,接口参数校验,数据库实现等都高度依赖数据校验,以此保证数据的安全性。
DeepKit 提供了is和validate两个函数,用于校验一个值是否符合类型定义。
is 函数接收类型信息,并对参数中的数据进行校验,返回一个布尔值。如上面的例子,定义了一个 People 的 interface,并对 peopleA 和 peopleB 两个数据进行校验,可以看出 peopleA 是符合 People 的 定义的,所以返回is<People>(peopleA)会返回 true 。peopleB 中的 info 属性缺少了必填的 phone 字段,因此is<People>(peopleB) 会返回 false 。
validate 函数和 is 函数的用法类似,区别是 validate 函数并不是返回一个布尔值 ,而是一个包含错误信息的数组。
path:错误路径,指向出错的具体属性
code:错误类型,目前好像只有type 一种。
message:具体的错误信息。
序列化
DeepKit 中 serialize/deserialize 两个方法,为用户提供了序列化/反序列化的能力
serialize 方法接收类型信息和需要序列化的数据,将数据序列化为符合类型定义的JSON对象。
deserialize 方法接收类型信息和需要反序列化的数据,将数据反序列化为符合类型信息定义的数据。代码中的 created 字段会被反序列化为 Date 字段。
类型装饰器
一句话概括装饰器:装饰器本质上就是一个函数,可以在运行时对被装饰对象进行自定义的加工处理。
DeepKit 中提供了一套类型装饰器,这里的类型装饰器和 TypeScript 的装饰器并不相同,TypeScript 多用于对类的装饰,类型装饰器顾名思义是对类型的装饰。这些类型装饰器可以被当作一个正常的 TypeScript 类型使用。
举一个简单的例子
我们对定义 count 类型为 integer(整型),可以看到,1.1这个浮点数类型并没有通过校验。
除此之外,DeepKit 还实现了如 PrimaryKey(主键),maxLength/minLength(最小/最大长度)等功能的类型装饰器。我们可以把这些类型装饰器看作对于 TypeScript 类型的拓展,这些类型装饰器使 TypeScript 能够实现数据库级别的类型定义。也正是基于这套拓展后的运行时类型,验证和序列化可以有更多的约束,DeepKit 也实现了一套高性能的 ORM 。
More
@deepKit/type 给我们提供了一套运行时调用类型信息的方案。除此之外,DeepKit 的作者还基于类型信息和反射机制实现了更多的能力。
- 事件系统:@deepkit/events
- HTTP 库:@deepkit/http
- RPC服务:@deepkit/rpc
- 数据库ORM:@deepkit/orm
- 模版引擎:@deepkit/templat ,但与react不兼容
- 大一统框架:@deepkit/framework ,集成了上述能力的 node 框架
如何保证性能
为了尽量压缩运行时的额外开销,DeepKit 的作者做出了不少优化。
类型缓存
在未使用泛型的情况下,DeepKit 会对使用到的类型对象进行缓存
可以看到,对于 case1 ,Mytype 对应的类型对象会被缓存,因此两次typeOf<MyType>() 的结果相等;但是对于泛型来说,我们无法确定传入的 T 具体是什么类型(理论上会有无限种),因此不会结果进行缓存,每次都会创建一个新的类型对象。
类型编译器
DeepKit 的核心原理是一个类型编译器,它会介入TypeScript 的编译流程,保留类型信息, 在这个过程中,Deepkit 的类型编译器会读取源码中的类型信息,产生相关的字节码(为了使它尽可能小),并将其插入 AST 中,将其转化为另一个包含这些字节码信息的 TypeScript AST。
在运行时,DeepKit 会有一个迷你虚拟机,负责解析和执行这些字节码,最后会返回一个类型对象。
更详细的原理可以参考:https://github.com/microsoft/TypeScript/issues/47658
在 DeepKit 官方提供的性能图中,可以看到 DeepKit 在数据读写上的表现是比较优秀的,这也归功于 DeepKit 提供的 运行时类型信息,这种预先知晓类型信息的机制可以使 序列化/验证等更加快速高效。
总结
DeepKit 是市场上第一个在 JavaScript 运行时提供全套 TypeScript 类型的解决方案。它使前端/服务端可以共用一套TypeScript定义的数据模型,并且使用基于 TypeScript 实现的一套反射机制。
但它依旧存在一些不足,比如 不支持外部类型,若代码中使用的类型信息来自第三方,且第三方库也没有经过 deepkit 的类型编译器的话,外部类型的类型信息在运行时也会全部丢失。
官方文档站:https://deepkit.io/
一些讨论
在TypeScript的仓库中,其实已经有许多人提出了issue,对在运行时保留Typescript的类型信息提出了自己的设想。可以看出,在基于 TypeScript支持动态类型这件事情上,是有需求的,但是 TypeScript 始终是保持保留意见,并没有实质去支持相关能力。
个人的看法,根本上是和 TypeScript 的设计目标[1] 挂钩, TypeScript 官方团队并不希望 TypeScript 会对运行时造成额外的开销,并且希望生成的 JavaScript 尽量纯净。TypeScript 官方团队 的保守严谨造就了 TypeScript 的成功。可能正因如此,TypeScript 官方团队才一直对支持运行时类型持保守态度。
参考文献
https://deepkit.io/ https://github.com/microsoft/TypeScript/issues/47658