1.3 本书范围与站内架构文章的分工
读完前两节,读者大概率会有一个疑问:站内已经有一大批游戏服务器架构文章了,这一卷又写一遍,是不是重复? 这个疑问值得正面回答,因为本站的游戏服务器内容确实不算少——一个 170 篇的实战专题、十一个月的月度归档、若干框架专题,再加上刚刚上线的卷一《网络与并发内核》。如果不把边界说清楚,读者会在「架构方案」和「从零实现」之间反复横跳,作者也会不自觉地把别人写透的内容再抄一遍。
这一节的立场很朴素:别人讲过的「是什么、为什么、怎么选」,我们全部外链出去;本卷只补别人没补的那一块——「把玩法系统从零写出来,并且跑出数字」。 这不是自夸。恰恰相反,站内既有内容在很多方面比本卷更完整——它们把架构取舍讲得更透。本卷的价值不在于「更多」,而在于「另一种」:它提供的不是又一份架构综述,而是一份能编译、能跑、能被断言检查的代码。
需要先厘清的是,这一节不是分工表。本卷从第 2 章起,每一章的第 1 节都会有一张表,说明「本章对应的站内既有文章是哪几篇、各自负责什么」。而这一节是总纲:它先讲清三份内容各自的位置,再给出一条可以被测试检查的边界。
三份内容,三种分工
把与本卷相关的三份内容摊开,它们的分工是这样的:
| 内容 | 位置 | 负责回答 | 形态 |
|---|---|---|---|
| 站内架构文(practice) | /posts/game/server/practice/ | 应该怎么设计、为什么这么选 | 纯架构设计文,零可运行代码 |
| 卷一《网络与并发内核》 | /posts/books/网络与并发内核/ | 网络底座从零怎么写 | 从零实现 + 真跑 + 压测数字 |
| 本卷《单服架构与玩法系统》 | /posts/books/ | 玩法系统从零怎么写 | 从零实现 + 真跑 + 端到端验收 |
这张表有两个关键点。
第一,站内的 practice 架构文和本卷的形态完全不同。它们回答「应该怎么设计」——网关该怎么切、状态该怎么归属、结算该怎么防重复发奖——但不会给你一份能 go test 通过的实现。这不是缺陷,是定位:它们的读者是来「看方案、做评审」的。本卷的读者是来「自己写出来、跑通、量出来」的。同一件事(比如状态归属),架构文给你一张取舍清单,本卷给你一份能跑的代码。
第二,卷一和本卷的边界是「网络底座」与「玩法系统」。卷一已经把 wire 格式、编解码、KCP、时间轮、Actor、会话路由这些底座写完了;本卷不重写底座,而是直接站在卷一的协议与并发模型之上,开始写玩法:背包、任务、公会、交易、匹配、战斗管线。卷一回答「包怎么流动」,本卷回答「状态怎么归属、玩法怎么落地」。两卷的 wire 格式和命令号是同一套,本卷的每一章都能接到卷一造出的网络层上。
一句话总结这条分工:「为什么、怎么选」看站内架构文,「网络底座怎么写」看卷一,「玩法系统怎么写、跑出什么数字」看本卷。
两个具体例子:分层与状态归属
抽象的分工不如具体例子清楚。本节讲的「分层」和上一节讲的「状态归属」,站内恰好各有一篇对应的架构文章,正好用来说明边界。
分层。站内有《在线游戏服务器分层架构设计》 ,它讲的是「一个线上游戏服务器应该怎么分层、每层负责什么、层与层之间怎么解耦」,重点在架构取舍:什么时候该拆服务、什么时候不该拆、分层过度会带来什么代价。本卷 1.1 不重复这些取舍,它只做一件事:把四层在玩法域上的状态归属写成能跑的代码——每条命令归到唯一的处理层、每份状态归到唯一的写入者,并用断言锁住「data 层没有 wire 命令」「scene 状态是单写者」这两条不变量。架构文告诉你「应该这么分」,本卷告诉你「这么分的代码长什么样,怎么验证它没被改坏」。
状态归属。站内有《游戏服务器状态归属架构设计》 ,它讲的是「每类状态要回答哪四个问题、状态归属一乱会导致哪些事故」,重点在方案设计:重复发奖、旧数据覆盖、跨服写错资产分别是怎么来的。本卷 1.2 同样不重复这些方案讨论,它把「版本单调」和「幂等键」两条守卫实现出来并跑绿:旧版本被拒、同一结算只发一次、64 个 goroutine 抢同一个结算只有一个人拿到奖。方案文告诉你「要防住这两类事故」,本卷告诉你「守卫代码怎么写,测试怎么证明它真的防住了」。
这两组例子的模式是一样的:架构文负责「应该怎么设计」,本卷负责「怎么写出来、怎么验证」。 两者不冲突,也不能互相替代——读得懂一篇方案文,和亲手把它实现出来并跑通,中间隔着一整个调试周期,本卷补的就是这一段。
明确不写的内容
边界讲清楚,还要把「不写什么」也钉死,否则读者会一直等一个不会来的章节。下面这张表是本卷的排除清单:
| 内容 | 为什么不在本卷 | 去哪里看 |
|---|---|---|
| 网络底座(协议、编解码、KCP、会话、路由) | 卷一已从零实现 | 卷一《网络与并发内核》 |
| 分布式与跨服(服务发现、RPC、分库分表) | 单服装不下,属于集群话题 | 卷三《分布式与跨服》 |
| 压测、部署发布、GM 后台、数据分析 | 属于交付与运营阶段 | 卷四《工程化与运营》 |
| 引擎与渲染、客户端表现、美术与内容管线 | 与服务器无关 | 站内引擎与客户端书稿 |
| 真实公网 DDoS、云厂商控制台操作 | 需真实环境,本机不可复现 | 仅作原理提及 |
这张表也是给读者的预期管理:本卷只覆盖「单服玩法系统」这一格,其余四格各有归属,不在本卷重复。
可运行产物:把分工编码成一张查询表
上面这张分工表是给人看的。但「不越界」这件事如果只靠自觉,迟早会漂——所以本卷把这条边界编码成一个可查询的函数:给它一个话题,它告诉你这个话题归谁(站内架构文、卷一,还是本卷)。这样「越界」就变成一条可以被测试断言的失败用例。
下面这段是 L1,go test 可以直接跑。先看 internal/scope/scope.go:
// file: internal/scope/scope.go
// tier: L1
// verify: go test ./internal/scope/
// Package scope encodes the division of labour between three bodies of work:
// the site's existing architecture articles ("practice"), volume one's network
// kernel, and this volume's gameplay systems. Section 1.3 explains the split in
// prose; this file makes it queryable, so "which one covers this topic" is an
// assertion a test can fail on rather than a rule nobody re-reads.
package scope
import "strings"
// Volume names which body of work owns a topic.
type Volume string
const (
// VolumePractice is the site's existing game-server articles: they answer
// "what should the architecture be, and why", with no runnable code.
VolumePractice Volume = "practice"
// VolumeNet is volume one《网络与并发内核》: the wire, codec and transport
// built from scratch.
VolumeNet Volume = "vol1-net"
// VolumeSingle is this volume《单服架构与玩法系统》: gameplay systems built
// from scratch, with numbers.
VolumeSingle Volume = "vol2-single"
)
// Rule maps a topic keyword to the volume that owns it. First match wins, so
// order is priority.
type Rule struct {
Keyword string
Volume Volume
}
// rules is the 1.3 split, in code.
var rules = []Rule{
// Architecture-only topics stay with the existing articles.
{"分层方案", VolumePractice},
{"状态归属方案", VolumePractice},
{"选型对比", VolumePractice},
{"部署拓扑", VolumePractice},
// Wire-level topics belong to volume one.
{"编解码", VolumeNet},
{"KCP", VolumeNet},
{"帧切分", VolumeNet},
{"时间轮", VolumeNet},
// Gameplay systems belong to this volume.
{"背包", VolumeSingle},
{"任务", VolumeSingle},
{"公会", VolumeSingle},
{"交易", VolumeSingle},
{"匹配", VolumeSingle},
{"掉落", VolumeSingle},
}
// Owner returns the volume responsible for a topic. A topic that matches no
// rule falls back to this volume: its job is exactly "the gameplay systems
// nobody else wrote from scratch", so the default must point here, never away.
func Owner(topic string) Volume {
for _, r := range rules {
if strings.Contains(topic, r.Keyword) {
return r.Volume
}
}
return VolumeSingle
}
// DeferredPaths returns the site paths this volume defers to for a given
// volume. Only section paths under /posts/ are ever returned, because a bare
// single-segment path is a dead link once articles are flattened to /<slug>/.
func DeferredPaths(v Volume) []string {
switch v {
case VolumePractice:
return []string{"/posts/game/server/practice/"}
case VolumeNet:
return []string{"/posts/books/网络与并发内核/"}
default:
return nil
}
}
配套测试 internal/scope/scope_test.go 断言两件事:话题按表路由,以及所有外链目标都是 /posts/ 下的合法栏目路径(单段 /<topic>/ 是死链,不能写):
// file: internal/scope/scope_test.go
// tier: L1
// verify: go test ./internal/scope/
package scope
import (
"strings"
"testing"
)
func TestOwnerRoutesToPractice(t *testing.T) {
for _, topic := range []string{"分层方案怎么写", "状态归属方案", "引擎选型对比"} {
if got := Owner(topic); got != VolumePractice {
t.Errorf("Owner(%q) = %s, want practice", topic, got)
}
}
}
func TestOwnerRoutesToVolumeOne(t *testing.T) {
for _, topic := range []string{"KCP 重传", "帧切分", "时间轮级联"} {
if got := Owner(topic); got != VolumeNet {
t.Errorf("Owner(%q) = %s, want vol1-net", topic, got)
}
}
}
func TestOwnerRoutesToThisVolume(t *testing.T) {
for _, topic := range []string{"背包并发修改", "公会仓库", "交易行竞价"} {
if got := Owner(topic); got != VolumeSingle {
t.Errorf("Owner(%q) = %s, want vol2-single", topic, got)
}
}
}
func TestFallbackIsThisVolume(t *testing.T) {
// A topic nobody else claimed is exactly what this volume is for.
if got := Owner("一个没人写过的话题 xyz"); got != VolumeSingle {
t.Fatalf("fallback = %s, want vol2-single", got)
}
}
func TestDeferredPathsAreSectionPaths(t *testing.T) {
for _, v := range []Volume{VolumePractice, VolumeNet} {
paths := DeferredPaths(v)
if len(paths) == 0 {
t.Fatalf("DeferredPaths(%s) is empty", v)
}
for _, p := range paths {
if !strings.HasPrefix(p, "/posts/") || !strings.HasSuffix(p, "/") {
t.Errorf("path %q must be a section path under /posts/ ending in /", p)
}
}
}
// This volume defers to nothing: it is where the from-scratch work lives.
if got := DeferredPaths(VolumeSingle); got != nil {
t.Fatalf("DeferredPaths(vol2-single) = %v, want nil", got)
}
}
本机实跑结果(go test ./internal/scope/ -v):
=== RUN TestOwnerRoutesToPractice
--- PASS: TestOwnerRoutesToPractice (0.00s)
=== RUN TestOwnerRoutesToVolumeOne
--- PASS: TestOwnerRoutesToVolumeOne (0.00s)
=== RUN TestOwnerRoutesToThisVolume
--- PASS: TestOwnerRoutesToThisVolume (0.00s)
=== RUN TestFallbackIsThisVolume
--- PASS: TestFallbackIsThisVolume (0.00s)
=== RUN TestDeferredPathsAreSectionPaths
--- PASS: TestDeferredPathsAreSectionPaths (0.00s)
PASS
ok aegis/internal/scope 0.207s
Owner() 的默认分支值得强调:没命中任何规则的话题,一律判给本卷。这是刻意的——本卷的职责就是「别人没写、要从零实现的玩法系统」,所以兜底方向必须是「自己写」,而不是「甩给别人」。测试里的 TestFallbackIsThisVolume 就是在守这条不变量。另外 TestDeferredPathsAreSectionPaths 守的是链接口径:外链一律指向 /posts/ 下的栏目页,因为普通文章被 permalink 压到了根级 /<slug>/,写单段 /<topic>/ 会变成死链。
本节小结
这一节把三份内容的分工讲清楚了:
- 站内 practice 架构文回答「应该怎么设计、为什么这么选」,形态是纯方案文,没有可运行代码。
- **卷一《网络与并发内核》**回答「网络底座从零怎么写」,造出了
aegis的 wire 格式、编解码、传输与会话模型。 - **本卷《单服架构与玩法系统》**回答「玩法系统从零怎么写、跑出什么数字」,直接站在卷一的底座上写背包、任务、公会、交易、匹配,并把边界编码成一张可查询、可测试的表。
第 1 章到此结束。从下一章开始,我们进入实体与运行时——先回答「玩家、怪物、掉落物在服务端到底是什么」,再回答「它们怎么被创建、回收和迁移」。
阅读导航:上一节:1.2 状态归谁:防重复发奖与旧数据覆盖 · 下一节:2.1 entity runtime 与句柄 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。