
如何管理项目中的 Protobuf
后端从单体切到微服务之后,很多团队会发现一件尴尬的事:业务代码分得清清楚楚,服务边界画得明明白白,可那份被所有服务共同引用的 .proto 文件,却成了没人说得清该归谁管的东西。
「Proto 代码到底放哪里?」是社区里反复出现的经典讨论,也是一个存在已久的老问题。各方答案大致归成五类,每一轮讨论都吵得不可开交——有说放业务仓库里的,有说必须独立仓库的,还有说干脆搞个集中仓库一劳永逸的。争论多年,答案并没有收敛,但问题的外延却悄悄变大了:存放位置只是 proto 管理的一个切面,围绕它还长出了版本兼容、工具链、代码生成、CI 治理一整条链路。
这篇文章想把这些串起来讲一遍:proto 为什么难管、业界都有哪些存放方案、版本怎么演进才不踩坑、工具链从 protoc 进化到了什么程度、生成代码到底该不该提交,然后给出我自己在用的完整组合——单仓库 + git tag 版本 + 生成外置,最后讲怎么把这一切固化进团队的日常。
一、为什么 proto 管理会成为一个真问题
先把问题摆清楚:一个 .proto 文件有什么好管理的?
第一,它是契约。一份 proto 定义了跨服务的消息结构和服务接口,一份文件往往被多个团队、多种语言的服务同时引用。改一行字段,影响的不是「这段代码的调用方」,而是整张服务调用网里所有实现方和消费方。它和函数签名完全不在一个量级——函数签名的破坏半径是一个进程,proto 的破坏半径是整个组织。
第二,它是二进制协议的蓝图,演进成本远比 JSON 高。JSON 接口加个字段、改个名字,前后端通个气就完事;protobuf 的线上身份是字段编号而不是字段名,老数据可能躺在日志、缓存、消息队列里数年不散。编号一旦改错或复用,老进程解析新字节,轻则字段错乱,重则直接反序列化失败。这是不可逆的历史债,只能靠纪律和工具双重约束。
第三,它是跨组织共享的代码。业务仓库里的普通代码只归自己,proto 却天然属于多个团队。于是「放哪里」变成了权限、拉取、同步、版本四个问题的集合:新同事入职要申请几个仓库权限?服务跑起来之前要拉几个仓库?别人改了 proto 我怎么知道?版本漂了算谁的?
第四,它牵扯出生成代码的次生问题。proto 本身不产生价值,产生价值的是 protoc 生成的各语言代码。谁生成、什么时候生成、用什么版本的编译器生成、生成结果放哪里,每个问题都有一堆讲究,后面单独展开。
最后还有一个隐蔽的杀手:复制粘贴。当 proto 没有可靠的分发通道时,最省事的做法就是「去隔壁组把文件要过来,拷进自己的 proto 目录」。一次两次没问题,时间一长,各服务手里的 proto 悄悄分叉,字段编号撞车、定义不一致,等到联调时才发现两边对「用户」的理解根本不一样。协议分叉比代码分叉更难发现,因为错误不在编译期爆发,而在运行时才现形。
二、proto 文件自身的组织规范
存放方案和版本纪律都是「大方向」,动手之前还有一个容易忽略的细节:proto 文件自己该怎么组织。一个仓库里几十个 proto 文件,如果命名、目录、包名各写各的,光 import 路径就能乱成一锅粥,lint 规则也无从谈起。
目录结构:按领域分层,而不是按团队分层
目录是 proto 的第一层命名空间,它决定了下游 import 时的观感。常见的健康结构是按「领域/服务/版本」组织:
proto/
├── user/
│ ├── v1/
│ │ ├── user.proto
│ │ └── user_service.proto
│ └── v2/
│ └── user.proto
└── order/
└── v1/
└── order.proto几个约定俗成的规则:目录只按领域 (Domain) 分,不按团队分——团队会重组,领域不会;版本号放在最内层目录,和大版本升级策略一一对应;文件名的单复数、动词命名(user_service.proto 还是 user.proto)全仓库统一,交给 lint 规则去强制。
包名:代码生成的第一张名片
package 声明是 proto 的全局命名空间,它同时决定了生成代码的包结构。业界通行做法是反向域名 + 领域 + 版本,比如 package acme.order.v1:
- 包名要全局唯一,跨仓库也不能撞——这靠约定,也靠 registry 的校验兜底;
- 包名里的版本和目录里的版本要一致,
v2/目录里不能出现package acme.order.v1(采用 tag 版本制的团队可以让包名不带版本,见第四节,但目录与 tag 的对应关系同样不能乱); option go_package这类语言选项,要么显式声明 (option go_package = "github.com/acme/gen/order/v1"),要么交给 buf 的 managed mode 统一注入。两者都行,但绝不能「有时写有时不写」,否则每次换机器生成的代码路径都不一样。
文件粒度:一个文件一个职责
一个 .proto 文件不要塞太多东西。常用的消息按领域放(user.proto 放 User、Address),服务接口单独一个文件(user_service.proto 放 UserService),request/response 消息跟着服务走。文件越小,import 的依赖图越干净,review 也越容易——评审人打开一个文件就知道自己要看什么。
这些规范看起来琐碎,但它们决定了后面 lint、breaking、generate 三类工具能不能顺畅工作。好在 buf lint 内置了四十多条规则覆盖大部分约定,团队只需要选定规则集(默认的 STANDARD 就够用),剩下的交给 CI 执行,不需要人肉背书。
三、proto 文件放哪里:五种常见方案
存放问题是 proto 管理的地基,先把它讲清楚。综合各家实践,大致有五类方案,各有各的取舍。
方案一:业务仓库内置 proto/ 目录
最朴素的做法:把服务依赖的所有 proto 都拷进自己仓库的 proto/ 目录下,随业务代码一起走版本控制。
优点很直接:没有权限问题,不需要申请任何仓库;proto 和实现代码在同一仓库,改契约和改实现可以在同一个 PR 里原子完成;本地开发不用关心「拉取」这件事,克隆一个仓库什么都有了。
缺点同样致命:共享靠人工拷贝。每次上游改了 proto,都要重新「要」一遍、拷一遍,沟通成本随服务数量指数上升;更重要的是,一旦允许拷贝,分叉几乎是必然结局——你手里的版本和他的版本,迟早不一样。
所以这个方案只适合两种场景:要么是单体/小团队,服务就两三个,互相就是自己人;要么是 proto 的引用关系非常浅,几乎没有跨组共享。一旦服务数量上两位数、跨组协作成为常态,它就会变成版本漂移的温床。
方案二:每个服务一个独立 proto 仓库
「一服务一仓库」是很多团队最早尝试的方案:每个服务配套一个 proto 仓库,权限按仓库划分,按需拉取。
这个方案的安全性和隔离性最好——权限粒度最细,只需要拉取自己真正依赖的 proto,与业务仓库一一对应,心智负担小。
但它最大的优点也是最大的缺点。上图里 svc-user 依赖了三个组的六个 proto 仓库:一个新入职的同事,光申请权限就要跑六个组;每次启动服务,要把关联的仓库全部拉下来——如果没有脚本或工具做半自动化,这套流程的繁琐程度足以劝退所有人。方案本身没有问题,问题是对工具化的依赖极重:没有工具,它就是一个权限黑洞。
方案三:集中式 proto 仓库
按公司或大事业部维度,把 proto 收进一个(或少数几个)集中仓库。
优点肉眼可见:所有团队只认一个仓库,拉一次就集齐所有 IDL(接口定义语言,Interface Definition Language),权限管理复杂度断崖式下降,新人入职不再是一场权限马拉松。
代价是隐私和安全性的牺牲:所有人的接口定义对其他所有组「泄露」,哪怕只是非按需的曝光;同时由于不是按需拉取,文件树里总有一堆与你无关的内容,浏览起来噪声很大。但对绝大多数公司来说,接口定义本身并没有太多秘密可言,这种「泄露」的实际成本远低于想象——这也是集中式方案在业界被广泛采用的原因。
方案四:镜像/聚合仓库
集中式方案的一种改良:自己服务的 proto 仍然放在业务仓库里,提交或发布时自动同步到镜像仓库;依赖别人的 proto 则统一从镜像仓库读取。
开发时只面对自己那一份,构建时所有依赖从镜像仓库一次性集齐。它把「开发体验」和「构建可用性」拆开处理,两边各取所需。代价是流程复杂:需要同步工具、需要构建流水线配合,属于「中间态」方案,常见于从方案二向方案三/五过渡的路上。
方案五:Schema Registry——proto 的包管理器
前面四种方案都有一个共同痛点:proto 的共享本质上是「文件级的复制」,没有真正的版本概念。你依赖的到底是 proto-user 的哪个 commit?没有人能回答。而包管理器给了我们现成的答案:像 npm 管 JS、Go module 管 Go 一样,让 proto 拥有自己的注册中心 (Registry) 和依赖锁定。
这就是 Buf Schema Registry (BSR) 做的事:proto 模块按版本发布到 registry,服务通过 buf.yaml 声明依赖、buf.lock 锁定版本,buf push / buf dep update 一整套流程和现代包管理器完全同构。消费者甚至可以跳过代码生成,直接通过 go get、npm install 安装 registry 发布的 SDK。
这套方案的含金量在于:版本语义、依赖锁定、破坏性变更拦截(registry 层面可以对每次 push 做 breaking 检查)全部平台化,团队不需要靠自律维持秩序。代价是需要自建或购买 registry 服务,对小团队偏重,但它是大规模微服务组织的最终形态。
五方案小结
| 方案 | 权限复杂度 | 版本管理 | 工具依赖 | 适合规模 |
|---|---|---|---|---|
| 业务仓库内置 | 最低 | 无(靠拷贝) | 无 | 单体/小团队 |
| 每服务独立仓库 | 最高 | 按仓库 | 极高 | 早期微服务 |
| 集中式仓库 | 低 | 按仓库 | 低 | 中大型组织 |
| 镜像/聚合仓库 | 中 | 按仓库 | 高 | 过渡期 |
| Schema Registry | 低 | 真正的版本化 | 中 | 大规模微服务 |
| 集中式 + tag 版本 + 生成外置 | 低 | git tag | 低 | 中小型组织(推荐起点) |
最后一行不是独立的第六种存放位置,而是「集中式」的工程化加强版——在集中仓库上叠加 tag 版本纪律、把生成代码外置。它是我自己在用的组合,完整展开放在第七节。
一个朴素但实用的选型建议:能集中就集中。proto 不是业务代码,它没有「保密」的必要,却有「一致」的刚需。除非组织已经有成熟的 registry 基建,否则集中仓库通常是性价比最高的起点;而无论选哪条路,都要尽快让「拉取」和「发布」自动化,手动拷贝在任何方案里都是灾难。集中仓库之上,再叠加两件事——git tag 版本纪律和生成代码外置——就是一套完整的落地形态,第七节展开。
四、版本与兼容性:proto 的演进纪律
存放方案解决的是「文件在哪儿」,接下来解决「文件怎么变」。protobuf 的演进纪律,浓缩起来其实就几条,但每一条都是血泪教训换来的。
字段编号是永久的身份证
protobuf 的线上格式里,字段的身份是编号,不是名字。所以:
- 编号一旦分配,永不更改。改了编号,老进程会把新字节按旧结构解析,数据直接错乱。
- 编号永不复用。就算字段删了、代码里没人引用了,也要记得日志里可能躺着旧数据,一旦复用,老解析器就会把新字段的字节当成旧字段的类型读,同样是数据损坏。
- 删除字段必须用
reserved占位,编号和名字都要占,编译器会帮你挡住意外复用:
syntax = "proto3";
message User {
reserved 2, 3; // 编号占位
reserved "phone"; // 名字占位(防止 JSON 序列化撞车)
string name = 1;
int64 created_at = 4; // 新增字段永远往后排
}- 字段类型不要改。
int32改成string会改变线格式的 wire type,属于彻底的破坏性变更;只有少量 varint 系的数字类型 (int32/int64/uint32/uint64/bool) 互相改在字节层面勉强兼容,但数值语义也可能悄悄截断,能不动就不动。 - 字段标签不要改:
optional和repeated互改是破坏性的。 - 枚举值不要删、不要改号:新增枚举值安全,删除或改号会破坏兼容。
把上面几条画成决策树,就是一张贴墙上的图:
还有一条容易忽略的实操细节:字段编号段是稀缺资源。1 到 15 的编号在线上只占一个字节,越靠后越贵,所以新字段尽量先占用小号段;而 19000 到 19999 是 protobuf 规范自己保留的内部段,永远不要分配。成熟的团队会约定「小号段留给最热门的字段,大号段留给可能新增的字段」,再配合 reserved 占位,把整个编号空间经营得像一份长期资产。提前规划编号段,比事后从泥潭里拔编号便宜得多。
破坏性变更要靠工具拦截,而不是靠人记
人记不住这些规则,尤其是在几十个 proto 文件的规模下。业界现在的标准做法是引入 buf breaking:把当前 schema 与上一次发布的版本做对比,按四档规则检测破坏:
FILE:生成代码按文件粒度的破坏(最严格,默认档)PACKAGE:按包粒度放宽,允许文件挪动WIRE_JSON:二进制线格式或 JSON 编码的破坏WIRE:仅二进制线格式的破坏
配合语义化版本,规则可以简化成一句话:在 v1 里只做非破坏性变更;必须破坏时,升 v2 并通过 import 路径(如 import "v2/user.proto")显式切换。路径里带版本号,比任何文档都诚实——下游一眼就能看出自己依赖的是哪个大版本,也不会被「悄悄的不兼容」蒙在鼓里。
用 git tag 表达「上一个版本」
「上一次发布的版本」在 git 世界里有一个现成的表达:tag。commit 是过程,tag 才是发布物——所以 CI 里的 breaking 检查不该对着 main,而该对着最近一次发布 tag:
buf breaking --against '.git#branch=main' # 本地开发时对 main
buf breaking --against '.git#tag=v1.2.0' # 发布前对最近一次 tag
buf breaking --against '.git#tag=v1.2.0,subdir=proto' # buf.yaml 不在仓库根目录时两个实操细节:CI 环境经常是 shallow clone,本地 tag 拉不全,这时 --against 可以直接指向远程仓库地址 (buf breaking --against 'https://github.com/acme/pb.git#tag=v1.2.0'),buf 只会取它需要的那个 commit,大仓库也很快;buf 官方的 GitHub Action (buf-action) 还能把破坏点逐行标注到 PR 里,review 时一眼看到底坏了哪里。
版本号写在 tag 里,而不是写在包名里
和 tag 纪律配套的,还有一条被反复争论的约定:包名里带不带版本号。两种风格都有大量实践:package acme.order.v1 把版本写进包名,升大版本时目录、包名、import 一起翻新;另一种是包名保持稳定 (package acme.order),版本完全交给 git tag 表达。我更倾向后者:包名带版本,每次升大版本,所有下游的 import 路径和生成代码路径跟着一起改,历史包袱越滚越大;而 tag 只在发布那一刻出现一次,平时完全隐身。社区里有人总结得直白:包名是 mddb 而不是 mddb.v1,因为改名会打断每一个现有客户端。
真正需要隔离的场景只有大版本并存——那时用目录区分(proto/order/v1/、proto/order/v2/),两条线各自生成、互不 import。目录里的版本号管「并存」,tag 管「演进」,各司其职。
五、工具链:从 protoc 到 buf
存放方案定下来、演进纪律立起来之后,剩下一半的工作是工具链。传统姿势是 protoc 全家桶:protoc -I proto --go_out=... --go-grpc_out=...,插件一多,命令长到只能靠 shell 脚本维护,而脚本里经常躺着过期的插件版本——某天 protoc-gen-go 升了级,生成的代码开始引用新 runtime 才有的内部符号,构建原地爆炸。这是所有 protoc 用户的共同记忆。
现代方案是 buf:把编译、格式化、lint、破坏性检测、代码生成、依赖管理、registry 交互收进一个工具,命令简单到近乎无聊:
buf build # 编译验证
buf format -w # 格式化
buf lint # 40+ 条风格与正确性规则
buf breaking --against '.git#branch=main' # 破坏性变更检测
buf generate # 按 buf.gen.yaml 生成代码
buf push # 发布到 BSR几个值得单独说说的设计:
buf.yamlv2:把 workspace 和模块配置合并成一个文件,monorepo 成为一等公民——一个仓库里可以有多个模块,每个模块一份 lint/breaking 规则,统一依赖管理,buf.lock锁定版本,彻底告别-I路径的手工维护和「歧义 import」。buf.gen.yaml:代码生成配置进版本库,替代手工拼命令;managed mode 允许你在.proto文件里不写任何语言特定选项(如go_package),由生成配置统一注入——API 定义保持纯净,生成结果各语言各取所需。细节上:go_package_prefix保留目录结构、只重写模块路径前缀,go_package则整个替换,两者互斥;生成后的 Go 包名可以用;name收短 (github.com/acme/gen/order/v1;orderv1);不属于自己的依赖模块(如googleapis)要用managed.disable排除,否则会被 managed mode 顺手改写 import 路径。再配上clean: true,每次生成前清空输出目录,删掉的 proto 不会留下过期文件。- 远程插件:生成器插件托管在 BSR 上,开发机不再需要安装和同步
protoc-gen-*全家桶。一定要把版本钉死 (buf.build/protocolbuffers/go:v1.34.2),不写版本等于每次都飘到最新。代价是描述符要发给 BSR 的服务器,受网络和限流影响;不想把生成环节挂在外部服务上的,也可以把插件本地化——用go run google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2把版本写死在生成命令里,效果等同。
把整个生命周期串起来看,buf 时代的 proto 工作流长这样:
六、生成代码:提交还是不提交?
proto 管理绕不开的终极争议:protoc/buf generate 生成的代码,到底要不要提交进 Git?
不提交派的理由很硬:proto 是唯一事实来源,生成代码永远新鲜,不会出现「改了 .proto 忘了重新生成」的过期问题;diff 干净,评审只需要看 schema 变更,不用在一堆自动生成的噪声里找真正的业务改动。
提交派也有自己的道理,而且有个语言层面的硬约束:Go 生态几乎是强制提交。go get 只拉源码不执行生成命令,Go module 的消费者拿不到没提交的 .pb.go;构建可复现、CI 不需要安装 protoc 全家桶、源码包在任何机器上都能直接编译——这些都是实打实的工程收益。
两派吵了很多年,业界逐渐收敛出一个共识,其实是个组合拳:
还有一个两派都容易踩的坑:生成器版本和 runtime 版本必须配套锁定。protoc-gen-go 升级后生成的代码可能引用新 runtime 才有的内部符号,而 go.mod 里锁着的还是旧 runtime,构建直接原地爆炸。无论提交还是不提交,都要把「编译器/生成器版本 + runtime 版本」当做一个整体来管——用统一入口(Docker 镜像、buf 远程插件、版本清单)固定下来,而不是靠各台机器上碰巧装了什么。这一条解决的是「生成的代码和跑它的环境对不上」的问题,它和提交与否正交,但经常被两派同时忽略。
- 要么不提交,把生成放进构建流程,靠
buf.gen.yaml保证任何机器生成结果一致; - 要么提交,但必须配 CI 一致性校验:CI 里重新跑一遍生成,和仓库里提交的生成代码做 diff,有差异就失败。这一条把「提交派」最大的软肋——生成代码过期——直接焊死;
- 要么干脆不生成:直接消费 BSR 之类 registry 发布的 SDK,
go get/npm install一把梭,代码生成这件事彻底从团队日常里消失。
上面三种形态覆盖了多数场景,还有两种细分的消费姿势值得单独展开——它们正是「集中仓库不生成代码」这套方案的两条腿。
生成代码需要二次加工:单独放 protogo 仓库
「不提交」和「构建时生成」都默认生成代码拿来即用。但有些语言的生成物需要再加工,最典型的是 Go:给生成的 message 挂上 sql.Scanner / driver.Valuer,让 protobuf 结构体能直接读写数据库的 JSONB(JSON 二进制格式)列。Go 的方法只能定义在类型所在的包内,定制代码必须和生成代码同包共存——手工文件跟着生成目录走,稍不留神就会被 clean: true 清掉,挤在服务仓库里越维护越难受。
这时候值得给生成代码一个独立仓库(比如叫 protogo):CI 监听 pb 仓库的新 tag,自动 buf generate 出全部 Go 代码,和手工维护的同包扩展文件一起提交:
protogo/
├── order/v1/
│ ├── order.pb.go # buf generate 产出
│ └── order_sql.go # 手工维护的同包扩展
└── go.mod # 服务 go get 消费,版本跟着 pb tag 走package orderv1
import (
"database/sql/driver"
"fmt"
"google.golang.org/protobuf/encoding/protojson"
)
// Scan 与 Value 让生成的 Order 能直接读写数据库的 JSONB 列
func (x *Order) Scan(src any) error {
b, ok := src.([]byte)
if !ok {
return fmt.Errorf("scan: unexpected type %T", src)
}
return protojson.Unmarshal(b, x)
}
func (x *Order) Value() (driver.Value, error) {
return protojson.Marshal(x)
}更正统的路线是写一个基于 protogen 的定制插件,像 proto-go-sql、protoql 那样,buf generate 时自动吐出 person_sql.go,gogo/protobuf 的维护者也建议这么做。但对多数团队,「同包文件手工写」的启动成本低得多:protogo 仓库把生成物和定制物钉在一起发布,服务端 go get github.com/acme/protogo@v1.2.0 一把梭,tag 与 pb 仓库严格对齐。它唯一的缺口是新增 message 时容易漏写方法——在 protogo 的 CI 里加一个断言(生成类型是否实现了约定的 sql.Scanner 接口)就能兜住。
各服务自行生成:git submodule 引入 pb 仓库
不需要二次加工的服务,连 protogo 都不必依赖:用 git submodule 把 pb 仓库引入自己的代码树,构建时本地跑 buf generate,生成物进 .gitignore。submodule 天生是「精确版本锁」——它 pin 的是一个 commit,配合 pb 仓库的 tag 纪律(大版本内无破坏),服务升级 proto 就是拉 tag、切指针、提交指针更新三步。
submodule 名声不好,一半骂名来自它自身的心智负担(克隆要 --recurse-submodules、指针更新要多一次提交、刚进去是 detached HEAD),另一半来自一个经典误解:把含生成代码的目录当 submodule 引进来。Go 不把 submodule 当 Go module 认,里面的 .pb.go import 路径对不上 go.mod,checksum 天天打架。而「pb 仓库不含生成代码」恰好绕开了全部问题:submodule 引入的只是 .proto 源文件,生成的 Go 代码长在服务自己的仓库里,go.mod、import 路径、构建缓存全是自己的。生成器版本则由服务自己的 buf.gen.yaml(或统一镜像)锁住,责任边界清清楚楚。
七、推荐组合:单仓库 + tag 版本 + 生成外置
前面把存放、版本、工具链、生成代码四块拆开讲完了。这一节把它们拼成一个完整方案——也是我自己在用的:所有 proto 收进一个仓库,buf 负责全部治理但唯独不生成代码,版本只用 git tag 表达,生成代码按需外置。先看全景:
四条原则,每条对应前面的一节:
第一,所有 proto 收进一个仓库。 这是第三节「集中式」的具体化:权限只申请一个,拉取只做一次,新人入职第一天就能读到全公司的契约。目录按领域分层(第二节),buf.yaml、buf.lock 各一份,全仓库依赖统一管理。
第二,buf 管所有事,唯独不生成代码。 lint、format、breaking 在 pb 仓库的 CI 里全量执行(第五节),buf generate 一步都不跑——pb 仓库是 schema 的单一事实源,只管「定义长什么样」,不管「代码长什么样」。三层好处:仓库永远干净,diff 里只有 schema 变更;「生成器该锁哪个版本、runtime 要不要跟着升」这团乱麻从 pb 仓库里彻底摘除,下放给消费方各自负责;同一份 schema 的 Go/Java/TS 生成物互不纠缠,谁用谁生成。
第三,版本只用 git tag 表达,纪律只有一条:大版本不升,破坏性变更不进。 每次发布打一个 tag,CI 里 buf breaking --against 上一个 tag(第四节),全绿才放行。真想破坏?升 v2/ 目录、打大版本 tag,新旧线并存迁移。这条纪律的妙处在于可被工具验证:不是「请大家遵守兼容性规范」的口号,而是流水线上一排红色的 X。
第四,生成代码按需外置,两条消费路径。 需要二次加工的语言(比如 Go 里给 message 挂 sql.Scan),生成物连同定制代码放进独立的 protogo 仓库,服务 go get 按版本消费;不需要加工的服务,git submodule 引入 pb 仓库,构建时自己 buf generate(第六节)。两条路径并存,互不干扰。
把这四件事合起来,改一个字段的完整生命周期是这样的:
- 开发者在 pb 仓库开 PR:改
.proto,本地跑buf lint、buf breaking --against上一个 tag; - CI 全绿,评审合并,打 tag
v1.3.0; - protogo 仓库的 CI 看到新 tag,重新生成、提交定制代码,发布同名
v1.3.0; - 各服务想升级时,submodule 派
git -C third_party/pb fetch --tags && git -C third_party/pb checkout v1.3.0,protogo 派go get github.com/acme/protogo@v1.3.0,跑自己的测试,升级完成。
升级的主动权在消费方手里,pb 的发布节奏不被任何下游绑架——这是「版本是 tag、消费是显式动作」带来的松弛感。
边界也要说清楚:这套方案适合一到几十个服务的规模。服务数量再往上、出现跨团队 SDK 分发需求时,把 pb 仓库原样 buf push 到 BSR 就是自然升级路径——tag 纪律、breaking 检查、目录结构全部无缝平移,第三节里 registry 的最终形态随时可以接上。它真正的代价只有两个:submodule 的心智负担(用脚本或 Makefile 包一层即可),和 protogo 仓库的一条生成流水线(一次搭好,长期复用)。
八、把治理固化进 CI 与流程
方案再好,靠「大家自觉」都会走样。proto 治理的最后一步,是把规则固化进流水线和流程,让工具而不是人来做裁判:
- PR 阶段跑
buf lint:格式、命名、风格问题在评审前自动阻断,评审人不用再当语法警察; - 合并前跑
buf breaking --against上一个发布 tag:任何破坏性变更都必须在 PR 里显式说明、评审通过,而不是悄悄混进主干。CI 的 shallow clone 拉不到历史 tag 时,直接把--against指向远程仓库 (buf breaking --against 'https://github.com/acme/pb.git#tag=v1.2.0');故意的破坏走显式逃生门——比如buf-action支持的buf skip breakinglabel——打标放行,而不是把检查悄悄关掉; - 生成一致性校验:如果提交了生成代码(无论提交在服务仓库还是 protogo 仓库),CI 里
buf generate后 diff,不一致直接红;protogo 仓库再加一道接口断言,确认生成类型实现了约定的sql.Scanner等定制接口,防止新增 message 漏写方法; - protogo 的版本对齐:CI 监听 pb 仓库的新 tag,重新生成、提交并打同名 tag——pb 与 protogo 版本严格一一对应,消费方永远不会拿到错位版本;
- 契约评审与所有权:跨组共享的 proto 变更,需要一个明确的「契约评审」环节——谁是这份 proto 的 owner,改动要不要拉相关消费方一起看,protobuf 的 break 往往不是技术问题,是沟通问题;
- 变更记录:schema 的 changelog 和大版本升级计划,从「
v1只做非破坏变更、v2走 import 路径」这样简单的约定起步。
如果团队还没有任何 proto 治理,我的建议是从最小闭环开始:一个集中仓库 + buf 的 lint/breaking 接入 CI + 明确每份 proto 的 owner。这三件事半天就能落地,却能把前面说的所有坑——版本漂移、编号复用、静默破坏——全部挡在门外。它们也正是第七节那套方案的底座:往上长,就是 tag 版本纪律和生成代码外置。
结语
回看开头那个经典问题:「proto 放哪里」。争论到今天,答案其实更清楚了:存放位置只是表层,它背后是契约的版本化、工具链的统一和治理的自动化。小团队从集中仓库起步,大组织走向 Schema Registry;无论哪条路,核心原则只有两条——schema 要集中、变化要受检。把这两条原则落到最省心的形态,就是第七节的组合:一个只装 schema 的仓库、一套 buf 纪律、一串 git tag,加上按需外置的生成代码。它不需要任何新基建,在现有 git 体系上就能跑起来。
proto 是分布式系统的接口税。管理好它,微服务之间的协作才有共同语言;管理不好,每个服务都在悄悄复制着属于自己的「那份真相」。
