protobuf 使用实例详解:从 .proto 设计到序列化与避坑

发布时间:2026/10/17 1:02:04
protobuf 使用实例详解:从 .proto 设计到序列化与避坑 搞接口联调最烦的就是两边为了个字段格式来回扯皮这边发的是字符串数字那边要的是整数JSON 一层套一层日志打出来密密麻麻解析还得小心翼翼。后来我第一次接触 protobuf 时只是把几个消息结构写完序列化出来一看体积小得离谱解析也变成单纯的内存拷贝操作瞬间就明白为什么现在内部服务通信都在往这个方向切。这篇 protobuf 使用实例我尽量按一个完整项目的路径来讲先讲清楚它要解决什么问题再带你从.proto文件设计、编译器安装、代码生成到真正把消息序列化、反序列化跑通最后把我在项目里踩过的字段编号、默认值、流式传输这些坑也一并列出来。适合刚准备在项目里引入 protobuf、或者已经写了几个.proto但总感觉没吃透的开发者看完至少能少走我当年走过的弯路。1. 为什么值得为 protobuf 写一份完整实例1.1 受够了 JSON 的“膨胀感”先说我自己的实际体验。以前做服务端接口前后端习惯用 JSON 通信结构确实清晰人眼也能看。但真到高并发场景问题就来了一个订单消息里可能带上收货地址、商品列表、优惠信息、时间戳JSON 会把这些字段名反复带上同一条消息发一万次字段名就重复了一万次。网络带宽浪费倒是其次更难受的是解析端要做字符串匹配、动态类型判断CPU 开销全花在格式化上了。我做过一次对比实验同一个订单结构用 JSON 序列化大概是 500 多字节换成 protobuf 后 100 字节左右。我一开始也怀疑是样本太小后来发现就是在消息字段多、嵌套层级深的时候差距反而更明显。因为 protobuf 用的是二进制编码字段名不传只传编号和值字符串、数字都有紧凑的编码方式体积自然就压下来了。1.2 protobuf 到底是靠什么赢的一句话解释protobuf 是把结构化数据编成二进制流的协议。它靠一个.proto文件描述数据结构然后用编译器生成各种语言的代码你在代码里直接操作生成的对象最后调用序列化接口拿到字节数组。它有四个我认为最核心的优势体积小字段名不参与传输用字段编号代替整型还有变长编码小数值只占几个字节。解析快二进制格式是定长或可预判长度的反序列化不需要逐个字符解析字符串直接按偏移量读数据。跨语言一份.proto文件可以生成 Java、Python、Go、C、Rust 等语言的代码不同语言的服务之间可以无缝对接。自带版本兼容方案新增字段不影响老代码只要保证字段编号不冲突老程序读到未知字段会跳过新程序也能读老数据。这套设计和 JSON 是两种思路。JSON 是“自描述”格式看到内容就能理解结构protobuf 是“预定义”格式想读懂必须先看协议文件。这也是很多人第一次接触它觉得“不直观”的原因但恰恰是这个特性帮它在性能和空间上扳回一城。1.3 适合与不适合的场景不是所有地方都用 protobuf 才好。我自己用下来最合适的场景是微服务之间的 RPC 通信两边都是内部系统协议文件可以统一维护。移动端和服务端的接口流量敏感减少传输字节能省用户的流量。游戏战斗同步、实时数据上报这类场景对序列化、反序列化性能要求高。需要长期存储结构化数据的场景用 protobuf 存二进制可以减少存储空间。不怎么适合的场景也很明确对外提供的公开 API调用方是你管不到的第三方让他们拿.proto文件去生成代码很不现实还有需要人直接读日志、手动排查数据的调试场景纯二进制可读性基本为零。2. 环境准备与 .proto 文件设计要点2.1 把 protoc 编译器装好不管最后用哪种语言写业务代码第一步都是装protoc它是把.proto编译成目标语言代码的工具。你可以在官方 release 页面下载对应系统的预编译包也可以走包管理器。我用过的几种安装方式macOSbrew install protobufUbuntu/Debianapt install protobuf-compilerWindows直接下载protoc-xxx-win64.zip把 bin 目录加进 PATH装完以后验证一下版本这步不能跳过因为不同大版本生成的代码风格会有差异我一般会先固定版本团队统一用同一个版本。protoc --version如果输出类似libprotoc 25.x就表示成功。Python 侧还需要运行时库直接pip install protobuf注意运行库的版本和编译器的版本最好保持同一个大版本否则偶尔会出现运行时兼容提示。2.2 看懂 .proto 语法核心一份.proto文件本质上就是在描述消息结构。先看一个最小例子syntax proto3; package shop; message User { int64 user_id 1; string nickname 2; }syntax指定语法版本现在新项目基本都用proto3。package类似命名空间防止不同模块的同名消息冲突。message定义一个新的消息类型相当于类或结构体。等号后面的数字是字段编号不是默认值是协议里真正用来标识字段的东西。proto3 里字段可以省略不设置序列化时默认值字段不会写进二进制流。这个特性挺省空间但也带来一个问题你区分不了“没设置”和“设置成默认值”后面我会单独讲。消息里还能嵌套消息、定义枚举、使用repeated表示列表、用map表示字典实际写起来表达能力完全不输 JSON 和 XML。2.3 字段编号和类型的隐藏门道字段编号是新手最容易忽略的设计点。编号的规则是消息内每个字段的编号必须唯一一旦上线就尽量不要修改。它不仅仅是序号还是编码的一部分客户端和服务端就是靠它来匹配字段的。proto3 的标量类型有int32、int64、uint32、uint64、sint32、sint64、fixed32、fixed64、bool、string、bytes、double、float。要注意表示负数用sint32、sint64它对绝对值较小的负数编码效率高负的int32会占满 10 字节。表示时间戳或 long 型 ID 用int64。金额、精度要求高的场景要么用string存要么用整数存最小单位比如金额按“分”存。ID 这种经常出现在业务里的字段尽量放在编号 1 到 15 之间因为编号 1 到 15 编码时只占 1 字节16 以上占 2 字节起。真正让我长记性的是编号 19000 到 19999 是协议实现预留的不能使用。我同事曾经从网上一段老代码里复制了一个字段定义就用了 19001编译直接报错查了半天才发现这个保留区间。实际情况里我建议设计新消息时把最常用、几乎每次都会出现的字段排在 1 到 15不常用的字段往后排同时预留几个未来可能扩展的编号方便后续加字段时不用大改。3. 一个能直接跑通的 protobuf 使用实例3.1 先写一份订单消息定义我现在用一个订单消息作为完整例子涵盖普通字段、枚举、嵌套消息、repeated列表、map字典基本能覆盖实战需求。新建一个order.protosyntax proto3; package shop; enum OrderStatus { STATUS_UNKNOWN 0; STATUS_PENDING 1; STATUS_PAID 2; STATUS_SHIPPED 3; STATUS_COMPLETED 4; STATUS_CANCELLED 5; } message Address { string receiver 1; string phone 2; string province 3; string city 4; string detail 5; } message OrderItem { string product_id 1; string product_name 2; int32 quantity 3; double price 4; } message Order { string order_id 1; uint64 user_id 2; Address address 3; repeated OrderItem items 4; OrderStatus status 5; double total_amount 6; int64 created_at 7; mapstring, string extra 8; }之所以把枚举单独拎出来是因为我见过很多新手把枚举当普通字符串处理直接在代码里写死“paid”“cancelled”结果协议层面根本没有约束调接口时只要写错大小写就直接出错。用枚举以后编译期和运行期都能检查可比字符串靠谱多了。3.2 生成代码并快速验证在order.proto同级目录执行protoc --python_out./py_out order.proto--python_out指定生成 Python 代码的输出目录。执行完以后py_out下会多出order_pb2.py。这个文件结构很规整但直接打开看会有点吓人里面全是模板生成的代码我的建议是你不需要去读它的实现只管 import 使用。编程语言不同这里有一点点差异Java 需要指定--java_outGo 需要额外装protoc-gen-go但 Python 最简单生成完就可以直接写业务代码了。为了快速验证生成结果可以先开一个 Python 终端from order_pb2 import Order print(Order())能输出order_id: 之类的内容就说明生成成功了。3.3 完整构建消息代码接下来就是完整的 protobuf 使用实例代码。我先构建一个订单对象再分别做序列化和反序列化from order_pb2 import Order, OrderItem, Address, OrderStatus import time # 1. 构建订单对象 order Order() order.order_id 20250101001 order.user_id 10086 # 嵌套消息直接操作属性 order.address.receiver 示例用户 order.address.phone 13800000000 order.address.province 某省 order.address.city 某市 order.address.detail 某街道 1 号 # repeated 字段用 add() 追加 item1 order.items.add() item1.product_id P001 item1.product_name 机械键盘 item1.quantity 1 item1.price 399.0 item2 order.items.add() item2.product_id P002 item2.product_name 显示器支架 item2.quantity 2 item2.price 129.0 order.status OrderStatus.STATUS_PAID order.total_amount 657.0 order.created_at int(time.time()) # map 字段直接当字典用 order.extra[coupon] SAVE50 order.extra[source] app # 2. 序列化为二进制 buf order.SerializeToString() print(序列化后字节数:, len(buf)) print(原始字节:, buf) # 3. 反序列化 new_order Order() new_order.ParseFromString(buf) print(反序列化后订单号:, new_order.order_id) print(收货人:, new_order.address.receiver) print(商品数量:, len(new_order.items)) print(第一个商品:, new_order.items[0].product_name) print(状态:, new_order.status) print(优惠券:, new_order.extra[coupon])这里有个非常典型的易错点repeated不是用append而是用add()。因为 protobuf 生成的repeated字段是一个特殊容器只有调用add()才会真正创建一个嵌套消息对象并追加到底层列表中。如果你直接order.items.append(OrderItem())某些语言版本会报错Python 版本有些情况下虽然不报错但字段的生成逻辑也不符合预期。我一开始就吃过这个亏一直以为和普通 list 一样结果数据全没了。3.4 序列化、反序列化与性能对比跑完上面的代码你大概会看到输出类似序列化后字节数: 83 原始字节: b\n\x0b20250101001\x10\xaaN\x1a\x1e\n...这个 83 字节是包含了订单号、地址、两个商品、金额、时间戳和 map 字段的完整数据。作为对比同样的内容如果写成 JSON随便一个格式化输出都要超过 300 字节。关键是解析时间在 Python 里解析 JSON 是字符串处理解析 protobuf 是直接读二进制性能差距在高频调用下非常明显。反序列化以后从new_order里读取所有字段与原对象完全一致。在真实项目里这一步骤经常发生在消息队列消费端、RPC 服务接收端你只需要把收到的字节流ParseFromString一下就能直接用强类型对象处理业务再也不用自己写一堆“取 JSON 字段并保证类型安全”的代码。还有一个很实用的场景你可以在不写网络请求的情况下用这个例子在本地测试协议的兼容性。构造一份老版本数据用新版本代码去解析看看字段是否有错乱。这个我在后面排查技巧里会再讲。4. 实战中避不开的坑与排查技巧4.1 字段编号改了会怎样这是个老生常谈但又总有人踩的问题。我在一个内部系统联调的时候有同事觉得消息里某个字段名不太对直接把.proto文件里的字段名改了顺带把字段编号也顺移了一下。部署完以后线上老数据解析出来全都对不上号订单号变成了收货人金额莫名其妙变成地址字符串。原因很简单protobuf 的编码不存字段名只存编号。新旧代码如果对同一个编号定义成不同字段反序列化时就会拿错数据。所以两条铁律字段名可以改因为字段名只影响代码生成后的属性名不影响线上协议。字段编号绝不能复用、不能改含义。废弃的字段不要直接删用reserved关键字保留编号。message Order { reserved 9, 10; reserved old_field; }这样后续别人哪怕不知道旧字段编译时也会被强制要求不要使用这些编号和名称。4.2 序列化结果“看不到”怎么办我接触 protobuf 的初期调试时最大的困扰是断点里看bytes全是乱码根本不知道对不对。后来找到几个实用的办法。第一个在 Python 里直接把消息对象print出来生成的类实现了__str__会以文本形式展示所有字段这个已经很够用了。但注意这只显示了代码里能识别的字段未知字段不会显示。第二个如果只有一段二进制数据没有业务代码环境可以用protoc自带工具解析。把二进制从代码里存成文件with open(order.bin, wb) as f: f.write(buf)然后执行protoc --decodeshop.Order order.proto order.bin它会按.proto定义把二进制解析成可读文本这对于排查线上数据问题极其关键。还有更野的办法protoc --decode_raw不依赖.proto文件直接把二进制解析成字段编号和值的形式虽然不能显示字段名但能帮你确认消息里到底有哪些编号、什么类型。4.3 proto3 的默认值与 optionalproto3 有一个很多人初学时理解偏差的点标量字段不设置时默认值是零值数字是 0、字符串是空串、布尔是 false而这个默认值在序列化时是不会写进二进制流的。举例说明我定义一个字段int32 discount 10;用户没有领取优惠券discount 为 0序列化后这个字段不占空间等反序列化时拿到 discount 还是 0。这时候如果业务上想区分“没有优惠券”和“优惠券抵扣了 0 元”光靠这个字段是分不出来的。解决方式有两种换成optional int32 discount 10;使用HasField()或代码里的HasField(discount)判断字段是否被显式设置。新增一个字段表示状态比如bool has_discount。我自己的经验是涉及“可选但语义重要”的字段尽量不要依赖零值来判断否则后面业务扩张的时候改协议又要带着兼容问题走一遍。4.4 流式传输和大消息怎么处理protobuf 本身只负责把一条消息序列化成一段二进制它不负责分帧。也就是说如果你通过 TCP 连接连续发送多条消息接收端必须自己知道每条消息的边界在哪里。我自己常用的做法是“长度前缀”方案序列化后先写一个 4 字节的整数表示消息长度再写二进制内容。接收端先读 4 字节拿到长度再读满对应长度的数据然后丢给ParseFromString。这个过程也可以直接用现成的库比如 gRPC 内部已经帮你处理了分帧但如果你是自己做长连接通信分帧这步一定要想清楚否则数据粘包会解析出一堆莫名其妙的错误。另外单条消息过大也会有问题。protobuf 的官方实现里默认的单条消息大小限制是 64MB不同语言有差异Python 默认也是 64MB超过了会抛异常。虽然正常业务很难触到这个上限但如果你在日志系统、埋点系统里把大批量数据塞进一条消息可能就会撞上。我一般会要求单条业务消息控制在几百 KB 内更大的数据单独走文件传输链路。4.5 与 JSON 互转的实用方案有些场景下你仍然需要 JSON比如把数据存到文档型数据库、给前端 Web 端做展示、或者底层系统只支持 JSON 格式。这时候不需要自己手写转换protobuf 的配套库已经提供了方案。Python 示例from google.protobuf import json_format json_str json_format.MessageToJson(order, preserving_proto_field_nameTrue) print(json_str) new_order2 Order() json_format.Parse(json_str, new_order2) print(new_order2.order_id)这里有一个细节默认情况下MessageToJson会把字段名转成驼峰式比如order_id变成orderId如果你希望保持和.proto里一致的命名就传preserving_proto_field_nameTrue。这个选项在不同语言里名字可能不一样但思路是一致的用之前最好查一下对应语言的文档。反过来的Parse就是把 JSON 文本解析成消息对象内部强类型字段会自动校准。注意 JSON 里如果出现了.proto里没有的字段默认行为是忽略还是报错取决于具体实现最好在联调前就确认清楚否则两边对“未知字段”的处理标准不一致排查起来很费劲。4.6 其他藏在细节里的坑还有一些零散但真实遇到过的点我也列一起Python 生成代码在加载时会打印 deprecation 警告如果你发现DeprecationWarning: Call to deprecated create function那是运行库和生成代码版本不匹配的提示优先升级protobuf库而不是忽略警告。map字段在大部分语言里不保证遍历顺序不要把顺序依赖写进业务逻辑。string类型默认 UTF-8如果你要塞进一段非 UTF-8 的二进制数据记得用bytes类型。枚举值 0 是默认值一个枚举字段如果设置成 0序列化时也会被省略。设计枚举时最好把第一个枚举值定义为类似STATUS_UNKNOWN的语义不要用 0 表示有效状态。跨服务传递时如果对方使用旧版本协议文件生成代码而你新增了必填项proto3 里都非必填但业务语义上可能是必填一定要靠逻辑判断兜底不要指望协议层帮你强制校验。结语一次完整的 protobuf 使用实例之后我的几点体会这套实例跑通以后我对 protobuf 的态度发生了比较大的变化。以前觉得它只是一个“压缩 JSON”的工具真正在项目里用起来才发现它的价值在于“约束”。协议文件把数据结构、枚举、字段编号全部显式定义清楚前后端、多语言服务之间沟通成本直线下降代码评审时看到.proto文件基本就能确定通信语义比翻文档靠谱得多。我也确实踩过不少坑最深的还是字段编号问题。后来我给自己定了一个习惯每次设计新消息时先在注释里写明每个编号的含义同时规划好预留编号评审时第一眼就检查有没有人动过已上线字段的编号。这个习惯帮我避免了好几次线上事故。如果你刚开始接触我的建议是别急着上 gRPC 之类的重框架先把.proto写清楚把序列化、反序列化、兼容性测试这些小环节跑明白再逐步嵌入到现有服务里。毕竟协议这层东西前期多花一小时设计后期就少熬几个通宵排错。希望这篇使用实例能给你省下我当年交过的学费。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询