1.3 一个 MMO 服务端最少要有哪些零件
前两节把边界划好了:四层、依赖只能向下、本书只补「从零实现」那一半。这一节做一件更朴素的事——把「MMO 服务端」拆到不能再拆,看它至少需要几个零件才能跑起来。拆得够狠,你才能看清后面 17 章到底在补哪一块。
判断标准只有一条:删掉这个零件,服务器还能不能跑通「登录 → 移动 → 看到别人」这条最小闭环。能,它就是可选的;不能,它就是必需的。按这个标准筛完,aegis 剩下十个零件。
这十个零件不是并列的——有的是地基(没有它后面都无从谈起),有的是「能跑之后才需要」(比如安全防护、压测工具)。但十个都值得在开工前先点名,因为它们决定了后面每一章的边界:每引入一个新模块,都要能回答「它是这十个里的哪一个」。
十个零件
逐个点名。每个零件一句话说清它解决什么,以及它在最小闭环里扮演什么角色:
- 协议编解码 Codec:把网络上的字节流变成内存里的结构体,再变回去。没有它,服务端只认识一堆
[]byte。最小版本是一个「长度前缀 + 命令号 + 定长头」的编解码器。 - 连接与会话 Session:把「一条 socket」升级成「一个玩家」。连接是易失的,会话是它背后的身份——谁在线、上次心跳什么时候、断线后能不能接回来。
- 网关 Gateway:接住成千上万条连接、鉴权、限速、把消息转给后面的层。它是唯一直接和 socket 打交道的一层,也是唯一能水平复制的一层。
- 消息路由 Router:把「命令号 0x0102」映射到「处理这个请求的函数」。没有它,
switch会长到没人敢改。 - 定时器 Timer:心跳超时踢人、技能冷却、世界 Tick,全靠它。最小版本是一个能按到期时间批量触发回调的调度器。
- Actor / 邮箱 Mailbox:让同一个实体(一个玩家、一个 NPC、一个房间)的所有消息串行处理,天然免锁。邮箱是它的输入队列,也是背压的发生点。
- 幂等 Idempotency:网络会重传、客户端会重试。同一个「买道具」请求收到两次,不能扣两次钱。最小版本是一个「请求编号 + 时间窗」的去重表。
- 同步模型 Sync:把权威世界状态发给客户端。状态同步还是帧同步、发快照还是发增量、给谁发(兴趣过滤),都在这里定。
- 安全防护 Guard:畸形包、洪泛、连接耗尽、封包篡改。它是「能跑之后」才补的,但设计时就要留位置。
- 压测工具 Load Test:一个能模拟成百上千个客户端的机器人程序。没有它,你无法证明前面九个零件真的能扛。
零件不是并列的:必需、可选与分工
这十个零件里,前八个是「跑通最小闭环」的必需件,后两个(安全防护、压测工具)是「上线前」的必需件、但在开发早期可以先欠着。下表给出每个零件由本书哪一章负责实现——它同时也是本卷的路线图:
| 零件 | 解决什么 | 必需 | 本书哪一章 |
|---|---|---|---|
| 协议编解码 | 字节 ↔ 结构体 | 是 | 第 3–4 章 |
| 连接与会话 | 谁在线、连接生命周期 | 是 | 第 6–7 章 |
| 网关 | 接入、转发、限流 | 是 | 第 7 章 |
| 消息路由 | 命令号 → 处理函数 | 是 | 第 8 章 |
| 定时器 | 心跳超时、冷却、Tick | 是 | 第 9 章 |
| Actor / 邮箱 | 实体串行、背压 | 是 | 第 10 章 |
| 幂等 | 重放/重试不重复执行 | 是 | 第 11 章 |
| 同步模型 | 快照、增量、兴趣过滤 | 是 | 第 12–13 章 |
| 安全防护 | fuzz、洪泛、加密签名 | 上线前 | 第 15–16 章 |
| 压测工具 | 机器人客户端与承载数字 | 上线前 | 第 17 章 |
这张表最想让你记住的是:「必需」不等于「简单」。协议编解码看起来最不起眼,却决定了后面所有零件的形状——长度前缀设计错了,粘包拆包会一路坑到第 6 章;命令号编码错了,路由表会在第 8 章推倒重来。所以本卷从第 2 章开始就按「零件」而不是按「概念」来组织,每章交付一个能测的零件。
可运行产物:把零件清单做成可校验的检查器
上面那张表是给人看的,但「还差哪些零件」这件事如果只靠脑子记,很快会乱。所以本节把清单做成一个可运行的检查器:它知道每个零件属于哪一章、是否必需,能告诉你「现在还卡在哪几个零件上」。这样「进度」就变成一条可以被测试断言的失败用例。
下面这段是 L1,go test 可以直接跑。先看 internal/overview/checklist.go:
// file: internal/overview/checklist.go
// tier: L1
// verify: go test ./internal/overview/
// Package overview also models the "minimum parts" question from section 1.3:
// what does an MMO server need before it can boot at all? The checklist turns
// that prose list into data a test can check, so the roadmap and the code
// cannot drift apart.
package overview
import (
"fmt"
"sort"
"strings"
)
// Status is how far a part has been built.
type Status int
const (
// Missing means the part does not exist yet.
Missing Status = iota
// Stubbed means there is a placeholder with no real behaviour.
Stubbed
// Ready means the part works and is covered by a test.
Ready
)
func (s Status) String() string {
switch s {
case Missing:
return "missing"
case Stubbed:
return "stubbed"
case Ready:
return "ready"
}
return "unknown"
}
// Part is one building block of an MMO server. Tier records which chapter of
// volume one is meant to build it, so the checklist doubles as a roadmap.
type Part struct {
ID string // stable key, e.g. "codec"
Name string // human label, e.g. "协议编解码"
Tier string // owning chapter, e.g. "第 3–4 章"
Required bool // without it the server cannot boot
Status Status
}
// Checklist is an ordered, addressable set of parts.
type Checklist struct {
order []string
byID map[string]*Part
}
// NewChecklist returns an empty checklist.
func NewChecklist() *Checklist { return &Checklist{byID: map[string]*Part{}} }
// Add inserts a part, rejecting duplicate or empty IDs so the roadmap cannot
// silently contain the same block twice.
func (c *Checklist) Add(p Part) error {
if p.ID == "" {
return fmt.Errorf("overview: part with empty ID")
}
if _, ok := c.byID[p.ID]; ok {
return fmt.Errorf("overview: duplicate part %q", p.ID)
}
cp := p
c.byID[p.ID] = &cp
c.order = append(c.order, p.ID)
return nil
}
// Set updates the status of an existing part.
func (c *Checklist) Set(id string, s Status) error {
p, ok := c.byID[id]
if !ok {
return fmt.Errorf("overview: unknown part %q", id)
}
p.Status = s
return nil
}
// Get returns a copy of a part by ID.
func (c *Checklist) Get(id string) (Part, bool) {
p, ok := c.byID[id]
if !ok {
return Part{}, false
}
return *p, true
}
// Blockers lists the required parts that are not Ready, in insertion order.
func (c *Checklist) Blockers() []Part {
var out []Part
for _, id := range c.order {
if p := c.byID[id]; p.Required && p.Status != Ready {
out = append(out, *p)
}
}
return out
}
// Ready reports whether every required part is Ready.
func (c *Checklist) Ready() bool { return len(c.Blockers()) == 0 }
// Report renders the checklist as a fixed-width table, required parts first.
func (c *Checklist) Report() string {
parts := make([]Part, 0, len(c.order))
for _, id := range c.order {
parts = append(parts, *c.byID[id])
}
sort.SliceStable(parts, func(i, j int) bool {
return parts[i].Required && !parts[j].Required
})
var b strings.Builder
b.WriteString("part tier status req\n")
b.WriteString("------------------------------------------\n")
for _, p := range parts {
req := "no"
if p.Required {
req = "yes"
}
fmt.Fprintf(&b, "%-15s %-12s %-8s %s\n", p.ID, p.Tier, p.Status, req)
}
return b.String()
}
// AegisChecklist is the ten parts a minimal MMO server needs, wired to the
// chapter of volume one meant to build each one. It is the machine-readable
// version of the parts table in section 1.3.
func AegisChecklist() *Checklist {
c := NewChecklist()
for _, p := range []Part{
{ID: "codec", Name: "协议编解码", Tier: "第 3–4 章", Required: true},
{ID: "session", Name: "连接与会话", Tier: "第 6–7 章", Required: true},
{ID: "gateway", Name: "网关", Tier: "第 7 章", Required: true},
{ID: "router", Name: "消息路由", Tier: "第 8 章", Required: true},
{ID: "timer", Name: "定时器", Tier: "第 9 章", Required: true},
{ID: "actor", Name: "Actor/邮箱", Tier: "第 10 章", Required: true},
{ID: "idempotency", Name: "幂等", Tier: "第 11 章", Required: false},
{ID: "sync", Name: "同步模型", Tier: "第 12–13 章", Required: true},
{ID: "guard", Name: "安全防护", Tier: "第 15–16 章", Required: false},
{ID: "loadtest", Name: "压测工具", Tier: "第 17 章", Required: false},
} {
// IDs are unique constants, so Add cannot fail here.
_ = c.Add(p)
}
return c
}
配套测试 internal/overview/checklist_test.go 断言:清单恰好十个零件;只把可选件标成 Ready 不会让整张清单通过;必需件全绿后清单才 Ready:
// file: internal/overview/checklist_test.go
// tier: L1
// verify: go test ./internal/overview/
package overview
import (
"strings"
"testing"
)
func TestAegisChecklistHasTenParts(t *testing.T) {
c := AegisChecklist()
if got := len(c.order); got != 10 {
t.Fatalf("parts = %d, want 10", got)
}
for _, id := range []string{"codec", "session", "gateway", "router", "timer", "actor", "idempotency", "sync", "guard", "loadtest"} {
if _, ok := c.Get(id); !ok {
t.Fatalf("missing part %q", id)
}
}
}
func TestBlockedUntilRequiredPartsDone(t *testing.T) {
c := AegisChecklist()
if c.Ready() {
t.Fatal("fresh checklist should not be ready")
}
// Optional parts being Ready must not unblock the checklist.
for _, id := range []string{"idempotency", "guard", "loadtest"} {
if err := c.Set(id, Ready); err != nil {
t.Fatalf("Set(%s): %v", id, err)
}
}
if c.Ready() {
t.Fatal("checklist must still be blocked by required parts")
}
blockers := c.Blockers()
if len(blockers) != 7 {
t.Fatalf("blockers = %d, want 7", len(blockers))
}
for _, b := range blockers {
if !b.Required {
t.Fatalf("blocker %q is not required", b.ID)
}
}
}
func TestReadyWhenAllRequiredDone(t *testing.T) {
c := AegisChecklist()
for _, id := range []string{"codec", "session", "gateway", "router", "timer", "actor", "sync"} {
if err := c.Set(id, Ready); err != nil {
t.Fatalf("Set(%s): %v", id, err)
}
}
if !c.Ready() {
t.Fatalf("checklist should be ready, blockers=%v", c.Blockers())
}
}
func TestRejectsDuplicateAndUnknown(t *testing.T) {
c := NewChecklist()
if err := c.Add(Part{ID: "codec", Required: true}); err != nil {
t.Fatalf("first Add: %v", err)
}
if err := c.Add(Part{ID: "codec"}); err == nil {
t.Fatal("expected duplicate error, got nil")
}
if err := c.Set("nope", Ready); err == nil {
t.Fatal("expected unknown-part error, got nil")
}
}
func TestReportPutsRequiredFirst(t *testing.T) {
c := AegisChecklist()
_ = c.Set("loadtest", Ready) // optional, but ready
report := c.Report()
ic := strings.Index(report, "codec")
il := strings.Index(report, "loadtest")
if ic < 0 || il < 0 || ic > il {
t.Fatalf("expected required part first, report=\n%s", report)
}
}
本机实跑结果(go test ./internal/overview/ -v,节选):
--- PASS: TestAegisChecklistHasTenParts (0.00s)
--- PASS: TestBlockedUntilRequiredPartsDone (0.00s)
--- PASS: TestReadyWhenAllRequiredDone (0.00s)
--- PASS: TestRejectsDuplicateAndUnknown (0.00s)
--- PASS: TestReportPutsRequiredFirst (0.00s)
PASS
ok aegis/internal/overview 0.324s
注意 TestBlockedUntilRequiredPartsDone 断言的是「可选件全绿也不放行」——它先把幂等、安全防护、压测工具三个可选件标成 Ready,清单仍然 Ready() == false,卡在 7 个必需件上。这正是这份清单要守的不变量:别让「我已经写了几个零件」的错觉,掩盖「还差 7 个必需件才能跑」的事实。
本节小结
这一节把「一个 MMO 服务端最少需要什么」落成了一份可执行的清单:
- 十个零件:协议编解码、连接与会话、网关、消息路由、定时器、Actor 邮箱、幂等、同步模型、安全防护、压测工具。
- 必需与可选的区分:前八个跑通最小闭环,后两个上线前补齐,但每个零件都由本卷的某一章负责实现。
- 清单是可校验的:
AegisChecklist()把路线图变成数据,测试守住「必需件没做完就不算 Ready」这条不变量。
第 1 章到此结束。下一章从最底下的一层开始——网络 I/O 与并发模型:先讲清阻塞、非阻塞与 Reactor 的区别,再看 Go 的 goroutine-per-connection 为什么能写得这么简单。
阅读导航:上一节:1.2 本书范围与站内分工 · 下一节:2.1 阻塞、非阻塞与 Reactor 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。