Protobuf 语法基础:消息类型定义、字段编号规则、字段类型、默认值

好,咱们开始聊 Protobuf 的消息定义。

说实话,我第一次写 .proto 文件的时候,觉得这不就是个结构体嘛,随便写写就行。后来线上出了兼容性问题,才意识到里面门道不少。这一节我把几个最基础也最容易踩坑的点捋清楚。

消息类型定义

Protobuf 里用 message 关键字来定义一个消息类型。你可以把它想象成一张数据表格的 schema,但比 JSON 严格得多。

message UserInfo {
  string user_id = 1;
  string nickname = 2;
  int32 age = 3;
  repeated string tags = 4;
}

每个消息就是一个逻辑单位,gRPC 的服务接口里,请求和响应都靠 message 包装。我习惯一个 .proto 文件里放一组相关的 message,比如 user.proto 里放 UserInfo、LoginRequest、LoginResponse,别把订单和用户杂糅在一起——你想想看,项目大了光翻文件就够呛。

命名风格:message 名用大驼峰(PascalCase),字段名用小驼峰(camelCase)。这是社区惯例,也是 protoc 生成代码时的默认映射规则。别搞特殊,不然队友会骂你。

字段编号规则

每个字段必须有一个唯一的编号,这个编号在二进制编码中就是字段的身份标识,不是字段顺序。字段编号一旦被使用,尽量不要改,哪怕你把那个字段删了,编号也要“占住”,用 reserved 声明。

编号范围:

  • 1 ~ 15:占用 1 个字节,用于最常用的字段
  • 16 ~ 2047:占用 2 个字节
  • 2048 ~ 536870911:占用 3~5 个字节(你基本用不到这么大)

我早期犯过一个错:把频繁出现的 id 字段用了 16 号,结果每个 message 都多花一个字节,积少成多几百兆流量就没了。所以高频字段尽量用 1~15。

我的习惯:预留前 5 个编号给基础标识字段(id、name、type、timestamp、status),后面的给业务扩展字段。这样后期迭代时,新字段用大编号,不影响高频字段的编码效率。

还有一点:字段编号不要超过 2^29 - 1,也不能用负数。如果以后要删字段,用 reserved 标记,防止别人误用。

message UserInfo {
  reserved 2, 15, 20 to 30;  // 这些编号以后不能再用
  string user_id = 1;
  // nickname 已删除,编号 2 被保留
  int32 age = 3;
}

字段类型

Protobuf 给了一套标量类型,跟各种语言的映射关系你得心里有数。我整理了一个常用对照表:

.proto 类型C++ / Go / Python 对应打包字节注意事项
doubledouble / float64 / float8高精度场景用
floatfloat / float32 / float4
int32int32 / int32 / int变长(1~5)负数值效率低,建议用 sint32
int64int64 / int64 / int变长(1~10)同理
uint32uint32 / uint32 / int变长无符号
uint64uint64 / uint64 / int变长
sint32int32 / int32 / int变长用 ZigZag 编码,负数友好
fixed32uint32 / uint32 / int4固定长度,编码快
fixed64uint64 / uint64 / int8
boolbool / bool / bool1
stringstring / string / strUTF-8 长度+内容必须 UTF-8
bytesstring / []byte / bytes长度+内容任意二进制
我曾经踩过的坑:用 int32 存负数,结果编码体积比 sint32 大好几倍,线上偶发超时就是因为频繁序列化大消息。后来换成 sint32,瞬间清爽。所以存储负数或可能为负的值,优先用 sint32 / sint64。

另外,repeated 可以定义列表字段,map 可以定义键值对。还有枚举(enum)和 oneof,咱们后面章节再细聊。

默认值

Protobuf 对每个标量类型都有隐式默认值。序列化时,如果字段的值等于默认值,默认是不写入字节流的——这是为了节省空间。

  • 字符串:默认是空字符串 ""
  • 数字类型:默认是 0
  • 布尔:默认是 false
  • bytes:默认是空字节串
  • 枚举:默认是第一个枚举值(必须是 0)
  • repeated:默认是空列表
  • map:默认是空 map

这里有个容易踩的坑:你从解析出来的 message 里拿一个字段,如果对方没设置,你就拿到了默认值。但你怎么区分“对方没设置”和“对方故意设了默认值”?比如年龄是 0,可能对方没发这个字段,也可能用户年龄真的是 0。我们在业务里通常用 optional 配合 google.protobuf.wrapper 类型来解决,或者用 oneof 包裹一个 has 标志。

我个人建议:关键字段尽量别用默认值做业务分支。如果非得用,最好在文档里说清楚。否则排查问题时会怀疑人生。

本章知识结构图

Protobuf 消息类型基础 · 知识结构 message 定义 消息类型定义 字段编号规则 字段类型 默认值规则 命名风格:大驼峰 标量类型表 reserved 保留编号 核心要点:字段编号一旦分配不可轻易修改,高频字段使用1~15; 默认值不参与序列化,能减小传输体积,但需注意业务上的“缺失”判断。

嗯,到这就算把 proto 消息的骨架搭起来了。记住字段编号是命根子,默认值是双刃剑,类型选择影响性能。下节课我们聊枚举和 oneof,那时候你会更理解为什么每个细节都值得推敲。