《MMO 服务器开发》9.1 《晨曦大陆》玩法装配

把前三卷的网络内核、单服玩法系统与分布式集群,装配成一个可以交付、可以起停、可以验收的《晨曦大陆》服务端:四个守护进程按依赖顺序拉起,中间件先探活、节点后健康门禁,一条命令起停,并以一条端到端冒烟断言收口,把散落的零件拼成一台可以运营的整机。

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/ 可跑;L2 TestLaunchAssembly 真起四个子进程并逐个冒烟,本机输出 assembly up: 4 nodes healthy, smoke passed。
  • 装配到位后,下一步是量它的性能预算(9.2),再把它串成上线清单(9.3)。

阅读导航:上一节:目录 · 下一节:9.2 性能预算达标 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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