《MMO 服务器开发》7.3 网关的职责边界

网关是玩家看到的第一层,也是最容易被塞进玩法逻辑的一层:一旦它开始解析 payload、缓存血量、计算伤害,就再也拆不掉了。本节给 aegis 写一个只做四件事的 Gateway——准入、鉴权前置、限流、按命令号路由——并用一个「路由函数拿不到 payload」的签名,把边界从纪律变成机械约束。

7.3 网关的职责边界

前两节写的会话表、顶号、迁移,都发生在一个网关进程内部。这一节把镜头拉远,问一个架构问题:网关这个角色,整体上该负责到哪一步?

几乎所有 MMO 的网关最后都会腐化。上线初期它只转发消息,很干净;某个功能「顺便在网关缓存一下玩家血量」、另一个「顺手在网关算个伤害」、再来一个「网关里查一下背包省一次 RPC」——半年后网关变成了第二个游戏服,而且它没法扩容、没法灰度、出了 bug 谁都不敢改。腐化的根源不是工程师不守规矩,而是边界没有被写成代码,只写在文档和口头约定里。

本节给 aegis 写一个把边界变成类型签名的 Gateway:它只做四件事,而且这四件事在编译期就堵死了「顺手做玩法」的可能。所有代码真跑,含 -race。

网关该做什么:四件事

网关是网络的边缘,职责可以穷举为四件:

职责做什么为什么必须在网关
接入接受连接、握手、维护会话表玩家第一跳,别的层够不着 socket
鉴权前置在建连接之前校验 token,拒绝未授权登录未授权流量不该消耗任何后端资源
限流按账号/连接数限速,挡洪泛洪水在这一层拦最便宜(详见第 16 章)
路由转发按命令号把帧转给对应的后端服务只有网关知道「这条连接现在属于哪个后端」

四件事有一个共同特征:它们都只看帧头,不需要理解 payload。鉴权看的是登录帧里的 token,限流看的是帧的频率,路由看的是命令号。没有任何一件需要知道「这个 payload 里的伤害数值是多少」。这就是边界的判据:如果一件事需要读懂 payload 的语义,它就不属于网关。

从零实现:一个只有边界的 Gateway

把上面那句判据变成代码。Gateway 的 Route 回调签名是 func(cmd uint16) string——它拿不到 payload。这不是疏忽,是刻意的设计:只要路由函数看不见 payload,网关就不可能「顺手」在里面实现玩法。下面这段是 L1,go test 可以直接跑:

// file: internal/session/gateway.go
// tier: L1
// verify: go test ./internal/session/ -run TestGateway

package session

import (
	"errors"
	"sync"
)

var (
	ErrUnauthorized = errors.New("session: unauthorized")
	ErrRateLimited  = errors.New("session: rate limited")
	ErrNoRoute      = errors.New("session: no route")
)

// Gateway is the network edge. Its job is exactly four things: admit,
// authenticate, rate-limit, and route. It owns no gameplay state, so any
// command whose handling depends on the payload is a boundary violation.
type Gateway struct {
	table *Table
	// Auth decides admission before any Conn exists.
	Auth func(account, token string) bool
	// RatePerWindow is the per-account command budget; 0 disables limiting.
	RatePerWindow int
	// Route maps a command number to a backend name. It receives only cmd --
	// never the payload -- which makes the boundary mechanical.
	Route func(cmd uint16) string

	mu     sync.Mutex
	budget map[string]int
	routed map[string]int
}

// NewGateway wires a gateway onto a session table.
func NewGateway(table *Table, auth func(account, token string) bool, route func(cmd uint16) string) *Gateway {
	return &Gateway{
		table:  table,
		Auth:   auth,
		Route:  route,
		budget: make(map[string]int),
		routed: make(map[string]int),
	}
}

// Admit authenticates before a Conn is ever created. A rejected login never
// reaches the table and never reaches a gameplay backend.
func (g *Gateway) Admit(account, token string) (*Conn, error) {
	if g.Auth == nil || !g.Auth(account, token) {
		return nil, ErrUnauthorized
	}
	c := NewConn(nil)
	g.table.Bind(account, c)
	return c, nil
}

// OnCommand is the only path a gameplay command takes through the gateway. It
// rate-limits, then routes by cmd. It deliberately takes no payload: if the
// gateway cannot read the payload, it cannot accidentally implement gameplay.
func (g *Gateway) OnCommand(account string, cmd uint16) (string, error) {
	if !g.allow(account) {
		return "", ErrRateLimited
	}
	backend := ""
	if g.Route != nil {
		backend = g.Route(cmd)
	}
	if backend == "" {
		return "", ErrNoRoute
	}
	g.mu.Lock()
	g.routed[backend]++
	g.mu.Unlock()
	return backend, nil
}

func (g *Gateway) allow(account string) bool {
	g.mu.Lock()
	defer g.mu.Unlock()
	if g.RatePerWindow > 0 && g.budget[account] >= g.RatePerWindow {
		return false
	}
	g.budget[account]++
	return true
}

// Routed reports how many commands have been routed to backend.
func (g *Gateway) Routed(backend string) int {
	g.mu.Lock()
	defer g.mu.Unlock()
	return g.routed[backend]
}

注意 Admit 的顺序:先 Auth 校验,通过了才 NewConn 并进表。一个被拒绝的登录从未在会话表里出现过,也从未占用过后端资源。反过来如果先建连接再鉴权,攻击者就能用无效 token 把会话表灌满——这正是第 16 章「连接耗尽」要防的。

OnCommand 同样只返回后端名字,不做转发动作。真正的「把帧写进后端连接」由网关的主循环在拿到后端名之后做。把「决定去哪」和「怎么发过去」拆开,测试时就能只测决定逻辑,不用起后端。

限流:为什么是令牌桶而不是固定窗口

四件事里最容易被写错的是限流。很多实现用「固定窗口计数器」——每个账号每秒清零一次,超过 N 次就拒绝。它有一个天然漏洞:玩家在窗口末尾和下一个窗口开头各发一批,瞬间能发出 2N 次,刚好绕过限制。令牌桶没有这个问题,因为令牌是连续补充的,桶的容量(burst)就是硬上限。

Limiter 的接口刻意只有 Allow(key string) bool:它同样拿不到 payload,只看「这个 key 现在还能不能花一个令牌」。下面这段是 L1,测试用可注入的时钟真跑,不依赖 sleep:

// snippet: internal/session/limiter.go(节选:令牌桶 Allow)

type Limiter struct {
	rate  float64 // 每秒补充的令牌数
	burst float64 // 桶容量 = 允许的突发上限
	now   func() time.Time

	mu      sync.Mutex
	buckets map[string]*bucket
}

// Allow reports whether key may spend one token right now. It is the whole
// gateway rate limit: no payload, no per-command meaning, just a counter.
func (l *Limiter) Allow(key string) bool {
	l.mu.Lock()
	defer l.mu.Unlock()
	now := l.now()
	b, ok := l.buckets[key]
	if !ok {
		b = &bucket{tokens: l.burst, last: now}
		l.buckets[key] = b
	}
	b.tokens += now.Sub(b.last).Seconds() * l.rate
	if b.tokens > l.burst {
		b.tokens = l.burst
	}
	b.last = now
	if b.tokens < 1 {
		return false
	}
	b.tokens--
	return true
}

测试断言 NewLimiter(10, 3)(每秒 10 个令牌、突发上限 3)时:前 3 次调用全过,第 4 次被拒;把时钟拨快 100ms,正好补回 1 个令牌,于是只放行 1 次。这条测试把「令牌桶不会在窗口边界被绕过」这件事钉死了,而 now 可注入让它在毫秒级完成、不 flaky。

网关绝不该做什么

边界靠四件事说清楚,也靠一组「绝不做」说清楚:

  • 不碰玩法逻辑:不算伤害、不掉物品、不判定技能能否释放。这些是后端服务的事,网关里做等于把玩法复制了一份。
  • 不持有游戏状态:不缓存血量、背包、位置。网关要能随时重启而不丢玩家数据,一旦它持有状态,重启就意味着数据不一致。
  • 不解析 payload 语义:可以读帧头(长度、命令号、序号),但读 payload 内容做业务判断就越界了。
  • 不做持久化:网关不该写库。它无状态,才能横向扩容、才能被负载均衡随意调度。

这些「绝不做」在代码里的对应物,就是那些签名上根本拿不到 payload 的函数。下面这个测试把边界变成可执行的断言——同一个命令号,无论 payload 是什么,路由结果必须完全一致:

// snippet: internal/session/gateway_test.go(节选)

// routeByModule 只按命令号的模块字节路由,签名里没有 payload。
func routeByModule(cmd uint16) string {
	switch cmd >> 8 {
	case 0x01:
		return "world"
	case 0x02:
		return "scene"
	case 0x03:
		return "chat"
	default:
		return ""
	}
}

// TestGatewayRoutesWithoutPayload proves the boundary mechanically: the router
// signature has no payload argument, so two different payloads with the same
// cmd necessarily route identically.
func TestGatewayRoutesWithoutPayload(t *testing.T) {
	tab := NewTable()
	g := NewGateway(tab, func(_, _ string) bool { return true }, routeByModule)

	b1, err := g.OnCommand("alice", 0x0201) // CmdMoveReq
	if err != nil {
		t.Fatal(err)
	}
	b2, err := g.OnCommand("alice", 0x0302) // CmdChatRecv
	if err != nil {
		t.Fatal(err)
	}
	if b1 != "scene" || b2 != "chat" {
		t.Fatalf("routes = %q, %q; want scene, chat", b1, b2)
	}
	if _, err := g.OnCommand("alice", 0x00FF); err != ErrNoRoute {
		t.Fatalf("unroutable cmd err = %v, want ErrNoRoute", err)
	}
}

这里用的命令号(0x0201 CmdMoveReq、0x0302 CmdChatRecv)也全部来自 §3.5 的命令号表,网关不新增、不改写任何命令语义——它只做 cmd >> 8 取模块字节这一个动作。

边界是机械的,不是纪律的

最后强调一个方法论。靠文档写「网关不许碰玩法」,半年后一定失守;靠 code review 拦,也拦不住「就这一次」。真正能长期守住的做法是让越界在编译期就写不出来:路由函数拿不到 payload,网关结构体里没有任何指向玩家状态的字段,Admit 不返回后端句柄。想越界的人不是「不愿意」,是「做不到」——他必须先改签名,而改签名会在 review 里被所有人看见。

这条原则贯穿整个卷一:第 15 章的权威边界、第 16 章的防护边界,用的都是同一个套路——把架构约束翻译成类型约束。

本节小结

网关的职责可以一句话概括:只看帧头,不看帧身。四件事(接入、鉴权前置、限流、路由)都满足这条,任何需要读懂 payload 的事都该下沉到后端服务。本节把这条从「团队规范」升级成了「函数签名」,越界因此从「需要自觉」变成「需要改代码」。

go test ./internal/session/ -race 本节相关的 4 个测试(网关准入/路由/限流 + 令牌桶)全绿,整个 internal/session 包累计 11 个测试在 -race 下通过。至此第 7 章的会话表、顶号与网关边界都跑通了,下一章进入消息路由与分发——把网关转发的帧真正送进后端服务,并处理序号、去重与顺序。

阅读导航:上一节:7.2 顶号、踢人与连接迁移 · 下一节:8.1 路由表与请求-响应-推送 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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