9.1 《晨曦大陆》玩法装配
前三卷分别把 aegis 的网络内核、单服玩法系统和分布式集群做了出来。它们各自都能跑、能测,但放在一起时还是三堆散件:网络层不知道玩法怎么注册,玩法层不知道集群怎么发现彼此,集群层不知道一台机器上到底该先起谁。综合项目要回答的就是这个装配问题——怎么把散件拼成一个可以交付、可以起停、可以验收的形态。
本节给出三样东西:一张《晨曦大陆》的装配图、一个能按依赖顺序把世界拉起来的启动器 cmd/dawnrealm,以及一条端到端冒烟断言。装配的三条原则贯穿全节:依赖先行(世界状态先于地图实例、地图实例先于副本)、健康门禁(上一个节点没通过健康检查,下一个绝不启动)、失败即整体回滚(任何一步失败就把已起的进程全部杀掉,不留半个世界)。
9.2 会给这套装配量性能预算,9.3 会把它串成一份可执行的上线清单。本节先证明它能被一条命令拉起来,并且起完之后每个角色都能应答。
分工:本节与站内既有文章的关系
综合项目不是从零讲架构,而是把站内既有的三篇实践文章装配成可交付产物。分工如下:
| 站内既有文章 | 主题 | 本节与它的分工 |
|---|---|---|
/game-server-architecture-review-checklist/ | 架构评审清单 | 它给评审项,本节把评审结论落成一个可启动的装配 |
/game-server-layered-architecture-for-live-games/ | 分层架构 | 它讲分层的取舍,本节把分层落成 gateway / scene / world / battle 四个进程 |
/game-server-world-state-snapshot-architecture/ | 世界状态快照 | 它讲快照怎么做,本节把 world 作为其它节点的上游依赖先行启动 |
一句话:既有文章回答「为什么这样分层」,本节回答「这样分层之后,怎么把它装起来、并且装得对」。
装配图:四个进程与它们的依赖
《晨曦大陆》在单机演示形态下由四个守护进程组成,外加一组必须先在线的中间件。依赖关系是单向的:world -> scene -> battle -> gateway,中间件在最前面。装配图如下:
+----------------------------------------------+
阶段 0 | 中间件探活(未通过则不启动任何节点) |
| redis :26379 etcd :32379 nats :34222 |
+-----------------------+----------------------+
| 三个端口全部可达
阶段 1 +---------------+ |
| world-01 |<------+ 世界状态快照 / 全局时间
+-------+-------+ |
| 注册 |
+-------+-------+ |
| scene-01 |<------+ 地图实例 / AOI / 玩法循环
+-------+-------+ |
| 注册 |
+-------+-------+ |
| battle-01 |<------+ 副本战斗 / 结算
+-------+-------+ |
| 注册 |
阶段 2 +-------+-------+ |
| gateway-01 |<------+ 连接接入 / 路由 / 会话(最后起)
+---------------+
读图要点:world 持有全局世界状态与时间,是被依赖的根;scene 承载地图实例与 AOI,依赖 world 的快照;battle 跑副本战斗,依赖 scene 的实例;gateway 只负责连接接入与路由,它依赖所有后端就绪,所以最后启动。中间件(redis / etcd / nats)是前置条件——它们不通,一个节点都不该起。
装配代码:顺序由依赖推导,不靠人手排
装配的顺序不能写死在脚本里,否则加一个节点就要改一遍。下面这段是 L1,go test ./internal/dawnrealm/ 可以直接跑:Plan() 用拓扑排序从 Depends 推导启动顺序,缺依赖或成环都在启动前报错。
// file: internal/dawnrealm/assembly.go
// tier: L1
// verify: go test ./internal/dawnrealm/
package dawnrealm
import (
"errors"
"fmt"
"sort"
)
// EntityID is shared across the volumes: the high 16 bits are the scene or
// instance number, the low 48 bits are a per-instance sequence.
type EntityID uint64
// PlayerID is the globally unique character id.
type PlayerID uint64
// Role is one daemon role in the 《晨曦大陆》 deployment. Middleware roles are
// probed for connectivity; node roles are started as child processes.
type Role string
const (
// Middleware preconditions, probed before any node starts.
RoleRedis Role = "redis"
RoleEtcd Role = "etcd"
RoleNATS Role = "nats"
// Node daemons, started in dependency order.
RoleGateway Role = "gateway"
RoleScene Role = "scene"
RoleWorld Role = "world"
RoleBattle Role = "battle"
)
// IsMiddleware reports whether the role is a precondition rather than a daemon.
func (r Role) IsMiddleware() bool {
switch r {
case RoleRedis, RoleEtcd, RoleNATS:
return true
default:
return false
}
}
// Node is one entry in the assembly: a daemon to bring up, or a middleware
// endpoint to probe first.
type Node struct {
Role Role `json:"role"`
ID string `json:"id"`
Addr string `json:"addr"`
Depends []Role `json:"depends,omitempty"`
Health string `json:"health,omitempty"`
}
// Assembly is the ordered startup plan for one 《晨曦大陆》 world.
type Assembly struct {
Nodes []Node `json:"nodes"`
}
// DefaultAssembly is the four-daemon topology the book uses: gateway accepts
// player connections, scene runs the map instances, world holds the shared
// world state, battle runs instanced combat. The gateway depends on the scene
// and world registries, the scene depends on the world snapshot.
func DefaultAssembly(basePort int) Assembly {
return Assembly{Nodes: []Node{
{Role: RoleGateway, ID: "gateway-01", Addr: fmt.Sprintf("127.0.0.1:%d", basePort)},
{Role: RoleScene, ID: "scene-01", Addr: fmt.Sprintf("127.0.0.1:%d", basePort+1), Depends: []Role{RoleWorld}},
{Role: RoleWorld, ID: "world-01", Addr: fmt.Sprintf("127.0.0.1:%d", basePort+2)},
{Role: RoleBattle, ID: "battle-01", Addr: fmt.Sprintf("127.0.0.1:%d", basePort+3), Depends: []Role{RoleScene}},
}}
}
// Plan returns the nodes in an order that starts every dependency before its
// dependents. It fails on a missing dependency or a cycle, so a mis-declared
// assembly is caught before anything is started.
func (a Assembly) Plan() ([]Node, error) {
byRole := make(map[Role]Node, len(a.Nodes))
for _, n := range a.Nodes {
if _, dup := byRole[n.Role]; dup {
return nil, fmt.Errorf("dawnrealm: duplicate role %q", n.Role)
}
byRole[n.Role] = n
}
for _, n := range a.Nodes {
for _, d := range n.Depends {
if _, ok := byRole[d]; !ok {
return nil, fmt.Errorf("dawnrealm: %s depends on unknown role %q", n.Role, d)
}
}
}
// Deterministic order: iterate roles sorted, then stable topological sort.
order := make([]Node, 0, len(a.Nodes))
done := make(map[Role]bool, len(a.Nodes))
var visit func(Role, map[Role]bool) error
visit = func(r Role, onStack map[Role]bool) error {
if done[r] {
return nil
}
if onStack[r] {
return fmt.Errorf("dawnrealm: dependency cycle at %q", r)
}
onStack[r] = true
n := byRole[r]
deps := append([]Role(nil), n.Depends...)
sort.Slice(deps, func(i, j int) bool { return deps[i] < deps[j] })
for _, d := range deps {
if err := visit(d, onStack); err != nil {
return err
}
}
delete(onStack, r)
done[r] = true
order = append(order, n)
return nil
}
roles := make([]Role, 0, len(a.Nodes))
for r := range byRole {
roles = append(roles, r)
}
sort.Slice(roles, func(i, j int) bool { return roles[i] < roles[j] })
for _, r := range roles {
if err := visit(r, map[Role]bool{}); err != nil {
return nil, err
}
}
return order, nil
}
// ErrHealthGate is returned when a node never becomes healthy within the gate.
var ErrHealthGate = errors.New("dawnrealm: health gate not satisfied")
关键在 Plan():它先建角色到节点的索引,校验每个 Depends 都指向已声明的角色(缺依赖直接失败),再用深度优先做稳定拓扑排序,成环时报 dependency cycle。DefaultAssembly 把四个节点的地址与依赖声明出来,地址从 base 端口递增,方便本机同时起多套。
启动器 cmd/dawnrealm 分两个子命令:node 起一个守护进程(暴露 /healthz 与 /smoke),up 读装配计划、按顺序把每个节点作为自身二进制的子进程拉起、逐个做健康门禁,最后对每个节点打一次 /smoke。核心就是调 BringUp:
// snippet: cmd/dawnrealm/main.go 的 up 子命令
deps := dawnrealm.GateDeps{
Prober: dawnrealm.TCPProber{Timeout: time.Second},
Checker: dawnrealm.HTTPChecker{Timeout: *timeout, Poll: 20 * time.Millisecond},
Start: start, // 每个节点 = exec 自身 "node" 子命令
}
if err := dawnrealm.BringUp(ctx, asm, mw, deps); err != nil {
for _, c := range children {
_ = c.Process.Kill() // 失败即整体回滚
}
return 1
}
BringUp 先探中间件,再按 Plan() 的顺序逐个 Start + 等健康;任一步失败就把已起的节点全部停掉并返回错误,所以不会留下半启动的世界。
冒烟断言:装配成功的判据
装配成功的判据不是「进程还活着」,而是「每个角色都能应答一次真实请求」。下面这段是 L2,go test -run TestLaunchAssembly -v ./internal/dawnrealm/ 会真的 go build 出启动器、以 up 拉起四个子进程(先探活本机的 redis / etcd / nats 容器),再逐个冒烟:
// file: internal/dawnrealm/orchestrate_test.go
// tier: L2
// verify: go test -run TestLaunchAssembly -v ./internal/dawnrealm/
package dawnrealm
import (
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
// moduleRoot walks up from this test file to the module root.
func moduleRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot locate test file")
}
return filepath.Join(filepath.Dir(file), "..", "..")
}
// TestLaunchAssembly builds cmd/dawnrealm and runs `up` for real: it probes
// the middleware containers, starts the four daemons as child processes,
// health-gates each, then smoke-checks every node. Exit code 0 means the whole
// assembly came up and answered.
func TestLaunchAssembly(t *testing.T) {
root := moduleRoot(t)
bin := filepath.Join(t.TempDir(), "dawnrealm")
build := exec.Command("go", "build", "-o", bin, "aegis/cmd/dawnrealm")
build.Dir = root
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build: %v\n%s", err, out)
}
up := exec.Command(bin, "up", "-base", "9210", "-timeout", "5s")
up.Dir = root
out, err := up.CombinedOutput()
got := string(out)
if err != nil {
t.Fatalf("dawnrealm up failed: %v\n%s", err, got)
}
for _, want := range []string{"gateway-01", "scene-01", "world-01", "battle-01", "smoke passed"} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
本机跑出来的输出(先探活中间件,再按顺序拉起并冒烟):
smoke world-01 world ok
smoke scene-01 scene ok
smoke battle-01 battle ok
smoke gateway-01 gateway ok
assembly up: 4 nodes healthy, smoke passed
go test ./... 在本章模块内全绿:装配顺序、缺依赖、成环、中间件不通不启动、节点不健康即回滚、健康门禁超时,以及这条端到端冒烟,共 14 个用例。
本节小结 / 实测结论
- 装配的三条原则:依赖先行、健康门禁、失败即整体回滚;顺序由
Plan()拓扑排序推导,不靠人手排。 - 四个节点依赖单向:
world -> scene -> battle -> gateway,gateway 最后起;中间件是前置探活条件。 - L1
assembly.go用go test ./internal/dawnrealm/可跑;L2TestLaunchAssembly真起四个子进程并逐个冒烟,本机输出assembly up: 4 nodes healthy, smoke passed。 - 装配到位后,下一步是量它的性能预算(9.2),再把它串成上线清单(9.3)。
阅读导航:上一节:目录 · 下一节:9.2 性能预算达标 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。