protobuf 语法:message、字段编号、枚举、oneof

.proto 是 gRPC 的源头:先写它,再生成代码。语法本身不大,但几条规则决定了以后能不能平滑加字段——字段编号一旦上线就不能改、枚举第一个值必须是 0、proto3 的标量字段分不清「没传」和「传了零值」。本章用 IM 消息投递的字段把这些规则讲透。

文件骨架

syntax = "proto3";              // 不写默认按 proto2 解析,务必显式声明
package im.message.v1;          // 命名空间,避免消息重名;建议 域名/模块/版本
option go_package = "example.com/im/gen/message/v1;messagev1";  // 导入路径;包名
import "google/protobuf/timestamp.proto";  // 复用标准库(well-known types)里的消息

go_package 分号前是 Go 导入路径、分号后是包名(省略时取路径最后一段)。它和第 3 章讲的 paths=source_relative 配合使用。

message 与标量类型

message MessageSendReq {
  int64 msg_id = 1;             // 雪花 ID 用 int64,别用 int32 或 string
  int64 from_uid = 2;
  int64 to_uid = 3;
  string content = 4;
  repeated int64 at_uids = 5;   // 列表;Go 里是 []int64
  bool is_group = 6;
  bytes extra = 7;              // 透传业务自定义数据;Go 里是 []byte
}

标量类型与 Go 类型的对应:

proto 类型Go 类型说明
int32 / int64int32 / int64负数用 varint 会占满 10 字节,有负数请用 sint
uint32 / uint64uint32 / uint64只放非负值
sint32 / sint64int32 / int64ZigZag 编码;定长的 fixed32 / fixed64 适合哈希值
float / double / boolfloat32 / float64 / bool
stringstring必须是 UTF-8 或 7 位 ASCII,不合法会解析失败
bytes[]byte任意字节,不做 UTF-8 校验
嵌套 message / repeated T*T / []Tmessage 未设置时为 nil;标量 repeated 默认 packed
map<K, V>map[K]VK 不能是 float / bytes / message

字段编号:兼容性的主要来源

  • 取值范围 1 ~ 536870911(2^29 - 1);其中 1 ~ 15 只占 1 字节 tag,留给最高频字段,16 ~ 2047 占 2 字节。
  • 19000 ~ 19999 是 protobuf 内部保留区间,不能占用。
  • 编号发布后不能改;删字段要用 reserved 占坑,防止后来的人复用:
message UserProfile {
  reserved 3, 8 to 10;          // 已下线字段的编号
  reserved "old_avatar_url";    // 已下线字段的名字
  int64 uid = 1;
  string nickname = 2;
}

同一 message 内编号与字段名都必须唯一,reserved 同时挡住这两个维度,是协议演进(第 15 章)最省事的保险。

枚举:第一个值必须是 0

enum MsgType {
  MSG_TYPE_UNSPECIFIED = 0;     // proto3 要求首个枚举值为 0,它就是默认值
  MSG_TYPE_TEXT = 1;
  MSG_TYPE_IMAGE = 2;
  MSG_TYPE_RECALL = 3;
}

两个约定:枚举值加前缀(MSG_TYPE_)避免同包内重名;0 值留给 _UNSPECIFIED,这样解析到未知值时不会误判成 TEXT。要下线枚举值同样用 reserved 3; 留号,否则新常量可能悄悄占回旧号。枚举的 JSON 表示是名字字符串("MSG_TYPE_TEXT"),遇到不认识的数字会原样保留。

oneof:一组互斥字段

IM 的推送载荷天然互斥——一条推送要么是文本、要么是图片、要么是撤回指令:

message TextBody {
  string content = 1;
}
message ImageBody {
  string url = 1;
  int32 width = 2;
  int32 height = 3;
}
message RecallNotice {
  int64 operator_uid = 1;
}

message PushPayload {
  int64 msg_id = 1;

  oneof body {
    TextBody text = 2;
    ImageBody image = 3;
    RecallNotice recall = 4;
  }
}

生成代码里会多出一个接口字段(Go 中是 Body isPushPayload_Body),并提供 GetText()、GetImage() 等访问器。要点:

  • 同一时刻只有一个成员被设置,设置新成员会清掉前一个(旧值置 nil)。
  • oneof 内不能再放 repeated 或 map;Go 侧用 switch p := payload.GetBody().(type) 做类型分支最省事。

存在性:没传和零值怎么区分

proto3 的标量字段没有「未设置」状态:int32 badge = 1 收到 0 时分不清「客户端传了 0」还是「没传」。需要区分就加上 optional(proto3 显式存在性):

message NotifyReq {
  optional int32 badge = 1;     // 生成的 Go 字段是 *int32,可用 nil 判断
}

取值仍用 req.GetBadge()(未设置时返回 0),判断是否设置用 req.Badge != nil。注意 optional 只对标量有意义;message 字段本身就是指针,天然能区分。

常见坑

坑后果改法
编号按 1、2、3 顺序排,高频字段排后面tag 多占字节1 ~ 15 留给最常用的字段
删字段不加 reserved后人复用编号,老数据被解析成新字段删字段时同时 reserved 号与名
枚举第一个值不是 0直接编译报错用 XXX_UNSPECIFIED = 0
依赖 map 或 repeated 的顺序跨端顺序不可依赖,消息错乱需要有序就自己带序号字段,或改 repeated
用 string 存二进制非 UTF-8 内容校验失败二进制一律用 bytes

小结:.proto 的核心规则只有几条——字段编号发布后不再改动、删字段留 reserved、枚举 0 值留给 UNSPECIFIED、互斥字段用 oneof,需要区分「未传」就加 optional。

笔记加载中…