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:
| 位 | 名称 | 含义 |
|---|---|---|
| 0 | FlagRequest | 客户端发起的请求 |
| 1 | FlagResponse | 服务端对某次请求的响应 |
| 2 | FlagPush | 服务端主动推送(无对应请求) |
| 3 | FlagCompressed | 载荷经压缩 |
| 4 | FlagEncrypted | 载荷经加密 |
| 5 | FlagFragment | 分片包(大消息被拆成多帧) |
位运算只提供四个原语: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 长度前缀、粘包与拆包 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。