Protobuf:把接口数据结构写成一份硬协议




2020-09-02

blog_main_img

很多接口一开始用 JSON 很爽:字段随便加,调试也直观。可当系统变大,服务变多,语言栈变杂,问题就冒出来了:字段名改了谁知道?类型变了谁兜底?移动端、后端、数据任务是不是都按同一份结构理解?

Protobuf,也就是 Protocol Buffers,解决的是“数据结构先立规矩”的问题。你先写 .proto 文件,把 message、字段类型、字段编号都定义清楚,再用编译器生成不同语言的代码。传输时走紧凑的二进制格式,需要调试或对外展示时也可以转成 JSON。

Protobuf 适合解决什么问题

它不是单纯把 JSON 压小一点。更关键的是这几件事:

  • 用 schema 明确字段、类型和结构
  • 用字段编号承载二进制格式
  • 支持多语言生成代码
  • 适合服务间通信、消息队列、文件存储
  • 和 gRPC 搭配时能自然描述请求和响应

如果只是一个很小的后台页面接口,JSON 足够清楚。可如果是跨团队、跨语言、跨服务的数据协议,Protobuf 会更像一份契约。

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:浮点数

金额不要用 floatdouble 随手存。更稳的方式是拆成整数单位:

message Money {
  string currency_code = 1;
  int64 units = 2;
  int32 nanos = 3;
}

比如 12.34 元,可以表达成:

units = 12
nanos = 340000000

如果业务只需要最小货币单位,也可以直接用整数:

int64 amount_cents = 1;

核心原则:协议字段要表达业务含义,不要只图一时方便。

repeatedmap、嵌套 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、局部更新、配置覆盖里很实用。

枚举从 0 开始,别偷懒

proto3 的枚举第一个值必须是 0。推荐给它一个明确的未指定状态:

enum OrderStatus {
  ORDER_STATUS_UNSPECIFIED = 0;
  ORDER_STATUS_CREATED = 1;
  ORDER_STATUS_PAID = 2;
}

不要这样写:

enum OrderStatus {
  CREATED = 0;
  PAID = 1;
}

因为 0 同时也是默认值。如果默认值就是某个真实业务状态,排查问题会很别扭。

Schema 演进:真正的重点在这里

Protobuf 好不好用,很大程度取决于你怎么演进协议。

Protobuf schema 演进规则

安全动作通常是:

新增字段
新增枚举值
重命名字段名但保留编号
删除字段后 reserved 编号和旧名

危险动作通常是:

复用旧编号
改变字段线类型
把 repeated 改成单值
把业务含义完全换掉
删除字段但不 reserved

删除字段时建议这样做:

message Order {
  string id = 1;
  string buyer_id = 2;

  reserved 3;
  reserved "legacy_items";
}

这样后续有人想复用编号 3 或字段名 legacy_items,编译阶段就能拦住。

生成 Python 代码

安装工具:

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 生成。协议变了,就重新生成。

Protobuf Python 使用流程

Python 里创建和序列化 message

假设已经生成 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 数据,会抛解析异常。生产环境里要把解析错误转成清晰的业务错误或死信消息。

和 JSON 互转

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 的边界。

gRPC 里怎么写 service

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 定义调用入口。两者搭配起来,接口契约就很清楚。

Python 服务端骨架

下面是一个非常轻量的 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、错误码、限流和可观测性。上面只是让调用链跑通。

设计协议时的几个小原则

字段名写清楚,不要用 datavaluepayload 糊弄过去。除非它真的就是一段通用负载。

请求和响应分开定义:

message GetOrderRequest {
  string id = 1;
}

message GetOrderResponse {
  Order order = 1;
}

这样后面加分页、错误细节、调试信息时更好扩展。

枚举值带上 message 或领域前缀:

ORDER_STATUS_PAID = 2;

比单独写 PAID = 2 更不容易在生成代码里撞名。

废弃字段不要直接删完就忘。用 reserved 把坑位封住。

大 bytes 字段要谨慎。图片、视频、大文件通常不适合直接塞进 Protobuf message。更常见的做法是存对象地址、文件 id 或分片信息。

Protobuf 和 JSON 怎么选

可以粗暴点判断:

对外开放、浏览器调试、字段变化很随意:JSON 更轻松
服务内部、跨语言、强契约、高吞吐:Protobuf 更合适
RPC 框架、双向流、强类型接口:Protobuf + gRPC 很自然

也不是非黑即白。很多系统内部用 Protobuf,网关对外转 JSON;消息队列里用 Protobuf,日志里记录 JSON 片段。关键是别混乱:同一条链路上要知道哪一层使用哪种格式。

常见坑

不要复用字段编号。

不要把字段类型随便改成另一个线类型。

不要把业务含义塞进 string json 里逃避 schema。

不要在没有兼容评审的情况下删除字段。

不要把默认值当成“字段一定被传了”。

不要把很大的二进制内容直接塞进 message。

不要手改生成出来的 *_pb2.py 文件。

参考资料: