17.2 登录、移动与广播端到端
上一节把零件装配成了可执行文件,但「能启动」离「能用」还差一整条业务链路。这一节要回答一个具体问题:三个客户端连上来,登录进世界、各自走两步,它们能不能互相看见? 这正是 MMO 最核心的那件事——不是「我动了」,而是「别人看见我动了」。
这件事难在三处。第一是状态归属:谁改位置、谁判合法、谁决定谁能看见谁,如果分散在多个 goroutine 里,就必须到处加锁。第二是权威边界:客户端说「我在 (9999, 0)」,服务端凭什么信?第三是广播成本:把每个位置变化告诉所有人是 O(N²),几百人就是灾难。
本节的做法:把整个世界做成一个 actor,用单 goroutine 串行所有状态变更,从而一把锁都不用;位置由服务端裁决,速度超限直接拒绝;广播只发集合差,用一张均匀网格把「谁能看见谁」压到常数时间。代码仍在 /tmp/mmobook/ch/ch17/。
世界是一个 actor:单 goroutine 串行所有状态变更
第 10 章讲过消息驱动的实体,这里把它放大到整个「世界」:World 内部有 sessions、byName、grid 三张表,它们只被一个 goroutine 读写。每条连接有一个读循环,读到的帧不直接处理,而是投进世界的邮箱 inbox;世界 goroutine 从邮箱里一条条取出来,串行处理。下面这段是 L2 的核心,go test ./internal/gateway/ 会真起监听并跑通:
// snippet: internal/gateway/world.go(世界 goroutine 与事件分派)
// event 是投进世界邮箱的一条消息。所有世界状态变更都由世界 goroutine
// 串行消费这些事件完成,因此 World 内部无需任何锁。
type event struct {
kind eventKind
sess *Session
frame proto.Frame
}
// World 是 aegis 的权威世界:一个 actor,所有消息进它的邮箱,单线程处理。
type World struct {
cfg Config
ln net.Listener
router *Router
sched *Scheduler
grid *Grid
inbox chan event
stopped chan struct{}
wg sync.WaitGroup
// 以下状态只由世界 goroutine 读写。
sessions map[uint64]*Session
byName map[string]*Session
nextID uint64
}
// run 是世界 goroutine:串行消费邮箱事件与定时任务,直到收到关停事件。
func (w *World) run() {
defer w.wg.Done()
defer close(w.stopped)
ticker := time.NewTicker(w.cfg.Tick)
defer ticker.Stop()
for {
select {
case e := <-w.inbox:
if e.kind == evShutdown {
w.closeAll()
return
}
w.handle(e)
case now := <-ticker.C:
w.sched.Run(now) // 心跳清扫等周期任务,也在同一个 goroutine 里跑
}
}
}
// handle 分派一条事件。它在世界 goroutine 里运行,可安全改状态。
func (w *World) handle(e event) {
switch e.kind {
case evConnect:
w.onConnect(e.sess)
case evFrame:
w.router.Dispatch(e.sess, e.frame)
case evDisconnect:
w.onDisconnect(e.sess)
}
}
读循环 serveConn 里没有任何业务:它 proto.NewDecoder(conn) 逐帧读出,更新一次活跃时间,然后 w.post(event{kind: evFrame, ...})。帧的解析在连接 goroutine,帧的处理在世界 goroutine——这条边界是「一把锁都不用」的全部秘密。
代价也必须说清:世界 goroutine 是单点,所有帧的处理能力加总就是它的吞吐上限。好处是逻辑永远串行,sessions 的并发 bug 从根上消失;坏处是它一旦被某个 handler 卡住,全世界都停。所以 send 到客户端时用的是非阻塞投递 + 丢弃计数,绝不在世界 goroutine 里等一个慢客户端。
权威移动:先校验,再广播
移动请求 CmdMoveReq 的载荷是目标坐标 {x, y}。服务端不会无条件采纳——它先算「这次位移除以时间」得到速度,超过 MaxSpeed 就拒绝并回 CmdErrorNotify。通过之后才改状态,并把变化广播给邻居。
// snippet: internal/gateway/world.go(权威移动与 AOI 广播)
// onMove 是权威移动的入口:先做速度校验,通过后再改状态并向 AOI 邻居广播。
func (w *World) onMove(s *Session, f proto.Frame) {
if !s.inWorld {
s.send(errorFrame(f.Seq, "move before enter world"))
return
}
var req moveReq
if err := json.Unmarshal(f.Payload, &req); err != nil {
s.send(errorFrame(f.Seq, "bad move payload"))
return
}
to := Vec3{X: req.X, Y: req.Y}
now := time.Now()
dt := now.Sub(s.lastMoveAt).Seconds()
if s.lastMoveAt.IsZero() || dt < 0.02 {
dt = 0.02 // 首个移动或同 tick 内连发,给 dt 一个下限,避免误判
}
if d := distance(s.Pos, to); d/dt > w.cfg.MaxSpeed {
log.Printf("[world] #%d 移动被拒: %.2f 单位 / %.0f ms 超过 %.0f 单位/s",
s.ID, d, dt*1000, w.cfg.MaxSpeed)
s.send(errorFrame(f.Seq, "move rejected: speed"))
return
}
from := s.Pos
w.grid.Move(s, from, to)
s.Pos = to
s.lastMoveAt = now
body, _ := json.Marshal(moveResp{OK: true, X: to.X, Y: to.Y})
s.send(proto.Frame{Cmd: proto.CmdMoveResp, Seq: f.Seq, Payload: body})
w.fanoutMove(s, from, to)
}
// fanoutMove 比较移动前后的可见集,只对「新进入」和「已离开」的实体发通知。
func (w *World) fanoutMove(s *Session, from, to Vec3) {
oldN := w.neighborSet(from, s.ID)
newN := w.neighborSet(to, s.ID)
for id := range newN {
if oldN[id] {
continue
}
if other := w.sessions[id]; other != nil {
other.send(aoiAddFrame(s)) // 对方开始能看见我
s.send(aoiAddFrame(other)) // 我开始能看见对方
w.metrics.AoiAdds.Add(2)
}
}
for id := range oldN {
if newN[id] {
continue
}
if other := w.sessions[id]; other != nil {
other.send(aoiRemoveFrame(s.ID))
s.send(aoiRemoveFrame(id))
w.metrics.AoiRemoves.Add(2)
}
}
}
广播的窍门全在「只发集合差」:移动前可见的集合是 oldN,移动后是 newN,只对 newN - oldN(新进入视野)发 CmdAoiAdd,只对 oldN - newN(离开视野)发 CmdAoiRemove。如果每次都把附近所有人重发一遍,带宽会随移动频率线性膨胀。
neighborSet 背后是一张均匀网格(第 12 章):单元边长等于 AOIRadius,查邻居只扫当前单元加周围 8 格(3×3)。单元边长必须 ≥ 兴趣半径,否则第 2 圈的邻居会被漏掉。网格本身的可见判定由 TestGridNeighbors 钉住,go test ./internal/gateway/ -run TestGridNeighbors 可直接跑。
端到端 L2 断言:三个客户端互相看见
自动化的验收不看日志,只看断言。下面这个测试真起一个监听端口、真连三个客户端,断言「每个都看到另外两个」,再让其中一个走远、断言其余收到它的移除:
// snippet: internal/gateway/world_test.go(核心 L2 断言节选)
// TestLoginMoveBroadcast 是本章的核心 L2 断言:三个客户端登录进世界后,
// 每个都能看到另外两个;其中一个走远后,其余收到它的 AOI 移除。
func TestLoginMoveBroadcast(t *testing.T) {
w := startWorld(t, Config{MaxSpeed: 1e6, AOIRadius: 30})
addr := w.Addr().String()
clients := map[string]*tclient{}
ids := map[string]uint64{}
for _, name := range []string{"alpha", "beta", "gamma"} {
c := dialT(t, addr)
ids[name] = loginT(t, c, name)
clients[name] = c
}
for _, name := range []string{"alpha", "beta", "gamma"} {
enterT(t, clients[name])
}
// 每个客户端都应看到另外两个(去重后)。
for _, name := range []string{"alpha", "beta", "gamma"} {
seen := map[uint64]bool{}
for _, f := range clients[name].collect(300 * time.Millisecond) {
if f.Cmd != proto.CmdAoiAdd {
continue
}
var e struct {
ID uint64 `json:"id"`
}
_ = json.Unmarshal(f.Payload, &e)
seen[e.ID] = true
}
if len(seen) != 2 {
t.Fatalf("%s 可见 %d 个邻居,期望 2(ids=%v)", name, len(seen), ids)
}
}
// gamma 跳到远处(超过 AOIRadius),alpha 与 beta 应收到它的 AOI 移除。
clients["gamma"].call(proto.CmdMoveReq, mustJSONT(map[string]float64{"x": 500, "y": 0}), proto.CmdMoveResp)
for _, name := range []string{"alpha", "beta"} {
got := false
for _, f := range clients[name].collect(300 * time.Millisecond) {
if f.Cmd != proto.CmdAoiRemove {
continue
}
var e struct {
ID uint64 `json:"id"`
}
_ = json.Unmarshal(f.Payload, &e)
if e.ID == ids["gamma"] {
got = true
}
}
if !got {
t.Fatalf("%s 未收到 gamma(#%d) 的 AOI 移除", name, ids["gamma"])
}
}
}
这里有个容易踩的细节:客户端收到的帧是混在一起的——CmdAoiAdd、CmdEnterWorldResp、CmdMoveResp 可能先后到达。测试客户端因此按 (cmd, seq) 匹配应答,把不匹配的帧缓存起来(pending),断言时再一次性收走,而不是假设「请求下一帧一定是应答」。
真起三个进程跑一遍
单测在进程内起监听,已经算 L2;但书稿要的是真二进制。把 world 与 gateway 各起一个进程,world 监听 127.0.0.1:19002、gateway 监听 19001 并转发到 world,再让机器人跑冒烟:
$ ./bin/aegis -role=world -listen 127.0.0.1:19002 -aoi-radius 30 &
$ ./bin/aegis -role=gateway -listen 127.0.0.1:19001 -upstream 127.0.0.1:19002 &
$ ./bin/bot -mode=smoke -addr 127.0.0.1:19001
[alpha] 登录应答 {"ok":true,"id":1,"name":"alpha"}
[beta] 登录应答 {"ok":true,"id":2,"name":"beta"}
[gamma] 登录应答 {"ok":true,"id":3,"name":"gamma"}
[alpha] 进入世界 (x=8.0)
[beta] 进入世界 (x=16.0)
[gamma] 进入世界 (x=24.0)
[alpha] 可见 2 个邻居: [beta gamma]
[beta] 可见 2 个邻居: [alpha gamma]
[gamma] 可见 2 个邻居: [alpha beta]
[alpha] 合法移动 -> MoveResp
[alpha] 瞬移到 x=9999 -> 被拒(ErrorNotify)
smoke OK
world 侧的日志把每一步都记了下来,包括那次被拒的瞬移:
[world] #1 "alpha" 进入世界 (8.0, 0.0)
[world] #2 "beta" 进入世界 (16.0, 0.0)
[world] #3 "gamma" 进入世界 (24.0, 0.0)
[world] #1 移动被拒: 9990.90 单位 / 20 ms 超过 12 单位/s
三个客户端在 X 轴上以 8 个单位间隔出生(spawnSpacing = 8),彼此都在半径 30 内,所以每个人进世界时就和先到的人互相 AoiAdd。alpha 从 x=8 合法挪到 8.1 被接受;接着想瞬移到 x=9999,位移 9990 个单位、用时 20 毫秒,折算速度远超 12 单位/秒,直接被拒。
实测结论
本机(Go 1.26,macOS arm64)跑出来的断言结果,同机相对量级,绝对数值不可移植:
| 断言 | 结果 |
|---|---|
| 三个客户端互相可见 | 各收到 2 条 CmdAoiAdd,世界累计 adds=6 |
| 走远后互相不可见 | 各收到 1 条 CmdAoiRemove,世界累计 removes=3 |
| 瞬移被拒 | 回 CmdErrorNotify:move rejected: speed |
| 合法小位移被接受 | 回 CmdMoveResp{ok:true} |
| 静默连接回收 | 200ms 无帧后心跳清扫关闭,live 回到 0 |
一句话:「登录 → 进世界 → 移动 → 彼此可见 → 越界移除」这条链路是真跑通的,不是靠 mock 拼出来的。下一节把它换成压测机器人,看看这套单 goroutine 的世界能扛多少连接。
阅读导航:上一节:17.1 把前 16 章拼成 aegis · 下一节:17.3 机器人客户端与第一组承载数字 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。