Pomelo 协议规范
Cherry 的默认网络协议是 pomelo 的二进制协议。它分两层编码,理解这两层是排查「客户端连不上」「消息发出去没人处理」这类问题的前提。
两层结构
应用数据
↓
[message 层] route 压缩 / 消息类型封装
↓
[package 层] type + length + body 封装
↓
tcp / websocket 字节流| 层 | 职责 | 是否必需 |
|---|---|---|
| package 层 | 握手、心跳、断线通知、数据封装 | 必需 |
| message 层 | route 压缩、(规范中还有)protobuf 压缩 | 规范里可选 |
package 层把数据当作任意二进制处理,不做解析。这正是 message 层可以被替换的原因——Cherry 就是用一个可插拔的 序列化器 替代了规范里的 message 层 protobuf 压缩。
package 层
包格式
每包 = header + body:
| 字段 | 长度 | 说明 |
|---|---|---|
type | 1 字节 | 包类型 |
length | 3 字节 | body 长度,大端整数 |
body | length 字节 | 内容 |
长度字段只有 3 字节,因此单包上限 2^24 = 16MB。
包类型
| 值 | 常量 | 含义 |
|---|---|---|
0x00 | None | 空 |
0x01 | Handshake | 客户端握手请求 / 服务端握手响应 |
0x02 | HandshakeAck | 客户端握手确认 |
0x03 | Heartbeat | 心跳 |
0x04 | Data | 数据包 |
0x05 | Kick | 服务端主动断开通知 |
前三个加 Kick 属控制包,Data 属数据包。
握手
握手让双方在连接建立后交换初始化数据,内容是 UTF-8 的 JSON 字符串(不压缩),放在 body 里。
握手请求(客户端 → 服务端):
{
"sys": {
"version": "1.1.1",
"type": "js-websocket"
},
"user": {
"desc": "any customized request data"
}
}sys.version— 客户端 SDK 版本号sys.type— 客户端类型
握手响应(服务端 → 客户端):
{
"code": 200,
"sys": {
"heartbeat": 60,
"dict": {},
"serializer": "json"
},
"user": {}
}| 字段 | 说明 |
|---|---|
code | 状态码,200 成功 |
sys.heartbeat | 心跳间隔(秒) |
sys.dict | route 压缩字典 |
sys.serializer | 序列化方式名,json 或 protobuf |
user | 用户自定义握手数据 |
握手时序(三步):
连接建立
→ 客户端发 Handshake(请求)
→ 服务端回 Handshake(响应)
→ 客户端发 HandshakeAck
握手完成心跳
- 心跳包
length为 0,body 为空 - 握手完成后由客户端发起第一个心跳
- 双方收到心跳后,延迟一个间隔再发下一个
- 超时判定:客户端按 2 倍间隔判超时
数据与断开
Data 包的 body 是上层传来的二进制数据,package 层不处理。
服务端主动断开(踢下线)时,先发一个 Kick 包(0x05)再关闭连接,客户端据此区分「被踢」和「网络断了」。
message 层
消息头
flag(1 字节,必需) + message id(0~5 字节,可选) + route(0~255 字节,可选)- message id 用 varint 变长编码,长度随数值大小变化
- route 长度取决于是否压缩
flag 标志位
flag 只用到了低 4 位:
bit: 7 6 5 4 3 2 1 0
──────── ───── ─────
保留 消息类型 route 压缩标志- 消息类型:占第 2–4 位,现有 4 类
- route 压缩标志:最低位,
0= 未压缩,1= 压缩 - 两部分相互独立
消息类型与结构
| 类型 | flag | 后随字段 |
|---|---|---|
| request | ----000- | <message id> <route> |
| notify | ----001- | <route> |
| response | ----010- | <message id> |
| push | ----011- | <route> |
只有 request 和 response 带 message id。notify 和 push 都是单向的,不等回复。
route 编码
| flag 最低位 | 编码方式 |
|---|---|
0(未压缩) | 1 字节长度 + UTF-8 route 字符串,最大 255 字节 |
1(压缩) | 2 字节字典编号(uInt16),查字典还原 |
字典由服务端在握手响应里下发(sys.dict),客户端收到后建立映射表。
Cherry 对 flag 的扩展
除了规范定义的位,Cherry 额外使用了两个标志位:
| 掩码 | 含义 |
|---|---|
0x10 | data 段 gzip 压缩,由 SetDataCompression 开启 |
0x20 | response 是错误响应 |
与规范实现的差异
Cherry 的实现遵循了 pomelo 规范的主体,但在几处做了简化或调整。对接时以这些实际行为为准:
| 项 | 规范 | Cherry 实现 |
|---|---|---|
握手响应 sys.protos | 可选,承载 protobuf 定义 | 不存在,改用 sys.serializer |
握手响应 user | 可选,用户自定义 | 服务端不产出,只有 code + sys |
握手响应 code | 200 / 500 / 501 | 硬编码 200,不解析请求体、不校验版本 |
| 心跳超时后 | 服务端不主动断开连接 | 服务端会关闭连接 |
| 心跳超时倍数 | 2 倍间隔 | 服务端按 1 倍判定(客户端仍按 2 倍) |
| message 层 protobuf 压缩 | 有 | 未实现,用序列化器整体替代 |
| route 最大长度 | 文档写 256B | 实际 255B(1 字节长度字段) |
| 最大包长 | 2^24 | 1<<24,可通过 SetReadMaxPacketSize / SetWriteMaxPacketSize 覆盖 |
心跳超时这点最容易被坑
规范说服务端不会因心跳超时断开,但 Cherry 的实现会断。默认心跳 60 秒,服务端超过 60 秒没收到心跳就关连接。
客户端必须保证按握手响应里的 sys.heartbeat 定期发心跳。如果客户端自己写的而没发心跳,表现就是「连上一分钟就掉线」。
服务端配置入口
agentActor := pomelo.NewActor("user")
agentActor.SetHeartbeat(30 * time.Second) // 心跳间隔,默认 60s
agentActor.SetDictionary(myDict) // route 压缩字典
agentActor.SetDataCompression(true) // 开启 data 段 gzip
agentActor.SetSysData("key", "value") // 追加自定义 sys 字段
agentActor.SetReadMaxPacketSize(1 << 20) // 读包上限
agentActor.SetWriteMaxPacketSize(1 << 20) // 写包上限心跳间隔小于 1 秒会被兜底
SetHeartbeat 对小于 1 秒的值会回落到默认的 60 秒。
参考实现位置
| 内容 | 文件 |
|---|---|
| 包编解码 | net/parser/pomelo/packet/ |
| 消息编解码与路由字典 | net/parser/pomelo/message/ |
| 握手 / 心跳 / 踢人处理 | net/parser/pomelo/command.go |
| 连接管理 | net/parser/pomelo/agent.go |
| Go 客户端实现 | net/parser/pomelo/client/ |
下一个环节:客户端怎么接这套协议 → 客户端对接。