为了解决从 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 的缺陷,具有静态类型声明,可以减少不必要的类型判断和人工查看类型的成本,开发过程中进行静态类型检查和类型提示,对提高开发效率有正向作用。
但在这个切换过程中,大量基础类型声明的构建成了一道必须跨越的鸿沟,主要体现在以下两点:
缺少内部公共库的类型定义。目前线上一些比较老旧的 JavaScript 库,不太可能用 TypeScript 改写,对这部分文件如果能够提供一份公用的类型定义会更合适。
缺失基于后端 Protobuf 定义对应的前端类型声明文件。目前整个微服务代码仓库已累积超过 700 个 Protobuf 接口定义文件、15k+ 个 message 定义。单纯靠开发人员手写实现转换并不现实。而且 Protobuf 接口仍在不断增加和修改,相应的类型声明文件也需要及时得到更新。
因此维护一个基于公司微服务层面的 TypeScript 类型中心化仓库的需求便呼之欲出。这个仓库既支持内部公共库的类型声明,还支持所有微服务的类型声明文件。通过发包共享给整个公司的同事使用,降低重复开发成本。
这一灵感来源于 TypeScript 社区最为热门的开源项目 DefinitelyTyped,它提供了很多 npm 上常用的包的类型声明文件,同时对于一些没有提供声明文件的包,也支持独立开发人员自行实现后上传到 DefinitelyTyped 里共享给大家使用,极大地促进了 TypeScript 的推广。但 DefinitelyTyped 中并不包含 Protobuf 文件对应前端类型声明文件的解决方案。为了早日在团队内部完成 TypeScript 的使用推广,亟需解决这一痛点。
2 自动化 TypeScript 类型库生成方案的技术选型与设计
DefinitelyTyped 珠玉在前,我们参考其思路并结合 FreeWheel 开发现状,设计并实现了一套自动维护中心化类型库 @fw-types 的方案。
一方面支持自动化地由 Protobuf 文件生成 TypeScript 类型声明文件。当 Protobuf 文件发生更改后触发生成 TypeScript 类型文件的自动化流水线,将更新后的文件自动上传到 @fw-types 库里,然后触发 npm 发包流水线将新的类型包上传到内部的 Artifactory 仓库中,从而保证能够追踪由 Protobuf 文件的更改而引起的类型声明文件的变化。
另一方面支持前端开发人员可以给较老的前端库补充类型定义,提交 Pull Request 合并到中心化库里,共享给大家使用。
技术选型
目前 GitHub 上由 Protobuf 文件生成 TypeScript 文件的工具有很多,我们分别调研并试用了这些工具,对比情况如下表所示。
由于我们期望使用 interface 语法定义的类型,要求可以保留原始字段的蛇形命名,同时能够生成 Protobuf 定义依赖的其他文件类型,最终选择 proto-loader 作为开发流程中的生成工具。对于变量名的转化,有三个工具是将 Protobuf 文件里的蛇形命名转化为驼峰命名。
AsObject 指的是有一类工具转化 TypeScript 包的语法中,以命名空间 namespace 的形式为主,对于空间本身定义成一个 AsObject 对象,命名空间可以有效的阻隔重名问题,但是每个类型在调用的过程中就需要添加 .AsObject 来使用。另一类转化以接口 interface 的形式转化,目前以 interface 形式的较少。
d.ts 文件是集中管理的类型声明文件,但实际我们关心的是类型声明文件的内容,内容符合预期的话,.ts 文件和 d.ts 文件对项目来说没有本质区别。
对于 import 的文件,只有两个工具可以生成其对应的.ts 文件。
在社区活跃度上,这些工具均比较活跃,最近一个月内都有相关 commit。
架构设计
整体解决方案的架构图如下图,从 @fw-types 代码仓库的入口来看可以划分为两个部分,一个是由于 Protobuf 文件的变化引发的自动由 Protobuf 文件生成 TypeScript 文件并上传到 @fw-types 库,另一个是和 DefinitelyTyped 一样,支持开发人员在本地实现类型声明文件并上传到共享库中,提供给大家使用。
整个流水线按照功能来说可以划分为三个阶段,分别是:
捕获接口定义文件改动
接口定义文件生成类型声明文件
类型声明文件发包
这三个阶段的工作将会在下一章节中详细介绍。
3 持续集成流水线的实践详解
捕获接口定义文件改动
由 Protobuf 转向 TypeScript 化的关键点在于维护好每个版本 Protobuf 文件定义和类型声明文件的一一对应关系。因此从 Protobuf 文件的生成开始,就需要持续集成流水线的介入。
捕获接口定义文件改动是整个流水线的第一阶段,如下图所示。后端开发人员提交 Protobuf 文件改动,当对应微服务的持续集成测试通过之后,会被合并到主分支。我们在微服务代码仓库的合并事件里增加了钩子(webhook)。每当合并事件触发,该钩子会检测发生变化的文件里是否包含 Protobuf 文件,如果包含则触发下一阶段的任务。
接口定义文件生成类型声明文件
这一阶段的核心工作是由 Protobuf 文件生成 TypeScript 类型声明文件,将有变化的类型声明文件自动上传到 @fw-types 里。考虑到 git 可以很直观地给出被改动文件的细节,因此这部分的重点只需要关注类型声明文件的生成和提交。
类型声明文件的生成
在技术选型时,我们对比了目前比较热门的一些开源项目,最终选择 proto-loader 作为开发流程中的生成工具。但工具本身只提供了初步的转化能力,我们还有一些额外的工作:
工具最终生成的是以.ts 后缀的文件,包含了我们所需要的变量类型声明。但在我们的使用场景中还需要对外暴露 index.d.ts 文件以方便前端开发人员使用,因此需要将.ts 文件统一在 index.d.ts 文件中向外 export。
对于生成的.ts 文件,我们还设计了相应的开头注释,体现当前文件是由工具自动生成的,并且显式地列出当前.ts 文件的 Protobuf 来源,方便溯源。
提交生成文件到中心化仓库
在提交文件改动之前,我们需要先对 @fw-types 库的整体目录结构有所了解:
以微服务为单位,每个微服务维护一个目录,包含当前服务所有的.d.ts 文件,以及统一向外暴露的 index.d.ts 文件。
除此以外每个微服务目录下还有一个 package.json 文件,这个文件是在接口定义文件生成类型步骤使用 npm init 生成得到的,该文件包含了当前服务的版本、依赖、名称等内容,提供给后续类型文件发包步骤使用。
commonTypes 为一些基础的类型声明文件,例如 Protocol Buffers 本身定义的一些基础 Protobuf 文件和内部定义的一些公共 Protobuf 文件。鉴于这些 proto 依赖几乎每个微服务都会用到,我们对此做了特殊处理,单独发包管理。
当类型声明文件生成之后,通过 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 包的支持。具体流程如下所示:
当 @fw-types 仓库的 webhook 检测到 push 事件时,会触发向 Artifactory 发包的任务,包以微服务为单位进行管理。
去每个服务下进行版本比较,拉取远端当前服务的最新版本与现在库里的版本比对,当不匹配时,说明当前代码仓库下的版本有所更新,需要调用 npm publish 发新包。
这里需要注意,第一次和第 N 次发包是有区别的。第一次发包的时候 Artifactory 上并没有该服务的包,如果读取版本会直接报错中断流程,因此这里需要对是否是第一次发包进行判断,结合第二阶段生成类型声明文件的任务,对第一次发包的版本进行特殊处理。
最终在 Artifactory 上以微服务为单位的目录结构如下:
使用情况
目前这套流程已经支持了 20+ 微服务,平均每天约有 5 个由于 Protobuf 文件变化自动触发的任务。平均每个 protobuf 改动合并之后能够在 30 分钟内从 Artifactory 下载到对应的包文件。
在 Web 前端的项目中也已经有 3 个项目开始逐步接入这些类型包,大大改善了团队前端工程师的开发体验。
下图为使用生成的 TypeScript 文件替换原先手写的类型。
4 落地应用的问题与解决方案
最终代码提取
我们从一开始生成.ts 文件到最终可用的.ts 文件提取流程如下图所示,包含工具生成和二次转化两部分。其中二次转化包含了冗余代码去除、命名变化和引用路径变化,下面逐个进行介绍。
冗余代码去除
proto-loader 的设计会生成很多文件:
对于每个 message 生成一个.ts 文件
对于 rpc 接口生成相应的 .Service.ts 文件
用于运行时 protobuf 类型获取的 ProtoGrpcType 文件
对于 FreeWheel 的业务场景,我们只关心与 message 相关的.ts 文件 。此外,对于每一个 message 所生成的 interface 还会有一个额外的__Output 类型,这个类型对于我们来说也是无用的。因此需要对这些冗余的代码进行删减,并根据情况对 import 里对引入进行调整。
命名变化
proto-loader 以 message 名作为.ts 文件名,有可能会出现文件名重名问题。例如当一个微服务下的两个 protobuf 文件里包含一个仅大小写存在差异的 message,此时生成的.ts 文件仅大小写存在差异,存储在同一路径下。一些不区分大小写的文件系统里会最终只保留其中一个文件。因此需要对于生成的文件名进行重复检测和重新命名,使用其所在的 Protobuf 文件名来区分。
生成文件import
路径的变化
使用 proto-loader 生成的类型声明文件里,存在对其他类型声明文件的引用。直接生成的结果里 import 的路径采用原先各个服务 Protobuf 文件的路径关系,存放在 proto 子路径下,例如下图所示 import …/proto/。
而我们维护的 @fw-types 路径如下,没有 proto 子目录,因此 import 的 .ts 文件路径如果和原先 proto 的路径一致的话,会无法正确读取,需要对其生成的文件 import 的路径进行更改,以我们 @fw-types 的文件管理形式读取,import ./。
除此之外,由于部分文件命名的变化,也需要更改对应文件的 import 的路径,才能最终实现正确类型的引入。
包版本管理
对于每一个微服务服务的类型声明文件包,其版本在每次 d.ts 文件存在更新后,都需要进行版本号的更新,并将更新后的版本信息一起作为 commit message 传到 @fw-types 里,我们采用语义化版本(SemVer)规范。其命名规则是以 x.y.z 的形式:
X 表示主版本号,当 API 兼容性变化时,X 递增
Y 表示次版本号,当存在不影响兼容性的功能增加时,Y 递增
Z 表示修订号,当存在不影响兼容性的 Bug 修复时,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 的迁移更为简单和顺利。
作者介绍:
许京爽,Freewheel 软件开发实习生,就读于北京航空航天大学计算机学院。
许侃,实习指导老师,FreeWheel 高级研发工程师,研究方向为云原生、数据可视化等领域,热衷于新技术的探索与分享。
系列文章推荐:
评论