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 / int64 | int32 / int64 | 负数用 varint 会占满 10 字节,有负数请用 sint |
| uint32 / uint64 | uint32 / uint64 | 只放非负值 |
| sint32 / sint64 | int32 / int64 | ZigZag 编码;定长的 fixed32 / fixed64 适合哈希值 |
| float / double / bool | float32 / float64 / bool | |
| string | string | 必须是 UTF-8 或 7 位 ASCII,不合法会解析失败 |
| bytes | []byte | 任意字节,不做 UTF-8 校验 |
| 嵌套 message / repeated T | *T / []T | message 未设置时为 nil;标量 repeated 默认 packed |
| map<K, V> | map[K]V | K 不能是 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。