DeepKit —— 赋予 TypeScript 更多可能性

开发 前端
传统开发上,Javascript 基本没有提供任何类型保护,所有的类型错误都需要在运行时才能发现,而TypeScript 为开发者提供了一套静态类型检查的方案,它提倡开发者在源码中主动声明类型信息,并与对应的变量和操作相匹配,并在编译阶段进行检查,类型相关的错误在编译时就暴露出来,一方面使代码更规范了,一方面也极大程度地规避了许多代码错误,提高了代码的健壮性。

本文为来自飞书 aPaaS Growth 研发 团队成员的文章,已授权 ELab 发布。

aPaaS Growth 团队专注在用户可感知的、宏观的 aPaaS 应用的搭建流程,及租户、应用治理等产品路径,致力于打造 aPaaS 平台流畅的 “应用交付” 流程和体验,完善应用构建相关的生态,加强应用搭建的便捷性和可靠性,提升应用的整体性能,从而助力 aPaaS 的用户增长,与基础团队一起推进 aPaaS 在企业内外部的落地与提效。

​背景​

之前在技术需求中曾调研了基于 TypeScript 的数据校验方案,其中调研了一个叫 Deepkit 的第三方库,可以将 TypeScript 的类型信息保留到运行时进行消费。

图片

​TypeScript 带来的​

传统开发上,Javascript 基本没有提供任何类型保护,所有的类型错误都需要在运行时才能发现,而TypeScript 为开发者提供了一套静态类型检查的方案,它提倡开发者在源码中主动声明类型信息,并与对应的变量和操作相匹配,并在编译阶段进行检查,类型相关的错误在编译时就暴露出来,一方面使代码更规范了,一方面也极大程度地规避了许多代码错误,提高了代码的健壮性。

图片

TypeScirpt 拥有完备的类型系统。但很可惜,它在这方面的能力在运行时几乎完全不存在。TypeScript Compiler在编译源码时会删除类型信息,不对运行时造成任何开销。

但其实在许多场景下,运行时的类型信息都是极具价值的!

​为什么需要运行时类型​

为什么我们需要运行时的类型信息呢?让我们看看下面两个场景

数据校验

数据校验并不是局限于传统前端所关注的表单校验,需要数据校验的场景数不胜数,比如:

在编写服务的时候,若我们需要实现一个接口。对于我们来说,传入的参数是未知的,我们永远不知道业务方会给我传来什么奇奇怪怪的参数。如果我们不对参数进行校验的话,后面的代码逻辑随时可能崩溃。而参数校验自然就需要在运行时消费参数的类型定义信息。

数据库,一张表中所有字段的类型都是有严格定义的,所以在数据写入数据库时,需要校验写入的数据是否符合字段的类型定义,这也需要运行时的类型信息。

序列化与反序列化

序列化是将数据类型转换为适合传输或存储的格式的过程。反序列化是撤消此操作的过程,这个过程需要保证是无损的。对于前端开发者来说,接触的最多的应该就是 JSON.parse()​ 和 JSON.stringify() 这两个方法。在简单场景下,用这两个方法做序列化和反序列化可能没有问题,但是在复杂场景中就不一定了,因为这两个方法并不能保证数据是无损的。

例如下面这个场景

const date = new Date();
const dateString = JSON.stringify(date);//"2022-11-02T17:49:03.240Z"
const dateJson = JSON.parse(dateString);//"2022-11-02T17:49:03.240Z"

对于日期类型的数据,先用 JSON.stringify(date) 将其序列化成了适合传输的格式,再用JSON.parse(dateString) 反序列化,发现日期这个类型在过程中已经丢失,最后反序列化的结果为一个字符串,这显然是不符合预期的。因此,在序列化和反序列化的过程中,类型信息也十分重要。

而 DeepKit 使将 TypeScript 类型保留到运行时成为现实。

​快速开始​

官方文档站:https://deepkit.io/

前置

使用 DeepKit 需要安装两个包:

  • @deepkit/type:提供运行时可以使用的方法
  • @deepkit/type-compiler:类型编译器,介入TypeScript 编译流程,保留类型信息。可以放在package.json​ 的devDependencies中,因为这个类型编译器只需要编译阶段使用。
npm install --save @deepkit/type
npm install --save-dev @deepkit/type-compiler

然后需要在 tsconfig.json​ 中配置 "reflection": true​ 。如果需要使用装饰器,还需要加入"experimentalDecorators": true 参数

// tsconfig.json
{
"compilerOptions":{
"module":"CommonJS",
"target":"es6",
"moduleResolution":"node",
"experimentalDecorators":true
},
"reflection":true
}

类型信息

DeepKit 定义了两种用于描述运行时的类型信息的数据结构,分别是类型对象和反射类。

类型对象

使用 typeOf 方法可以快速获取某个类型对应的类型对象。

import { typeOf } from '@deepkit/type';
type Title<T> = T extends true ? string : number;

typeOf<Title<true>>();
//Type {kind: 5, typeName: 'Title', typeArguments: [{kind: 7}]}

从上面的例子中,我们可以看到一个类型对象的基本数据结构(当然,这还不是它的全貌)。详细的类型对象定义:https://github.com/deepkit/deepkit-framework/blob/feature/autotype/packages/type/src/reflection/type.ts#L21-L452

  • kind:ReflectionKind,表示传入的类型。例子中对应 Title 的类型
  • typeName:string,如果用到了类型别名,会返回这个字段,标识该类型
  • typeArguments:当我们用了泛型时,传递进去的类型信息也会保留到类型对象中,会返回typeArguments 字段中记录的就是对应的类型信息。
enum ReflectionKind {
never, //0
any, //1
unknown, //2
void, //3
object, //4
string, //5
number, //6
boolean, //7
symbol, //8
bigint, //9
null, //10
undefined, //11

//... and even more
}

反射类

反射类多用于 类/接口/对象类型等等比较复杂的场景

import { ReflectionClass } from '@deepkit/type';

interface User {
id: number;
username: string;
}

const reflection = ReflectionClass.from<User>();

reflection.getProperty('id'); //ReflectionProperty,记录id类型信息

reflection.getProperty('id').name; //'id'
reflection.getProperty('id').type; //{kind: ReflectionKind.number}
reflection.getProperty('id').isOptional(); //false
reflection.removeProperty('id');
reflection.getProperty('id');//Error: No property id found in User

对于复杂场景,我们可以通过 ReflectionClass.from 方法得到类型对应的放射类实例 ReflectionClass ,通过调用ReflectionClass中的方法可以获取更深层次的类型信息,也可以对类型信息做一些操作。

验证

需要数据验证的场景数不胜数,接口参数校验,数据库实现等都高度依赖数据校验,以此保证数据的安全性。

DeepKit 提供了is和validate两个函数,用于校验一个值是否符合类型定义。

interface People {
name: string
age: number,
info?: {
address?: string,
phone: number
}
}

const peopleA = {
name: 'Jack',
age: 20,
}

const peopleB = {
name: 'Peter',
age: 18,
info: {}
}

is<People>(peopleA)//true
is<People>(peopleB)//false

is 函数接收类型信息,并对参数中的数据进行校验,返回一个布尔值。如上面的例子,定义了一个 People 的 interface,并对 peopleA 和 peopleB 两个数据进行校验,可以看出 peopleA 是符合 People 的 定义的,所以返回is<People>(peopleA)​会返回 true 。peopleB 中的 info 属性缺少了必填的 phone 字段,因此is<People>(peopleB) 会返回 false 。

validate<People>(peopleA)//[]

validate<People>(peopleB)
// [{
// path: 'info.phone',
// code: 'type',
// message: 'Not a number'
// }]

validate 函数和 is 函数的用法类似,区别是 validate 函数并不是返回一个布尔值 ,而是一个包含错误信息的数组。

path:错误路径,指向出错的具体属性

code:错误类型,目前好像只有type 一种。

message:具体的错误信息。

序列化

DeepKit 中 serialize/deserialize 两个方法,为用户提供了序列化/反序列化的能力

import { serialize } from '@deepkit/type';

class MyModel {
id: number = 0;
created: Date = new Date;

constructor(public name: string) {
}
}

const model = new MyModel('Peter');

const jsonObject = serialize<MyModel>(model);
//{
// id: 0,
// created: 2022-11-02T17:49:03.240Z,
// name: 'Peter'
//}

serialize 方法接收类型信息和需要序列化的数据,将数据序列化为符合类型定义的JSON对象。

const myModel = deserialize<MyModel>({
id: 5,
created: 'Sat Oct 13 2018 14:17:35 GMT+0200',
name: 'Peter',
});

is<Date>(myModel.created)// true

deserialize 方法接收类型信息和需要反序列化的数据,将数据反序列化为符合类型信息定义的数据。代码中的 created 字段会被反序列化为 Date 字段。

类型装饰器

一句话概括装饰器:装饰器本质上就是一个函数,可以在运行时对被装饰对象进行自定义的加工处理。

DeepKit 中提供了一套类型装饰器,这里的类型装饰器和 TypeScript 的装饰器并不相同,TypeScript 多用于对类的装饰,类型装饰器顾名思义是对类型的装饰。这些类型装饰器可以被当作一个正常的 TypeScript 类型使用。

举一个简单的例子

import { integer } from '@deepkit/type';

// case 1
type count = integer;
is<count>(1) // true
is<count>(1.1) // false

我们对定义 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
type MyType = string;

typeOf<MyType>() === typeOf<MyType>(); //true

// case2
type MyType<T> = T;

typeOf<MyType<string>>() === typeOf<MyType<string>>();//false

可以看到,对于 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

责任编辑:武晓燕 来源: ELab团队
相关推荐

2017-07-21 16:40:29

网易云场景专属云

2019-10-09 17:28:08

程序员人生第一份工作技术

2021-06-17 11:14:22

云计算云原生

2021-09-29 18:59:42

戴尔

2011-04-20 10:07:15

2018-03-02 11:38:11

2012-06-04 13:28:51

AndroidChrome OS

2016-09-21 09:16:55

Qlik

2011-04-18 13:43:42

2023-10-27 14:25:26

组件库无限可能性

2021-02-20 12:04:51

比特币区块链美元

2013-03-19 11:13:14

Google广告SXSW

2020-05-15 13:42:03

物联网人工智能军事物联网

2009-03-11 18:27:04

Windows 7商业版

2019-04-15 10:30:38

程序员技能开发者

2019-04-22 08:57:46

硅谷996ICU

2018-11-26 09:48:57

服务器异常宕机

2020-08-11 09:38:40

微信苹果美国
点赞
收藏

51CTO技术栈公众号