Proto3 基础:message / enum / repeated 和订阅协议模式

行情推送、IM、游戏后端这类双向长连接服务,常见的做法是走 TCP/WebSocket + Protobuf 编码。Proto3 定义消息的基础语法不多,但组合起来能表达很复杂的协议。按一个典型的”订阅推送”协议模式走一遍。

一个订阅推送协议的骨架

先看一个典型定义(脱敏后的通用示例):

syntax = "proto3";

package sample.pushv1;

// ============ 消息类型枚举 ============
enum MsgID {
    MSG_UNKNOWN         = 0;
    MSG_HEART_BEAT      = 1;
    MSG_AUTH            = 2;
    MSG_SUBSCRIBE       = 10;
    MSG_UNSUBSCRIBE     = 11;
    MSG_DATA_BROADCAST  = 20;
    MSG_STATUS          = 30;
    MSG_ERROR           = 99;
}

enum SubscribeFlag {
    SUBSCRIBE   = 0;
    KEEP        = 1;   // 保持订阅(心跳)
    UNSUBSCRIBE = 2;
}

// ============ 主消息体 ============
message Envelope {
    MsgID msg_id  = 1;
    sint32 seq    = 2;

    // 双工:request 客户端来,response 服务器来
    Request  request  = 4;
    repeated Response response = 5;

    // 备用 JSON 通道(调试用)
    string json_req  = 8;
    string json_resp = 9;

    string err_msg = 17;
}

message Request {
    repeated string codes = 1;
    SubscribeFlag   flag  = 2;
    Auth            auth  = 3;
}

message Response {
    repeated DataField data = 1;
    sint32             err_code = 2;
    string             msg     = 3;
}

message Auth {
    string apptype = 1;
    bytes  token   = 2;
}

message DataField {
    string  code   = 1;
    fixed64 ts     = 2;
    double  value  = 3;
}

看着长,本质就四件事:枚举定消息类型 → 主 envelope 包一层 → Request / Response 承载数据 → 心跳保活

核心语法

message

数据结构声明,类似 struct:

message User {
  int32  id   = 1;
  string name = 2;
  bytes  data = 3;
}

每个字段有唯一 tag= 1, = 2 …),wire format 上按 tag 编码,改字段名不影响兼容。

enum

enum Color {
  COLOR_UNKNOWN = 0;   // proto3 里第一个必须是 0
  RED   = 1;
  GREEN = 2;
  BLUE  = 3;
}

Proto3 强制第一项 = 0(作为默认值)。

repeated

数组 / 列表:

message Order {
  repeated string items = 1;
  repeated User   buyers = 2;
}

对应各语言里的 List<T> / []T

常用标量

Proto3 类型说明对应 JavaGo
int32变长整数(负数编码差)intint32
sint32变长整数(负数省字节)intint32
uint32无符号变长整数intuint32
fixed32固定 4 字节(大数字省)intuint32
sfixed32有符号固定 4 字节intint32
float4 字节浮点floatfloat32
double8 字节浮点doublefloat64
boolbooleanbool
stringUTF-8 字符串Stringstring
bytes二进制ByteString[]byte

timestamp 别用 int32——2038 年就爆。用 int64google.protobuf.Timestamp

oneof(互斥字段)

message Notification {
  string title = 1;
  oneof body {
    TextBody text = 2;
    ImageBody image = 3;
    VideoBody video = 4;
  }
}

三选一,永远只有一个字段被设置。适合”消息内容多种类型”这种场景。

map

map<string, int32> counts = 1;

底层其实是 repeated Entry,但语法糖后用起来就是 map。

订阅推送协议的常用模式

1. 一个大 Envelope 包一层

避免为每种消息设 endpoint,客户端 / 服务端都只处理一种 message,靠 msg_id 分发:

switch (envelope.getMsgId()) {
    case MSG_HEART_BEAT: handleHeartbeat(envelope); break;
    case MSG_SUBSCRIBE:  handleSubscribe(envelope.getRequest()); break;
    case MSG_DATA_BROADCAST: handleData(envelope.getResponse()); break;
}

好处:加新消息只加个 enum 值 + 新 case,不用改协议框架。

2. Request / Response 分离

一个 envelope 同时能塞 request(客户端来)和 response(服务器来)。虽然一次只填一边,但复用同一个消息类型简化编解码。

3. 心跳保活

TCP 长连接会被 NAT / 中间设备清理。定期发 MSG_HEART_BEAT 保持连接:

每 30 秒 → client → { msg_id: MSG_HEART_BEAT, seq: N }
                <-- server ack

或者复用订阅消息:SubscribeFlag = KEEP 表示”我还在,别断”。

4. 序列号 seq

请求带 seq、响应带同样 seq——做请求 - 响应对齐。异步双工协议里没这个就乱了。

5. 备用 JSON 通道

json_req / json_resp 备用字段——调试时可以传 JSON,服务端也返 JSON,跳过 proto 编解码。方便排查协议问题。

编译

# 装 protoc
brew install protobuf         # macOS
apt install protobuf-compiler # Ubuntu

# 生成代码
protoc --java_out=./gen-java --go_out=./gen-go quote.proto

各语言都有 protoc 插件,一个 .proto 文件能同时给 Java / Go / Python / Kotlin / TS 用。这是 Protobuf 最大的价值。

常见坑

1. 默认值不会传输

Proto3 里字段等于默认值(0、空串、false)不会占字节——但也意味着接收端分不清”没设”和”设为 0”

需要区分时用 oneofgoogle.protobuf.Int32Value 之类的 wrapper 类型。

2. tag 号一旦发布就不能改

改了 tag 号 = 完全不同的字段。老客户端拿到新数据会解析错误。

3. 删除字段用 reserved

message User {
  reserved 3, 5;
  reserved "old_name";
  int32 id = 1;
  // 不能再用 tag 3 或 5
}

避免后人不小心用了同一个 tag。

4. proto2 vs proto3

syntax = "proto2";optional / required,字段能显式 null。syntax = "proto3"; 简化了很多,新项目都用 proto3。

一句话总结

订阅推送协议的常用模式:Envelope + MsgID enum + Request/Response + seq + 心跳。Proto3 的核心就是 message / enum / repeated / oneof / map。一份 .proto 编成多语言,是它最大的优势。