《MMO 服务器开发》4.1 二进制编解码从零写

二进制编解码是 MMO 服务端最底层的性能开关。本节从零写一个 internal/codec 包:Writer 按协议顺序压入 uint8/16/32/64、varint、字符串与字节块,Reader 逐字段读取并对每次越界返回错误而不是 panic;再用它编解码真实的 MoveNotify 结构体,test 覆盖往返一致、varint 边界与越界读取。

4.1 二进制编解码从零写

上一章定好了消息头与长度前缀:现在我们知道一帧从哪开始、到哪结束、命令号是多少。但帧里装的那个「身体」——玩家移动、技能释放、属性变化——还只是一串没有意义的字节。把它变成服务端能读的结构体、再变回字节,就是编解码(序列化)要干的事。

这件事看起来平淡,却是 MMO 服务端最底层的性能开关。同一条移动通知,用 JSON 编出来要九十多字节,用手写二进制只要三十字节;前者一次编解码要几百纳秒和几次堆分配,后者只要十几纳秒、零到一次分配。一台网关每秒要处理几十万条消息,这中间的差距会直接变成 CPU 占用与 GC 压力。

本节不引入任何第三方库,从零写一个 internal/codec 包:Writer 负责把字段按协议顺序压进 []byte,Reader 负责按同样的顺序读回来。硬要求只有一条——任何越界读取都必须返回错误,绝不 panic,因为越界的输入来自网络,是攻击者可控的。全部代码是 L1,go test 可以直接跑。

本章与站内既有内容的分工

站内已有几篇文章讲过协议网关、多版本协议、快照增量与带宽优化,它们负责「架构方案与取舍」。本章不复述结论,只做一件事:把编解码从零写出来并跑出真实数字。分工如下。

既有文章它负责什么本节只做什么
/game-server-protocol-gateway-architecture/协议网关的分层与职责只写网关里「字节 ↔ 结构体」这一步
/message-protocol-versioning-for-game-servers/多版本协议如何共存只做单版本编解码,版本协商见 3.3
/game-server-snapshot-delta-sync-architecture/快照与增量同步架构只提供增量编码所需的字节读写原语
/how-state-synchronization-games-reduce-bandwidth-pressure/状态同步如何省带宽只量化「编码格式本身省多少字节」

一个文件写完全套读写

先写 Writer 与 Reader。设计上有两条硬约束:不写任何自描述信息(没有字段名、没有类型标记、没有分隔符,顺序与类型全由两端事先约定),以及每一次读取都先做边界检查。下面这段是 L1,go test ./internal/codec/ 可以直接跑:

// file: internal/codec/binary.go
// tier: L1
// verify: go test ./internal/codec/
package codec

import (
	"encoding/binary"
	"errors"
	"math"
)

// 越界一律返回错误、绝不 panic:越界输入来自网络,是攻击者可控的。
var (
	ErrShortBuffer    = errors.New("codec: 读取越界,剩余字节不足")
	ErrVarintOverflow = errors.New("codec: varint 溢出")
	ErrStringTooLong  = errors.New("codec: 字符串长度超过 65535")
	ErrBytesTooLong   = errors.New("codec: 字节块长度超过 4GiB")
)

// Writer 按协议顺序追加字段,不写字段名、类型标记或分隔符。
type Writer struct{ buf []byte }

func NewWriter(capacity int) *Writer {
	if capacity < 0 {
		capacity = 0
	}
	return &Writer{buf: make([]byte, 0, capacity)}
}

func (w *Writer) Bytes() []byte { return w.buf }
func (w *Writer) Len() int      { return len(w.buf) }

func (w *Writer) Reset() { w.buf = w.buf[:0] }

func (w *Writer) PutUint8(v uint8)     { w.buf = append(w.buf, v) }
func (w *Writer) PutUint16(v uint16)   { w.buf = binary.BigEndian.AppendUint16(w.buf, v) }
func (w *Writer) PutUint32(v uint32)   { w.buf = binary.BigEndian.AppendUint32(w.buf, v) }
func (w *Writer) PutUint64(v uint64)   { w.buf = binary.BigEndian.AppendUint64(w.buf, v) }
func (w *Writer) PutFloat32(v float32) { w.PutUint32(math.Float32bits(v)) }

func (w *Writer) PutVarint(v uint64) {
	for v >= 0x80 {
		w.buf = append(w.buf, byte(v)|0x80)
		v >>= 7
	}
	w.buf = append(w.buf, byte(v))
}

func (w *Writer) PutString(s string) error {
	if len(s) > math.MaxUint16 {
		return ErrStringTooLong
	}
	w.PutUint16(uint16(len(s)))
	w.buf = append(w.buf, s...)
	return nil
}

func (w *Writer) PutBytes(b []byte) error {
	if uint64(len(b)) > math.MaxUint32 {
		return ErrBytesTooLong
	}
	w.PutUint32(uint32(len(b)))
	w.buf = append(w.buf, b...)
	return nil
}

type Reader struct {
	buf []byte
	pos int
}

func NewReader(b []byte) *Reader { return &Reader{buf: b} }
func (r *Reader) Remaining() int { return len(r.buf) - r.pos }
func (r *Reader) Pos() int       { return r.pos }

func (r *Reader) GetUint8() (uint8, error) {
	if r.pos+1 > len(r.buf) {
		return 0, ErrShortBuffer
	}
	v := r.buf[r.pos]
	r.pos++
	return v, nil
}

func (r *Reader) GetUint16() (uint16, error) {
	if r.pos+2 > len(r.buf) {
		return 0, ErrShortBuffer
	}
	v := binary.BigEndian.Uint16(r.buf[r.pos:])
	r.pos += 2
	return v, nil
}

func (r *Reader) GetUint32() (uint32, error) {
	if r.pos+4 > len(r.buf) {
		return 0, ErrShortBuffer
	}
	v := binary.BigEndian.Uint32(r.buf[r.pos:])
	r.pos += 4
	return v, nil
}

func (r *Reader) GetUint64() (uint64, error) {
	if r.pos+8 > len(r.buf) {
		return 0, ErrShortBuffer
	}
	v := binary.BigEndian.Uint64(r.buf[r.pos:])
	r.pos += 8
	return v, nil
}

func (r *Reader) GetFloat32() (float32, error) {
	v, err := r.GetUint32()
	if err != nil {
		return 0, err
	}
	return math.Float32frombits(v), nil
}

// GetVarint 读 LEB128;移位数到 64 仍未终止即报溢出,防止被全 0x80 的输入拖死。
func (r *Reader) GetVarint() (uint64, error) {
	var v uint64
	for shift := uint(0); ; shift += 7 {
		if shift >= 64 {
			return 0, ErrVarintOverflow
		}
		if r.pos >= len(r.buf) {
			return 0, ErrShortBuffer
		}
		b := r.buf[r.pos]
		r.pos++
		v |= uint64(b&0x7f) << shift
		if b < 0x80 {
			return v, nil
		}
	}
}

func (r *Reader) GetString() (string, error) {
	n, err := r.GetUint16()
	if err != nil {
		return "", err
	}
	if int(n) > r.Remaining() {
		return "", ErrShortBuffer
	}
	s := string(r.buf[r.pos : r.pos+int(n)])
	r.pos += int(n)
	return s, nil
}

func (r *Reader) GetBytes() ([]byte, error) {
	n, err := r.GetUint32()
	if err != nil {
		return nil, err
	}
	if uint64(n) > uint64(r.Remaining()) {
		return nil, ErrShortBuffer
	}
	out := make([]byte, n)
	copy(out, r.buf[r.pos:r.pos+int(n)])
	r.pos += int(n)
	return out, nil
}

注意几个刻意的选择。整数一律大端序——网络序,抓包时字节顺序与数值顺序一致,排错更快。字符串长度前缀用 uint16,单字段上限 64KiB,足够装玩家名、聊天内容,又防止一条超长字符串把内存撑爆。GetString 与 GetBytes 在读内容之前,先把长度前缀和 Remaining() 比一遍——这是防畸形包的关键一步:否则 buf[pos:pos+n] 在 n 被攻击者写成天文数字时就是一个越界切片,直接 panic。

varint 与越界:用测试把边界钉死

PutVarint 的边界值是最容易写错的地方,用一张表把 0、127、128、2^31、2^64-1 全覆盖,同时验证「越界返回错误而非 panic」。下面是 internal/codec/binary_test.go 里两段测试的摘录,属于同一个已跑通的 L1 包:

// snippet: internal/codec/binary_test.go(摘录)
func TestVarintBoundaries(t *testing.T) {
	cases := []struct {
		v     uint64
		bytes int // 期望占用的字节数
	}{{0, 1}, {127, 1}, {128, 2}, {1 << 31, 5}, {math.MaxUint64, 10}}
	for _, c := range cases {
		w := NewWriter(10)
		w.PutVarint(c.v)
		if got := w.Len(); got != c.bytes {
			t.Errorf("PutVarint(%d) 占 %d 字节,期望 %d", c.v, got, c.bytes)
		}
		if got, err := NewReader(w.Bytes()).GetVarint(); err != nil || got != c.v {
			t.Errorf("GetVarint = %d (err=%v),期望 %d", got, err, c.v)
		}
	}
}

// 本测试是「越界读取返回错误而不是 panic」这条硬要求的核心验收。
func TestShortBufferReturnsErrorNotPanic(t *testing.T) {
	// 只有 1 字节,却要读 4 字节。
	r := NewReader([]byte{0x01})
	if _, err := r.GetUint32(); !errors.Is(err, ErrShortBuffer) {
		t.Fatalf("GetUint32 越界 = %v,期望 ErrShortBuffer", err)
	}
	// 长度前缀声称 100 字节,实际只有 3 字节:必须报错,不能构造越界切片。
	liar := NewReader([]byte{0, 0, 0, 100, 'a', 'b', 'c'})
	if _, err := liar.GetBytes(); !errors.Is(err, ErrShortBuffer) {
		t.Fatalf("谎报长度 GetBytes = %v,期望 ErrShortBuffer", err)
	}
}

127 占 1 字节、128 占 2 字节——这是 varint 的「临界点」,差一就多花一个字节。2^31 占 5 字节而不是 4:LEB128 每字节只装 7 位,32 位要 ceil(32/7)=5 字节,这一点常被想当然地写错。最后一段「谎报长度」测试对应真实攻击场景:客户端发一个长度前缀写着 100、实际只有 3 字节的包,实现若不先比对就切片,服务端进程直接崩。

用它编解码真实结构体 MoveNotify

有了读写原语,编一条真实的移动通知就是顺理成章的事。MoveNotify 是《晨曦大陆》里服务端广播给周围玩家的「某玩家在移动」——它同时包含几类典型字段:单调递增的雪花 ID、连续量坐标、毫秒时间戳。下面这段同样是 L1,go test ./internal/codec/ 会一并跑到:

// file: internal/codec/entity.go
// tier: L1
// verify: go test ./internal/codec/
package codec

import "fmt"

// CmdMoveNotify 是「玩家移动」通知的命令号,落在报文首部(3.1 讲的消息头之后)。
const CmdMoveNotify uint16 = 0x1001

// MoveNotify 是一条移动通知:PlayerID 是雪花 ID(高位常为 0,适合 varint),
// X/Y/Z/Speed 是连续量(定长 float32),Timestamp 是毫秒时间戳(约 2^40)。
// 字段声明顺序就是协议里的字节顺序,两端按同一张表读写。
type MoveNotify struct {
	PlayerID  uint64
	X, Y, Z   float32
	Speed     float32
	Timestamp uint64
}

// Encode 用变长二进制编码:ID 与时间戳按实际大小伸缩,坐标与速度是定长 float32。
func (m MoveNotify) Encode() []byte {
	w := NewWriter(64)
	w.PutUint16(CmdMoveNotify)
	w.PutVarint(m.PlayerID)
	w.PutFloat32(m.X)
	w.PutFloat32(m.Y)
	w.PutFloat32(m.Z)
	w.PutFloat32(m.Speed)
	w.PutVarint(m.Timestamp)
	return w.Bytes()
}

// DecodeMoveNotify 解析 Encode 的产物。任何越界都返回错误,绝不 panic。
func DecodeMoveNotify(b []byte) (MoveNotify, error) {
	var m MoveNotify
	r := NewReader(b)
	cmd, err := r.GetUint16()
	if err != nil {
		return m, err
	}
	if cmd != CmdMoveNotify {
		return m, fmt.Errorf("codec: 未知命令号 0x%04x", cmd)
	}
	if m.PlayerID, err = r.GetVarint(); err != nil {
		return m, err
	}
	if m.X, err = r.GetFloat32(); err != nil {
		return m, err
	}
	if m.Y, err = r.GetFloat32(); err != nil {
		return m, err
	}
	if m.Z, err = r.GetFloat32(); err != nil {
		return m, err
	}
	if m.Speed, err = r.GetFloat32(); err != nil {
		return m, err
	}
	if m.Timestamp, err = r.GetVarint(); err != nil {
		return m, err
	}
	return m, nil
}

PlayerID 与 Timestamp 用 varint,是因为它们的值域高度不均匀:雪花 ID 的高位常年是 0,当前毫秒时间戳约 2^40(6 字节)。坐标与速度用定长 float32,因为连续量几乎不重复、varint 占不到便宜,而定长还能让解码器用固定偏移直接定位。DecodeMoveNotify 里每一步都 if err != nil { return }——读越界、命令号对不上,都返回错误交给上层断开连接,而不是让一个畸形包把服务端带走。

本节小结 / 实测结论

本机(Go 1.26.0,darwin/arm64)实跑结果:

$ go test ./internal/codec/
ok  	aegis/internal/codec	0.575s     (12 个 test 全绿)

$ go test ./internal/codec/ -run TestSizes -v
    bench_test.go: json=95 flat=32 varint=30

同一条 MoveNotify:JSON 95 字节、定长扁平化 32 字节、varint 二进制 30 字节。varint 版把两个 8 字节字段按实际大小压缩,比定长还省 2 字节——省得不多,但方向正确。以上数字是同机相对量级,绝对数值不可移植;真正的吞吐与分配对比留到 4.3,下一节先看 Go 切片与 Java ByteBuffer 两种「零拷贝」的实现差异。

阅读导航:上一节:目录 · 下一节:4.2 双栈对照:Go 编解码与 Netty ByteBuf 零拷贝 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标