行情推送、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 类型 | 说明 | 对应 Java | Go |
|---|---|---|---|
| int32 | 变长整数(负数编码差) | int | int32 |
| sint32 | 变长整数(负数省字节) | int | int32 |
| uint32 | 无符号变长整数 | int | uint32 |
| fixed32 | 固定 4 字节(大数字省) | int | uint32 |
| sfixed32 | 有符号固定 4 字节 | int | int32 |
| float | 4 字节浮点 | float | float32 |
| double | 8 字节浮点 | double | float64 |
| bool | boolean | bool | |
| string | UTF-8 字符串 | String | string |
| bytes | 二进制 | ByteString | []byte |
timestamp 别用 int32——2038 年就爆。用 int64 或 google.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”。
需要区分时用 oneof 或 google.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 编成多语言,是它最大的优势。
