8.1 路由表与请求-响应-推送
第 3 章把帧的字节格式定死了,第 7 章把「这条连接属于哪个会话」也理清了。但一条帧从 socket 读出来、拆好包之后,还差最后一步:它该交给谁处理。
这一步看起来平淡,却是整个服务端最容易长歪的地方。项目早期往往写成一个大 switch cmd,几百行后没人敢改;等到请求-响应、服务端推送、双向通知三种消息混在一起,switch 里开始出现「这个分支不回复」「那个分支回两个包」的隐性约定,客户端和服务端的理解就此分叉。这一节要把这种隐性约定变成一张显式的表。
做法是:定义三种消息形态,建一张「命令号 → handler」的表,给 handler 一个统一的上下文对象,然后让所有异常路径(未知命令、handler 报错)都退化成一条明确的错误通知。最后用一组 L1 测试真跑,证明请求-响应和推送两条路径都走得通。
与站内实战文章的分工
站内 game/server/practice/ 专题里有几篇与路由、扇出、命令管线直接相关的架构文。它们负责讲「方案怎么选、线上怎么踩坑」,本节负责「从零写出可运行的那一层」。分工如下:
| 站内既有文章 | 它负责的问题 | 本节负责的部分 |
|---|---|---|
/game-server-gateway-router-architecture-design/ | 网关与路由层的架构切分、后端如何寻址 | 从零写一张进程内的命令号路由表 |
/game-server-message-fanout-architecture-design/ | 消息扇出的选型与治理 | 只量化「编一次发 N 次」的代价(见 8.3) |
/game-server-combat-command-pipeline-architecture/ | 战斗命令管线的编排与交错问题 | 只做单命令的路由,管线编排留给玩法卷 |
/game-server-command-deduplication-architecture/ | 命令去重与业务幂等的方案 | 只做传输层序号去重(见 8.2),业务幂等见第 11 章 |
一句话:要看架构取舍去读那四篇,要一份能 go test 通过的实现读本节。
三种消息形态:请求-响应、推送、通知
把 MMO 里所有消息归一下类,其实只有三种:
| 形态 | 方向 | 是否期待回复 | 例子 |
|---|---|---|---|
| 请求-响应 | 客户端 → 服务端 | 是,恰好一条 | CmdMoveReq 0x0201 → CmdMoveResp 0x0202 |
| 服务端推送 | 服务端 → 客户端 | 否 | CmdAoiAdd 0x0203、CmdAttrSync 0x0103 |
| 双向通知 | 双向 | 否 | CmdChatSend 0x0301 / CmdChatRecv 0x0302 |
这三种形态的差别不是学究式的分类,而是契约:客户端收到 CmdMoveResp 时知道这是一次移动请求的结果,可以把 seq 对回去;收到 CmdAoiAdd 时知道这是服务端单方面通知「有人进了你的视野」,不需要也不该回复。把契约写进类型系统,客户端就不会为推送等一个永远不来的响应。
一个细节:同一个命令号只能属于一种形态。CmdChatSend 是通知(发出去不回包),服务端的回音是另一个命令号 CmdChatRecv。如果硬让一个命令号既能请求又能推送,客户端就无法从命令号判断该不该等回复,契约立刻失效。所以下面路由表的每个条目都记着自己的 Kind。
路由表:命令号到 handler
先看本节复用的帧类型。它逐字沿用第 3 章的 wire 格式(固定头 11 字节 length(4)|cmd(2)|seq(4)|flags(1)),只是把命令号常量在本章 module 内重新声明了一份——章与章之间不互相 import,每章自包含。下面是节选:
// snippet: internal/router/frame.go(节选)
// 固定头 11 字节:length(4)|cmd(2)|seq(4)|flags(1)
const (
HeaderSize = 11
MaxPayload = 1 << 20
)
// Cmd 是 16 位命令号:高字节是模块,低字节是模块内的命令。
type Cmd uint16
// 命令号沿用第 3 章的 internal/proto。
const (
CmdHeartbeatReq Cmd = 0x0001
CmdErrorNotify Cmd = 0x0003
CmdLoginReq Cmd = 0x0010
CmdEnterWorldReq Cmd = 0x0101
CmdAttrSync Cmd = 0x0103
CmdMoveReq Cmd = 0x0201
CmdMoveResp Cmd = 0x0202
CmdAoiAdd Cmd = 0x0203
CmdChatSend Cmd = 0x0301
CmdChatRecv Cmd = 0x0302
)
// Frame 是一条解码后的帧。Payload 别名解码缓冲区,跨 goroutine 前要复制。
type Frame struct {
Cmd Cmd
Seq uint32
Flags uint8
Payload []byte
}
路由表本身在下面这个文件里。它是 L1,go test 可以直接跑:
// file: internal/router/router.go
// tier: L1
// verify: go test ./internal/router/
package router
import (
"fmt"
"sync"
)
// Kind classifies a message by its direction and whether a reply is expected.
type Kind uint8
const (
// KindRequest is client -> server. The client expects exactly one response.
KindRequest Kind = iota
// KindPush is server -> client only. The client never replies to it.
KindPush
// KindNotify travels in either direction and never expects a response.
KindNotify
)
// Handler runs one decoded frame. A handler never touches the socket directly:
// it returns an error and the router turns it into a CmdErrorNotify frame, so
// every handler stays trivially testable against a fake connection.
type Handler func(*Context) error
// Conn is the minimal transport the router needs. A real *session.Session from
// chapter 7 satisfies it; tests use an in-memory fake that records frames.
type Conn interface {
ID() uint64
Send(cmd Cmd, seq uint32, flags uint8, payload []byte) error
}
// Context is the per-dispatch scratch space handed to a Handler. It carries the
// originating connection, the frame being handled, and the reply command id the
// table knows for this request.
type Context struct {
Conn Conn
Frame Frame
ReplyCmd Cmd
}
// Reply sends the response frame back to the originating connection, reusing
// the request's seq so the client can correlate the two. seq correlation is the
// subject of 8.2.
func (c *Context) Reply(payload []byte) error {
return c.Conn.Send(c.ReplyCmd, c.Frame.Seq, 0, payload)
}
// Push sends a server-initiated frame on the same connection.
func (c *Context) Push(cmd Cmd, payload []byte) error {
return c.Conn.Send(cmd, 0, 0, payload)
}
// Router maps command ids to handlers and dispatches decoded frames. The table
// is fixed after startup; a sync.RWMutex lets dispatch run fully parallel while
// registration stays safe.
type Router struct {
mu sync.RWMutex
handlers map[Cmd]entry
}
type entry struct {
kind Kind
reply Cmd
handler Handler
}
// New returns an empty Router.
func New() *Router { return &Router{handlers: make(map[Cmd]entry)} }
// HandleRequest registers a client request whose response command is reply.
func (r *Router) HandleRequest(cmd, reply Cmd, h Handler) {
r.mu.Lock()
defer r.mu.Unlock()
r.handlers[cmd] = entry{kind: KindRequest, reply: reply, handler: h}
}
// HandleNotify registers a fire-and-forget command (either direction).
func (r *Router) HandleNotify(cmd Cmd, h Handler) {
r.mu.Lock()
defer r.mu.Unlock()
r.handlers[cmd] = entry{kind: KindNotify, handler: h}
}
// HandlePush registers a server-to-client push command. Keeping pushes in the
// same table means a single place can enumerate everything the server may send.
func (r *Router) HandlePush(cmd Cmd, h Handler) {
r.mu.Lock()
defer r.mu.Unlock()
r.handlers[cmd] = entry{kind: KindPush, handler: h}
}
// Dispatch routes one frame to its handler. An unknown command id, or a handler
// that returns an error, produces a CmdErrorNotify frame instead of a panic, so
// a hostile or stale client cannot take down the read loop.
func (r *Router) Dispatch(c Conn, f Frame) error {
r.mu.RLock()
e, ok := r.handlers[f.Cmd]
r.mu.RUnlock()
if !ok {
msg := fmt.Sprintf("unknown cmd 0x%04x", uint16(f.Cmd))
return c.Send(CmdErrorNotify, f.Seq, 0, []byte(msg))
}
ctx := &Context{Conn: c, Frame: f, ReplyCmd: e.reply}
if err := e.handler(ctx); err != nil {
return c.Send(CmdErrorNotify, f.Seq, 0, []byte(err.Error()))
}
return nil
}
// KindOf reports how a command was registered, for tests and tooling.
func (r *Router) KindOf(cmd Cmd) (Kind, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
e, ok := r.handlers[cmd]
return e.kind, ok
}
// Len reports how many commands are registered.
func (r *Router) Len() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.handlers)
}
三个设计决定值得单独说。第一,handler 不直接写 socket,只返回 error;写字节这一步由 Context.Reply / Context.Push 代劳。这样 handler 面对的是一个 Conn 接口,测试里换成假连接就能断言「它到底发了什么、seq 对不对」,完全不需要真起端口。第二,未知命令不 panic,而是回一条 CmdErrorNotify。老版本客户端发来一个服务端已经删掉的命令号,是线上常态;读循环因为一个 switch 漏分支而崩掉,是事故。第三,回包复用请求的 seq。Context.Reply 用 c.Frame.Seq 而不是新编号,客户端才能把响应和请求对上——这正是 8.2 要展开的序号语义。
L1 真跑:请求-响应与推送两条路径
测试不 mock 任何东西,用一个记录所有出站帧的假连接。核心断言是:发一条 CmdMoveReq,服务端必须回一条 CmdMoveResp,且 seq 原样带回来;发一个没注册的命令号,必须回 CmdErrorNotify 而不是崩。
// snippet: internal/router/router_test.go(节选)
type fakeConn struct {
id uint64
mu sync.Mutex
sent []Frame
}
func (c *fakeConn) ID() uint64 { return c.id }
func (c *fakeConn) Send(cmd Cmd, seq uint32, flags uint8, payload []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
c.sent = append(c.sent, Frame{Cmd: cmd, Seq: seq, Flags: flags, Payload: append([]byte(nil), payload...)})
return nil
}
func TestDispatchRequestReply(t *testing.T) {
r := New()
r.HandleRequest(CmdMoveReq, CmdMoveResp, func(ctx *Context) error {
return ctx.Reply([]byte("moved"))
})
c := &fakeConn{id: 1}
if err := r.Dispatch(c, Frame{Cmd: CmdMoveReq, Seq: 42}); err != nil {
t.Fatalf("dispatch: %v", err)
}
got, ok := c.last()
if !ok || got.Cmd != CmdMoveResp {
t.Fatalf("reply = %+v, want CmdMoveResp", got)
}
if got.Seq != 42 {
t.Fatalf("reply seq = %d, want the request seq 42", got.Seq)
}
}
func TestUnknownCommandYieldsErrorNotify(t *testing.T) {
r := New()
c := &fakeConn{id: 7}
_ = r.Dispatch(c, Frame{Cmd: Cmd(0x7fff), Seq: 5})
got, _ := c.last()
if got.Cmd != CmdErrorNotify || got.Seq != 5 {
t.Fatalf("want an error notify with seq 5, got %+v", got)
}
}
本机实跑(Go 1.26,macOS arm64):
$ go vet ./... && go test ./internal/router/
ok aegis/internal/router 0.704s
17 个用例全绿,覆盖请求-响应、未知命令、handler 报错转通知、Context.Push、KindOf,以及帧的编解码往返。
本节小结
路由表要解决的其实是一件事:把「这条帧归谁管」从代码里的隐性约定,变成一张能被枚举、能被测试的表。三种形态(请求-响应、推送、通知)是表的骨架,Kind 是写进类型的契约,Context 是 handler 与网络之间的唯一接口。做到这三点,后面加命令号就是往表里插一行,而不是往 switch 里塞一个分支。
下一节处理路由之前必须解决的另一个问题:客户端和服务端之间,怎么用序号判断「这条消息我是不是已经处理过了」。
阅读导航:上一节:目录 · 下一节:8.2 消息序号、去重与顺序保证 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。