apis 仓库的 proto→Go gRPC 代码生成流程
apis 仓库的 proto→Go gRPC 代码生成流程
本文以
apis仓库(gitlab.infini-ai.com/mizar/apis)真实代码为例,讲清从一份.proto一路生成到 Go gRPC 代码的完整流程:protoc 工具链、各protoc-gen-*插件、go_package/module=如何决定落地路径、grpc-gateway 怎么把 HTTP 反代到 gRPC、CI 怎么守门保证生成物与 proto 同步。读完你能回答"protoc 和 protoc-gen-go 是什么关系"“一份 proto 怎么同时产出消息结构/gRPC stub/HTTP 网关/校验/深拷贝/mock”“为什么 CI 跑
make generate && git diff能卡住没同步的提交”。
一、apis 仓库的定位:proto 作为 single source of truth
apis 是一个多服务 API 定义仓库——所有对外的 RPC/HTTP 接口、消息结构、字段校验,都以 pb/<group>/<version>/<name>.proto 为唯一事实来源(source of truth),手写代码几乎不碰接口定义,全由生成器产出。目录约定:
1 | apis/ |
一个服务 = 一个 .proto,proto 里 service 定义 RPC、message 定义数据、google.api.http 注解绑定 HTTP 路由。改接口 = 改 proto,再 make generate,所有语言/层(Go gRPC、Go HTTP 网关、TS、openapi、mock、k8s CRD)的代码一次性重生。
面试金句:“apis 仓库把 proto 当唯一事实来源,所有 Go gRPC stub/HTTP 网关/校验/深拷贝/mock/TS/openapi 都由 protoc+插件从 proto 生成,改接口只改 proto 再 make generate——这是 API 仓库的标准范式。”
二、protoc 工具链:本体 + 插件
理解生成流程的关键是分清 protoc 本体和插件两层:
- protoc(Protocol Buffers 编译器本体):解析
.proto(语法、message/service/enum、import、option),构建内部描述符(descriptor),然后把描述符喂给各插件让它各自生成目标语言代码。protoc 自己只懂 proto、不生成 Go/TS——那是插件的事。 - 插件(
protoc-gen-*):独立可执行程序,接收 protoc 传来的CodeGeneratorRequest(含描述符),吐出CodeGeneratorResponse(含生成的文件)。一种插件产一种产物。protoc 用--<name>_out=调用名为protoc-gen-<name>的插件。
apis 用的插件(见 hack/genpb.sh):
| protoc 选项 | 插件 | 产物 | 作用 |
|---|---|---|---|
--go_out |
protoc-gen-go | cluster.pb.go |
message/enum 的 Go 结构体 + 序列化 |
--go-grpc_out |
protoc-gen-go-grpc | cluster_grpc.pb.go |
gRPC client 接口 + server 桩(*_grpc.pb.go) |
--grpc-gateway_out |
protoc-gen-grpc-gateway | cluster.pb.gw.go |
HTTP→gRPC 反向代理(读 google.api.http 注解) |
--validate_out |
protoc-gen-validate | cluster.pb.validate.go |
字段校验(validate 注解 → 校验函数) |
--deepcopy_out |
protoc-gen-deepcopy | cluster_deepcopy.pb.go |
深拷贝方法(k8s 场景需要) |
--openapi_out |
protoc-gen-openapi | cluster.openapi.yaml |
OpenAPI/Swagger 文档 |
--ts_out(genpb_ts.sh) |
protoc-gen-es | ts/src/pb/*.ts |
前端 TypeScript 绑定 |
外加 mockgen(不是 protoc 插件,是独立工具,扫生成的 *_grpc.pb.go 里的 interface 给 gRPC client/server 产 mock)。
面试金句:“protoc 只解析 proto 产描述符,真正生成 Go 的是插件:protoc-gen-go 产消息结构、protoc-gen-go-grpc 产 gRPC stub、protoc-gen-grpc-gateway 产 HTTP 反代、protoc-gen-validate 产校验。一个
--xxx_out=一个插件,一行 proto 同时喂多个插件产多种产物。”
三、genpb.sh 逐行拆解(生成流程的真正入口)
Makefile 的 generate-pb 目标对每个 pb/* 子目录调一次 hack/genpb.sh(和 genpb_ts.sh)。核心是这条 protoc 命令(去掉循环外壳后的实质):
1 | protoc -I ./pb \ |
逐段拆:
3.1 -I:proto import 搜索路径
-I ./pb:当前仓库 proto 根。-I ./pb/third_party:google/api/annotations.proto、google/protobuf/empty.proto这些外部依赖放在pb/third_party/,让import "google/api/annotations.proto"能找到。install_protoc.sh还会把google/protobuf装到/usr/local/include,是 protoc 自带的 well-known types。
3.2 --<plugin>_out=<dir> + <plugin>_opt=module=...:输出路径控制
--go_out=.:Go 代码输出根目录是当前目录.。--go_opt=module=gitlab.infini-ai.com/mizar/apis:关键——告诉 protoc-gen-go “目标 module 路径是gitlab.../apis”,于是生成器把go_package里的这段 module 前缀剥掉,剩下相对路径拼到--go_out=.上。- proto 里
option go_package = "gitlab.infini-ai.com/mizar/apis/pkg/grpcapis/cluster/v1alpha1;v1alpha1";→ 剥掉gitlab.../apis后剩pkg/grpcapis/cluster/v1alpha1→ 最终落在./pkg/grpcapis/cluster/v1alpha1/cluster.pb.go。 - 没有
module=的话,protoc 会按完整go_package路径建一堆目录,落地位置乱套。module=是让生成物落到期望包路径的标准手段。
3.3 一个 proto 喂 6 个插件
- 一行
protoc同时--go_out / --go-grpc_out / --grpc-gateway_out / --validate_out / --deepcopy_out / --openapi_out——proto 解析只做一次,描述符分发给所有插件,并行产 6 种产物。这是 proto 工具链"一份定义多产物"的精髓。 - 各插件独立工作:protoc-gen-go 不关心 gRPC,protoc-gen-go-grpc 只读
service块产 stub,grpc-gateway 只读google.api.http注解产网关。互不依赖。
3.4 mockgen:扫生成的 interface 再产 mock
脚本最后一段(非 protoc):
1 | dir=${1/"pb"/"pkg/grpcapis"}/$version |
- 把
pb/换成pkg/grpcapis/定位到生成的 Go 目录。 - 扫所有生成的
*_grpc.pb.go,grep 出type Xxx interface {拿到 client/server 接口名。 - 对每个接口跑
mockgen产mock/mock_xxx_client.go、mock/mock_xxx_server.go,供单测 mock。 - 用
run函数开 3 个 worker 并发跑 mockgen(生成任务多、I/O 独立,并行加速)。
面试金句:“genpb.sh 的灵魂是
--<plugin>_out=. --<plugin>_opt=module=<repo>:module 前缀剥掉 go_package 剩余路径拼到输出根,让生成物精准落到pkg/grpcapis/...;一条 protoc 喂 6 个插件一次性产消息/stub/网关/校验/深拷贝/openapi;最后 mockgen 扫生成的 interface 再产 mock。”
四、一个 proto 长什么样、生成物对应什么
以 pb/cluster/v1alpha1/cluster.proto 为例(精简):
1 | syntax = "proto3"; |
生成物落 pkg/grpcapis/cluster/v1alpha1/:
| 生成文件 | 由哪个插件产 | 内容 |
|---|---|---|
cluster.pb.go |
protoc-gen-go | type Cluster struct{...}、序列化方法、Reset/String/ProtoMessage |
cluster_grpc.pb.go |
protoc-gen-go-grpc | ClusterV1alpha1Client 接口 + clusterV1alpha1Client 实现(NewClusterV1alpha1Client)、ClusterV1alpha1Server 接口 + UnimplementedClusterV1alpha1Server、RegisterClusterV1alpha1Server、*_FullMethodName 常量 |
cluster.pb.gw.go |
protoc-gen-grpc-gateway | RegisterClusterV1alpha1HandlerServer/FromEndpoint——把 HTTP POST /mizar/v1alpha1/clusters/{id} 解析后转成 gRPC CreateCluster 调用 |
cluster.pb.validate.go |
protoc-gen-validate | 每个字段的 Validate() error(按 [(validate.rules)...] 注解) |
cluster_deepcopy.pb.go |
protoc-gen-deepcopy | DeepCopy()/DeepCopyInto()(k8s controller 场景要) |
cluster.openapi.yaml |
protoc-gen-openapi | HTTP 接口的 OpenAPI 文档 |
mock/mock_cluster_v1_alpha1_client.go |
mockgen | gRPC client 的 mock(单测用) |
mock/mock_cluster_v1_alpha1_server.go |
mockgen | gRPC server 的 mock |
ts/src/pb/...cluster.ts(genpb_ts.sh) |
protoc-gen-es | 前端 TS 类型 + gRPC-web/REST 调用 |
gRPC stub 的关键(cluster_grpc.pb.go 片段)
1 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. |
service ClusterV1alpha1→ 客户端接口 + 服务端接口 +Register+ 全方法名常量。- 服务端实现方
embed UnimplementedClusterV1alpha1Server即可只实现关心的方法。 - 全方法名
/package.Service/Method是 gRPC 在线协议里的 method 标识,HTTP/gateway/metadata 都靠它路由。
grpc-gateway 怎么把 HTTP 反代到 gRPC
proto 里 option (google.api.http) = { post: "/mizar/v1alpha1/clusters/{id}" body: "cluster" } 被 grpc-gateway 插件读到,生成 cluster.pb.gw.go 里的 HTTP handler:收到 POST /mizar/v1alpha1/clusters/{id},把 {id} 填进 request、body 映射到 cluster 字段、构造 ClusterRequestWithBody,再调 gRPC CreateCluster。于是一份 service 同时给 gRPC 客户端和 HTTP/REST 客户端用——gRPC 性能、HTTP 易用兼得。
五、CI 守门:make generate + git diff
.gitlab-ci.yml 的 lint-generation 阶段是这套范式的命门:
1 | lint-generation: |
逻辑:生成物是提交进 git 的(pkg/grpcapis/、ts/ 都入库),CI 在干净 clone 上重新 make generate,若结果和仓库里已提交的不一致 → 说明有人改了 proto 却没重新生成/没提交生成物 → git diff 非空 → CI 失败。
这强制了"改接口必走 make generate、必提交生成物",保证proto 与所有生成代码永远一致:
- 不会出现"proto 改了但 stub 没同步"的隐蔽 bug。
- 评审只看 proto 就够,生成物是机械产物。
- 版本对齐:
cluster_grpc.pb.go头部写明protoc-gen-go-grpc v1.3.0 / protoc v6.30.2,CI 跑的插件版本固定(make download装指定版本),避免不同人本地插件版本不同导致生成物漂移。
面试金句:“CI 在干净 clone 上重跑 make generate 再 git diff,非空即失败——这强制’改 proto 必重新生成并提交生成物’,保证 proto 与 stub/网关/校验/TS 永远一致,评审只需看 proto。生成物头部标注插件版本、CI 固定插件版本,防版本漂移。”
六、完整流程图
1 | 开发: 改 pb/cluster/v1alpha1/cluster.proto (service/message/google.api.http 注解) |
七、实践要点与坑
- 改接口只改 proto,再
make generate——别手改生成物(文件头都写着DO NOT EDIT,手改会被下次生成覆盖)。 - 生成物必须入库——CI 靠
git diff守门,生成物不入库则 CI 永远失败;团队约定生成物随 proto 一起提交。 - 插件版本固定——
make download装指定版本 protoc/插件(如 protoc v24.4/v6.30.2、protoc-gen-go-grpc v1.3.0),不同版本生成产物格式会变导致git diff误报。 go_package+module=必须对齐——go_package的 module 段要和--go_opt=module=一致,否则路径剥不对、生成物乱落。- import 路径要
-I全——google/api/annotations.proto这类要放pb/third_party/并-I,否则 protoc 找不到。 - grpc-gateway 要 service 每个方法都加
google.api.http——没注解的方法 gateway 不产 HTTP handler(仍可走 gRPC)。 - mockgen 路径要对——它要扫已生成的
*_grpc.pb.go,所以必须在 protoc 之后跑(脚本里就是这个顺序)。 - 新加服务:在
pb/<group>/<version>/加.proto,Makefile不用改(generate-pb: pb/*自动枚举);proto 里service名按<Resource>V1alpha1命名规范。
面试金句:“这套范式的好处:单一事实来源(proto)、多语言多层一次生成、CI 强制同步;坑主要在插件版本漂移、go_package/module 对齐、third_party import 路径、gateway 注解缺失。”
八、面试速答清单
Q1:protoc 和 protoc-gen-go 是什么关系?
protoc 是 protobuf 编译器本体,只解析 .proto 构建描述符,自己不生成 Go。真正生成 Go 的是插件 protoc-gen-go:protoc 用
--go_out=调用它,把描述符以 CodeGeneratorRequest 传给插件,插件吐出 CodeGeneratorResponse(生成的 .pb.go)。一种--xxx_out=一个protoc-gen-xxx插件,一行 protoc 喂多个插件产多种产物。
Q2:apis 仓库一份 proto 怎么同时产这么多东西?
一份 .proto 经 protoc 解析一次,描述符分发给 6 个插件:protoc-gen-go 产消息结构(.pb.go)、protoc-gen-go-grpc 产 gRPC stub(_grpc.pb.go)、protoc-gen-grpc-gateway 产 HTTP 反代(.pb.gw.go)、protoc-gen-validate 产校验、protoc-gen-deepcopy 产深拷贝、protoc-gen-openapi 产文档;外加 genpb_ts.sh 的 protoc-gen-es 产 TS、mockgen 扫 stub 的 interface 产 mock。
Q3:生成的 .pb.go 怎么落到 pkg/grpcapis/cluster/v1alpha1/ 这个路径?
proto 里
option go_package = ".../apis/pkg/grpcapis/cluster/v1alpha1;v1alpha1"给出完整 import 路径,--go_out=.给输出根,--go_opt=module=gitlab.../apis让 protoc-gen-go 把 go_package 里的 module 前缀剥掉,剩pkg/grpcapis/cluster/v1alpha1拼到输出根,就是落地路径。module= 不对齐会乱落。
Q4:grpc-gateway 干了什么?
读 proto 里
google.api.http注解(如post: "/mizar/v1alpha1/clusters/{id}" body: "cluster"),生成 HTTP handler:收到该 HTTP 请求时把 path 参数/body 映射成 gRPC request message、调对应 RPC,把 gRPC 响应转回 HTTP。让一份 service 同时给 gRPC 和 REST 用。没注解的方法 gateway 不产 HTTP handler。
Q5:为什么 CI 跑 make generate 再 git diff 能卡住没同步的提交?
生成物是入库的。CI 在干净 clone 上重跑 make generate,若结果和已提交的不一致(有人改了 proto 没重新生成、或没提交生成物),git diff 非空 → CI 失败。强制"改接口必走 make generate 并提交生成物",保证 proto 与所有产物永远一致,评审只看 proto 即可。
Q6:服务端怎么用生成的 stub?
生成的 *_grpc.pb.go 里有
XxxServer接口和UnimplementedXxxServer。业务 struct embedUnimplementedXxxServer、实现关心的方法,再RegisterXxxServer(grpcServer, &impl{})注册。客户端NewXxxClient(conn)拿接口调 RPC;HTTP 客户端走 gateway 生成的 handler。
Q7:新加一个 gRPC 服务要改什么?
在 pb/
/ / 加 .proto(含 service/message/google.api.http 注解、option go_package 对齐),跑 make generate(Makefile 的 generate-pb: pb/* 自动枚举新 proto,不用改 Makefile),生成物入库提交。CI 守门保证同步。注意插件版本固定、import 路径 -I 全、service 命名按 ResourceV1alpha1 规范。
九、一句话总结
apis 仓库以 proto 为单一事实来源:
hack/genpb.sh用一条protoc把 .proto 解析一次、描述符喂给 go/go-grpc/grpc-gateway/validate/deepcopy/openapi 六个插件(+ ts 和 mockgen),用go_package+--go_opt=module=精准控制落地到pkg/grpcapis/...,生成物入库、CI 用make generate && git diff守门保证 proto 与产物永远同步。 把 protoc/插件分层、module= 路径控制、gateway 注解、CI 守门这套讲顺,proto→Go gRPC 生成流程这关就稳了。
参考资料
- apis 仓库源码:
Makefile、hack/genpb.sh、hack/genpb_ts.sh、hack/install_protoc.sh、pb/cluster/v1alpha1/cluster.proto、pkg/grpcapis/cluster/v1alpha1/* - protoc 插件协议:
plugin.proto(CodeGeneratorRequest/Response) - protoc-gen-go / protoc-gen-go-grpc:https://pkg.go.dev/google.golang.org/protobuf
- grpc-gateway:https://github.com/grpc-ecosystem/grpc-gateway
- protoc-gen-validate:https://github.com/envoyproxy/protoc-gen-validate