《游戏服务端编程实践》3.4.2 通用消息封装结构

逐字段拆解游戏协议的通用包头:magic、version、flags、cmd、seq、body_len、checksum 各自解决什么问题;讨论字节序、长度上限、粘包拆包、版本协商与未知命令处理,并给出带半包、粘包、超长包和校验失败测试的 Go 实现。

一、包络和 body 是两件事

一条游戏消息可以分成两层:外层是传输包络(envelope),负责“这段字节从哪里开始、到哪里结束、属于哪个请求”;内层是业务负载(body),负责“这次操作的具体参数”。两层解决的是不同问题,混在一起设计,通常会在协议演进时付出代价。

包络必须由协议栈自己定义,因为它决定了拆包、校验、关联和限流。业务负载则适合交给 IDL,例如 Protobuf、FlatBuffers 或自定义的二进制结构。把整条消息都交给 IDL,会让“长度未知时如何安全读下一包”变成一个无法回答的问题;反过来,把业务字段全部摊进包头,又会让每条新命令都修改传输层。

一个实用的分工是:

层负责不该负责
包络定界、校验、版本、命令号、关联号玩家等级、背包格子等业务字段
IDL body业务字段、可选字段、嵌套结构决定连接上的下一帧从哪里开始

二、包头字段逐个解释

本实验使用固定 20 字节包头,所有多字节整数采用大端序。

偏移字段宽度为什么存在
0magic2快速识别“这看起来是不是一帧”,用于错位后的重同步
2version1协议版本,接收方可明确拒绝或降级处理
3flags1请求/响应/压缩等标志,避免为每种组合新增命令号
4cmd2业务命令号,决定交给哪个处理器
6seq4请求与响应的关联号;推送时为 0
10body_len4长度前缀,拆包的唯一依据,也是分配上限的依据
14checksum4CRC32,覆盖除自身外的包头与 body
18reserved2预留位,当前必须为 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 里可能出现任意字节,分隔符需要转义,成本更高。

正确的读取循环是:

  1. 缓冲区不足 20 字节,继续读;
  2. 读满包头,校验 magic、version、body_len 上限;
  3. 按 20 + body_len 判断整包是否到齐;
  4. 不齐就保留缓冲区,继续读;
  5. 到齐后校验 checksum,解析 body,消费掉这一帧;
  6. 缓冲区里若还有剩余字节,回到第 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,这个分工能让协议在版本迭代中保持可控。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist