码到成功
Protobuf:把接口数据结构写成一份硬协议
Protobuf,也就是 Protocol Buffers,解决的是“数据结构先立规矩”的问题。你先写 `.proto` 文件,把 message、字段类型、字段编号都定义清楚,再用编译器生成不同语言的代码。传输时走紧凑的二进制格式,需要调试或对外展示时也可以转成 JSON。
## Protobuf 适合解决什么问题
它不是单纯把 JSON 压小一点。更关键的是这几件事:
- 用 schema 明确字段、类型和结构
- 用字段编号承载二进制格式
- 支持多语言生成代码
- 适合服务间通信、消息队列、文件存储
- 和 gRPC 搭配时能自然描述请求和响应
如果只是一个很小的后台页面接口,JSON 足够清楚。可如果是跨团队、跨语言、跨服务的数据协议,Protobuf 会更像一份契约。

## 一个 `.proto` 文件长什么样
先看一个订单协议:
```proto
syntax = "proto3";
package shop.v1;
message Money {
string currency_code = 1;
int64 units = 2;
int32 nanos = 3;
}
message OrderItem {
string sku = 1;
string name = 2;
int32 count = 3;
Money price = 4;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_CREATED = 1;
ORDER_STATUS_PAID = 2;
ORDER_STATUS_SHIPPED = 3;
ORDER_STATUS_CANCELED = 4;
}
message Order {
string id = 1;
string buyer_id = 2;
repeated OrderItem items = 3;
OrderStatus status = 4;
map<string, string> labels = 5;
optional string remark = 6;
reserved 7, 9 to 12;
reserved "legacy_code";
}
```
这份文件里有几个重点:
- `syntax = "proto3"` 表示使用 proto3 语法
- `package` 用来隔离命名空间
- `message` 是数据结构
- `enum` 是枚举
- `repeated` 表示列表
- `map` 表示键值对
- `optional` 表示字段存在性可被判断
- `reserved` 用来保留废弃字段编号或旧字段名
字段后面的 `= 1`、`= 2`、`= 3` 不是装饰,它们是 Protobuf 的核心。
## 字段编号比字段名更硬
JSON 靠字段名读数据:
```json
{
"id": "A100",
"buyer_id": "U88"
}
```
Protobuf 二进制格式更看重字段编号。比如:
```proto
string id = 1;
string buyer_id = 2;
```
编码后,接收方靠编号知道哪个值属于哪个字段。字段名主要给代码和 JSON 映射使用。
所以有一条铁律:
```text
字段编号一旦发布,就不要拿去表达另一个含义
```
如果 `buyer_id = 2` 被旧客户端用过,你就不应该把编号 `2` 改成 `coupon_code`。旧数据读起来会变成奇怪的东西。
官方文档也强调,小编号编码更紧凑。常用字段可以优先放在较小编号区间,但不要为了省几个字节去频繁调整已经发布的编号。
## 类型选错,后面会难受
常见标量类型可以这样理解:
```text
string:文本
bool:布尔
bytes:原始字节
int32 / int64:整数
uint32 / uint64:非负整数
sint32 / sint64:可能为负的整数,编码更友好
float / double:浮点数
```
金额不要用 `float` 或 `double` 随手存。更稳的方式是拆成整数单位:
```proto
message Money {
string currency_code = 1;
int64 units = 2;
int32 nanos = 3;
}
```
比如 `12.34` 元,可以表达成:
```text
units = 12
nanos = 340000000
```
如果业务只需要最小货币单位,也可以直接用整数:
```proto
int64 amount_cents = 1;
```
核心原则:协议字段要表达业务含义,不要只图一时方便。
## `repeated`、`map`、嵌套 message
订单行用列表:
```proto
repeated OrderItem items = 3;
```
标签用 map:
```proto
map<string, string> labels = 5;
```
复杂结构就拆成 message:
```proto
message Address {
string country = 1;
string province = 2;
string city = 3;
string detail = 4;
}
message UserProfile {
string id = 1;
string nickname = 2;
repeated Address addresses = 3;
}
```
不要把复杂对象全塞进一个 `string json_payload`。那样看起来灵活,实际上等于绕开了 Protobuf 的类型检查和兼容规则。
## 默认值和字段存在性
proto3 里,标量字段有默认值:
```text
string 默认是空字符串
int32 默认是 0
bool 默认是 false
enum 默认是第一个枚举值
```
这会带来一个常见问题:字段没传,和字段传了默认值,有时看起来一样。
如果你需要判断“有没有显式传这个字段”,可以使用 `optional`:
```proto
message UpdateOrderRequest {
string id = 1;
optional string remark = 2;
}
```
Python 里可以判断:
```python
if request.HasField("remark"):
print("客户端传了 remark")
else:
print("客户端没有传 remark")
```
这在 PATCH、局部更新、配置覆盖里很实用。
## 枚举从 0 开始,别偷懒
proto3 的枚举第一个值必须是 `0`。推荐给它一个明确的未指定状态:
```proto
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_CREATED = 1;
ORDER_STATUS_PAID = 2;
}
```
不要这样写:
```proto
enum OrderStatus {
CREATED = 0;
PAID = 1;
}
```
因为 `0` 同时也是默认值。如果默认值就是某个真实业务状态,排查问题会很别扭。
## Schema 演进:真正的重点在这里
Protobuf 好不好用,很大程度取决于你怎么演进协议。

安全动作通常是:
```text
新增字段
新增枚举值
重命名字段名但保留编号
删除字段后 reserved 编号和旧名
```
危险动作通常是:
```text
复用旧编号
改变字段线类型
把 repeated 改成单值
把业务含义完全换掉
删除字段但不 reserved
```
删除字段时建议这样做:
```proto
message Order {
string id = 1;
string buyer_id = 2;
reserved 3;
reserved "legacy_items";
}
```
这样后续有人想复用编号 `3` 或字段名 `legacy_items`,编译阶段就能拦住。
## 生成 Python 代码
安装工具:
```bash
pip install protobuf grpcio-tools
```
假设目录是:
```text
proto/
order.proto
```
生成 Python 代码:
```bash
python -m grpc_tools.protoc \
-I proto \
--python_out=. \
proto/order.proto
```
会生成类似:
```text
order_pb2.py
```
这个文件不要手写,应该由 `.proto` 生成。协议变了,就重新生成。

## Python 里创建和序列化 message
假设已经生成 `order_pb2.py`。
```python
import order_pb2
order = order_pb2.Order(
id="A100",
buyer_id="U88",
status=order_pb2.ORDER_STATUS_CREATED,
)
item = order.items.add()
item.sku = "SKU-001"
item.name = "Keyboard"
item.count = 2
item.price.currency_code = "CNY"
item.price.units = 199
item.price.nanos = 0
order.labels["channel"] = "app"
order.remark = "deliver carefully"
payload = order.SerializeToString()
print(type(payload), len(payload))
```
`SerializeToString()` 会得到二进制 bytes,适合写入文件、发到消息队列、放进 RPC 请求里。
## 反序列化
```python
import order_pb2
payload = read_payload_from_somewhere()
order = order_pb2.Order()
order.ParseFromString(payload)
print(order.id)
print(order.buyer_id)
print(order.status)
for item in order.items:
print(item.sku, item.count, item.price.units)
```
如果 payload 不是合法 Protobuf 数据,会抛解析异常。生产环境里要把解析错误转成清晰的业务错误或死信消息。
## 和 JSON 互转
Protobuf 主打二进制,但调试、日志、HTTP 网关里经常需要 JSON。
```python
from google.protobuf.json_format import MessageToJson, Parse
import order_pb2
order = order_pb2.Order(
id="A100",
buyer_id="U88",
status=order_pb2.ORDER_STATUS_PAID,
)
json_text = MessageToJson(order, preserving_proto_field_name=True)
print(json_text)
next_order = order_pb2.Order()
Parse(json_text, next_order)
print(next_order.id)
```
`preserving_proto_field_name=True` 会保留 `.proto` 里的蛇形命名,比如 `buyer_id`。如果不设置,JSON 映射可能会使用驼峰风格。
注意一点:JSON 只是映射格式,不是 Protobuf 的原生线格式。服务间内部通信通常还是二进制更合适。
## 写入文件和读取文件
```python
from pathlib import Path
import order_pb2
path = Path("order.bin")
order = order_pb2.Order(id="A100", buyer_id="U88")
path.write_bytes(order.SerializeToString())
loaded = order_pb2.Order()
loaded.ParseFromString(path.read_bytes())
print(loaded)
```
如果要存很多条 message,不建议简单把 bytes 拼在一起。可以自己加长度前缀,或者用更适合的容器格式、消息队列、数据库字段。
一个简单的长度前缀写法:
```python
import struct
from pathlib import Path
import order_pb2
def write_message(path: Path, message) -> None:
payload = message.SerializeToString()
with path.open("ab") as file:
file.write(struct.pack(">I", len(payload)))
file.write(payload)
def read_messages(path: Path):
with path.open("rb") as file:
while size_data := file.read(4):
size = struct.unpack(">I", size_data)[0]
payload = file.read(size)
order = order_pb2.Order()
order.ParseFromString(payload)
yield order
```
这个示例能说明思路:先写长度,再写内容,读取时就知道每条 message 的边界。
## gRPC 里怎么写 service
Protobuf 经常和 gRPC 一起出现。`.proto` 里除了 message,还能定义 service:
```proto
syntax = "proto3";
package shop.v1;
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc CreateOrder(CreateOrderRequest) returns (Order);
}
message GetOrderRequest {
string id = 1;
}
message CreateOrderRequest {
string buyer_id = 1;
repeated OrderItem items = 2;
}
```
生成 gRPC Python 代码:
```bash
python -m grpc_tools.protoc \
-I proto \
--python_out=. \
--grpc_python_out=. \
proto/order_service.proto
```
会生成:
```text
order_service_pb2.py
order_service_pb2_grpc.py
```
message 定义数据结构,service 定义调用入口。两者搭配起来,接口契约就很清楚。
## Python 服务端骨架
下面是一个非常轻量的 gRPC 服务端骨架:
```python
from concurrent import futures
import grpc
import order_pb2
import order_service_pb2_grpc
class OrderService(order_service_pb2_grpc.OrderServiceServicer):
def GetOrder(self, request, context):
return order_pb2.Order(
id=request.id,
buyer_id="U88",
status=order_pb2.ORDER_STATUS_CREATED,
)
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=8))
order_service_pb2_grpc.add_OrderServiceServicer_to_server(
OrderService(),
server,
)
server.add_insecure_port("127.0.0.1:50051")
server.start()
server.wait_for_termination()
if __name__ == "__main__":
serve()
```
客户端:
```python
import grpc
import order_service_pb2
import order_service_pb2_grpc
with grpc.insecure_channel("127.0.0.1:50051") as channel:
stub = order_service_pb2_grpc.OrderServiceStub(channel)
request = order_service_pb2.GetOrderRequest(id="A100")
response = stub.GetOrder(request)
print(response)
```
真实项目里要补上鉴权、TLS、错误码、限流和可观测性。上面只是让调用链跑通。
## 设计协议时的几个小原则
字段名写清楚,不要用 `data`、`value`、`payload` 糊弄过去。除非它真的就是一段通用负载。
请求和响应分开定义:
```proto
message GetOrderRequest {
string id = 1;
}
message GetOrderResponse {
Order order = 1;
}
```
这样后面加分页、错误细节、调试信息时更好扩展。
枚举值带上 message 或领域前缀:
```proto
ORDER_STATUS_PAID = 2;
```
比单独写 `PAID = 2` 更不容易在生成代码里撞名。
废弃字段不要直接删完就忘。用 `reserved` 把坑位封住。
大 bytes 字段要谨慎。图片、视频、大文件通常不适合直接塞进 Protobuf message。更常见的做法是存对象地址、文件 id 或分片信息。
## Protobuf 和 JSON 怎么选
可以粗暴点判断:
```text
对外开放、浏览器调试、字段变化很随意:JSON 更轻松
服务内部、跨语言、强契约、高吞吐:Protobuf 更合适
RPC 框架、双向流、强类型接口:Protobuf + gRPC 很自然
```
也不是非黑即白。很多系统内部用 Protobuf,网关对外转 JSON;消息队列里用 Protobuf,日志里记录 JSON 片段。关键是别混乱:同一条链路上要知道哪一层使用哪种格式。
## 常见坑
不要复用字段编号。
不要把字段类型随便改成另一个线类型。
不要把业务含义塞进 `string json` 里逃避 schema。
不要在没有兼容评审的情况下删除字段。
不要把默认值当成“字段一定被传了”。
不要把很大的二进制内容直接塞进 message。
不要手改生成出来的 `*_pb2.py` 文件。
参考资料:
- [Protocol Buffers 官方文档](https://protobuf.dev/)
- [Proto3 Language Guide](https://protobuf.dev/programming-guides/proto3/)
- [Python Generated Code Guide](https://protobuf.dev/reference/python/python-generated/)
- [Protocol Buffer JSON Mapping](https://protobuf.dev/programming-guides/json/)
- [gRPC Python Basics](https://grpc.io/docs/languages/python/basics/)