|
TLDR: 第三章提到的那个插件已经开源,地址在这:eater-altria/protoc-gen-http-ts: A proto buffer plugin which can complie grpc methods to http request methods by TypeScript (github.com),有任何想法都欢迎给我提star,我希望可以在本月底发布第一个版本。 protobuf文件是gRPC的通信协议,前端虽然也有Web gRPC等工具可以实现前端发起gRPC请求,但应用范围相比后台来说非常稀有,这导致两者实际上有割裂的,即便他们可以产生交集,但目前并没有一套靠谱的方案将他们联系在一起,我们团队在探索过程中,尝试将proto不仅作为后台代码的一部分,同时也希望作为前端代码的一部分,一份proto文件编译成不同产物同时给前后台分别去使用,从而降低沟通成本和对接口的麻烦。这篇文章主要是针对我们团队在这方面踩坑的记录和摸索,希望可以启发其他人。
为什么前后台可以共用proto文件?
我所在的项目组采取的后台架构是微服务架构,每一个后台服务可能只包含个位数的接口,每一个后台接口都是gRPC接口,后台服务之间通过互相调用gRPC实现通信。虽然前端也能使用类似Web gRPC的方案来实现gRPC接口的请求,但我们的前端并没有采用,仍然使用的传统http接口的形式。后台的gRPC和前端的http接口就必然会有一个转换过程,我们采取了网关做自动转换,在这个网关的帮助下,一个rpc接口总是会和一个http接口之间绑定,呈现下面的样子:
网关 - gRPC - 前端模型
在这个模式下,每一个http接口都与protobuf的某一个service下的某个method一一对应,这就使得原本毫不相干的两者产生了关系。一个接口对于前端往往意味着以下两点:
- 虽然项目本身有公共request方法可以发起请求,但为了可复用,我需要将一个接口封装成一个方法直接调用。
- 封装出来方法的输入值就是接口入参,返回值就是接口返回数据,我需要对其用TS进行类型定义,来约束对接口的使用。
当我们带着前端的目光去看后台proto的时候,就可以发现了,前端的请求方法对应了rpc method,前端的接口类型定义对应了proto的message,既然有对应关系,我们就应该可以构建一座桥,直接沟通两者!
换句话说,只要团队采取的架构是:微服务 + gRPC + 网关 + 前端TS化,那么proto就必然会和前端产生关联。虽然目前来看采取这样架构的也基本只有大厂的新项目,小团队很少将架构做的这么复杂,但微服务毕竟是一个趋势,后续应该会有越来越多的项目可以用到。
第一步,让我们先把类型搞定
接口协议的定义的重要性是毋庸置疑的,但传统的接口协议定义却也有其自身的局限性,比如说:
- 接口协议不是代码的一部分,不具备强制性。
- 接口协议更改之后,通知不到位,导致前后台接口协议不一致。
第一个问题导致我们更倾向于将接口直接使用TS来写,输入输出类型直接定义成固定的interface,从而使其具备强制性,不小心写错会触发编译阶段的报错。如果我们需要手写TS类型来解决问题,那为什么不能更进一步,我们干脆直接用proto文件自动生成TS类型呢?
实际上这一步并不难,如果我们考虑一下protobuf的工作方式和原理就知道这是可能的:
- protobuf是否可以将proto文件编译成TS? ——虽然官方没有实现,但是有社区实现的插件,总之可以。
- TS是否是强类型语言——显然是的,方法的输入和输出都应有类型做约束。
- proto文件的rpc method会被编译成什么?——开箱即用的SDK,可以简单发起rpc请求的TS方法。
- 发起rpc请求的method的输入输出类型,是不是就是接口的输入输出类型?——显然,是的。
于是,我们的工作就很明了了:使用protobuf和对应插件将proto文件编译成TS,我们不去管其中grpc的那部分代码,只使用其中的类型。我们这里采取的编译插件是ts-proto,对应地址放在这里:stephenh/ts-proto: An idiomatic protobuf generator for TypeScript (github.com)
插件本身使用并没有什么问题,这里牵扯到的问题可能是proto文件的组织问题,后台的proto文件在使用之初可能没有考虑前端使用的问题,这方面到底是对proto文件做修改,还是前端的编译过程做适配,就成了一个需要双方协商的问题了。这很难有一个标准解,我只能说我们采取的方案是前端编译过程兼容后台。
ts-proto一个很强大的点是它可以自动寻找一个proto文件的所有依赖,然后全部进行编译,这帮助我们节省了很多时间,因为我们后台使用的proto文件并非只有我们自己写的,还有在仓库当中基于submodule引入的其他团队的proto、googleapi当中的proto等等,这些文件加在一起非常大,但可能只用到了其中的一小部分。我们这让我们避免了判断哪些文件是有用的。唯一的小问题上,ts-proto代码是TS写的,性能上略有不足,编译速度比较慢,这个问题暂时也没有很好的解决办法,后续如果有时间,可以尝试使用其他语言重写一个更高性能的版本出来。
proto文件编译成功之后,我们采取了使用自建npm发布的方案,这主要是考虑到前端项目虽然也开始大仓化,但是并不彻底,管理端、前端等项目还是没有放一块,打包成独立的npm包更有助于我们多项目共用。
还记得我们上文提到的接口协议的第二个问题吗:
接口协议更改之后,通知不到位,导致前后台接口协议不一致。 现在我们只要使用自动化的流水线就可以解决这个问题。我们采取了以下的方案来做版本管理:
- 监听后台proto的提交记录,如果有提交记录,则触发流水线自动编译
- 基于特定规则生成版本号,发布到自建npm
- 邮件通知前端团队
这样,类型定义会随着proto的改变而自动更新,这就能规避通知不到位的问题,如果新修改的协议和之前不一致,那么就触发了TS的类型错误,被开发人员感知到。
那版本号怎么来定呢?
上述的方案解决了接口文档同步的问题,但是这里牵扯到一个新的问题是版本号的计算规则,毕竟想把编译产物发到npm总归需要一个版本号的。这里仍然是需要根据自己团队的Git管理便宜行事,难以说有什么银弹。这里我只能说一下我们考虑过的两种方式。两种方式的核心思想是一致的,但是细节不同,匹配我们的前后不同的Git管理策略。整个版本管理的核心思想是——将前后台分支区都分为合流分支和特性分支,后台proto基于分支不同,打包的版本分为合流版本和特性版本。前后台无论Git合流模型有多大不同,遵循,前端项目内能用合流版本解决问题就用合流版本,特性版本可以用,但只能在前端特性分支用。
最开始的时候,我们设想的流程是这样的:
- 新需求进入开发,前端基于master分支拉新分支feature/frontend_a,后台也基于后台项目master拉新分支feature/backend_a
- 后台实现proto文件对接口的定义
- 基于feature/backend_a编译TS文件,版本号为特性版本号,体现为基于合流版本号后面加alpha通道和时间戳,格式大约为:2.1.0-alpha-20220815142356
- 前端将对应版本的npm包引入项目后进行开发、自测、联调等工作
- 在需要合流的时间,后台必须先合流。至于对合流的定义,双方可以不同,比如一方可以认为合并到master算合流,一方可以认为合并到test算合流,但不管如何,后台要比前端先合流。合流之后在主版本号上递增,发布合流版本,格式为:2.1.1
- 前端将合流版本替换掉自己特性分支的特性版本,也发起合流。
- 需求开发完成。
后来,随着原子化提交的推行,后台的工作流程发生了一些变化,最大的变化就是,proto文件作为接口协议和文档,必须优先于一切代码的开发先行开发,开发完毕之后,以技术评审的方式,优先对proto文件做代码MR,MR通过之后直接把proto文件合并到master分支。在这一思想的宣讲之下,上述流程也做了调整,调整之后的流程里面,因为后台master分支具备了近乎全量的proto,所以新需求开发流程变成这样:
- 新需求进入开发,前端基于master分支拉新分支feature/frontend_a,后台也基于后台项目master拉新分支feature/backend_a
- 后台实现proto文件对接口的定义
- 后台优先将分支合流,发布合流版本2.1.2
- 前端将最新的proto文件安装到feature/frontend_a,双方开始逻辑开发、联调
- 开发完毕,前端合流,合流如有冲突,proto文件版本取最新版本。
这样,版本号的问题也得以解决了。基于版本号管理和上面的TS打包,我们在前端项目内就可以相对愉快的写接口了:
// 从编译产物当中引入特定的ts文件,不再需要自己写
import {
SearchRequest,
SearchResponse,
} from 'protopath/test'
//封装特定接口,加入TS的类型约束
// 将方法导出,方便在多个地方使用
export SearchByKeyword(payload: SearchRequest, options?: any): Promise<SearchResponse> {
return new Promise((resolve, reject) => {
this.GeneralRequestMethod<SearchRequest, SearchResponse>(payload, &#39;SearchByKeyword&#39;, options).then(res => {
resolve(res);
}).catch(error => {
reject(error);
});
});
};更进一步,能不能连方法都不需要封装了?
基于以上,我们实际上节省掉了以下的时间:
但是最终,前端仍然需要将接口封装为一个可复用的方法,这一步仍然是手动的,而我们下一步就是将这一步过程也给干掉!
这一步实际上我已经实现了,我开源了一个叫做protoc-gen-http-ts的插件,地址如下:eater-altria/protoc-gen-http-ts: A proto buffer plugin which can complie grpc methods to http request methods by TypeScript (github.com)
这一步就没有那么好走了,我没有找到类似的插件,所以考虑之后我决定自己实现这么一个插件。
首先,我们如果想实现这样一个插件,我们必须先了解protobuf插件的工作原理。
简单来说,整个proto文件的编译逻辑,被放在两部分当中,一部分是protobuf提供的编译器protoc,另一部分则是对应的插件,而官方本身也给它内置了一些插件。如果我们把它看作一个编译器,那么protoc就相当于编译器的前端,对于一个编译器来讲,它主要干了这么几件事:
我认为它没有做类似于中间代码生成的工作,因为它实际传输给编译器后端的数据,本身也仅仅是一个树状结构,明显对应了语法分析阶段得到的AST,而非是类似IR的中间代码。因此上述操作可以简单概括为:proto文件 -> AST
而我们想要实现的编译器插件,则是编译器后端,我们只需要将上述操作做一个逆操作,将AST还原为文本文件,只不过不再是proto文件,而是TS文件。
protoc和编译器插件之间是基于STDIN和STDOUT进行通信,通信的信息是二进制buffer,比较有意思的是,这些二进制buffer本身就是基于protobuf协议进行编码的,具体的proto文件Google放在protobuf/descriptor.proto当中。
这样,我们就捋清楚了我们需要做什么:
- protoc将proto文件解析、编码,传入stdin
- 插件读取stdin信息,拿到proto的AST,对其进行分析,拼接我们需要的目标语言的代码
- 将拼接的信息基于stdout输出,protoc读取到信息,将信息写入对应文件
所以,理论上可以使用stdin/stdout和protobuf的语言都可以干这事,原本我是想用Rust来实现,作为我的Rust联手项目的,但最终我还是选择了Golang,没别的,主要是Golang提供的库太香了。基于google推出的google.golang.org/protobuf/compiler/protogen专门用于写protoc插件的库,我们可以很方便的实现插件编写。具体的使用我有参考涛叔的这篇文章,因此这里我不做太多重复性的陈述:如何开发 protoc 插件 (taoshu.in),感谢大佬带飞!
这里我主要着重讲一下我的思路,以及我对插件本身的设计。
首先,我们的目的,很显然,就像本章开头我写的一样,我的目的就是让接口称为封装好的方法这一步自动化,并且能够尽可能的,成为方便其他人使用的开源项目,作为一个开源项目再方便其他人的同时,也能反哺给我们的团队,优化大家的开发体验,而非是一个,纯粹在我们团队内部使用的工具。
既然我需要考虑方便其他人使用,那么它的通用request方法就必须具备充分的功能,我原本是希望可以手动实现这么一个request方法的,但是后来我还是放弃了。原因在于,一个request方法的逻辑其实非常复杂,它可以包含很多歌阶段的钩子,可以对入参和返回值做很多样化的处理,这些逻辑,依靠配置信息来表述将会非常痛苦。即便我有充分的时间将其实现,用户使用也需要琢磨非常复杂的配置信息,使用体验未必很好,因此,我采取的方法是,开发者将他们自己的业务的request方法,传入生成的代码,从而让生成的代码直接使用,这样就可以解决一系列的问题,那我们希望得到的生成产物,就变成了这个样子:
export type GeneralRequest = <TReq, TResp>(TReq, cmd: string, options?: any) => Promise<TResp>;
export class GeneralClass {
GeneralRequestMethod: GeneralRequest;
constructor(GeneralRequestMethod: any) {
this.GeneralRequestMethod = GeneralRequestMethod as GeneralRequest;
};
};
export class SearchService extends GeneralClass {
constructor(GeneralRequestMethod: GeneralRequest) {
super(GeneralRequestMethod);
};
SearchByKeyword(payload: SearchRequest, options?: any): Promise<SearchResponse> {
return new Promise((resolve, reject) => {
this.GeneralRequestMethod<SearchRequest, SearchResponse>(payload, &#39;SearchByKeyword&#39;, options).then(res => {
resolve(res);
}).catch(error => {
reject(error);
});
});
};
};在我的认知当中,如果采取了微服务 + gRPC + 网关的技术架构, 那么所有的接口的url的前缀一般都是固定的,区分在于命令字,所以request方法放三个参数:接口入参数、命令字、外加一个options存放可能的配置项是可以满足绝大多数需求的。如果开发者本身的request方法的入参顺序不一致或者有什么细微区别,也可以将自己的request方法包装一层,自行简单处理一下来实现。
这里有一个小细节,注意看类GeneralClass的构造函数的入参,这里并非是GeneralRequest类型,而是any类型。这么设计的原因在于,虽然编译的不同文件里面都有GeneralRequest类型,但因为其来自不同文件的定义,所以不是一个类型,因此即便用户构造了这么一搞类型,仍然会类型不一致。因此这里设计为接受any类型,由开发者保证这个any类型本质上是一个GeneralRequest类型。
另外一个问题就是上面生成的代码的泛型,还记得我们搞定类型时候用的插件ts-proto吗,它本身就可以生成,这部分我们不需要自己实现,我们需要实现的是如何让我们生成的代码,可以从ts-proto生成的类型里面,直接查找上述的interface的路径,将其引入进来
实现两个插件的通信比较有难度,但我们可以换一个思路。ts-proto会按照proto文件原本的路径组织编译文件的路径,那如果我也这么干,只要两个插件的输出路径一致,那么我生成的.http.ts文件和ts-proto生成的.ts文件,以及原本的.proto文件就具备相同的路径,换句话说,它们的相对路径是保持一致的,我完全可以基于原本proto文件计算相对路径,然后直接拿来使用:
// Calculate relative path of two files
func getRelativePath(pathA string, pathB string) string {
var pathASlice = strings.Split(pathA, &#34;/&#34;)
var pathBSlice = strings.Split(pathB, &#34;/&#34;)
pathASlice = ReverseSlice(pathASlice)
var res = &#34;&#34;
for i, _ := range pathASlice {
if i == 0 {
res = res + &#34;./&#34;
} else {
res = res + &#34;../&#34;
}
}
for i, v := range pathBSlice {
if i != len(pathBSlice)-1 {
res = res + v + &#34;/&#34;
} else {
res = res + v
}
}
return res
}
// generate code such as `import {xxx} from &#34;xxx&#34;`
/**
* needImportInterfaces: all interfaces need to be imported
sourcePath: generate code&#39;s path
*/
func (protoMessage *ProtoMessage) GenerateImportSourceCode(
needImportInterfaces map[string][]string,
sourcePath string,
t *protogen.GeneratedFile,
) {
for path, interfaces := range needImportInterfaces {
t.P(&#34;import {&#34;)
for _, interfae := range interfaces {
if len(interfae) > 0 {
t.P(&#34; &#34; + interfae + &#34;,&#34;)
}
}
// relativePath is based on sourcePath and the interface&#39;s path,
var relativePath = getRelativePath(sourcePath, path)
t.P(&#34;}from &#39;&#34; + strings.Replace(relativePath, &#34;.proto&#34;, &#34;&#34;, 1) + &#34;&#39;&#34;)
}
t.P(&#34;&#34;)
}
这个插件现在可以用了吗
目前来说简单的编译是没问题的,多文件的import部分也能够正确处理,但是我仍然认为有一些地方需要改进:
- 如果牵扯到多个proto_path参数,指定多个proto文件的搜索路径,那么依赖查找逻辑能否正常工作,这部分待验证,但如果只有一个proto_path参数则不会存在问题
- 目前二进制包没有发布release,使用过程会比较麻烦,需要clone仓库并编译,我正在想办法发布出去,目前有两个想法:
- 发布二进制release包到GitHub,用户自行下载。
- 提供一个npm包,虽然这个包本身是golang写的,但是可以通过install狗子里面写一点逻辑,使其可以从Github上下载代码并存放到node_modules/.bin下面。这依赖第一步。
总之,这个插件现在使用可能会有一些风险和不适,但我会尽款将这些问题完善和验证,欢迎一切issue和项目的贡献。 |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|