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 零拷贝 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。