为了解决从 JavaScript 逐步迁移到 TypeScript 过程中遇到的痛点,FreeWheel 核心业务团队评估并提出了一套由 Protobuf 文件自动化生成 TypeScript 类型声明文件的流程,支持 Protobuf 文件的变化触发类型声明文件的自动更新。所有的 TypeScript 类型声明文件以微服务为单位储存,集中维护在公司级别的 TypeScript 中心化仓库里。
1背景
FreeWheel 核心业务团队前后端开发现状
FreeWheel 核心业务系统采用微服务架构,并使用 Go 语言作为微服务的开发语言,基于 gRPC 进行服务的远程调用。微服务之间的数据接口采用 Protobuf 进行定义,使用 protoc 自动生成相应的 RPC 接口代码。
微服务需要对外提供 Restful 接口用于 Web 前端和 Open API,而基于 protoc 生成的服务一般用于集群内部通信。为了兼容 HTTP 调用,FreeWheel 使用 grpc-gateway 进行 Restful 接口的转化和代理转发。
目前 Web 前端基于 React 组件化开发,以 JavaScript 为官方语言。JavaScript 是一种弱类型语言,在运行时才明确变量的类型,由当前的值决定当前的类型。在前后端交互需要了解变量类型时,前端开发人员只能通过查看 Protobuf 文件的定义来得到当前变量的类型,开发体验不好且影响开发效率。
中心化 TypeScript 类型库的需求
基于该现状,FreeWheel 核心业务前端开发团队正在逐步将前端开发语言从 JavaScript 向 TypeScript 切换。这么做的原因主要在于,TypeScript 作为 JavaScript 的类型化超集,弥补了静态、弱类型的 JavaScript 的缺陷,具有静态类型声明,可以减少不必要的类型判断和人工查看类型的成本,开发过程中进行静态类型检查和类型提示,对提高开发效率有正向作用。
但在这个切换过程中,大量基础类型声明的构建成了一道必须跨越的鸿沟,主要体现在以下两点:
因此维护一个基于公司微服务层面的 TypeScript 类型中心化仓库的需求便呼之欲出。这个仓库既支持内部公共库的类型声明,还支持所有微服务的类型声明文件。通过发包共享给整个公司的同事使用,降低重复开发成本。
这一灵感来源于 TypeScript 社区最为热门的开源项目 DefinitelyTyped,它提供了很多 npm 上常用的包的类型声明文件,同时对于一些没有提供声明文件的包,也支持独立开发人员自行实现后上传到 DefinitelyTyped 里共享给大家使用,极大地促进了TypeScript的推广。但DefinitelyTyped 中并不包含 Protobuf 文件对应前端类型声明文件的解决方案。为了早日在团队内部完成 TypeScript 的使用推广,亟需解决这一痛点。
2自动化 TypeScript 类型库生成方案的技术选型与设计
DefinitelyTyped 珠玉在前,我们参考其思路并结合 FreeWheel 开发现状,设计并实现了一套自动维护中心化类型库 @fw-types 的方案。
技术选型
目前 GitHub 上由Protobuf文件生成 TypeScript 文件的工具有很多,我们分别调研并试用了这些工具,对比情况如下表所示。
架构设计
整体解决方案的架构图如下图,从 @fw-types 代码仓库的入口来看可以划分为两个部分,一个是由于Protobuf文件的变化引发的自动由Protobuf文件生成TypeScript文件并上传到@fw-types库,另一个是和DefinitelyTyped一样,支持开发人员在本地实现类型声明文件并上传到共享库中,提供给大家使用。
整个流水线按照功能来说可以划分为三个阶段,分别是:
这三个阶段的工作将会在下一章节中详细介绍。
3持续集成流水线的实践详解
捕获接口定义文件改动
由Protobuf转向TypeScript化的关键点在于维护好每个版本Protobuf文件定义和类型声明文件的一一对应关系。因此从Protobuf 文件的生成开始,就需要持续集成流水线的介入。
捕获接口定义文件改动是整个流水线的第一阶段,如下图所示。后端开发人员提交Protobuf 文件改动,当对应微服务的持续集成测试通过之后,会被合并到主分支。我们在微服务代码仓库的合并事件里增加了钩子(webhook)。每当合并事件触发,该钩子会检测发生变化的文件里是否包含Protobuf文件,如果包含则触发下一阶段的任务。
接口定义文件生成类型声明文件
这一阶段的核心工作是由Protobuf文件生成TypeScript类型声明文件,将有变化的类型声明文件自动上传到@fw-types 里。考虑到 git 可以很直观地给出被改动文件的细节,因此这部分的重点只需要关注类型声明文件的生成和提交。
类型声明文件的生成
在技术选型时,我们对比了目前比较热门的一些开源项目,最终选择proto-loader作为开发流程中的生成工具。但工具本身只提供了初步的转化能力,我们还有一些额外的工作:
//DON'T EDIT. THIS IS GENERATED CODE!
//package: 微服务名
//source: / 主仓库 / 微服务 /proto/Protobuf 文件
/**
*Generated by @fw-types.
*Designed by @jsxu
*@freewheel
**/
提交生成文件到中心化仓库
在提交文件改动之前,我们需要先对@fw-types库的整体目录结构有所了解:
@fw-types
|__serviceA
|----index.d.ts
|----type1.d.ts
|----type2.d.ts
|____package.json
|__serviceB
|__serviceC
|__commonTypes
当类型声明文件生成之后,通过 git status命令可以获取到被改动文件列表,这里存在两种情况:
a. 对于新的微服务服务,对应的类型包还没发布,因此不存在 package.json 文件,我们通过 npm init 生成,并配置上相应的参数。
b. 对于已有的微服务,则需要对 package.json 文件中的 version 字段进行更新,详细内容将在后续包版本管理中介绍。
当全部改动都准备就绪,便可以调用 git commit 命令向远端仓库提交改动。我们对于 commit message 进行了特殊设计,将对应的 commit branch 也包含在其中,从而方便通过commit message里对类型声明文件和对应的Protobuf文件更改进行回溯。
类型声明文件发包
Freewheel 目前采用 Artifactory 进行制品内容(Artifacts)的管理与存储。Artifactory 是 JFrog 的一个产品,不但可以管理二进制包文件,还可以对市面上几乎所有语言的包依赖进行管理。这一阶段的类型声明文件的发包操作也有赖于 Artifactory 对 npm 包的支持。具体流程如下所示:
这里需要注意,第一次和第 N 次发包是有区别的。第一次发包的时候 Artifactory 上并没有该服务的包,如果读取版本会直接报错中断流程,因此这里需要对是否是第一次发包进行判断,结合第二阶段生成类型声明文件的任务,对第一次发包的版本进行特殊处理。
最终在 Artifactory 上以微服务为单位的目录结构如下:
————————————————Artifactory————————————————
——@fw-types
|————service A
|———— -
|————@fw-types
|—————— service A-0.0.0.tgz
|—————— service A-0.0.1.tgz
|—————— service A-0.1.0.tgz
|—————— service A-0.2.0.tgz
|————service B
|————service C
|————service D
使用情况
目前这套流程已经支持了 20+ 微服务,平均每天约有 5 个由于Protobuf文件变化自动触发的任务。平均每个 protobuf 改动合并之后能够在 30 分钟内从 Artifactory 下载到对应的包文件。
在 Web 前端的项目中也已经有 3 个项目开始逐步接入这些类型包,大大改善了团队前端工程师的开发体验。
下图为使用生成的 TypeScript 文件替换原先手写的类型。
4落地应用的问题与解决方案
最终代码提取
我们从一开始生成.ts文件到最终可用的.ts文件提取流程如下图所示,包含工具生成和二次转化两部分。其中二次转化包含了冗余代码去除、命名变化和引用路径变化,下面逐个进行介绍。
冗余代码去除
proto-loader的设计会生成很多文件:
对于 FreeWheel 的业务场景,我们只关心与message相关的.ts文件 。此外,对于每一个message所生成的interface还会有一个额外的__Output类型,这个类型对于我们来说也是无用的。因此需要对这些冗余的代码进行删减,并根据情况对import里对引入进行调整。
命名变化
proto-loader以message名作为.ts文件名,有可能会出现文件名重名问题。例如当一个微服务下的两个protobuf文件里包含一个仅大小写存在差异的message,此时生成的.ts文件仅大小写存在差异,存储在同一路径下。一些不区分大小写的文件系统里会最终只保留其中一个文件。因此需要对于生成的文件名进行重复检测和重新命名,使用其所在的Protobuf文件名来区分。
生成文件import路径的变化
使用proto-loader生成的类型声明文件里,存在对其他类型声明文件的引用。直接生成的结果里import的路径采用原先各个服务Protobuf文件的路径关系,存放在proto子路径下,例如下图所示import ../proto/。
————————————————proto————————————————
——micro_services_repo
|————service A
|—————— proto
|—————— a.proto
|—————— b.proto
|—————— c.proto
|————service B
|————service C
|————service D
而我们维护的@fw-types路径如下,没有 proto子目录,因此import的 .ts 文件路径如果和原先proto的路径一致的话,会无法正确读取,需要对其生成的文件import的路径进行更改,以我们@fw-types的文件管理形式读取,import ./。
————————————————typescript————————————————
——@fw-types
|————service A
|—————— a.ts
|—————— b.ts
|—————— c.ts
|—————— index.d.ts
|————service B
|————service C
|————service D
除此之外,由于部分文件命名的变化,也需要更改对应文件的import的路径,才能最终实现正确类型的引入。
包版本管理
对于每一个微服务服务的类型声明文件包,其版本在每次d.ts文件存在更新后,都需要进行版本号的更新,并将更新后的版本信息一起作为 commit message 传到@fw-types里,我们采用语义化版本(SemVer)规范。其命名规则是以 x.y.z 的形式:
目前 FreeWheel 主要使用 proto3 版本,基于默认前向兼容的情况下,我们暂时只对 x 和 y 位进行更新。因为对于Protobuf很难界定 bug 修复的行为,所以只存在兼容性变化和新特征的添加,具体结合Protobuf的改动来确定最终得到的版本,x 位表示无法兼容的变更,y 位表示新字段新功能的增加。
前端库的类型支持
本解决方案旨在维护一个公司级别的TypeScript类型中心化仓库,除了对于Protobuf文件生成TypeScript类型声明文件以外, 还期望覆盖一些前端库的类型声明。因此,我们也支持前端开发人员在 @fw-types仓库里以 Pull Request 的形式提交对目前公司内部使用的JavaScript库手写的类型声明文件,共享给全公司的同事使用,期望在公司层面维护一个活跃的TypeScript 生态。
5未来计划与展望
基于前文其实可以看出,虽然proto-loader 解决了大多数问题,但也引入了不少额外工作。我们计划基于proto-loader定制一版专门应对 FreeWheel 需求的生成工具,降低二次转化部分代码的维护成本。这一部分工作已经在进行之中。
此外,目前生成的代码尚未被 lint 和格式化,为了保证统一的生成文件样式,我们还需要加入对 lint 和格式化的支持。
最后,@fw-types 仓库的推广使用还需要提供更加精简的接入步骤,继续增加对更多微服务和前端库的支持,使 JavaScript 往 TypeScript 的迁移更为简单和顺利。