编辑 | 蔡芳芳
为了解决从 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来源,方便溯源。
//DON'T EDIT. THIS IS GENERATED CODE!
//package: 微服务名
//source: / 主仓库 / 微服务 /proto/Protobuf 文件
/**
*Generated by @fw-types.
*Designed by @jsxu
*@freewheel
**/
提交生成文件到中心化仓库
在提交文件改动之前,我们需要先对@fw-types库的整体目录结构有所了解:
- 以微服务为单位,每个微服务维护一个目录,包含当前服务所有的.d.ts文件,以及统一向外暴露的index.d.ts文件。
- 除此以外每个微服务目录下还有一个package.json文件,这个文件是在接口定义文件生成类型步骤使用npm init生成得到的,该文件包含了当前服务的版本、依赖、名称等内容,提供给后续类型文件发包步骤使用。
- commonTypes为一些基础的类型声明文件,例如 Protocol Buffers 本身定义的一些基础 Protobuf 文件和内部定义的一些公共 Protobuf 文件。鉴于这些 proto 依赖几乎每个微服务都会用到,我们对此做了特殊处理,单独发包管理。
@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 包的支持。具体流程如下所示:
- 当@fw-types仓库的 webhook 检测到 push 事件时,会触发向 Artifactory 发包的任务,包以微服务为单位进行管理。
- 去每个服务下进行版本比较,拉取远端当前服务的最新版本与现在库里的版本比对,当不匹配时,说明当前代码仓库下的版本有所更新,需要调用 npm publish发新包。
这里需要注意,第一次和第 N 次发包是有区别的。第一次发包的时候 Artifactory 上并没有该服务的包,如果读取版本会直接报错中断流程,因此这里需要对是否是第一次发包进行判断,结合第二阶段生成类型声明文件的任务,对第一次发包的版本进行特殊处理。
最终在 Artifactory 上以微服务为单位的目录结构如下:
代码语言:javascript复制————————————————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的设计会生成很多文件:
- 对于每个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/。
代码语言:javascript复制————————————————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 ./。
代码语言:javascript复制————————————————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 的形式:
- 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 高级研发工程师,研究方向为云原生、数据可视化等领域,热衷于新技术的探索与分享。