本文为飞书aPaaSGrowth研发团队成员的文章,已获得ELab授权发表。aPaaSGrowth团队专注于用户可感知和宏观的aPaaS应用构建过程,以及租户和应用治理等产品路径。致力于在aPaaS平台打造流畅的“应用交付”流程和体验,完善应用构建相关生态,加强应用构建的便捷性和可靠性,提升应用的整体性能,从而助推aPaaS的成长用户,并与基础团队一起推动aPaaS在企业内外的落地和效率。背景之前在技术需求中调研了一个基于TypeScript的数据校验方案,调研了一个第三方库Deepkit,可以保留TypeScript类型信息,供运行时消费。TypeScript带来的传统开发中,Javascript基本不提供任何类型保护,所有的类型错误都需要在运行时发现,而TypeScript为开发者提供了一套静态类型检查方案。类型信息在源代码中主动声明,与相应的变量和操作匹配,并在编译阶段进行检查。编译期间会暴露与类型相关的错误。一方面让代码更加规范,另一方面大大提高了避免很多代码错误,提高了代码的健壮性。TypeScript有一个完整的类型系统。但可惜的是,它在这方面的能力在运行时几乎完全不存在。TypeScript编译器在编译源代码时会删除类型信息,而不会在运行时造成任何开销。但实际上,在很多场景下,运行时的类型信息是极其宝贵的!为什么我们需要运行时类型为什么我们需要运行时类型信息?让我们来看看以下两个场景。数据验证。数据校验不局限于传统前端所关注的表单校验。需要数据校验的场景数不胜数。比如写服务的时候,如果我们需要实现一个接口。对于我们来说,传入的参数是未知的,我们永远不知道业务方会给我发送什么奇怪的参数。如果我们不对参数进行校验,后面的代码逻辑随时可能崩溃。而参数校验自然需要在运行时消费参数的类型定义信息。在数据库中,表中所有字段的类型都有严格的定义,因此在向数据库写入数据时,需要验证写入的数据是否符合字段类型定义,这也需要运行时类型信息。序列化和反序列化序列化是将数据类型转换为适合传输或存储的格式的过程。反序列化就是撤销这个操作的过程,需要保证无损。对于前端开发者来说,JSON.parse()和JSON.stringify()应该是接触最多的两个方法了。在简单的场景下,用这两种方式序列化和反序列化可能没问题,但在复杂的场景下就不一定了,因为这两种方式都不能保证数据无损。比如下面的场景constdate=newDate();constdateString=JSON.stringify(date);//"2022-11-02T17:49:03.240Z"constdateJson=JSON.parse(dateString);//"2022-11-02T17:49:03.240Z"对于date类型的数据,先用JSON.stringify(date)序列化成适合传输的格式,再用JSON.parse(dateString)反序列化找到date类型在这个过程中丢失了,最终反序列化的结果是一个字符串,这显然不符合预期。因此,类型信息在序列化和反序列化的过程中也非常重要。DeepKit使得在运行时之前保留TypeScript类型成为可能。快速入门官方文档站点:https://deepkit.io/使用DeepKit前需要安装两个包:@deepkit/type:提供运行时可以使用的方法@deepkit/type-compiler:类型编译器,介入TypeScript编译过程,保留类型信息。可以放在package.json的devDependencies中,因为这类编译器只需要在编译阶段使用。npminstall--save@deepkit/typenpminstall--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';typeTitle=Textendstrue?string:number;typeOf>();//Type{kind:5,typeName:'Title',typeArguments:[{kind:7}]}从上面的例子我们可以看出基本的数据结构类型对象(当然,这不是它的全貌)。详细类型对象定义:https://github.com/deepkit/deepkit-framework/blob/feature/autotype/packages/type/src/reflection/type.ts#L21-L452kind:ReflectionKind,表示传入的类型。例子中,Title对应的类型typeName:string,如果使用类型别名,会返回该字段来标识类型typeArguments:当我们使用泛型时,传入的类型信息也会保留在类型对象中,而typeArguments会在返回的字段中记录相应的类型信息。enumReflectionKind{never,//0any,//1unknown,//2void,//3object,//4string,//5number,//6boolean,//7symbol,//8bigint,//9null,//10undefined,//11//...甚至更多}反射类反射类多用于类/接口/对象类型等更复杂的场景import{ReflectionClass}from'@deepkit/输入';接口用户{id:number;用户名:字符串;}constreflection=ReflectionClass.from();reflection.getProperty('id');//ReflectionProperty,记录id类型信息reflection.getProperty('id').name;//'id'reflection.getProperty('id').type;//{种类:ReflectionKind.number}reflection.getProperty('id').isOptional();//falsereflection.removeProperty('id');reflection.getProperty('id');//Error:NopropertyidfoundinUser对于复杂的场景,我们可以通过ReflectionClass.from获取对应类型的反射类实例ReflectionClass方法,调用ReflectionClass中的方法可以获得更深层次的类型信息,也可以对类型信息进行一些操作。验证需要数据验证的场景数不胜数。接口参数校验和数据库实现都高度依赖数据校验来保证数据安全。DeepKit提供了两个函数is和validate,用于验证一个值是否符合类型定义。interfacePeople{name:stringage:number,info?:{address?:string,phone:number}}constpeopleA={name:'Jack',age:20,}constpeopleB={name:'Peter',年龄:18,info:{}}is(peopleA)//trueis(peopleB)//falseis函数接收类型信息,验证参数中的数据,返回一个布尔值。如上例,定义了一个People接口,验证peopleA和peopleB两个数据。可以看出peopleA符合People的定义,所以返回is(peopleA)会返回true。peopleB中的信息属性缺少必需的电话字段,因此is(peopleB)返回false。validate(peopleA)//[]validate(peopleB)//[{//path:'info.phone',//code:'type',//message:'Notanumber'//}]validate函数类似于is函数,不同的是validate函数返回的不是布尔值,而是一个包含错误信息的数组。path:错误路径,指向错误码的具体属性:错误类型,目前好像只有一种。message:具体的错误信息。序列化DeepKit中的序列化/反序列化两种方法为用户提供了序列化/反序列化的能力import{serialize}from'@deepkit/type';classMyModel{id:number=0;创建:日期=新日期;constructor(publicname:string){}}constmodel=newMyModel('Peter');constjsonObject=serialize(model);//{//id:0,//创建时间:2022-11-02T17:49:03.240Z,//name:'Peter'//}serialize方法接收类型信息和需要序列化的数据,将数据序列化为符合类型定义的JSON对象。constmyModel=deserialize({id:5,created:'SatOct13201814:17:35GMT+0200',name:'Peter',});is(myModel.created)//truedeserialize方法接收类型信息和需要反序列化的数据,将数据反序列化为符合类型信息定义的数据。代码中创建的字段将被反序列化为日期字段。类型装饰器用一句话概括装饰器:装饰器本质上是一个函数,可以在运行时对被装饰对象进行自定义处理。DeepKit提供了一组类型装饰器。这里的类型装饰器不同于TypeScript装饰器。TypeScript主要用于装饰类。类型装饰器,顾名思义,就是对类型的装饰。这些类型装饰器可以用作普通的TypeScript类型。举个简单的例子import{integer}from'@deepkit/type';//case1typecount=integer;is(1)//trueis(1.1)//false我们定义count类型为Integer(整型),可以看到浮点数类型1.1没有通过校验。此外,DeepKit还实现了PrimaryKey(主键)、maxLength/minLength(最小/最大长度)等类型装饰器等功能。我们可以将这些类型装饰器视为对TypeScript类型的扩展,使TypeScript能够实现数据库级别的类型定义。也正是基于这种扩展的运行时类型,验证和序列化可以有更多的约束,DeepKit也实现了一套高性能的ORM。更多@deepKit/type为我们提供了一套在运行时调用类型信息的解决方案。此外,DeepKit的作者实现了更多基于类型信息和反射机制的能力。事件系统:@deepkit/eventsHTTP库:@deepkit/httpRPC服务:@deepkit/rpc数据库ORM:@deepkit/orm模板引擎:@deepkit/templat,但不兼容react统一框架:@deepkit/framework,集成如何具有以上能力的节点框架保证性能?为了尽量减少运行时的额外开销,DeepKit的作者做了很多优化。类型缓存在不使用泛型的情况下,DeepKit会缓存使用过的类型对象//case1typeMyType=string;typeOf()===typeOf();//true//case2typeMyType=T;typeOf>()===typeOf>();//false可以看到对于case1,对应的类型对??象Mytype会被缓存,所以这两个typeOf()的结果是相等的;但是对于泛型,我们无法确定传入的T的具体类型(理论上会有无限的类型),所以不会缓存结果,每次都会创建一个新的类型对象。类型编译器DeepKit的核心原理是类型编译器,会介入TypeScript的编译过程,保留类型信息。在这个过程中,Deepkit的类型编译器会读取源代码中的类型信息并生成相关的字节码(以使其尽可能小),并将其插入AST中将其转换为另一个包含这些字节码信息的TypeScriptAST。在运行时,DeepKit会有一个微型虚拟机来解析和执行这些字节码,最后返回一个类型对象。更详细的原理可以参考:https://github.com/microsoft/TypeScript/issues/47658在DeepKit官方提供的性能图表中,可以看到DeepKit在数据读写方面表现比较出色,这也是归功于基于DeepKit提供的运行时类型信息,这种预知类型信息机制可以让序列化/验证更快更高效。总结DeepKit是市场上第一个在运行时在JavaScript中提供全套TypeScript类型的解决方案。它使前端/服务器共享一个由TypeScript定义的数据模型,并使用一套基于TypeScript实现的反射机制。但是它还是有一些缺点,比如不支持外部类型。如果代码中使用的类型信息来自第三方,而第三方库没有通过deepkit类型编译器,那么外部类型的类型信息会在运行时丢失。.官方文档站点:https://deepkit.io/一些讨论在TypeScript仓库中,其实很多人都提出了问题,针对在运行时保留Typescript类型信息提出了自己的想法。由此可见,基于TypeScript是有支持动态类型的需求,但TypeScript始终保持保留,并没有真正支持相关能力。个人观点与TypeScript[1]的设计目标有着根本的联系。TypeScript官方团队不希望TypeScript造成额外的运行时开销,希望生成的JavaScript尽可能纯净。保守严谨的TypeScript官方团队促成了TypeScript的成功。这可能就是TypeScript官方团队一直对支持运行时类型持保守态度的原因。参考资料https://deepkit.io/https://github.com/microsoft/TypeScript/issues/47658