1.2 本书范围与站内分工
上一节画了四层图,这一节要回答一个更不好回答的问题:这本书和站内已经存在的那一大批游戏服务器内容,到底谁写什么?
之所以要专门花一节讲这件事,是因为本站的游戏服务器内容已经不算少:一个 170 篇的实战专题、十个多月的月度归档、两个框架专题,外加一本写到一半的书稿。如果本书不把自己的边界说清楚,读者会在「架构方案」和「从零实现」之间反复横跳,作者也会不自觉地把别人已经写透的内容再抄一遍。这一节的立场很朴素:别人讲过的「是什么、为什么、怎么选」,我们全部外链出去;本书只补别人没补的那一块——「从零写出来,并且跑出数字」。
这不是自夸。恰恰相反,下面这份盘点会诚实地告诉你:站内既有内容在很多方面比本书更完整。本书的价值不在于「更多」,而在于「另一种」——它提供的不是更多的架构综述,而是一份能编译、能跑、能压出数字的代码。
站内已经有什么:一次体量盘点
先老老实实把站内既有的游戏服务器内容盘一遍。下表按目录统计(用 os.listdir 数落盘的 .md,不含各目录自身的 _index.md):
| 内容 | 位置 | 体量 | 形态 |
|---|---|---|---|
| 游戏服务器实战专题 | /posts/game/server/practice/ | 170 篇 | 纯架构设计文,零可运行代码 |
| 月度归档 2021.02–2021.12 | /posts/game/server/
下 11 个月度目录 | 110 篇 | 按月的架构设计文,同属「方案型」 |
| Skynet 框架专题 | /posts/game/server/skynet/ | 17 篇 | Lua 框架用法与最佳实践 |
| Pitaya 框架专题 | /posts/game/server/pitaya/ | 3 篇 | 框架介绍与框架对比 |
| 《游戏服务端编程实践》 | /posts/books/游戏服务端编程实践/ | 21 章大纲,实际只落盘第 1–3 章 | 书稿,正文偏概念与综述 |
这张表有两个数字值得单独点出来。
第一,实战专题的 170 篇里,一篇可运行的代码都没有。我逐篇扫过 content/posts/game/server/practice/ 下全部 170 个文件,go / java / python / c / rust / lua 等任何语言的代码围栏出现次数是 0。它们全是「架构方案与取舍」型文章——告诉你网关该怎么切、结算该怎么防重复发奖、合服该怎么迁移——但不会给你一份能 go test 通过的实现。这不是缺陷,是定位:它们的读者是来「看方案、做评审」的。
第二,《游戏服务端编程实践》的 21 章大纲里,正文只落盘了第 1–3 章。它的目录篇列了完整的 21 章(从「游戏服务器的定义与职责」一直到「未来方向与生态」),但真正写出来的只有入门篇的三章,且以概念与综述为主。换句话说,站内确实有一本「游戏服务端」的书稿,但它离「完整、可运行」还有很长一段路。
把这两条合起来看,结论很清楚:站内在「架构方案」这一维度上已经非常厚,但在「从零实现 + 可复现数字」这一维度上几乎是空的。本书要补的正是后者。
四重区隔:本书和它们的四条不同
盘完存量,再说本书和它们的差异。差异不止一条,而是四重——形态、技术栈、验收、游戏。四重都不同,才说明本书不是既有内容的又一次重复:
| 维度 | 站内既有内容 | 本书《网络与并发内核》 |
|---|---|---|
| 形态 | 架构方案与取舍:「应该怎么设计」 | 从零实现:「自己写出来、跑通、量出来」 |
| 技术栈 | 多语言综述(Java / Go / Rust / Lua / C++ 混着讲) | Go 主线(框架名 aegis)+ Java 对照 |
| 验收 | 无:没有端到端断言,没有压测数字 | 端到端断言 + 本机真跑的压测数字 |
| 游戏 | SLG 异步弱实时(收菜、建筑、异步战斗) | MMO 同屏强实时(移动、AOI、广播) |
逐条解释这四重:
- 形态。既有文章回答「应该怎么设计」,本书回答「怎么写出来」。同样讲网关,既有文章给你一张部署拓扑图和一个取舍清单;本书给你一份能编译的网关代码和一条
go test命令。这两件事不冲突,但也不能互相替代。 - 技术栈。既有内容是多语言综述——今天看 Java 的线程池,明天看 Rust 的 async,后天看 Lua 的 Skynet。本书收窄到一条主线:Go(框架名
aegis),并在关键处用 Java 做双栈对照。收窄是为了「能跑通」——一门语言写到底,才有连贯的可运行产物。 - 验收。这是最硬的一条区隔。既有文章不需要产出任何数字;本书每一节都要么有
go test断言,要么有本机实测的压测数字(且明确标注「同机相对量级,绝对数值不可移植」)。没有数字的实现,在本书里不算完成。 - 游戏。既有内容默认的语境是 SLG——异步、弱实时、状态变更慢,一次收菜晚 200 毫秒没人会死。本书默认的语境是 MMO——同屏几百人、移动每帧都在变、广播必须在一帧内发出去。同样的「状态同步」,在 SLG 和 MMO 里是两套完全不同的难度,本书只讲后者。
一句话总结:「为什么、怎么选」看既有文章,「怎么写、跑出什么数字」看本书。 这条线划得干净,读者和作者都省事。
把这条线落到具体话题,就是下面这张「不写什么、去哪里看」的清单。左列是本书不展开的话题,中列一句话交代为什么,右列给出站内既有内容:
| 本书不展开的话题 | 一句话结论 | 去哪里看 |
|---|---|---|
| 网关部署拓扑与选型 | 属于方案层,与实现无关 | /posts/game/server/practice/ |
| 结算防重复发奖 | 属于业务方案,非网络内核 | /posts/game/server/practice/ |
| 合服与跨服数据迁移 | 属于运维与数据方案 | /posts/game/server/practice/ |
| 框架与商业引擎怎么选 | 属于项目决策 | /posts/game/server/pitaya/ |
| Skynet 框架的具体用法 | 属于框架 API,非从零实现 | /posts/game/server/skynet/ |
| 游戏服务端的概念总论 | 属于综述,既有书稿已覆盖 | /posts/books/游戏服务端编程实践/ |
有人会问:既然站内已经写了 170 篇,为什么还要自己实现一遍?因为读得懂和写得出是两种能力。看懂一篇「网关该怎么分层」的方案文,和亲手处理长连接的会话表、心跳超时、断线重连票据、消息乱序,中间隔着一整个调试周期。本书要补的正是这一段。
最后交代一下阅读方式。本书是一条依赖链:第 1 章立边界,第 2–4 章把网络 I/O、协议、编解码从零写出来,第 5 章补可靠 UDP,第 6–11 章做接入、会话、路由、定时器、Actor、幂等,第 12–16 章进入同步、预测、时间、权威、安全,第 17 章把前 16 章拼成 aegis 并压出第一组承载数字,第 18 章回顾。建议顺序读——跳读的代价不是看不懂某一章,而是写到第 12 章时发现第 3 章的长度前缀留了坑,得回头重做。想只当手册查也可以,但要跑通代码仍建议先读前几章。
可运行产物:把边界编码成路由表
上面两张表是给人看的。但「不越界」这件事如果只靠自觉,迟早会漂。所以本书把这张边界表编码成一个可查询的路由函数:给它一个话题,它告诉你这个话题归谁——是站内既有文章,还是本书。这样「越界」就变成一条可以被测试断言的失败用例。
下面这段是 L1,go test 可以直接跑。先看 internal/overview/scope.go:
// file: internal/overview/scope.go
// tier: L1
// verify: go test ./internal/overview/
// Package overview also encodes this book's boundary against the game-server
// content already on the site. The tables in section 1.2 are for humans; this
// file is the machine-readable version, so "stay in your lane" becomes an
// assertion a test can fail on instead of a slogan nobody re-reads.
package overview
import "strings"
// Owner says who is responsible for a topic: an existing section of the site,
// or this book (the "from scratch, with numbers" half).
type Owner struct {
// Kind is either "practice" (existing site content) or "this-book".
Kind string
// Path is the absolute site path the reader should go to.
Path string
}
// Owners for the content this book deliberately does not rewrite.
var (
// PracticeServer is the 170-article architecture-only practice section.
PracticeServer = Owner{Kind: "practice", Path: "/posts/game/server/practice/"}
// PracticeSkynet is the Skynet (Lua framework) usage section.
PracticeSkynet = Owner{Kind: "practice", Path: "/posts/game/server/skynet/"}
// PracticePitaya is the Pitaya framework section.
PracticePitaya = Owner{Kind: "practice", Path: "/posts/game/server/pitaya/"}
// ExistingBook is the half-finished《游戏服务端编程实践》.
ExistingBook = Owner{Kind: "practice", Path: "/posts/books/游戏服务端编程实践/"}
// ThisBook is where the from-scratch implementation lives.
ThisBook = Owner{Kind: "this-book", Path: "/posts/books/网络与并发内核/"}
)
// Rule maps a topic keyword to its owner. First match wins.
type Rule struct {
Keyword string
Owner Owner
}
// scopeRules is the 1.2 boundary table, in code. Order is priority.
var scopeRules = []Rule{
{"架构方案", PracticeServer},
{"选型对比", PracticeServer},
{"Skynet", PracticeSkynet},
{"Pitaya", PracticePitaya},
{"SLG 项目", ExistingBook},
{"从零实现", ThisBook},
{"端到端压测", ThisBook},
}
// Route returns the owner of a topic. A topic that matches no rule defaults to
// this book: the book's job is exactly "what nobody else wrote from scratch",
// so the fallback must point at us, never away.
func Route(topic string) Owner {
for _, r := range scopeRules {
if strings.Contains(topic, r.Keyword) {
return r.Owner
}
}
return ThisBook
}
// ExternalPaths returns every distinct site path this book defers to. Callers
// use it to assert that each one is a real, absolute section path.
func ExternalPaths() []string {
seen := map[string]bool{}
var out []string
for _, r := range scopeRules {
if r.Owner.Kind == "practice" && !seen[r.Owner.Path] {
seen[r.Owner.Path] = true
out = append(out, r.Owner.Path)
}
}
return out
}
配套测试 internal/overview/scope_test.go 断言两件事:路由表按表走,以及所有外链目标都是 /posts/ 下的合法 section 路径(单段 /<topic>/ 是死链,不能写):
// file: internal/overview/scope_test.go
// tier: L1
// verify: go test ./internal/overview/
package overview
import (
"strings"
"testing"
)
func TestRouteToPractice(t *testing.T) {
cases := map[string]Owner{
"网关分层的架构方案": PracticeServer,
"引擎选型对比": PracticeServer,
"Skynet 服务模型": PracticeSkynet,
"Pitaya 集群": PracticePitaya,
"SLG 项目实战": ExistingBook,
}
for topic, want := range cases {
if got := Route(topic); got != want {
t.Errorf("Route(%q) = %+v, want %+v", topic, got, want)
}
}
}
func TestRouteToThisBook(t *testing.T) {
for _, topic := range []string{"从零实现 KCP", "端到端压测与承载数字", "一个没人写过的话题"} {
if got := Route(topic); got != ThisBook {
t.Errorf("Route(%q) = %+v, want this-book", topic, got)
}
}
}
func TestExternalPathsAreAbsoluteSectionPaths(t *testing.T) {
paths := ExternalPaths()
if len(paths) == 0 {
t.Fatal("expected at least one external path")
}
for _, p := range paths {
// Section pages live under /posts/; a bare single-segment path would
// be a dead link, because articles are flattened to /<slug>/.
if !strings.HasPrefix(p, "/posts/") {
t.Errorf("external path %q is not under /posts/", p)
}
if !strings.HasSuffix(p, "/") {
t.Errorf("external path %q must be a section path ending in /", p)
}
}
}
func TestRouteFallbackIsNeverExternal(t *testing.T) {
if got := Route("完全未知的话题 xyz"); got.Kind != "this-book" {
t.Fatalf("fallback owner = %+v, want this-book", got)
}
}
本机实跑结果(go test ./internal/overview/ -v,节选):
--- PASS: TestRouteToPractice (0.00s)
--- PASS: TestRouteToThisBook (0.00s)
--- PASS: TestExternalPathsAreAbsoluteSectionPaths (0.00s)
--- PASS: TestRouteFallbackIsNeverExternal (0.00s)
PASS
ok aegis/internal/overview 0.324s
Route() 的默认分支值得强调:没命中任何规则的话题,一律判给本书。这是刻意的——本书的职责就是「别人没写、要从零实现的」,所以兜底方向必须是「自己写」,而不是「甩给别人」。测试里的 TestRouteFallbackIsNeverExternal 就是在守这条不变量。
本节小结
这一节做了两件事,都是为了让本书和既有内容各归其位:
- 盘点存量:站内有 170 篇实战文(零可运行代码)、110 篇月度归档、17 篇 Skynet、3 篇 Pitaya,以及一本只落盘前 3 章的《游戏服务端编程实践》。「架构方案」这一维已经很厚,「从零实现 + 数字」这一维几乎是空的。
- 划清边界:形态、技术栈、验收、游戏四重区隔,本书只补后者那一半,并把这条边界编码成可运行的路由表。
下一节把「本书要补的那一半」具体化成一张零件清单:一个 MMO 服务端,最少需要哪些零件才能跑起来。
阅读导航:上一节:1.1 分层与职责边界:一个 MMO 服务端长什么样 · 下一节:1.3 一个 MMO 服务端最少要有哪些零件 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。