《MMO 服务器开发》1.3 一个 MMO 服务端最少要有哪些零件

一个 MMO 服务端最少要有哪些零件?答案是十类:协议编解码、连接与会话、网关、消息路由、定时器、Actor 邮箱、幂等、同步模型、安全防护、压测工具。本节用一份可运行的 Go 检查器把这十个零件建模成可校验的清单,并给出每个零件由本书哪一章负责实现。

1.3 一个 MMO 服务端最少要有哪些零件

前两节把边界划好了:四层、依赖只能向下、本书只补「从零实现」那一半。这一节做一件更朴素的事——把「MMO 服务端」拆到不能再拆,看它至少需要几个零件才能跑起来。拆得够狠,你才能看清后面 17 章到底在补哪一块。

判断标准只有一条:删掉这个零件,服务器还能不能跑通「登录 → 移动 → 看到别人」这条最小闭环。能,它就是可选的;不能,它就是必需的。按这个标准筛完,aegis 剩下十个零件。

这十个零件不是并列的——有的是地基(没有它后面都无从谈起),有的是「能跑之后才需要」(比如安全防护、压测工具)。但十个都值得在开工前先点名,因为它们决定了后面每一章的边界:每引入一个新模块,都要能回答「它是这十个里的哪一个」。

十个零件

逐个点名。每个零件一句话说清它解决什么,以及它在最小闭环里扮演什么角色:

  1. 协议编解码 Codec:把网络上的字节流变成内存里的结构体,再变回去。没有它,服务端只认识一堆 []byte。最小版本是一个「长度前缀 + 命令号 + 定长头」的编解码器。
  2. 连接与会话 Session:把「一条 socket」升级成「一个玩家」。连接是易失的,会话是它背后的身份——谁在线、上次心跳什么时候、断线后能不能接回来。
  3. 网关 Gateway:接住成千上万条连接、鉴权、限速、把消息转给后面的层。它是唯一直接和 socket 打交道的一层,也是唯一能水平复制的一层。
  4. 消息路由 Router:把「命令号 0x0102」映射到「处理这个请求的函数」。没有它,switch 会长到没人敢改。
  5. 定时器 Timer:心跳超时踢人、技能冷却、世界 Tick,全靠它。最小版本是一个能按到期时间批量触发回调的调度器。
  6. Actor / 邮箱 Mailbox:让同一个实体(一个玩家、一个 NPC、一个房间)的所有消息串行处理,天然免锁。邮箱是它的输入队列,也是背压的发生点。
  7. 幂等 Idempotency:网络会重传、客户端会重试。同一个「买道具」请求收到两次,不能扣两次钱。最小版本是一个「请求编号 + 时间窗」的去重表。
  8. 同步模型 Sync:把权威世界状态发给客户端。状态同步还是帧同步、发快照还是发增量、给谁发(兴趣过滤),都在这里定。
  9. 安全防护 Guard:畸形包、洪泛、连接耗尽、封包篡改。它是「能跑之后」才补的,但设计时就要留位置。
  10. 压测工具 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 服务端最少需要什么」落成了一份可执行的清单:

  1. 十个零件:协议编解码、连接与会话、网关、消息路由、定时器、Actor 邮箱、幂等、同步模型、安全防护、压测工具。
  2. 必需与可选的区分:前八个跑通最小闭环,后两个上线前补齐,但每个零件都由本卷的某一章负责实现。
  3. 清单是可校验的:AegisChecklist() 把路线图变成数据,测试守住「必需件没做完就不算 Ready」这条不变量。

第 1 章到此结束。下一章从最底下的一层开始——网络 I/O 与并发模型:先讲清阻塞、非阻塞与 Reactor 的区别,再看 Go 的 goroutine-per-connection 为什么能写得这么简单。

阅读导航:上一节:1.2 本书范围与站内分工 · 下一节:2.1 阻塞、非阻塞与 Reactor 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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