《MMO 服务器开发》3.1 消息头与命令号

字节流本身没有消息边界,双方必须先约定头几个字节表示什么。本节定下 aegis 的固定消息头——长度、命令号、序号与标志位四个字段,讲清「高字节模块号 + 低字节序号」的命令号分配策略、标志位的位域压缩,并给出可运行的 header.go 与边界测试。

3.1 消息头与命令号

上一节我们让每个连接跑在一条 goroutine 上,数据在字节流里流动。但字节流本身没有「消息」的概念——它是一串没有边界的字节。要把它切回一条条消息,通信双方必须先约定:一条消息的头几个字节表示什么。

这一节定下 aegis 的第一层契约:固定消息头。它只解决一件事——让接收方知道「这一条消息从哪开始、到哪结束、是什么、属于哪次请求」。3.2 的长度前缀分帧、第 4 章的编解码、第 8 章的路由,全都建立在这个头之上。

设计目标很具体:用尽量少的字节把四件事说清楚——长度、命令号、序号、标志位。然后我们回答一个常被忽略的问题:为什么不用 JSON 的字段名。

固定头里该放什么

一个头要回答四个问题:这条消息有多长(不然切不开)、它是什么命令(不然不知道该谁处理)、它是哪一次请求(不然响应配不上请求)、它有没有特殊处理(压缩、加密、推送)。把这四件事塞进最小的字节数,就是下面的布局:

+----------------+----------------+----------------+----------------+
|                  length (4 bytes, big-endian)                    |  bytes 0..3
+----------------+----------------+----------------+----------------+
|  cmd (2 bytes)  |            seq (4 bytes, big-endian)            |  bytes 4..9
+----------------+----------------+----------------+----------------+
| flags (1 byte)  |             payload (N bytes) ...              |  byte 10..
+----------------+----------------+----------------+----------------+
  • length:统计它自身之后的字节数,即 cmd(2) + seq(4) + flags(1) + payload(N)。把长度放在最前面、且不含自身,是为了让接收方能先读 4 字节就知道「还差多少字节到下一帧」。
  • cmd:16 位命令号,下文详述。
  • seq:连接内自增序号。客户端发请求时填,服务端响应时原样带回,用来把响应配回请求;它也是第 11 章去重的抓手。
  • flags:8 位标志位,一个字节压缩 8 个布尔语义。

固定头因此是 11 字节(HeaderSize)。别小看这 4 字节的 length:它把「流」变成「帧」的全部秘密都在这里。

命令号:高字节模块号 + 低字节序号

命令号只有 16 位,但 MMO 的命令动辄几百条。朴素的编号(0、1、2……)有两个问题:看不出归属,且加一条命令就要在末尾追加,模块之间犬牙交错。

aegis 的做法是把 16 位劈成两半:高字节是模块号,低字节是模块内序号。于是 0x0201 一眼就能读成「场景模块的第 1 号命令」(0x02 是模块号,0x01 是模块内序号),路由时不必再查表,抓包时人眼也能直接读懂。

命令号常量按模块分组定义,每个模块预留 256 个序号,加命令时只在模块内递增,不会踩到别的模块。

标志位与位域压缩

方向与处理方式这类「是/否」信息,如果每个都占一个字节,8 个布尔就要 8 字节。标志位把它们压进同一个字节的 8 个 bit:

位名称含义
0FlagRequest客户端发起的请求
1FlagResponse服务端对某次请求的响应
2FlagPush服务端主动推送(无对应请求)
3FlagCompressed载荷经压缩
4FlagEncrypted载荷经加密
5FlagFragment分片包(大消息被拆成多帧)

位运算只提供四个原语:Has(命中任意一位)、HasAll(全部命中)、With(置位,返回新值)、Clear(清除,返回新值)。全部返回新值、不改原值,避免共享的 Flags 被某处顺手改掉。

为什么不用 JSON 的字段名

新手最直觉的协议是 {"cmd":"move","seq":42,"x":12,"y":34}。它可读,但每个字段名都在线上重复发送,而且接收方每次都要做字符串匹配。一条移动请求的体量对比:

方案线上一帧说明
JSON 带字段名约 60 字节"cmd"、"seq" 这些键名每帧重发
二进制 + 命令号11 + 8 = 19 字节命令号 2 字节代替 "cmd":"move"

在每秒几十万条消息的 MMO 里,这不是审美问题:省下的每一个字节都要乘以消息数,字符串解析的开销也要乘以消息数。JSON 适合配置与调试接口,不适合热路径。

可运行产物:头编解码与边界

下面 internal/proto/header.go 是完整的头定义,层级 L1,命令 go test ./internal/proto/。注意命令号与标志位都只是 uint8/uint16 上的位运算,没有任何魔法。

// file: internal/proto/header.go
// tier: L1
// verify: go test ./internal/proto/
// Package proto 定义 aegis 服务端的线上协议:消息头、长度前缀分帧与版本协商。
package proto

import (
	"encoding/binary"
	"errors"
)

// ErrShortFrame 表示缓冲区不足一个固定头(或不足声明的帧长)。
var ErrShortFrame = errors.New("proto: buffer shorter than fixed header")

// HeaderSize 是固定头的字节数:length(4) + cmd(2) + seq(4) + flags(1)。
const HeaderSize = 11

// MaxPayload 是单个帧载荷的字节上限(1 MiB)。
//
// 长度前缀来自网络,是不可信输入。没有上限时,一个声明「长度 = 4 GiB」的
// 畸形包就能让服务端按声明预分配巨量内存——这是最省事的 DoS。
const MaxPayload = 1 << 20

// Cmd 是 16 位命令号:高字节是模块号,低字节是模块内序号。
//
// 这样一条消息的「归属」与「动作」都能从命令号本身推出来,路由时不必再查表,
// 抓包时人眼也能直接读懂(0x0201 = 场景模块的第 1 号命令)。
type Cmd uint16

// 模块号占用命令号的高字节,最多 256 个模块。
const (
	ModuleSystem uint8 = 0x00 // 系统:心跳、登录、错误
	ModulePlayer uint8 = 0x01 // 玩家:属性、背包
	ModuleScene  uint8 = 0x02 // 场景:移动、AOI
	ModuleChat   uint8 = 0x03 // 聊天
	ModuleGuild  uint8 = 0x04 // 公会
	ModuleMail   uint8 = 0x05 // 邮件
)

// MakeCmd 把模块号与模块内序号压进一个 16 位命令号。
func MakeCmd(module, sub uint8) Cmd {
	return Cmd(uint16(module)<<8 | uint16(sub))
}

// Module 返回命令号的模块号(高字节)。
func (c Cmd) Module() uint8 { return uint8(c >> 8) }

// Sub 返回命令号的模块内序号(低字节)。
func (c Cmd) Sub() uint8 { return uint8(c & 0x00FF) }

// 命令号常量按模块分组。高字节模块号 + 低字节序号 = 可读的十进制/十六进制常量。
const (
	// ModuleSystem(0x00)
	CmdHeartbeatReq  = Cmd(0x0001) // 客户端心跳
	CmdHeartbeatResp = Cmd(0x0002) // 服务端心跳应答
	CmdErrorNotify   = Cmd(0x0003) // 服务端错误通知
	CmdLoginReq      = Cmd(0x0010) // 登录请求
	CmdLoginResp     = Cmd(0x0011) // 登录响应(含版本协商结果)

	// ModulePlayer(0x01)
	CmdEnterWorldReq  = Cmd(0x0101) // 进入世界
	CmdEnterWorldResp = Cmd(0x0102) // 进入世界应答
	CmdAttrSync       = Cmd(0x0103) // 属性同步(服务端推送)

	// ModuleScene(0x02)
	CmdMoveReq    = Cmd(0x0201) // 移动请求
	CmdMoveResp   = Cmd(0x0202) // 移动应答(服务端裁决后的位置)
	CmdAoiAdd     = Cmd(0x0203) // AOI 进入视野(推送)
	CmdAoiRemove  = Cmd(0x0204) // AOI 离开视野(推送)

	// ModuleChat(0x03)
	CmdChatSend = Cmd(0x0301) // 发送聊天
	CmdChatRecv = Cmd(0x0302) // 收到聊天(推送)
)

// Flags 是 8 位标志位:一个字节压缩 8 个布尔语义,比在载荷里塞 8 个 bool 省 7 字节。
type Flags uint8

// 标志位定义。前三位描述消息方向,其余描述载荷处理方式。
const (
	FlagRequest    Flags = 1 << 0 // 客户端发起的请求
	FlagResponse   Flags = 1 << 1 // 服务端对某次请求的响应
	FlagPush       Flags = 1 << 2 // 服务端主动推送(无对应请求)
	FlagCompressed Flags = 1 << 3 // 载荷经压缩
	FlagEncrypted  Flags = 1 << 4 // 载荷经加密
	FlagFragment   Flags = 1 << 5 // 分片包(大消息被拆成多帧)
)

// Has 判断是否置位了 x 中的任意一位。
func (f Flags) Has(x Flags) bool { return f&x != 0 }

// HasAll 判断是否置位了 x 中的全部位。
func (f Flags) HasAll(x Flags) bool { return f&x == x }

// With 返回置位 x 后的新标志集,不修改原值。
func (f Flags) With(x Flags) Flags { return f | x }

// Clear 返回清除 x 后的新标志集,不修改原值。
func (f Flags) Clear(x Flags) Flags { return f &^ x }

// String 用竖线拼接已置位的标志名,便于日志与测试输出。
func (f Flags) String() string {
	names := [...]struct {
		bit  Flags
		name string
	}{
		{FlagRequest, "REQ"}, {FlagResponse, "RESP"}, {FlagPush, "PUSH"},
		{FlagCompressed, "COMP"}, {FlagEncrypted, "ENC"}, {FlagFragment, "FRAG"},
	}
	out := ""
	for _, n := range names {
		if f.Has(n.bit) {
			if out != "" {
				out += "|"
			}
			out += n.name
		}
	}
	if out == "" {
		return "0"
	}
	return out
}

// Header 是一条消息的固定头。Length 统计本头之后的字节数
// (即 cmd(2) + seq(4) + flags(1) + payload),不含 length 字段自身的 4 字节。
type Header struct {
	Length uint32 // 本头之后的字节数
	Cmd    Cmd    // 命令号
	Seq    uint32 // 连接内自增序号,用于请求-响应配对与去重
	Flags  Flags  // 标志位
}

// PayloadLen 由 Length 推出载荷长度。
func (h Header) PayloadLen() int {
	return int(h.Length) - (HeaderSize - 4)
}

// EncodeHeader 把固定头写进 dst 的前 HeaderSize 个字节,dst 长度必须足够。
func EncodeHeader(dst []byte, h Header) {
	_ = dst[HeaderSize-1] // 越界时在此处 panic,避免静默写坏后续字节
	binary.BigEndian.PutUint32(dst[0:4], h.Length)
	binary.BigEndian.PutUint16(dst[4:6], uint16(h.Cmd))
	binary.BigEndian.PutUint32(dst[6:10], h.Seq)
	dst[10] = byte(h.Flags)
}

// DecodeHeader 从 src 解出固定头,src 至少要有 HeaderSize 字节。
func DecodeHeader(src []byte) (Header, error) {
	if len(src) < HeaderSize {
		return Header{}, ErrShortFrame
	}
	return Header{
		Length: binary.BigEndian.Uint32(src[0:4]),
		Cmd:    Cmd(binary.BigEndian.Uint16(src[4:6])),
		Seq:    binary.BigEndian.Uint32(src[6:10]),
		Flags:  Flags(src[10]),
	}, nil
}

配套的 header_test.go 专门盯边界:命令号的上下界(0x0000 与 0xFFFF)、模块号与序号拆解不串扰、位运算只命中该命中的位,以及头往返编解码的逐字节比对。

// file: internal/proto/header_test.go
// tier: L1
// verify: go test ./internal/proto/
package proto

import (
	"bytes"
	"testing"
)

func TestCmdModuleSub(t *testing.T) {
	for _, c := range []struct{ m, s uint8 }{{0, 0}, {0xFF, 0xFF}, {ModuleScene, 1}, {ModuleChat, 0x80}} {
		got := MakeCmd(c.m, c.s)
		if got.Module() != c.m || got.Sub() != c.s {
			t.Fatalf("MakeCmd(%#x,%#x)=%#x 拆解错误", c.m, c.s, got)
		}
	}
	if MakeCmd(0, 0) != 0x0000 || MakeCmd(0xFF, 0xFF) != Cmd(0xFFFF) {
		t.Fatal("命令号上下界错误")
	}
}

func TestCmdGrouping(t *testing.T) {
	if CmdLoginReq.Module() != ModuleSystem || CmdMoveReq.Module() != ModuleScene {
		t.Fatal("命令号未落在声明的模块里")
	}
	seen := map[Cmd]bool{}
	for _, c := range []Cmd{CmdMoveReq, CmdMoveResp, CmdAoiAdd, CmdAoiRemove} {
		if seen[c] {
			t.Fatalf("ModuleScene 内命令号重复:%#x", c)
		}
		seen[c] = true
	}
}

func TestFlagsBitOps(t *testing.T) {
	var f Flags
	f = f.With(FlagRequest).With(FlagCompressed)
	if !f.Has(FlagRequest) || !f.HasAll(FlagRequest|FlagCompressed) {
		t.Fatalf("置位失败:%v", f)
	}
	if f.Has(FlagPush) {
		t.Fatal("未置位的 FlagPush 不应命中")
	}
	if got := f.Clear(FlagRequest); got.Has(FlagRequest) || !got.Has(FlagCompressed) {
		t.Fatalf("Clear 结果不对:%v", got)
	}
	if !f.Has(FlagRequest) {
		t.Fatal("With/Clear 不应修改原值")
	}
	if f.String() != "REQ|COMP" {
		t.Fatalf("String()=%q,期望 REQ|COMP", f.String())
	}
}

func TestHeaderRoundTrip(t *testing.T) {
	h := Header{Length: HeaderSize - 4 + 5, Cmd: CmdMoveReq, Seq: 0xDEADBEEF, Flags: FlagRequest | FlagEncrypted}
	buf := make([]byte, HeaderSize)
	EncodeHeader(buf, h)
	want := []byte{0x00, 0x00, 0x00, 0x0C, 0x02, 0x01, 0xDE, 0xAD, 0xBE, 0xEF, 0x11}
	if !bytes.Equal(buf, want) {
		t.Fatalf("编码字节\n got %x\nwant %x", buf, want)
	}
	got, err := DecodeHeader(buf)
	if err != nil || got != h || got.PayloadLen() != 5 {
		t.Fatalf("往返不一致:%+v err=%v", got, err)
	}
}

func TestDecodeHeaderShort(t *testing.T) {
	if _, err := DecodeHeader(make([]byte, HeaderSize-1)); err != ErrShortFrame {
		t.Fatalf("短缓冲应返回 ErrShortFrame,得到 %v", err)
	}
}

本机实跑:ok aegis/internal/proto,本节这 5 条全绿。注意 TestHeaderRoundTrip 里那一行手算的 want 字节——它是协议文档最诚实的形式:0x0C 是 length,0x0201 是命令号,0xDEADBEEF 是序号,末字节 0x11 是 FlagRequest | FlagEncrypted。

本节小结

  • 固定头 11 字节:length(4) | cmd(2) | seq(4) | flags(1),length 统计它自身之后的字节数。
  • 命令号 16 位劈成「高字节模块号 + 低字节序号」,路由与抓包都能直接读懂。
  • 标志位把 8 个布尔压进 1 字节,位运算原语全部返回新值、不改原值。
  • 不用 JSON 的字段名不是审美:键名每帧重发,解析也要乘以消息数。

下一节处理这个头引出的核心问题:长度前缀怎么把字节流切成帧,粘包与拆包到底怎么发生。

阅读导航:上一节:目录 · 下一节:3.2 长度前缀、粘包与拆包 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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