图片


小来说:只编写一份proto定义,就可以自动生成client,自动生成API文档,自动做版本管理的方式你造吗?


本文详细讲述了来也科技研发团队是如何在Protobuf上大做文章的,踩过的坑,总结下来的经验,统统讲给你听~



一.前序

在一个大型系统中,不同的服务、系统之间不可避免的会出现结构化的数据存储、传输和交换,在这个时候需要将结构化的数据进行序列化和反序列化,为了达到这种目的一般对结构化数据有以下两种编码方式:
1.序列化后的数据本身可以自举数据结构的编码方式,如常用的json和xml等;
2.需要依赖IDL描述序列化数据的编码方式,如Protobuf,Thrift等;

方式1一般是最常见的使用方式。优点就是使用简单灵活,不需要引入额外的描述文件,特别是结构会经常变化的场景会减少不少工作量;但是缺点也很明显,比如序列化和反序列化性能、数据包大小,还有过分灵活的定义方式也会在一个多人协作的大型项目中带来隐患,如一个工程师因为个人原因修改json接口可能会导致上游调用程序崩溃,引发灾难性后果。

来也科技在创业开始阶段,为了保证快速的开发迭代,服务之间的数据交换方式也采用json编码,但是随着项目日益庞大,上述json编码的缺点越来越明显的显现,我们决定将服务之间的数据交换方式迁移到上边提到的第二种

从需要IDL强约束的编码方式上来看,经过从性能,包大小,上手难度,和其他系统集成的便捷性上综合考虑,我们选择Protobuf这种方式,目前来也科技内部基于Protobuf+gRPC实现几百个微服务,承载在每日几亿的调用量,内部很多结构化数据也是用Protobuf进行序列化压缩存储。

虽然pb(Protobuf的简写,后文全用pb代替Protobuf)是一门非常成熟的编码技术,被广泛用在很多场景,但是在一个多人团队要真正推广,让大家高效低成本的使用还是要做很多工作,在下文将会提到来也科技在pb中一些工程实践。


二.pb简介

pb 是由 Google 设计的一种高效、轻量级的信息描述格式。起初是在 Google 内部使用, 后来被开放出来, 它具有语言中立、平台中立、高效、可扩展等特性,。它非常适合用来做数据存储、RPC数据交换等。与 json、xml 相比,Protobuf 的编码长度更短、传输效率更高。

下图是pb在一个场景,序列化、反序列、序列化后的包大小的对比:

我们假定客户端和服务端之间要传递一个包含两个字段的结构, 这两个字段的类型均为 int32, 我们随机生成 3 组 int32 数值, 比较 json 和 Protobuf 序列化后的包长度如下:

图片

紧接着, 我们随机生成 3 组 1~32 位长的随机字符串, 比较 json 和 Protobuf 序列化后的包长度对比如下:

图片

最后, 我们随机生成 3 组 int32 和 字符串类型混合的结构, 比较 json 和 Protobuf 序列化后的包长度对比如下:

图片

可以看到 pb 序列化的长度均远小于 json, 这使得 pb 的传输效率要比 json 好很多, 大大节省了服务之间信息传递的效率,。

pb 之所以可以实现这么高的压缩比, 得益于其巧妙的编码方式, 关于 pb 的编码方式可以在 【Protobuf 编码原理 中了解更多。我们继续来对比序列化/反序列的速度。对于一个客户端/服务端构成的通信系统, 客户端和服务端完成一次通信的时间为序列化时间+传输时间+反序列化时间, 因此我们以序列化时间+反序列化时间之和来对比 pb 和 json 的性能,:

我们先以字符串为例, 我们随机生成500w的随机字符串, 然后分别使用 json 和 pb 这 500w 随机字符串进行序列化和反序列化, 计算整个过程的时间总和, 结果如下:

图片

可以看到对于字符串, pb 的序列化/反序列化速度远快于 json, 我们再继续比较整型的数据, 随机生成 1kw 的整型数据, 二者的序列化/反序列化的性能, 结果如下:

图片

综上来看, 无论是对于序列化后的包大小还是序列化/反序列化速度, pb 的性能均远远好于 json。


三.问题出现

在选定pb+gRPC后,首先在一个小组进行快速推广。大家对其性能,便捷性表示很满意,初步验证后决定全公司推广。但是当使用范围脱离出一个可以时刻交流且工程能力较强的小组后,很快暴露出很多问题:

1.pb命名不规范的问题。

proto的定义除了作为IDL,在来也科技内部还起到另外两个作用:

  1. 内部gRPC微服务api的文档定义

  2. 来也科技对开发者提供开放平台,其中开发平台的RESTful的api文档全部由定义好Protobuf自动生成

由于命名不规范,语义不清楚或者命名语义重复导致大家使用编译好的pb实现时,还需要再去看定义注释,效率很低(工程师使用的IDE会有强大的代码提示),比如有定义 MessageImage ,也有定义的MessagePic ;

此外生成对外的开放平台文档会出现非常低级的大小写不统一,显得非常不专业;

还有有些同学会喜欢定义多重嵌套的Message,让使用同学很难看懂。

2.命名冲突的问题

前边提到了来也科技后端有几百个微服务组成,proto的定义文件会非常多,而且proto定义都是各个微服务负责团队来完成,缺乏规则约束导致命名经常冲突。比如:最基本的enum都会定义一个default,那么在编译时就会出现冲突。这个时候有些同学会“取巧”,只编译自己服务要用的proto,给其他同学埋下炸弹,其他同学若需要再引入这个proto,就会出现无法编译通过。简单的问题,修改成本却很高。如果不断重复定义,就会出现很多冗余的结构。

3.向后兼容的问题

有些时候,一些同学删掉service接口字段或者更改字段类型,会因为无法同步导致已上线的client端出现异常,更严重的情况是上线后若一方出现异常要进行回滚,这个时候就需要多方都回滚。在服务调用链很长的地方,若发生这种情况,可能会出现级联回滚的灾难情况。

4.编译成本的问题。

来也科技后端是一个多语言栈的微服务系统,用到开发语言包括但不限于:go,python,c++,Node.js ......

在使用pb的时候需要将定义好的proto编译成对应的语言实现。不同的语言编译难度不一样(比如C++编译需要较多的依赖),如果要每个同学都需要在自己的笔记本搭建一个编译环境,成本太高。

5.编译路径的问题

最早工程师在自己的笔记本编译时,会存在编译的路径不统一的问题,这导致有些语言在使用编译后的实现时出现引用路径的报错,。如下图所示:

图片

ai_service.proto引用了common.proto, 以下两种引用方式都可以成功编译, 但编译出的目标代码中的引用路径是不同的。

假设一个项目中有两个pb都引用common.proto, 而二者编译生成的目标代码对于common.proto的引用路径是不同的, 此时便会报错。需要开发人员手动更改编译后的目标代码的引用路径。

图片

6.使用编译的问题

每次有新的gRPC接口定义(或者修改),至少两个服务(客户端、服务端)都需要更换新的编译实现。其中一方同学忘记更换导致服务异常,往往花费较多时间去排查。此外,编译的pb代码都是拷贝到项目中,这也增加了不必要的工作量。

7.版本管理的问题

来也科技是一家提供ToB服务的公司,核心产品除了saas服务,还给客户提供私有部署。私有部署的都是历史上比较稳定的saas版本,有时候会因为一些个性化需求不可避免的要修改历史的代码,要利用历史版本的proto重新编译代码。

这个时候就需要找到历史上节点的proto定义,重新编译实现再加入代码中。来也科技的pb是统一的仓库管理(载了所有微服务的文档描述),更新频率很快。要剥离出历史特定代码版本proto会非常痛苦。

8.重复代码的问题

虽然利用proto的定义已经可以自动生成一个使用非常友好的gRPC client ,但是在实际使用中,仍然需要为client增加不少代码。比如:超时参数,服务端地址,心跳参数等等。

此外,因为gRPC基于http2.0为单链接复用,有些时候为了提高客户端性能,我们会同时生成多个client,轮询调用client等(tidb也碰到这个问题:https://www.infoq.cn/article/tidb-and-gRPC/),而这些代码都是重复的。

针对以上问题,我们从以下几个方面逐一解决:

  1. 约束命名规范;

  2. 做更精细化的review机制

  3. 统一的CI自动编译

  4. 自动生成客户端,统一引用

  5. 统一为编译后的tag命名

针对来也科技的业务特点,我们还将前边提到的开放平台的RESTful api文档也实现自动化生成和上线。下文会做详细介绍。


四.命名规范

1.消息名称统一使用驼峰形式, 如:GetUserNameRequest

2.消息字段统一使用蛇形风格, 如:string user_name = 1

3.Enum的值必须大写,default不许使用,防止程序使用忘记填写带来错误语义,且枚举值必须加上结构的前缀,如:

//图片格式enum ImageFileType {    IMAGE_DEFAULT=0;    IMAGE_PNG=1;    IMAGE_JPEG=2;}

4.我们使用gRPC-gateway自动将gRPC接口和RESTful HTTP接口进行转换。对外的API接口, 消息字段必须附加json_name描述, 以显式指定生成的json接口的字段名称

5.接口的请求结构名称统一以Request作为后缀, 接口的响应结构名称统一以 Response 作为后缀, 这样从消息名称便可获知该接口是请求体还是响应体

6.字段的序号一定从 1 开始顺序递增, 尽管proto字段序号可以不从 1 开始定义, 但大序号会占用较大的空间来存储, 原理可参考我们之前的文章 (Protobuf 编码原理)

7.废弃的字段增加Deprecated注释, 以标明该字段或接口已不推荐使用

8.任何时候都不允许对废弃的字段做删除操作, 因为字段删除以后, pb的序号会空出来, 当有新的开发者修改该proto定义时可能会使用这个曾经的序号, 造成潜在的兼容性问题


五.Review 机制

为了保证pb定义的正确、合理和规范, 我们认为增加精细的review机制,主要是围绕两个规范做起:

  1. 代码中使用pb必须来自主干分支

  2. 设计的pb必须经过静态扫描、review后才能合并到主干

具体的流程图如下:

图片

任何一个开发人员要修改pb定义时,都首先要基于主分支构建一个开发分支, 在新构建的分支上进行pb变更。

修改完成后, 开发人员将修改提交到统一的代码仓库, 并向主分支发起一个Pull Request。之后, Protobuf就进入了审议阶段, 相关的开发人员都可以对该Pull Request进行Pre-Review, 并以issue的方式进行评论。当该pull request被高级工程师review通过后, 项目的maintainer便可操作合并。

修改被合并到主分支后,会自动删除新分支,并触发自动编译流程。


六.统一的CI自动编译

之前有提到来也科技后端是一个多语言栈的, 每份pb要生成多种语言的目标代码,编译成本很高。

为了简化这一过程, 在来也科技内部, 我们将多种语言的Protobuf编译器集成到了一个docker镜像内, 使用统一的编译命令以自动生成诸如:C/C++、Go、JavaScript、Java、Python等语言的目标代码, 即便在新增加的环境上也可以轻松地从镜像仓库拉、编译, 简化了各种语言编译器繁琐的配置。

此外, 我们使用Jenkins来统一、自动化的编译pb,pb的编译现已集成到内部的CI系统。我们在pb仓库的主分支上加了Webhook, 当主分支有新的提交时, 该Webhook接口会自动触发Jenkins构建相应的proto编译Jobs(会为不同的目标语言创建不同的Job,顺序执行), 每个Jenkins Job会通过Docker编译生成特定语言的目标代码。编译完成后,自动将生成的目标代码提交到目标代码仓库中。

图片

目标代码提交成功后, 自动通过企业微信机器人和邮件通知开发人员。开发人员直接从目标代码仓库引用编译实现即可。

图片图片


七.自动生成客户端

前边提到了虽然通过proto可以自动生成client,但是调用时还是需要加不少可选参数的代码(特别是一些复杂策略比如退火重试等)。若代码量较大,重复拷贝又会带来维护上的问题,这些都属于不必要的工作。

此外如果client可选参数随意配置也会带来服务调用上的问题。如:一个服务TP99约需要1s,但client设置的超时是500ms,那么就会带来频繁的客户端调用超时报错。

针对以上问题我们决定使用统一生成的gRPC client,工程师只允许使用自动生成的client,不允许自己去定义。

具体实现上,我们采用pb扩展的方式,基于gapic-generator-go做二次开发,通过json预定义pb client的特性,在编译pb时自动生成带特性参数的client。基于内部的要求,目前我们实现以下4种参数特性:

  1. 重试。包含复杂的重试策略,如退火重试,支持根据不同的状态码和超时配置重试策略。

  2. keep-alive策略。我们所有的微服务运行在isito上,基于Sidecar机制,一次服务调用会有多次代理以及网络穿透。加上来也科技产品提供标准的私有化部署,服务会运行在各种公、私有云上,由于各个云负载均衡特性不一致(ELB,SLB,GLB等)(如:阿里云SLB长时间链接不活跃,会关闭链接,不发reset包,经常会导致gRPC长连接失效而带来的服务偶发报错)。所以基于不同代理的特性我们自动加入合适的keep-alive策略。

  3. 多链接负载策略。由于gRPC基于http2.0,client和server只会建立一条链接,但是在压测和实际使用时,经常发现一些调用并发高的client性能无法达到最大,经过测试发现多个链接会解决这个问题,所以我们将多链接策略自动编译到到客户端中。

  4. 客户端注入版本号。由于微服务数目较多,有时服务端做了向后兼容的升级,客户端不一定同步升级,加入客户端版本号并在服务端进行拦截可以更好帮助排查问题,还可以根据版本号做分流功能验证。

如下图是服务aiService.QaService ,两个method BatchGetKnowledgeDetail和GetQaTaskResponse ,出现4S超时或service出现特定错误码的时候进行退火重试:

图片

上述的定义json在自动生成client时,会编译成以下gRPC call option参数:

图片

提高性能的多链接策略使用起也非常简单,下图就是初始化一个有3个链接池gRPC的client:

图片


八.统一的编译后的tag命名

在pb进行统一编译,统一仓库存储,统一引用后,就不可避免遇到一个版本适配的问题,在开发中很难一直做到向后兼容的API升级,还有上文提到的私有部署需要历史的pb。所以很有必要对编译好的pb实现统一的版本管理,项目按需导入相关版本即可。

在具体实现上,我们采用存储仓库的tag做为具体的版本号,而且做到pb版本号和产品的版本号保持对应关系,实现方式举例如下:

当自动编译完成后,会自动去产品需求管理工具Tapd获取产品迭代的版本号。假设当前的版本号是v1.0 ,那么会首先扫描proto目标代码的Git仓库已存在的所有Git Tag。如若已有形如 v1.0.x 的Tag, 便自动创建v1.0.(x+1) 的 Git Tag, 如若当前没有形如v1.0.x的 Tag, 则会自动创建v1.0.0的Git Tag。

编译完成后,创建的Git Tag将随通知消息一同发送, 开发人员可以使用相应的包管理工具简单方便地拉取该版本的目标代码。

以Go项目为例, 假设tag为v5.16.8, 则开发人员可以在go mod中增加引用行, 执行命令go mod tidy 便可以自动地拉取pb包,然后便可以在源文件引用。

图片


图片

再以Python语言为例, 我们只需将引用行添加到requirments.txt中并执行安装,便可将 pb 文件拉取到本地。

图片


九.swagger文档生成

来也科技内部的微服务之间通过gRPC相互通信,而对外开放平台的API为了减少接入成本,使用标准的http RESTful API。我们使用gRPC-gateway来做gRPC和HTTP之间的自动转化,并自动化地生成RESTful API接口文档。

来也科技Chatbot平台的API文档 (https://openapi.wul.ai/docs/latest/saas.openapi.v2/openapi.v2.html) 便是自动生成的。开发人员只需在定义 proto 时在接口和字段上以注释的形式加标注即可,如下图:

图片

定义好的pb通过protoc-gen-swagger插件, 便可以自动生成对应的swagger json文件。

目前有多种工具支持swagger json的web化展示, 我们使用的是Redoc。自动地将swagger json呈现为API文档站点, 如下图:

图片

我们基于Git Tag来标记文档版本,当要发布一个新的API版本时,由维护人员在swagger仓库的主分支上创建一个新的Git Tag,然后Jenkins会自动拉取该Tag对应的swagger文件,通过Redoc工具将swagger文件渲染为HTML 页面,。

从开发人员编写pb文件到最终生成HTML文件整个过程都是全自动化完成的, 极大地提升了效率, 开发人员可以避免将人力浪费在文档编写上。


十.结语

经过上述工作,我们的pb只需要一处定义,就可以满足多种用途。为基于pb开发的各种上游打下一个非常好的基础,减低了使用门槛,极大的提高了开发效率,在内部受到各个研发团队的好评。


本文作者:摄影师王同学,孙同学,张勇

本文编辑:刘桐烔

拓展阅读:《Protobuf 编码原理