用 Python 玩转 Protocol Buffers(基于 edition=2023)
1. 为什么是 Protocol Buffers(protobuf)
在 Python 里,序列化结构化数据有很多方法,但都各有短板:
- Pickle:内置方便,但跨语言差、模式演进差(升级字段容易崩)。
- 自定义串格式:比如把 4 个 int 拼成
"12:3:-23:67"
,轻量但需要手写编码/解析,后期成本高,只适合非常简单的数据。 - XML:人类可读、语言绑定多,但体积大、编解码慢,DOM 访问也麻烦。
Protocol Buffers 兼具高效二进制、自动代码生成、良好演进性三大优势:
你写好 .proto
,protoc
就会生成 Python 类;这些类支持快速编码/解析,并能在不破坏兼容的前提下逐步扩展数据结构。
2. 示例目标与目录结构
我们实现一个“地址簿”小程序,完成两件事:
- 将用户输入写入地址簿文件(protobuf 二进制);
- 从文件中读取所有人的信息并打印,顺便演示 JSON 序列化与反序列化。
建议项目结构:
project/
├─ proto/
│ └─ addressbook.proto
├─ gen/ # 生成的 Python 代码输出目录
│ └─ addressbook_pb2.py
├─ add_person.py
├─ list_people.py
└─ requirements.txt
requirements.txt
(如果需要 JSON 转换):
protobuf>=4.25
3. 定义协议:addressbook.proto
本文示例使用 editions(
edition = "2023"
)。它代表了一组语言/编译选项的组合,便于随着时间演进。
edition = "2023";package tutorial;message Person {string name = 1;int32 id = 2;string email = 3;enum PhoneType {PHONE_TYPE_UNSPECIFIED = 0;PHONE_TYPE_MOBILE = 1;PHONE_TYPE_HOME = 2;PHONE_TYPE_WORK = 3;}message PhoneNumber {string number = 1;PhoneType type = 2 [default = PHONE_TYPE_HOME];}repeated PhoneNumber phones = 4;
}message AddressBook {repeated Person people = 1;
}
要点速览
- package:避免跨项目命名冲突(Python 最终模块路径由目录决定,但 package 仍有意义,且对其他语言有效)。
- 标签号(tag):
= 1, 2, ...
是字段在二进制里的唯一标识。1–15 编码更省空间,优先留给高频或 repeated 字段。 - repeated:可出现任意次(含 0),并保序,可视作动态数组。
- 不支持类继承:protobuf 通过组合与嵌套来建模。
4. 生成 Python 代码
4.1 安装 protoc
从官方发布页获取你的平台版本,按 README 安装。Python 依赖:
pip install protobuf
4.2 一条命令生成
protoc --proto_path=proto \--python_out=gen \proto/addressbook.proto
可选:生成类型注解存根(便于 IDE 补全)
--pyi_out=gen
会额外生成addressbook_pb2.pyi
。
完成后,gen/addressbook_pb2.py
即可在 Python 中导入使用(注意 PYTHONPATH
或以包形式引用)。
5. Python 端生成物到底是什么?
与 Java/C++ 不同,Python 生成器不会直接把所有字段访问方法写死在源码里。它生成的是:
- 描述符(descriptors):记录消息、枚举、字段的“结构化元信息”;
- “空类”定义:为每个消息创建一个类,然后由
- 元类
GeneratedProtocolMessageType
在模块加载时读取描述符,动态注入真正的方法与属性(如SerializeToString()
、字段访问等)。
因此你可以像普通对象一样操作字段,但如果你:
- 设置了 不存在 的字段 →
AttributeError
- 赋了 错误类型 的值 →
TypeError
- 读取 未设置 的字段 → 得到类型默认值(字符串空、数值 0、布尔 false)
示例:
import addressbook_pb2 as abp = ab.Person()
p.id = 1234
p.name = "John Doe"
p.email = "jdoe@example.com"
phone = p.phones.add()
phone.number = "555-4321"
phone.type = ab.Person.PHONE_TYPE_HOME
6. 写入示例:交互创建并持久化
文件:add_person.py
#!/usr/bin/env python3
import sys
sys.path.append("gen") # 让 Python 找到生成文件;也可改为包导入
import addressbook_pb2 as abdef prompt_for_person(person: ab.Person) -> None:person.id = int(input("Enter person ID number: "))person.name = input("Enter name: ")email = input("Enter email address (blank for none): ").strip()if email:person.email = emailwhile True:number = input("Enter a phone number (or leave blank to finish): ").strip()if not number:breakphone = person.phones.add()phone.number = numberphone_type = input("Is this a mobile, home, or work phone? ").strip().lower()if phone_type == "mobile": phone.type = ab.Person.PHONE_TYPE_MOBILEelif phone_type == "home": phone.type = ab.Person.PHONE_TYPE_HOMEelif phone_type == "work": phone.type = ab.Person.PHONE_TYPE_WORKelse:print("Unknown phone type; using default (HOME).")def main():if len(sys.argv) != 2:print("Usage:", sys.argv[0], "ADDRESS_BOOK_FILE")sys.exit(2)path = sys.argv[1]book = ab.AddressBook()# 读取已有文件(若不存在则新建)try:with open(path, "rb") as f:book.ParseFromString(f.read())except IOError:print(f"{path}: Could not open file. Creating a new one.")prompt_for_person(book.people.add())with open(path, "wb") as f:f.write(book.SerializeToString())print(f"Saved to {path}")if __name__ == "__main__":main()
运行:
python add_person.py addressbook.bin
7. 读取示例:遍历并打印
文件:list_people.py
#!/usr/bin/env python3
import sys
sys.path.append("gen")
import addressbook_pb2 as abdef list_people(book: ab.AddressBook) -> None:for person in book.people:print("Person ID:", person.id)print(" Name:", person.name)# edition=2023/proto2 中,可对可选字段使用 HasFieldif person.HasField("email"):print(" E-mail address:", person.email)for phone in person.phones:kind = {ab.Person.PHONE_TYPE_MOBILE: "Mobile",ab.Person.PHONE_TYPE_HOME: "Home",ab.Person.PHONE_TYPE_WORK: "Work",}.get(phone.type, "Unknown")print(f" {kind} phone #: {phone.number}")def main():if len(sys.argv) != 2:print("Usage:", sys.argv[0], "ADDRESS_BOOK_FILE")sys.exit(2)book = ab.AddressBook()with open(sys.argv[1], "rb") as f:book.ParseFromString(f.read())list_people(book)if __name__ == "__main__":main()
8. JSON 读写:json_format
一把梭
有时候你需要把 protobuf 与 JSON 互转(便于调试或与 Web 组件对接):
from google.protobuf import json_format
import addressbook_pb2 as abperson = ab.Person(id=1234, name="John Doe", email="jdoe@example.com")# 序列化为 JSON 字符串
json_str = json_format.MessageToJson(person)# 从 JSON 解析回消息对象
parsed = ab.Person()
json_format.Parse(json_str, parsed)
注意:
SerializeToString()
/ParseFromString()
面向的是二进制;JSON 互转需使用json_format
。
9. 标准消息方法速查
IsInitialized()
:检查required
字段是否齐全(更常见于 proto2 风格)。__str__()
:可读字符串(print(msg)
时触发)。CopyFrom(other)
:用另一条消息整体覆盖。Clear()
:清空到初始状态。
10. 设计建议:封装(组合)而非继承
protobuf 生成类本质是数据载体(像 C 的 struct
),并不适合作为拥有复杂行为的一等领域对象。
最佳实践:写一个应用层封装类,内部组合 protobuf 消息对象,并在封装类中实现业务行为与校验。
- ✅ 组合(wrap):隐藏细节、提供便捷方法、可演进
- ❌ 继承(inherit):会破坏生成类内部机制,也不符合 OOP 最佳实践
11. 协议演进的“硬规则”(保持新旧兼容)
当你需要升级 .proto
:
- 不要修改既有字段的 tag;
- 不要添加或删除任何
required
字段; - 可以删除
optional
/repeated
字段; - 可以新增
optional
/repeated
字段,但必须使用从未使用过的 tag(被删字段用过的编号也不能复用)。
遵守后:
- 旧代码能读新消息,并忽略未知字段;
- 新代码能读旧消息;
- 新增的可选字段在旧消息中不存在:你需要在代码里处理类型默认值(字符串空、布尔 false、数值 0);
- 新增的
repeated
字段没有HasField
,新代码无法区分“留空”与“从未设置过”。
12. 常见坑位与排查
- 导入失败:确保把
gen/
加入PYTHONPATH
,或把它做成包(加__init__.py
)。 - bytes vs str:二进制 API 用
bytes
;不要误把str
直接喂给ParseFromString()
。 - HasField 的语义:对消息字段与 proto2/edition 可选字段有效;对
repeated
与部分标量(proto3 默认)无效。 - tag 规划:1–15 更省空间,优先给高频/重复字段;永远不要复用历史 tag。
- 类型约束:赋错类型会
TypeError
,不要忽略。
13. 一键跑通清单(Checklist)
- 安装
protoc
与protobuf
(pip install protobuf
) - 写好
proto/addressbook.proto
(合理分配 tag) - 运行
protoc --proto_path=proto --python_out=gen proto/addressbook.proto
- 运行
python add_person.py addressbook.bin
;输入若干人 - 运行
python list_people.py addressbook.bin
;确认输出 - 如需联调 Web/CLI,使用
json_format
做 JSON 互转