一、包络和 body 是两件事
一条游戏消息可以分成两层:外层是传输包络(envelope),负责“这段字节从哪里开始、到哪里结束、属于哪个请求”;内层是业务负载(body),负责“这次操作的具体参数”。两层解决的是不同问题,混在一起设计,通常会在协议演进时付出代价。
包络必须由协议栈自己定义,因为它决定了拆包、校验、关联和限流。业务负载则适合交给 IDL,例如 Protobuf、FlatBuffers 或自定义的二进制结构。把整条消息都交给 IDL,会让“长度未知时如何安全读下一包”变成一个无法回答的问题;反过来,把业务字段全部摊进包头,又会让每条新命令都修改传输层。
一个实用的分工是:
| 层 | 负责 | 不该负责 |
|---|---|---|
| 包络 | 定界、校验、版本、命令号、关联号 | 玩家等级、背包格子等业务字段 |
| IDL body | 业务字段、可选字段、嵌套结构 | 决定连接上的下一帧从哪里开始 |
二、包头字段逐个解释
本实验使用固定 20 字节包头,所有多字节整数采用大端序。
| 偏移 | 字段 | 宽度 | 为什么存在 |
|---|---|---|---|
| 0 | magic | 2 | 快速识别“这看起来是不是一帧”,用于错位后的重同步 |
| 2 | version | 1 | 协议版本,接收方可明确拒绝或降级处理 |
| 3 | flags | 1 | 请求/响应/压缩等标志,避免为每种组合新增命令号 |
| 4 | cmd | 2 | 业务命令号,决定交给哪个处理器 |
| 6 | seq | 4 | 请求与响应的关联号;推送时为 0 |
| 10 | body_len | 4 | 长度前缀,拆包的唯一依据,也是分配上限的依据 |
| 14 | checksum | 4 | CRC32,覆盖除自身外的包头与 body |
| 18 | reserved | 2 | 预留位,当前必须为 0,为后续扩展留空间 |
几个容易设计错的地方:
- flags 不要只做压缩位。至少预留“请求/响应”位,否则接收方只能靠命令号区间猜方向,命令号一多就会冲突。
- body_len 描述的是 body,不是整包。两种口径都可行,但必须写进文档并在两端一致;本实现选择 body 长度,整包长度等于 20 加 body_len。
- checksum 的计算范围必须固定。如果发送方算了 reserved,接收方没算,平时都对,某次 reserved 非零时才开始随机失败。本实现覆盖
header[0:14] + header[18:20] + body。 - reserved 不是“随便写”。当前接收方应忽略其值,但校验和覆盖它,避免中间设备悄悄改写。
三、字节序、变长整数与最大包长
3.1 字节序
跨语言、跨平台协议应固定字节序。网络字节序是大端,Go 的 encoding/binary.BigEndian、Java 的 ByteBuffer 默认大端都能直接对应。小端并非不能用,但一旦写进协议,就要在所有客户端、网关、日志工具里保持一致。
判断一段 dump 是大端还是小端,可以看长度字段:00 00 00 03 在大端下是 3,在小端下是 50331648。后者通常离谱到足以立刻暴露问题。
3.2 变长整数
包络里的长度和 seq 建议用定长整数,因为拆包代码需要可预测的偏移。变长整数(varint)适合 body 内部的 ID、坐标等字段,不适合“必须先读它才能知道头有多长”的位置。否则解析头部本身就需要递归读长度,复杂度和被攻击面都会上升。
3.3 最大包长
body_len 来自网络,必须视为不可信输入。没有上限的解析器会被一个声明 4 GB 的包头直接打爆内存。本实验把上限设为 1 MiB,并在分配 body 之前校验;校验顺序是 magic、version、body_len,最后才检查实际字节是否够。
上限不是越大越好。它应当来自业务最大消息的实测分布,并留出余量;超过上限的消息应该走分片或对象存储,而不是把单包上限调到几百 MB。
四、长度前缀和粘包拆包
TCP 是字节流,不保留消息边界。一次 Read 可能拿到半个包、一个半包或三个包。解决方式只有两类:分隔符和长度前缀。二进制游戏协议几乎都选长度前缀,因为 body 里可能出现任意字节,分隔符需要转义,成本更高。
正确的读取循环是:
- 缓冲区不足 20 字节,继续读;
- 读满包头,校验 magic、version、body_len 上限;
- 按
20 + body_len判断整包是否到齐; - 不齐就保留缓冲区,继续读;
- 到齐后校验 checksum,解析 body,消费掉这一帧;
- 缓冲区里若还有剩余字节,回到第 1 步继续解析。
关键在第 4 步:半包时不能消费任何字节,否则下一轮会从包中间开始解析。实现上让 DecodeBytes 在字节不足时返回 ErrShortFrame 且 consumed 为 0,调用方保留原缓冲区即可。
另一个常见误区是“按 Read 一次的结果当整包”。Read 只保证读到至少 1 字节(或错误),不保证读满请求长度;即使一次读到了完整包,也不保证下次不会粘包。
五、协议演进
协议一定会变。包头设计要提前考虑四件事。
| 演进动作 | 推荐做法 | 不推荐做法 |
|---|---|---|
| 新增字段 | 使用 reserved 位或升级 version,并在文档中写明偏移 | 直接在 body 前面插字段,改变所有后续偏移 |
| 保留位 | 发送方写 0,接收方忽略但纳入校验 | 收到非零就断开,导致新客户端无法连接旧服 |
| 版本协商 | 登录阶段交换版本与能力位,服务端决定降级或拒绝 | 每个包都带完整能力列表 |
| 未知 cmd | 返回明确的“不支持”错误码,连接保持可用 | 直接 panic、断开或静默丢弃 |
版本策略要区分“兼容扩展”和“破坏性变更”。新增一个可选字段、复用 reserved 位,通常可以在同一 version 内完成;改变长度字段含义、调整校验范围,则必须提升 version,并让服务端在握手时明确拒绝不支持的版本。
未知命令号的处理尤其重要。旧服务端收到新客户端的新命令,最差的反应是崩溃;次差是静默丢弃,让客户端一直等到超时。正确做法是回一个带原 seq 的错误响应,客户端据此提示“版本过旧”,而不是让用户面对一个永远不会结束的 loading。
六、实现:带边界检查的帧编解码
下面的实现把“校验顺序”和“半包不消费”写进了 API:DecodeBytes 返回消费字节数,Decode 用于流式读取,TotalLen 供缓冲层提前判断需要多少字节。
// file: ch03/wire/frame.go
// tier: L1
// verify: go test ./ch03/wire/
// Package wire defines the transport envelope for game messages. The envelope
// is deliberately small and self-describing; the body is an opaque payload
// that an IDL such as Protobuf can encode.
package wire
import (
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
"io"
)
const (
// HeaderSize is fixed so a reader can buffer exactly one header first.
HeaderSize = 20
// Magic rejects random bytes and most desynchronised streams early.
Magic = 0x4753 // "GS"
// Version1 is the only version this lab accepts.
Version1 = 1
// MaxBody caps allocations triggered by an untrusted length field.
MaxBody = 1 << 20
MaxFrame = HeaderSize + MaxBody
FlagRequest = 1 << 0
FlagResponse = 1 << 1
// Offsets inside the header. Reserved bytes 18..19 are included in the
// checksum so a peer cannot silently repurpose them.
offMagic = 0
offVersion = 2
offFlags = 3
offCmd = 4
offSeq = 6
offBodyLen = 10
offChecksum = 14
offReserved = 18
)
var (
ErrShortHeader = errors.New("wire: short header")
ErrShortFrame = errors.New("wire: short frame")
ErrBadMagic = errors.New("wire: bad magic")
ErrBadVersion = errors.New("wire: unknown version")
ErrTooLarge = errors.New("wire: body too large")
ErrChecksum = errors.New("wire: checksum mismatch")
)
// Frame is one application message after envelope decoding.
type Frame struct {
Version uint8
Flags uint8
Cmd uint16
Seq uint32
Body []byte
}
// frameChecksum covers every header byte except the checksum field itself,
// followed by the body. Both sides must use the same byte ranges, otherwise
// valid frames fail only under load, when a field they thought was covered
// happens to differ.
func frameChecksum(hdr []byte, body []byte) uint32 {
crc := crc32.ChecksumIEEE(hdr[:offChecksum])
crc = crc32.Update(crc, crc32.IEEETable, hdr[offReserved:HeaderSize])
return crc32.Update(crc, crc32.IEEETable, body)
}
// Encode serialises one frame, including its length prefix.
func Encode(f Frame) ([]byte, error) {
if f.Version == 0 {
f.Version = Version1
}
if f.Version != Version1 {
return nil, fmt.Errorf("%w: %d", ErrBadVersion, f.Version)
}
if len(f.Body) > MaxBody {
return nil, fmt.Errorf("%w: %d > %d", ErrTooLarge, len(f.Body), MaxBody)
}
buf := make([]byte, HeaderSize+len(f.Body))
binary.BigEndian.PutUint16(buf[offMagic:], Magic)
buf[offVersion] = f.Version
buf[offFlags] = f.Flags
binary.BigEndian.PutUint16(buf[offCmd:], f.Cmd)
binary.BigEndian.PutUint32(buf[offSeq:], f.Seq)
binary.BigEndian.PutUint32(buf[offBodyLen:], uint32(len(f.Body)))
copy(buf[HeaderSize:], f.Body)
binary.BigEndian.PutUint32(buf[offChecksum:], frameChecksum(buf[:HeaderSize], f.Body))
return buf, nil
}
// inspectHeader validates everything that can be checked from the fixed
// header alone and returns the declared body length.
func inspectHeader(hdr []byte) (int, error) {
if len(hdr) < HeaderSize {
return 0, ErrShortHeader
}
if got := binary.BigEndian.Uint16(hdr[offMagic:]); got != Magic {
return 0, fmt.Errorf("%w: 0x%04x", ErrBadMagic, got)
}
if v := hdr[offVersion]; v != Version1 {
return 0, fmt.Errorf("%w: %d", ErrBadVersion, v)
}
bodyLen := binary.BigEndian.Uint32(hdr[offBodyLen:])
if bodyLen > MaxBody {
return 0, fmt.Errorf("%w: %d > %d", ErrTooLarge, bodyLen, MaxBody)
}
return int(bodyLen), nil
}
// TotalLen is what a buffering reader needs before it can decode the frame
// that starts with hdr.
func TotalLen(hdr []byte) (int, error) {
bodyLen, err := inspectHeader(hdr)
if err != nil {
return 0, err
}
return HeaderSize + bodyLen, nil
}
// DecodeBytes parses the first frame in buf and reports how many bytes it
// consumed. It never consumes a partial frame: on ErrShortFrame the caller
// keeps the bytes and retries after reading more from the socket.
func DecodeBytes(buf []byte) (Frame, int, error) {
bodyLen, err := inspectHeader(buf)
if err != nil {
return Frame{}, 0, err
}
total := HeaderSize + bodyLen
if len(buf) < total {
return Frame{}, 0, fmt.Errorf("%w: have %d need %d", ErrShortFrame, len(buf), total)
}
want := binary.BigEndian.Uint32(buf[offChecksum:])
if got := frameChecksum(buf[:HeaderSize], buf[HeaderSize:total]); got != want {
return Frame{}, 0, fmt.Errorf("%w: got 0x%08x want 0x%08x", ErrChecksum, got, want)
}
body := make([]byte, bodyLen)
copy(body, buf[HeaderSize:total])
return Frame{
Version: buf[offVersion],
Flags: buf[offFlags],
Cmd: binary.BigEndian.Uint16(buf[offCmd:]),
Seq: binary.BigEndian.Uint32(buf[offSeq:]),
Body: body,
}, total, nil
}
// Decode reads exactly one frame from a stream. A short read means the
// connection died mid-frame; callers that need to survive that should use
// DecodeBytes with their own buffer instead.
func Decode(r io.Reader) (Frame, error) {
var hdr [HeaderSize]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
return Frame{}, ErrShortHeader
}
return Frame{}, err
}
bodyLen, err := inspectHeader(hdr[:])
if err != nil {
return Frame{}, err
}
body := make([]byte, bodyLen)
if _, err := io.ReadFull(r, body); err != nil {
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
return Frame{}, ErrShortFrame
}
return Frame{}, err
}
want := binary.BigEndian.Uint32(hdr[offChecksum:])
if got := frameChecksum(hdr[:], body); got != want {
return Frame{}, fmt.Errorf("%w: got 0x%08x want 0x%08x", ErrChecksum, got, want)
}
return Frame{
Version: hdr[offVersion],
Flags: hdr[offFlags],
Cmd: binary.BigEndian.Uint16(hdr[offCmd:]),
Seq: binary.BigEndian.Uint32(hdr[offSeq:]),
Body: body,
}, nil
}
注意 Decode 与 DecodeBytes 的错误语义不同:前者在 body 读到一半时已经消费了包头,无法原地重试;后者不消费任何字节。需要在不稳定连接上恢复的调用方,应使用后者加自己的缓冲区。
七、测试覆盖
ch03/wire 的测试直接构造字节流,覆盖以下路径:
| 测试 | 验证点 |
|---|---|
| 往返 | 编码后再解码,头字段与 body 完全一致 |
| 粘包 | 三帧拼接后循环解析,每帧边界正确 |
| 半包 | 少一个字节时返回 ErrShortFrame 且消费 0 字节,补齐后可继续 |
| 超长包 | 声明长度超过上限时,在分配前返回 ErrTooLarge |
| 校验失败 | 翻转 body 一个字节后返回 ErrChecksum |
| 坏 magic / 未知版本 | 分别返回 ErrBadMagic 与 ErrBadVersion |
| 编码参数 | 非法版本与超限 body 被 Encode 拒绝 |
go test -race ./ch03/wire/
这三个包共 24 个测试,本机执行全部通过。半包测试刻意检查“消费字节数为 0”,因为只检查错误码会漏掉“报错了但缓冲区已被截断”的实现。
常见错误与边界
- 只按一次
Read当整包:TCP 不保证消息边界,必须循环读满头部和 body。 - 长度字段不校验上限:攻击者用 4 GB 的长度让服务端先分配再判断,内存直接被打爆。
- checksum 覆盖范围不一致:发送方和接收方对 reserved 或长度字段的处理不同,平时正常、边界才失败。
- 半包时消费了字节:下一轮从包中间开始,表现为持续 magic 错误或随机命令号。
- 校验顺序错误:先读 body 再验版本,等于让旧版本客户端用错误布局解析新包。
- 未知命令直接断开:客户端只能看到连接消失,无法区分“版本过旧”和“服务器故障”。
- 把 Protobuf 当传输层:IDL 解析器需要完整消息,而“如何得到完整消息”正是包络要解决的问题。
小结
通用消息封装的关键不在字段多,而在边界清楚:magic 用于识别,version 用于协商,flags 区分方向,cmd 决定路由,seq 负责关联,body_len 负责定界并限制分配,checksum 负责发现损坏,reserved 负责演进。字节序、最大包长、半包处理和未知命令策略必须写进协议文档并被测试覆盖。包络自己定,body 交给 IDL,这个分工能让协议在版本迭代中保持可控。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。