应用层协议设计:长度前缀、JSON 与 protobuf 编码

WebSocket 本身是面向消息的:一帧就是一条消息,边界天然存在,所以「在 WebSocket 上再套一层 TCP 长度前缀」是重复造轮子。但长度前缀思想仍有两个必须落地的场景——大 payload 分块传输,以及 WebSocket 之外的自建回源通道。真正需要精心设计的是信封(envelope):类型、版本、关联与序号,缺一样就在 ack、重试、灰度、补拉上留下窟窿。

1. 信封字段

{"ver":2,"type":21,"id":"01HX9K2M7Q","seq":88512,"ts":1735689600123,"replyTo":"01HX9K2M1A","payload":{}}
字段类型作用
veruint8协议版本,握手期用子协议名协商(如 im.v2),首帧 hello 双向确认
typeuint161x 控制帧(hello / ping / ack / subscribe),2x 数据帧(消息、已读回执、正在输入)
id字符串全局唯一消息 ID(ULID / UUIDv7),用于 ack、幂等与客户端去重
sequint64会话内单调序号,补拉与空洞检测的依据
tsint64毫秒时间戳,不用 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) 返回。

维度JSONprotobufMessagePack
单条 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,并用「只增字段 + 忽略未知类型」两条纪律换来回滚与灰度空间。

笔记加载中…