如何管理项目中的 Protobuf

如何管理项目中的 Protobuf

•约 8.6 千字

后端从单体切到微服务之后,很多团队会发现一件尴尬的事:业务代码分得清清楚楚,服务边界画得明明白白,可那份被所有服务共同引用的 .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 时的观感。常见的健康结构是按「领域/服务/版本」组织:

text
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/ 目录下,随业务代码一起走版本控制。

svc-order 仓库拷贝共享业务代码proto/ 目录含所有依赖的 .proto订单服务用户服务

优点很直接:没有权限问题,不需要申请任何仓库;proto 和实现代码在同一仓库,改契约和改实现可以在同一个 PR 里原子完成;本地开发不用关心「拉取」这件事,克隆一个仓库什么都有了。

缺点同样致命:共享靠人工拷贝。每次上游改了 proto,都要重新「要」一遍、拷一遍,沟通成本随服务数量指数上升;更重要的是,一旦允许拷贝,分叉几乎是必然结局——你手里的版本和他的版本,迟早不一样。

所以这个方案只适合两种场景:要么是单体/小团队,服务就两三个,互相就是自己人;要么是 proto 的引用关系非常浅,几乎没有跨组共享。一旦服务数量上两位数、跨组协作成为常态,它就会变成版本漂移的温床。

方案二:每个服务一个独立 proto 仓库

「一服务一仓库」是很多团队最早尝试的方案:每个服务配套一个 proto 仓库,权限按仓库划分,按需拉取。

业务组 B业务组 A本组proto-userproto-orderproto-productproto-payproto-smsproto-logsvc-user

这个方案的安全性和隔离性最好——权限粒度最细,只需要拉取自己真正依赖的 proto,与业务仓库一一对应,心智负担小。

但它最大的优点也是最大的缺点。上图里 svc-user 依赖了三个组的六个 proto 仓库:一个新入职的同事,光申请权限就要跑六个组;每次启动服务,要把关联的仓库全部拉下来——如果没有脚本或工具做半自动化,这套流程的繁琐程度足以劝退所有人。方案本身没有问题,问题是对工具化的依赖极重:没有工具,它就是一个权限黑洞。

方案三:集中式 proto 仓库

按公司或大事业部维度,把 proto 收进一个(或少数几个)集中仓库。

集中 proto 仓库拉取一次user/order/pay/服务 A服务 B

优点肉眼可见:所有团队只认一个仓库,拉一次就集齐所有 IDL(接口定义语言,Interface Definition Language),权限管理复杂度断崖式下降,新人入职不再是一场权限马拉松。

代价是隐私和安全性的牺牲:所有人的接口定义对其他所有组「泄露」,哪怕只是非按需的曝光;同时由于不是按需拉取,文件树里总有一堆与你无关的内容,浏览起来噪声很大。但对绝大多数公司来说,接口定义本身并没有太多秘密可言,这种「泄露」的实际成本远低于想象——这也是集中式方案在业界被广泛采用的原因。

方案四:镜像/聚合仓库

集中式方案的一种改良:自己服务的 proto 仍然放在业务仓库里,提交或发布时自动同步到镜像仓库;依赖别人的 proto 则统一从镜像仓库读取。

只关心自己的 protofeature 提交后自动同步构建/部署时拉取开发者本服务 proto 仓库集中镜像仓库其他服务 protoCI 与部署环境

开发时只面对自己那一份,构建时所有依赖从镜像仓库一次性集齐。它把「开发体验」和「构建可用性」拆开处理,两边各取所需。代价是流程复杂:需要同步工具、需要构建流水线配合,属于「中间态」方案,常见于从方案二向方案三/五过渡的路上。

方案五: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。

buf pushbuf dep update + buf.lock锁定发布生成 SDK.proto 源文件Buf Schema Registry服务 A服务 Bgo get / npm install

这套方案的含金量在于:版本语义、依赖锁定、破坏性变更拦截(registry 层面可以对每次 push 做 breaking 检查)全部平台化,团队不需要靠自律维持秩序。代价是需要自建或购买 registry 服务,对小团队偏重,但它是大规模微服务组织的最终形态。

五方案小结

方案权限复杂度版本管理工具依赖适合规模
业务仓库内置最低无(靠拷贝)无单体/小团队
每服务独立仓库最高按仓库极高早期微服务
集中式仓库低按仓库低中大型组织
镜像/聚合仓库中按仓库高过渡期
Schema Registry低真正的版本化中大规模微服务
集中式 + tag 版本 + 生成外置低git tag低中小型组织(推荐起点)

最后一行不是独立的第六种存放位置,而是「集中式」的工程化加强版——在集中仓库上叠加 tag 版本纪律、把生成代码外置。它是我自己在用的组合,完整展开放在第七节。

一个朴素但实用的选型建议:能集中就集中。proto 不是业务代码,它没有「保密」的必要,却有「一致」的刚需。除非组织已经有成熟的 registry 基建,否则集中仓库通常是性价比最高的起点;而无论选哪条路,都要尽快让「拉取」和「发布」自动化,手动拷贝在任何方案里都是灾难。集中仓库之上,再叠加两件事——git tag 版本纪律和生成代码外置——就是一套完整的落地形态,第七节展开。

四、版本与兼容性:proto 的演进纪律

存放方案解决的是「文件在哪儿」,接下来解决「文件怎么变」。protobuf 的演进纪律,浓缩起来其实就几条,但每一条都是血泪教训换来的。

字段编号是永久的身份证

protobuf 的线上格式里,字段的身份是编号,不是名字。所以:

  • 编号一旦分配,永不更改。改了编号,老进程会把新字节按旧结构解析,数据直接错乱。
  • 编号永不复用。就算字段删了、代码里没人引用了,也要记得日志里可能躺着旧数据,一旦复用,老解析器就会把新字段的字节当成旧字段的类型读,同样是数据损坏。
  • 删除字段必须用 reserved 占位,编号和名字都要占,编译器会帮你挡住意外复用:
protobuf
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 互改是破坏性的。
  • 枚举值不要删、不要改号:新增枚举值安全,删除或改号会破坏兼容。

把上面几条画成决策树,就是一张贴墙上的图:

加新字段改类型/标签删字段改字段名改字段编号加枚举值删/改枚举值想改字段?✅ 用新编号追加, 安全❌ 破坏性变更⚠️ reserved 占位后删除⚠️ 二进制兼容但 JSON 与生成代码不兼容❌ 绝对禁止✅ 安全❌ 破坏性变更

还有一条容易忽略的实操细节:字段编号段是稀缺资源。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:

bash
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 交互收进一个工具,命令简单到近乎无聊:

bash
buf build                                    # 编译验证
buf format -w                                # 格式化
buf lint                                     # 40+ 条风格与正确性规则
buf breaking --against '.git#branch=main'    # 破坏性变更检测
buf generate                                 # 按 buf.gen.yaml 生成代码
buf push                                     # 发布到 BSR

几个值得单独说说的设计:

  • buf.yaml v2:把 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 工作流长这样:

buf buildbuf format -wbuf lintbuf breaking --againstbuf generate提交 .proto 与生成代码buf push 发布模块下游 buf dep update 消费

六、生成代码:提交还是不提交?

proto 管理绕不开的终极争议:protoc/buf generate 生成的代码,到底要不要提交进 Git?

不提交派的理由很硬:proto 是唯一事实来源,生成代码永远新鲜,不会出现「改了 .proto 忘了重新生成」的过期问题;diff 干净,评审只需要看 schema 变更,不用在一堆自动生成的噪声里找真正的业务改动。

提交派也有自己的道理,而且有个语言层面的硬约束:Go 生态几乎是强制提交。go get 只拉源码不执行生成命令,Go module 的消费者拿不到没提交的 .pb.go;构建可复现、CI 不需要安装 protoc 全家桶、源码包在任何机器上都能直接编译——这些都是实打实的工程收益。

两派吵了很多年,业界逐渐收敛出一个共识,其实是个组合拳:

Go 生态老传统需要二次加工简单消费多语言消费生成代码怎么办?提交 .pb.go✅ 构建可复现❌ diff 噪声CI 中重新生成并 diff 校验不一致即失败 ✅protogo 仓库生成代码 + 同包扩展一起提交服务 go get 消费版本对齐 pb 仓库 tag构建时生成, 不提交submodule 引入 pb 仓库发布为制品/SDKgo module / npm 包

还有一个两派都容易踩的坑:生成器版本和 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 代码,和手工维护的同包扩展文件一起提交:

text
protogo/
├── order/v1/
│   ├── order.pb.go      # buf generate 产出
│   └── order_sql.go     # 手工维护的同包扩展
└── go.mod               # 服务 go get 消费,版本跟着 pb tag 走
order_sql.go
go
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 表达,生成代码按需外置。先看全景:

submodule pin tagsubmodule pin tagCI 生成 + 定制代码CI 全绿后打 tagpb 仓库 只有 .proto全部 .protobuf.yaml / buf.locklint · format · breakinggit tag v1.2.0服务 A构建时本地 buf generate服务 Bprotogo 仓库服务 go get 消费

四条原则,每条对应前面的一节:

第一,所有 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(第六节)。两条路径并存,互不干扰。

把这四件事合起来,改一个字段的完整生命周期是这样的:

  1. 开发者在 pb 仓库开 PR:改 .proto,本地跑 buf lint、buf breaking --against 上一个 tag;
  2. CI 全绿,评审合并,打 tag v1.3.0;
  3. protogo 仓库的 CI 看到新 tag,重新生成、提交定制代码,发布同名 v1.3.0;
  4. 各服务想升级时,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 breaking label——打标放行,而不是把检查悄悄关掉;
  • 生成一致性校验:如果提交了生成代码(无论提交在服务仓库还是 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 是分布式系统的接口税。管理好它,微服务之间的协作才有共同语言;管理不好,每个服务都在悄悄复制着属于自己的「那份真相」。

如何管理项目中的 Protobufhttps://aoang.x2ox.com/2023/protobuf-management/
作者Aoang
发布于2023-04-21
更新于2023-04-21
许可协议CC BY-NC-SA 4.0
转载或引用本文时请遵守许可协议,并注明出处。