应用层协议设计:长度前缀、JSON 与 protobuf 编码
WebSocket 本身是面向消息的:一帧就是一条消息,边界天然存在,所以「在 WebSocket 上再套一层 TCP 长度前缀」是重复造轮子。但长度前缀思想仍有两个必须落地的场景——大 payload 分块传输,以及 WebSocket 之外的自建回源通道。真正需要精心设计的是信封(envelope):类型、版本、关联与序号,缺一样就在 ack、重试、灰度、补拉上留下窟窿。
1. 信封字段
{"ver":2,"type":21,"id":"01HX9K2M7Q","seq":88512,"ts":1735689600123,"replyTo":"01HX9K2M1A","payload":{}}
| 字段 | 类型 | 作用 |
|---|---|---|
| ver | uint8 | 协议版本,握手期用子协议名协商(如 im.v2),首帧 hello 双向确认 |
| type | uint16 | 1x 控制帧(hello / ping / ack / subscribe),2x 数据帧(消息、已读回执、正在输入) |
| id | 字符串 | 全局唯一消息 ID(ULID / UUIDv7),用于 ack、幂等与客户端去重 |
| seq | uint64 | 会话内单调序号,补拉与空洞检测的依据 |
| ts | int64 | 毫秒时间戳,不用 RFC3339 字符串 |
| replyTo | 字符串 | 请求-响应关联,承载 RPC 语义 |
| payload | 对象 | 业务体,按 type 决定结构 |
长连接上的每帧额外开销并不小(服务端到客户端 2~14 字节帧头,外加浏览器每帧一次事件循环调度),所以高频小消息应把多条合进一个帧的数组信封里,而不是一条一个 WriteMessage。
2. 长度前缀用在哪儿
- 大 payload 分块:先发
begin{总长, 校验和, 块大小},再发若干 4 字节大端长度 + 8 字节偏移 + 数据的块,最后end。单帧上限(SetReadLimit)就不用放大到几十 MB。 - 自建 TCP/QUIC 回源通道:这里没有帧边界,长度前缀是必需项。实现的关键是读到一半不能算错,粘包也不能算错:
// readFrame 从流式连接读取一个「4 字节大端长度 + 负载」的帧。
func readFrame(r io.Reader, maxSize uint32) ([]byte, error) {
var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil { // io.ReadFull 处理半包,Read 不行
return nil, fmt.Errorf("读取长度前缀失败: %w", err)
}
n := binary.BigEndian.Uint32(hdr[:])
if n == 0 || n > maxSize { // 不校验上限,对端声明 4GB 就直接 OOM
return nil, fmt.Errorf("帧长度非法: %d (max %d)", n, maxSize)
}
buf := make([]byte, n)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, fmt.Errorf("读取帧体失败: %w", err)
}
return buf, nil
}
3. 编码选择
编码收敛到一个 Codec 接口,连接建立时按客户端能力选定,之后整条连接不再切换(同一连接混用文本与二进制帧会让客户端解析逻辑变成一团乱麻):
// Codec 在信封与线缆字节之间转换,实现必须并发安全。
type Codec interface {
Marshal(v any) ([]byte, error)
Unmarshal(data []byte, v any) error
Binary() bool // true 表示必须用 websocket.BinaryMessage 发送
}
type JSONCodec struct{}
func (JSONCodec) Binary() bool { return false }
func (JSONCodec) Marshal(v any) ([]byte, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("JSON 编码失败: %w", err)
}
return b, nil
}
func (JSONCodec) Unmarshal(data []byte, v any) error {
if err := json.Unmarshal(data, v); err != nil {
return fmt.Errorf("JSON 解码失败: %w", err)
}
return nil
}
// ProtoCodec 走二进制帧:protobuf 结果不是合法 UTF-8,
// 用文本文帧发送会被浏览器以 1007 关闭。
type ProtoCodec struct{}
func (ProtoCodec) Binary() bool { return true }
func (ProtoCodec) Marshal(v any) ([]byte, error) {
m, ok := v.(proto.Message)
if !ok {
return nil, fmt.Errorf("protobuf 编码需要 proto.Message,得到 %T", v)
}
b, err := proto.Marshal(m)
if err != nil {
return nil, fmt.Errorf("protobuf 编码失败: %w", err)
}
return b, nil
}
发送侧据此选 opcode:op := websocket.TextMessage,codec.Binary() 为真则换成 websocket.BinaryMessage,写入失败统一包装成 fmt.Errorf("写入帧失败: %w", err) 返回。
| 维度 | JSON | protobuf | MessagePack |
|---|---|---|---|
| 单条 IM 消息体积 | 约 180 B | 约 60 B | 约 95 B |
| 可读性与调试 | 最好,肉眼可查 | 需 .proto 与工具 | 差 |
| schema 约束 | 无(靠约定) | 强,编译期检查 | 弱 |
| 向后兼容 | 靠「只增字段」纪律 | 字段号机制天然支持 | 一般 |
| CPU 开销 | 反射,偏高 | 生成代码,低 | 中 |
选型结论通常是:对外提供开放接口或调试期用 JSON,内部高频通道与移动端弱网用 protobuf,不要为了省事在一条连接上随时切换。
4. 前向兼容规则
- 只增字段、不改字段语义、不回收字段号(proto 用
reserved,JSON 用deprecated注释)。 - 未知 type 必须忽略并打点,不能 panic、不能断开连接——老客户端遇到新消息是常态。
- JSON 解码忽略未知字段(
encoding/json默认行为),不要开DisallowUnknownFields。 - proto3 的零值无法区分「未设置」与「设为 0」,需要存在性语义时用
optional或 wrapper 类型。 - 版本协商失败时下发一条 error 帧再优雅关闭(如 4005
unsupported version),别静默断。
5. 常见坑与排查
- 大整数精度丢失:JS 的 Number 只有 53 位精度,用户 ID、
seq用字符串编码(Go 里加json:",string"tag)。 - 时间格式不统一:
time.Time默认序列化成 RFC3339,各语言解析差异大,统一发毫秒整数。 - 文本帧发二进制:protobuf 数据里出现非法 UTF-8,浏览器直接 1007 断连;gorilla 不校验,但中间代理可能校验。
- 类型码散落各处:集中到
pkg/protocol一个包,常量与生成代码一起版本化。 - 忘设
SetReadLimit:对端发个超大帧就能把内存打爆,协议里必须写死上限。 - 只做序列化不做校验:枚举越界、字符串超长、必填缺失要在入口拦掉,否则脏数据直接进库。
排查看四组指标:各 type 的 QPS 与帧长分布(P50 / P99)、编解码 CPU 占比、反序列化失败率按 type 与客户端版本分组(能直接定位协议不一致的版本)、未知 type 计数(灰度期的前哨)。抓 wss 流量需要客户端设置 SSLKEYLOGFILE 再用 Wireshark 解,本地联调则用 wscat 直接发原始帧验证边界条件。
小结:WebSocket 负责消息边界,应用层负责信封——ver 管兼容、type 管分发、id 管幂等与 ack、seq 管补拉、ts 与 replyTo 管时序与关联;长度前缀留给分块与自建回源通道,编码在连接期固定为 JSON 或 protobuf,并用「只增字段 + 忽略未知类型」两条纪律换来回滚与灰度空间。