ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Protobuf代码生成实战:protoc与buf的完整使用指南

Protobuf代码生成实战:protoc与buf的完整使用指南 简介protobuf是Google推出的一种跨语言、跨平台的二进制数据交换格式相比XML/JSON具有更小的体积和更高的解析效率适用于网络传输、配置文件与数据存储等场景。这份谷歌protobuf代码生成工具包面向需要在Java、C、ActionScript等环境快速生成PB序列化代码的开发者可有效解决手动编写重复代码、格式易错等痛点。压缩包仅1.09MB共15个文件核心内容包括protoc.exe代码生成器、protobuf-java-2.4.1.jar与protoc-gen-as3.jar等运行时库以及生成java.bat、生成c.bat、生成as.bat等一键调用脚本另有message.proto、options.proto示例协议文件便于对照学习.proto语法与生成流程并附有README说明文档结构清晰下载解压后即可按脚本指引完成多语言代码生成。目前已有1539人学习下载适合希望快速上手protobuf自动生成流程的初中级开发者参考是一份体量精简、开箱即用的实用工具集。1. protobuf代码生成工具把.proto一次性变成各语言代码少走一半弯路一个接口要同时喂给Java后端、Kotlin客户端和TypeScript前端最常见的做法是每端各维护一套数据结构再靠接口文档对齐字段。字段少还能忍字段一多、版本一迭代这种对齐几乎必然翻车。Google的protobuf代码生成工具把.proto文件当作唯一事实源single source of truth一次编译就能产出各语言的结构体、序列化方法和RPC服务骨架两端代码严格同步再也不用肉眼对齐字段。这篇文章我会用自己的Go项目为例讲清protoc怎么装、proto文件怎么写、生成命令每段参数什么意思以及我踩过的几个坑最后演示怎么用buf方案替代原生protoc。适合刚接触protobuf的后端也适合想把手写JSON模型换成类型安全方案的中型团队。2. 为什么代码生成器值得信protoc的工作链路与生成器选型2.1 protoc内部到底做了什么从proto文本到代码产物的三个阶段很多第一次用protoc的人看到一条命令吐出几百行代码会觉得它是个“黑匣子”。其实它的工作链路可以拆成三段词法/语法解析、描述符构建、代码生成。前两步由protoc核心完成第三步由语言插件完成。先看第一段protoc读取.proto文件流做词法分析和语法分析遇到语法错误会直接报“Expected field name”这类信息。这一步生成的是一棵语法树还不是任何语言的代码。语法树会被转成DescriptorProto——这是protobuf对“消息、字段、枚举、服务”的中间表示。DescriptorProto里的字段编号、字段类型、是否repeated这些信息是后续代码生成的唯一依据所以它也被称为FileDescriptorSet。可以通过protoc --descriptor_set_outout.pb --include_imports输出这个中间文件想排查生成问题可以把它解析出来看。第二段是校验protoc检查字段编号是否重复、package是否合法、依赖的import是否都能在proto_path里找到。这步查的是“proto语义”比如字段编号全局唯一、enum第一个值必须为0。这些规则不是代码风格而是protobuf二进制格式兼容性的一部分——字段值在线上是按编号传送的改名不影响数据传输改编号就是破坏协议。第三段交给插件protoc把FileDescriptorSet通过stdin传给插件插件以CodeGeneratorRequest/Response协议来回传输出目标语言代码。插件看到的是编译后的描述符而不是源码文本这意味着代码生成逻辑完全与解析器解耦社区就能围绕描述符写任意语言、任意风格的生成器。所以“protobuf代码生成工具”严格说是protoc加插件这一整套链条单独装protoc只支持内置语言。这段里有个很重要的推论既然线上按字段编号传输那么“proto文件里写什么”比“生成的代码长什么样”更值得投入精力。后面第5章的若干翻车现场根源都在proto描述符层面而不是生成的代码本身。我在生产环境里的习惯是每多一个团队接入先把FileDescriptorSet导出来给双方确认一遍字段编号和类型再生成代码。这一步不用写代码但能把“两端字段对不上”的问题提前暴露。在生产里还有一个经常被忽略的点是“生成的代码应该被视为编译产物而不是业务代码”。这意味着不要手改pb.go、不要在上面加私有方法、也不要放到代码review的重点路径里——review的重点永远应该是.proto文件的字段编号和类型。把protoc升级当一次重构来做而不是当普通依赖升级才是这条工具链长期不翻车的关键。2.2 内置生成器vs第三方插件什么时候该用官方什么时候换社区方案protoc内置的生成器覆盖C、Java和Python。Go不在内置列表里需要单独安装protoc-gen-go插件TypeScript、Kotlin、Swift这类语言官方没有正式生成器统一靠社区插件。常见对应关系如下目标语言生成器是否内置典型产物Cprotoc内置是.pb.h / .pb.ccJavaprotoc内置是.javaPythonprotoc内置是_pb2.pyGoprotoc-gen-go否.pb.goKotlinprotoc-gen-kotlin需另外配置.ktTypeScriptts-proto / protoc-gen-ts否.tsGo为什么要单独搞插件是历史问题。早期社区用gogo/protobuf优化反射和内存分配生成代码路径和官方分叉后来官方做了google.golang.org/protobuf这套新运行时插件也换成protoc-gen-go。现在新项目直接用官方插件即可但存量项目里如果看到import了github.com/gogo/protobuf千万别把生成器混着用方法和字段getter的签名都对不上编译期会炸。我接手过的项目里这种混用是最常见的“protoc能生成、go build却报错”来源。选型上的建议是仅做内部消息序列化、不需要RPC直接用内置生成器加protoc-gen-go最省事需要gRPC时在Go侧再加protoc-gen-go-grpc生成service接口前端如果只有几个简单消息ts-proto够用但消息量大、想要Tree-shaking时改用ts-proto带es module选项或者切换到protobuf-es后者对前端打包更友好。不要因为社区插件看起来功能多就立刻换先确认它维护者是否还在跟进protobuf版本否则升级一次protoc就得连带升级插件很容易踩到第5章的版本坑。另外如果公司多个语言团队同时维护同一份接口选型的重点不应该是“哪个生成器功能多”而是“生成结果是否稳定可复现”。我用过两套插件并存的环境同一份.proto一个团队用官方的protoc-gen-go另一个团队用第三方的gogofaster两边生成的代码风格完全不同而且彼此不能直接互相Marshal/Unmarshal同一个bytes因为gogo的wire format和runtime默认行为有差异。最后是统一到官方运行时才消停。这个教训让我在选第三方插件时多了个标准看它是否声明兼容google.golang.org/protobuf的runtime没有这个声明再好用也不碰。3. 把protobuf代码生成跑通安装、proto编写与第一条生成命令3.1 安装protoc与语言插件版本尽量一致不然早晚出事先装编译器。主流环境的装法# Ubuntu / Debian sudo apt install protobuf-compiler # macOSHomebrew brew install protobuf # Windows 建议用包管理器或直接下载 release zip把 bin 目录加进 PATH winget install Google.Protobuf这里有个容易被忽略的点官方release包里的protoc版本往往比系统包管理器新。如果你要管多个项目我更推荐从protobuf的GitHub Release页下载对应系统压缩包解压后把bin加进PATH而不是依赖系统包管理器。原因是apt和brew的protoc版本往往滞后于runtime机器上如果装的是protoc 3.21而Go侧跑着protobuf v1.31生成的pb.go会带着旧版依赖编译期大概率报“undefined”方法。然后是Go插件go install google.golang.org/protobuf/cmd/protoc-gen-golatest go install google.golang.org/grpc/cmd/protoc-gen-go-grpclatestgo install会把可执行文件装到$GOBIN默认是$HOME/go/bin需要确认这个目录在PATH里。装完验证一下执行protoc --version和protoc-gen-go --version两个输出不要差太多。macOS上如果装过多个Go版本$GOBIN容易串最稳的办法是which protoc-gen-go看一眼路径不在预期目录就改PATH。常见做法是像Java生态锁版本一样把protoc和插件版本写进CI的安装脚本或Makefile。我一般用asdf管理protoc版本但小团队不折腾工具链直接固定版本号装在CI里就够了。3.2 编写第一个proto文件字段编号和package是重点一个合格的proto文件最小形态如下syntax proto3; package user.v1; option go_package demo/user/v1;userpb; message User { string user_id 1; string nickname 2; int32 age 3; repeated string tags 4; }syntax一行指定proto3影响字段是否有显式presence、enum首值是否必须为0。package user.v1是protobuf世界的命名空间跨文件引用时用package.MessageName也决定了部分语言产物的包名。go_package是给Go插件看的分号前是Go的import路径分号后是Go包名不写分号时默认取路径最后一段做包名。很多新手把go_package写成demo/user/v1但没有分号生成的包名就成了v1在跨包引用时非常别扭。字段编号1、2、3不是随便标的记号是线上传输时真正用的ID。编号1到15只占1个字节16到2047占2个字节所以高频字段尽量用小编号低频和将来要删的字段往大里排。repeated对应Go里的切片也是序列化时不保证顺序的字段类型。写文件时还要补一个package视角如果要被别的模块import它的proto路径要跟目录结构对齐比如proto/user/v1/user.proto里写import user/v1/user.proto就应该能直接引用。很多项目在proto/根目录下又叠了一层proto/目录结果import路径多一段生成时全靠--proto_path救能救但每次都要人解释建议一开始就把根目录定成唯一的import根。3.3 执行生成命令每个参数都别乱抄假设proto目录结构是proto/user/v1/user.proto生成Go代码到gen/下命令如下protoc \ --proto_pathproto \ --go_outgen \ --go_optpathssource_relative \ proto/user/v1/user.proto--proto_path指定import根目录相当于把这个目录映射成import起点。刚才的proto如果import了user/v1/address.protoprotoc会去proto/user/v1/address.proto找。--go_out是输出根目录生成的文件会出现在gen/下。--go_optpathssource_relative是关键中的关键它要求输出路径相对proto文件本身而不是相对go_package否则protoc默认按go_package路径再叠一层产出会在gen/demo/user/v1/这种和你预期不符的位置。两个目录差一层git diff时被误删误改的情况我见过太多次。执行完在gen/proto/user/v1/user.pb.go看到产物。这个pb.go文件包含三块User结构体、Get开头的一系列字段getter、以及Reset/String/ProtoMessage三个接口方法。凡是proto里定义的message必然有这套方法它们是runtime识别消息类型的基石。字段getter不是装饰用的是proto3对“字段未设置”下放给代码层的标准访问方式——当字段没被设置时直接读字段返回空字符串而getter返回同样的空值但通过getter能统一处理nil receiver这在把消息当指针传来传去时不至于panic。如果还要生成gRPC service代码在proto里定义service UserService再多加一条grpc插件命令protoc \ --go_outgen --go_optpathssource_relative \ --go-grpc_outgen --go-grpc_optpathssource_relative \ proto/user/v1/user.proto--go-grpc_out单独指定生成user_grpc.pb.go里面是service描述、客户端stub和服务端注册函数。注意顺序先跑go_out再跑go-grpc_out不会有依赖问题但两条命令的--proto_path要完全一致漏掉一个常见报错是Please specify a proto file这种误导信息。最后如果项目里proto文件不止一个不要写protoc --go_out ... proto/**/*.proto因为protoc本身不解析shell通配符**是不是递归取决于shell的globstar开关开启后没问题不开就只匹配一层。更可控的做法是在Makefile里显式列出proto路径列表PROTOS $(shell find proto -name *.proto) gen: protoc --proto_pathproto --go_outgen --go_optpathssource_relative $(PROTOS)这一小段Makefile值得保留它保证CI和本地生成的输入集完全一致不会因为某台机器shell配置不同而产生差异。4. 生成代码之后Go工程里的产物组织、序列化与跨语言使用4.1 生成代码放哪、怎么进仓库目录约定与生成开关第一个问题是pb.go要不要提交进git。多数团队的答案是“提交”因为很多同事不装protoc直接把生成的代码当作普通go文件编译。我的习惯是提交但会同时把proto文件、Makefile target写清楚保证任何机器都能一键重新生成。提交有一个附加收益git diff里能直观看到改proto后代码的变化CI里也可以加一个make gen git diff --exit-code步骤防呆防止有人手改pb.go。目录组织上有两条路。一条是把gen/放在业务代码旁边比如internal/pb/另一条是单独建gen/目录里面路径严格复制proto目录结构这就是pathssource_relative的意义。我倾向后者因为proto是跨语言共享的不同语言产物可以各占一个子目录比如gen/go/、gen/java/而proto/保持纯描述文件状态。生成规则写成MakefileGO_PROTO_ROOT : proto GO_OUT_DIR : gen/go GRPC_PLUGIN : $(shell which protoc-gen-go-grpc) .PHONY: gen gen: protoc \ --proto_path$(GO_PROTO_ROOT) \ --go_out$(GO_OUT_DIR) --go_optpathssource_relative \ --go-grpc_out$(GO_OUT_DIR) --go-grpc_optpathssource_relative \ $(shell find $(GO_PROTO_ROOT) -name *.proto)这段Makefile里最重要是$(shell find ...)它会列出proto目录下所有.proto文件并且按目录传递。前面说的glob问题这里规避掉了。GRPC_PLUGIN变量只是用来做一个启动前检查如果环境里没装插件make会报错而不是让protoc抛个含糊消息。第二个问题是包名冲突。如果proto里有package user.v1生成的pb.go里package是userpb另一个目录也有一个userpbimport时就重名。我的做法是在go_package里强制带品牌或模块前缀比如github.com/yourcompany/demo/gen/user/v1;userpb这样import路径唯一包名保持简写。版本目录v1/v2保留在路径里避免线上新旧版本同时存在时包级别分不开。4.2 序列化与反序列化别让代码生成背锅生成代码只解决“定义”真正跑起来还要靠runtime的序列化逻辑。Go侧核心就两个函数import ( google.golang.org/protobuf/proto userpb demo/gen/proto/user/v1 ) func main() { msg : userpb.User{ UserId: u_001, Nickname: soulteary, Age: 30, Tags: []string{golang, grpc}, } data, err : proto.Marshal(msg) if err ! nil { panic(err) } var decoded userpb.User if err : proto.Unmarshal(data, decoded); err ! nil { panic(err) } }proto.Marshal输出的是二进制格式与JSON相比长度更短、解析更快但不可直接读。它有这个特性字段按字段编号顺序排列不是按Struct定义顺序空值字段默认不编码比如Age如果为0、Tags如果为空切片marshal出来的字节里就没有这两个字段——这不是丢数据是proto3的默认行为整数负数用变长编码会更长如果业务里有连续负数JSON编码有时反而更小。这些结论和“python生成器”“java生成器”无关是wire format本身的性质。反序列化时proto.Unmarshal对未知字段的处理在不同版本里不同老版本会保留未知字段新版本可能随proto.UnmarshalOptions配置丢弃它们。如果线上消息要保留前向兼容建议在Unmarshal时不配置DiscardUnknown如果发现老客户端解析新消息总报警字段丢失原因大概率是proto文件里新字段编号刚好命中了老版本里已经reserved的编号段。对应到日志场景大多数人拿二进制数据去打日志查问题全靠肉眼猜这是让runtime背了代码生成的锅。正确做法是日志侧用JSONjsonData, err : protojson.Marshal(msg)protojson按proto字段名输出JSON默认把int64变成字符串这是为了和JavaScript的安全整数对齐。如果客户端解析int64失败先在protojson的UseProtoNames和EmitUnpopulated两个选项上查不要急着改proto字段类型。跨语言使用时代码生成工具只保证“字节兼容”。Java端生成的User类和Go端的User类只要字段编号和类型一致Marshal出来的bytes就能互相解析。这也就是为什么第2章强调描述符的重要性——跨语言只认编号不认语言名称。还在用JSON做内部接口的公司切到Protobuf后往往先被“字段解析错位”“额外字段丢失”这类问题困扰根因不是protobuf有问题而是他们没有先对字段编号做一次diff。另外还有个容易踩的误区是直接拿生成的Message当业务模型用。生成代码里全是getter和Marshal方法没有业务方法一旦你往里塞业务逻辑下次重新生成会把这些代码熔掉。所以我在项目里会在业务层包一层领域对象只在边界处做Message和领域对象互转。这个转换层虽然是样板代码但它让proto升级时对业务的影响面收敛在一个文件里非常值得。5. 避坑protobuf代码生成最常见的六个翻车现场5.1 现象生成的代码编译不过报错指向runtime版本打开新生成的pb.go顶部注释写着protoc-gen-go v1.30.0但项目go.mod里锁的是v1.33.0编译报undefined: protoimpl.MessageState或者类方法缺失。原因protoc-gen-go插件版本与runtime版本差距大插件生成代码时调用的内部结构在runtime里还没定义。解决把go install google.golang.org/protobuf/cmd/protoc-gen-go版本号改成和go.mod一致或者反过来统一到最新版。这个坑在升级protobuf库后会集中爆发因为CI里latest永远指向新的插件而go.mod可能只升了一半。我现在的做法是把插件版本写进go.mod同款版本变量Makefile里用go run直接运行插件而不是依赖PATH里的可执行文件go run google.golang.org/protobuf/cmd/protoc-gen-go -version5.2 现象改动proto后老客户端数据解析错乱线上有个User消息增加了一个score字段随手插在了user_id后面并把编号设为2。发布后老版本客户端解析到一串乱码字段丢字段、类型错乱轮着来。原因protobuf的wire format字段编号决定解析归属编号2原本是nickname旧客户端收到编号2的bytes就按string解析新写入的是int32自然错。解决新增字段永远用未使用过的新编号并给旧编号做reserved保护message User { reserved 6; reserved old_score; string user_id 1; string nickname 2; }reserved同时占住编号和字段名后续想复用会让protoc直接报错从根本上杜绝重排。这个习惯比任何代码review都管用因为它把“字段编号不可变”变成编译期约束。5.3 现象import路径时好时坏生成目录却莫名多一层现象protoc --proto_pathproto --go_outgen ... proto/user/v1/user.proto生成文件却出现在gen/demo/user/v1/user.pb.go和预期gen/proto/user/v1/user.pb.go差一层。原因--go_optpathssource_relative没加protoc默认按go_package路径组织输出。解决命令里统一加pathssource_relative并且写进Makefile而不是口头约定。另一个变种是import了user/v1/address.proto但proto_path指到了proto/user/v1放开import时会报找不到文件——search根和目标proto文件必须在同一个proto_path下不能一个根一个子目录混着写。5.4 现象protoc能在本地跑CI里却报找不到插件本地which protoc-gen-go有路径CI的bash里跑setup脚本也装了但protoc在子进程里找不到插件。原因CI的PATH里$GOBIN没加或者插件装了但权限不是executable。解决checkout后先打印protoc --pluginprotoc-gen-go$GOBIN/protoc-gen-go把插件路径显式传给protoc不依赖隐式搜索。这条是排查protoc相关报错最优先查的因为报错信息里protoc可能只说protoc-gen-go: program not found or is not executable并不会告诉你它找的是哪个目录。5.5 现象proto3加了optional后生成的代码变了现象给字段加上optional后生成的Go结构体里那个字段从值类型变成指针类型既有代码大量编译报错。原因proto3的optional在语义上带presence生成器为了表达“字段是否被设置”只能改成指针或包装类型。解决改之前先想清楚是否真的需要区分“没设置”和“设置为零值”。如果是在做数据迁移需要区分就接受指针类型如果只是怕空值不要用optional用google.protobuf.StringValue在语义上更强但会引入额外的import包。这条经验在跨语言时也一样Java侧optional字段变OptionalT客户端的读取逻辑全要跟着改。5.6 现象同一份proto在不同机器生成结果不一致现象同一份proto在本地和CI生成出的pb.go内容不一样或者两个开发机生成结果不同diff一开全是无关改动。原因protoc和插件版本、proto_path参数、go_package写法各自不同。解决生成入口要单一化。我一般把生成命令收敛到Makefile或buf.gen.yaml并让CI把生成结果与提交的pb.go做diff任何人改生成链路都会在PR里暴露出来make gen git diff --exit-code这些坑总结下来只有一条主线写proto时想的是编号、类型、兼容性而不是“这个字段放在哪好看”。所有翻车现场几乎全部能在proto描述符层面找到根源和具体语言的生成器没有太大关系。所以排查的顺序也反过来先看proto是否合法、编号是否reserved、import路径是否唯一再怀疑protoc插件版本。把这条顺序焊死在排查流程里能省下大量无效排查时间。6. 进阶用buf把生成入口管起来再写一个自己的代码生成插件6.1 buf generate一条命令统一proto管理前面所有命令都围绕原生protoc但它有两个不得不接受的痛点proto_path要自己记多语言多插件时命令长长一串而且没有lint。buf从2021年前后在社区普及它把目录扫描、import解析、生成配置都收了。最小用法go install github.com/bufbuild/buf/cmd/buflatest buf init buf lint buf breaking --against git://github.com/yourrepo/proto.git#branchmain,refHEAD buf generatebuf.gen.yaml是核心配置version: v1 managed: enabled: true go_package_prefix: default: github.com/yourcompany/demo/gen plugins: - name: go out: gen/go opt: pathssource_relative - name: go-grpc out: gen/go opt: pathssource_relative对比第4章的Makefilebuf省去了--proto_path因为buf自动以配置目录为根多语言团队只维护这一个yaml。managed.go_package_prefix会自动重写go_package防止各proto文件里go_package写法不一致。breaking检查在protoc里没有对应物它拿你现在的proto和过去的git版本对比能在合并前把“字段编号复用”这类问题挡在CI里。切到buf不是重写proto把原来protoc命令行替换成buf generateyaml里把插件和out写齐就行。唯一要注意的是buf对import路径的解析比protoc严格原来靠多个proto_path硬凑的目录在buf下跑得通或跑不通取决于目录根定义转换期建议先跑buf lint把warning修完再切生成链路。6.2 自定义插件给protoc加一个自己的代码生成“开关”如果你的工作流还需要做一些原生生成器没覆盖的检查比如强制所有message都要有updated_at字段或生成一份文档不用改protoc源码写一个自定义插件就够了。插件就是一个读stdin、写stdout的小程序#!/usr/bin/env python3 import sys from google.protobuf.compiler import plugin_pb2 request plugin_pb2.CodeGeneratorRequest() request.ParseFromString(sys.stdin.buffer.read()) messages [] for file in request.proto_file: for msg in file.message_type: messages.append(msg.name) response plugin_pb2.CodeGeneratorResponse() response.error checked %d messages % len(messages) sys.stdout.buffer.write(response.SerializeToString())protoc会把CodeGeneratorRequest作为二进制串写到插件stdin插件解析后如果一切正常就返回空的CodeGeneratorResponse如果设置了response.errorprotoc会把这一行作为错误输出并中断。用起来只需给protoc加两个参数protoc \ --pluginprotoc-gen-check./check_msg.py \ --check_out. \ proto/user/v1/user.proto--pluginprotoc-gen-check...告诉protoc“我们有一个叫protoc-gen-check的插件路径在这”--check_out触发它。实际CI里把这个自定义插件挂在protoc后面配合buf lint能做到比代码review更稳的强制性约束。真正做输出时response.file里加name和content即可生成的内容可以是markdown文档、空文件或某种语言代码。我和团队现在把接口变更检查也写成了这类插件——每次生成时自动比对字段编号和reserved声明漏掉就让CI红牌。从那以后我改完proto文件后强制走一遍“buf lint buf breaking buf generate 自定义检查插件”再提交已经很久没被字段兼容性问题半夜叫醒希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进