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
2
3
4
5
6
7
8
9
10
11
apis/
├── pb/ # proto 源(.proto 在此)
│ ├── cluster/v1alpha1/cluster.proto
│ ├── job/v1alpha1/job.proto
│ └── third_party/ # 依赖的外部 proto(google/api、google/protobuf 等)
├── pkg/grpcapis/ # 生成的 Go gRPC 代码落地处
│ └── cluster/v1alpha1/cluster.pb.go / cluster_grpc.pb.go / .pb.gw.go / ...
├── ts/src/pb/ # 生成的 TypeScript 绑定(给前端)
├── hack/ # 生成脚本: genpb.sh / genpb_ts.sh / install_protoc.sh
├── Makefile # make generate 总入口
└── .gitlab-ci.yml # CI 守门

一个服务 = 一个 .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 逐行拆解(生成流程的真正入口)

Makefilegenerate-pb 目标对每个 pb/* 子目录调一次 hack/genpb.sh(和 genpb_ts.sh)。核心是这条 protoc 命令(去掉循环外壳后的实质):

1
2
3
4
5
6
7
8
protoc -I ./pb \
-I ./pb/third_party \
--go_out=. --go_opt=module=$PRJ_SRC_PATH \
--go-grpc_out=. --go-grpc_opt=module=$PRJ_SRC_PATH \
--grpc-gateway_out=. --grpc-gateway_opt=module=$PRJ_SRC_PATH \
--validate_out . --validate_opt=module=$PRJ_SRC_PATH --validate_opt=lang=go \
--deepcopy_out . --deepcopy_opt=module=$PRJ_SRC_PATH \
./pb/cluster/v1alpha1/cluster.proto

逐段拆:

3.1 -I:proto import 搜索路径

  • -I ./pb:当前仓库 proto 根。
  • -I ./pb/third_partygoogle/api/annotations.protogoogle/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
2
3
4
5
6
dir=${1/"pb"/"pkg/grpcapis"}/$version
interface_names=$(cat $dir/* | grep '^type' | grep 'interface {$' | awk '{print $2}' | grep -v '^Unsafe' | grep '^[A-Z]')
for service in $interface_names; do
dest="${dir}/mock/mock_$(...小写化).go"
mockgen -package mock -destination=$dest <import-path> ${service}
done
  • pb/ 换成 pkg/grpcapis/ 定位到生成的 Go 目录。
  • 扫所有生成的 *_grpc.pb.go,grep 出 type Xxx interface { 拿到 client/server 接口名。
  • 对每个接口跑 mockgenmock/mock_xxx_client.gomock/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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
syntax = "proto3";
package infinigence.mizar.cluster.v1alpha1;
option go_package = "gitlab.infini-ai.com/mizar/apis/pkg/grpcapis/cluster/v1alpha1;v1alpha1";

import "google/api/annotations.proto";
import "google/protobuf/empty.proto";

service ClusterV1alpha1 {
rpc CreateCluster(ClusterRequestWithBody) returns (Cluster) {
option (google.api.http) = { post: "/mizar/v1alpha1/clusters/{id}" body: "cluster" };
}
rpc GetCluster(ClusterRequest) returns (Cluster) {
option (google.api.http) = { get: "/mizar/v1alpha1/clusters/{id}" };
}
// Update/Delete/List...
}

message Cluster { string api_version = 1; ClusterSpec spec = 2; ... }

生成物落 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 接口 + UnimplementedClusterV1alpha1ServerRegisterClusterV1alpha1Server*_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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
const (
ClusterV1Alpha1_CreateCluster_FullMethodName = "/infinigence.mizar.cluster.v1alpha1.ClusterV1alpha1/CreateCluster"
// ...
)

type ClusterV1Alpha1Client interface {
CreateCluster(ctx context.Context, in *ClusterRequestWithBody, opts ...grpc.CallOption) (*Cluster, error)
GetCluster(ctx context.Context, in *ClusterRequest, opts ...grpc.CallOption) (*Cluster, error)
// ...
}
func NewClusterV1Alpha1Client(cc grpc.ClientConnInterface) ClusterV1Alpha1Client {...}

type ClusterV1Alpha1Server interface {
CreateCluster(context.Context, *ClusterRequestWithBody) (*Cluster, error)
// ...
}
func RegisterClusterV1alpha1Server(s grpc.ServiceRegistrar, srv ClusterV1alpha1Server) {...}
  • 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.ymllint-generation 阶段是这套范式的命门:

1
2
3
4
5
6
lint-generation:
script:
- make download # 装 protoc + 各 protoc-gen-* 插件
- make generate # proto→全产物重新生成
- git diff # 看工作区有没有改动
- if [ "`git diff`" != "" ]; then exit 1; fi # 有改动 → CI 失败

逻辑:生成物是提交进 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
开发: 改 pb/cluster/v1alpha1/cluster.proto (service/message/google.api.http 注解)

▼ make generate (= generate-pb + generate-apis + generate-kubeclients + generate-crd)

hack/genpb.sh 对每个 pb/* 子目录跑:
protoc -I ./pb -I ./pb/third_party \
--go_out=. --go_opt=module=<repo> ─► pkg/grpcapis/.../cluster.pb.go (消息结构)
--go-grpc_out=. --go-grpc_opt=module=<repo> ─► pkg/grpcapis/.../cluster_grpc.pb.go (gRPC stub)
--grpc-gateway_out=. --grpc-gateway_opt=module=<repo> ─► .../cluster.pb.gw.go (HTTP→gRPC)
--validate_out . --validate_opt=module=<repo> --validate_opt=lang=go ─► .../cluster.pb.validate.go
--deepcopy_out . --deepcopy_opt=module=<repo> ─► .../cluster_deepcopy.pb.go
--openapi_out=.../ ─► cluster.openapi.yaml
go_package 剥 module 前缀 → 落 pkg/grpcapis/<group>/<version>/

hack/genpb_ts.sh: protoc --ts_out ./ts/src/pb ─► ts/src/pb/*.ts (前端绑定)
mockgen 扫 *_grpc.pb.go 的 interface ─► pkg/grpcapis/.../mock/mock_*.go
(并行: generate-apis/controller-gen 产 k8s API 类型; gencrd.sh 产 CRD; kube_codegen.sh 产 clientset/lister/informer)

▼ git add 生成物 + 提交
CI: 干净 clone 重跑 make generate → git diff 非空即失败 (守门, 保证 proto 与生成物永远同步)

服务端: embed UnimplementedXxxServer 实现业务 ─► RegisterXxxServer 注册到 grpc.Server
客户端: NewXxxClient(conn) 调 RPC; 或 HTTP 经 grpc-gateway 反代到 gRPC

七、实践要点与坑

  1. 改接口只改 proto,再 make generate——别手改生成物(文件头都写着 DO NOT EDIT,手改会被下次生成覆盖)。
  2. 生成物必须入库——CI 靠 git diff 守门,生成物不入库则 CI 永远失败;团队约定生成物随 proto 一起提交。
  3. 插件版本固定——make download 装指定版本 protoc/插件(如 protoc v24.4/v6.30.2、protoc-gen-go-grpc v1.3.0),不同版本生成产物格式会变导致 git diff 误报。
  4. go_package + module= 必须对齐——go_package 的 module 段要和 --go_opt=module= 一致,否则路径剥不对、生成物乱落。
  5. import 路径要 -I——google/api/annotations.proto 这类要放 pb/third_party/-I,否则 protoc 找不到。
  6. grpc-gateway 要 service 每个方法都加 google.api.http——没注解的方法 gateway 不产 HTTP handler(仍可走 gRPC)。
  7. mockgen 路径要对——它要扫已生成的 *_grpc.pb.go,所以必须在 protoc 之后跑(脚本里就是这个顺序)。
  8. 新加服务:在 pb/<group>/<version>/.protoMakefile 不用改(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 embed UnimplementedXxxServer、实现关心的方法,再 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 生成流程这关就稳了。


参考资料