2020-09-02
很多接口一开始用 JSON 很爽:字段随便加,调试也直观。可当系统变大,服务变多,语言栈变杂,问题就冒出来了:字段名改了谁知道?类型变了谁兜底?移动端、后端、数据任务是不是都按同一份结构理解?
Protobuf,也就是 Protocol Buffers,解决的是“数据结构先立规矩”的问题。你先写 .proto 文件,把 message、字段类型、字段编号都定义清楚,再用编译器生成不同语言的代码。传输时走紧凑的二进制格式,需要调试或对外展示时也可以转成 JSON。
它不是单纯把 JSON 压小一点。更关键的是这几件事:
如果只是一个很小的后台页面接口,JSON 足够清楚。可如果是跨团队、跨语言、跨服务的数据协议,Protobuf 会更像一份契约。
.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 靠字段名读数据:
{
"id": "A100",
"buyer_id": "U88"
}
Protobuf 二进制格式更看重字段编号。比如:
string id = 1;
string buyer_id = 2;
编码后,接收方靠编号知道哪个值属于哪个字段。字段名主要给代码和 JSON 映射使用。
所以有一条铁律:
字段编号一旦发布,就不要拿去表达另一个含义
如果 buyer_id = 2 被旧客户端用过,你就不应该把编号 2 改成 coupon_code。旧数据读起来会变成奇怪的东西。
官方文档也强调,小编号编码更紧凑。常用字段可以优先放在较小编号区间,但不要为了省几个字节去频繁调整已经发布的编号。
常见标量类型可以这样理解:
string:文本
bool:布尔
bytes:原始字节
int32 / int64:整数
uint32 / uint64:非负整数
sint32 / sint64:可能为负的整数,编码更友好
float / double:浮点数
金额不要用 float 或 double 随手存。更稳的方式是拆成整数单位:
message Money {
string currency_code = 1;
int64 units = 2;
int32 nanos = 3;
}
比如 12.34 元,可以表达成:
units = 12
nanos = 340000000
如果业务只需要最小货币单位,也可以直接用整数:
int64 amount_cents = 1;
核心原则:协议字段要表达业务含义,不要只图一时方便。
repeated、map、嵌套 message订单行用列表:
repeated OrderItem items = 3;
标签用 map:
map<string, string> labels = 5;
复杂结构就拆成 message:
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 里,标量字段有默认值:
string 默认是空字符串
int32 默认是 0
bool 默认是 false
enum 默认是第一个枚举值
这会带来一个常见问题:字段没传,和字段传了默认值,有时看起来一样。
如果你需要判断“有没有显式传这个字段”,可以使用 optional:
message UpdateOrderRequest {
string id = 1;
optional string remark = 2;
}
Python 里可以判断:
if request.HasField("remark"):
print("客户端传了 remark")
else:
print("客户端没有传 remark")
这在 PATCH、局部更新、配置覆盖里很实用。
proto3 的枚举第一个值必须是 0。推荐给它一个明确的未指定状态:
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_CREATED = 1;
ORDER_STATUS_PAID = 2;
}
不要这样写:
enum OrderStatus {
CREATED = 0;
PAID = 1;
}
因为 0 同时也是默认值。如果默认值就是某个真实业务状态,排查问题会很别扭。
Protobuf 好不好用,很大程度取决于你怎么演进协议。
安全动作通常是:
新增字段
新增枚举值
重命名字段名但保留编号
删除字段后 reserved 编号和旧名
危险动作通常是:
复用旧编号
改变字段线类型
把 repeated 改成单值
把业务含义完全换掉
删除字段但不 reserved
删除字段时建议这样做:
message Order {
string id = 1;
string buyer_id = 2;
reserved 3;
reserved "legacy_items";
}
这样后续有人想复用编号 3 或字段名 legacy_items,编译阶段就能拦住。
安装工具:
pip install protobuf grpcio-tools
假设目录是:
proto/
order.proto
生成 Python 代码:
python -m grpc_tools.protoc \
-I proto \
--python_out=. \
proto/order.proto
会生成类似:
order_pb2.py
这个文件不要手写,应该由 .proto 生成。协议变了,就重新生成。
假设已经生成 order_pb2.py。
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 请求里。
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 数据,会抛解析异常。生产环境里要把解析错误转成清晰的业务错误或死信消息。
Protobuf 主打二进制,但调试、日志、HTTP 网关里经常需要 JSON。
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 的原生线格式。服务间内部通信通常还是二进制更合适。
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 拼在一起。可以自己加长度前缀,或者用更适合的容器格式、消息队列、数据库字段。
一个简单的长度前缀写法:
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 的边界。
Protobuf 经常和 gRPC 一起出现。.proto 里除了 message,还能定义 service:
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 代码:
python -m grpc_tools.protoc \
-I proto \
--python_out=. \
--grpc_python_out=. \
proto/order_service.proto
会生成:
order_service_pb2.py
order_service_pb2_grpc.py
message 定义数据结构,service 定义调用入口。两者搭配起来,接口契约就很清楚。
下面是一个非常轻量的 gRPC 服务端骨架:
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()
客户端:
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 糊弄过去。除非它真的就是一段通用负载。
请求和响应分开定义:
message GetOrderRequest {
string id = 1;
}
message GetOrderResponse {
Order order = 1;
}
这样后面加分页、错误细节、调试信息时更好扩展。
枚举值带上 message 或领域前缀:
ORDER_STATUS_PAID = 2;
比单独写 PAID = 2 更不容易在生成代码里撞名。
废弃字段不要直接删完就忘。用 reserved 把坑位封住。
大 bytes 字段要谨慎。图片、视频、大文件通常不适合直接塞进 Protobuf message。更常见的做法是存对象地址、文件 id 或分片信息。
可以粗暴点判断:
对外开放、浏览器调试、字段变化很随意:JSON 更轻松
服务内部、跨语言、强契约、高吞吐:Protobuf 更合适
RPC 框架、双向流、强类型接口:Protobuf + gRPC 很自然
也不是非黑即白。很多系统内部用 Protobuf,网关对外转 JSON;消息队列里用 Protobuf,日志里记录 JSON 片段。关键是别混乱:同一条链路上要知道哪一层使用哪种格式。
不要复用字段编号。
不要把字段类型随便改成另一个线类型。
不要把业务含义塞进 string json 里逃避 schema。
不要在没有兼容评审的情况下删除字段。
不要把默认值当成“字段一定被传了”。
不要把很大的二进制内容直接塞进 message。
不要手改生成出来的 *_pb2.py 文件。
参考资料: