《MMO 服务器开发》17.1 把前 16 章拼成 aegis

前 16 章各自造了零件却从没装在一起跑过。本节把 proto、session、router、timing、aoi、world 按依赖顺序装配成可执行文件 aegis,讲清配置结构、启动顺序与优雅关停,并用一次真进程启动与关停日志验证「端口绑定失败即退出」与「SIGTERM 后连接与计数正确收摊」。

17.1 把前 16 章拼成 aegis

前面 16 章各自造了一个零件:帧格式、会话表、路由表、定时器、Actor 邮箱、AOI 网格、权威校验。每个零件的单测都过了,但没有任何一章把它们装在一起跑过。零件能单独工作,不代表拼起来能工作——这一章就做这件事:把它们装配成一个可执行文件 aegis,真起进程,看它能不能同时接住三个客户端。

装配不是「import 一下就完」。真正的难点有三个。第一是依赖顺序:谁先初始化、谁依赖谁,顺序错了就是 nil map panic 或启动即死锁。第二是启动与失败的边界:端口被占用时必须在进入服务前就退出,而不是 accept 到一半才崩。第三是优雅关停:收到 SIGTERM 后要停止接受新连接、把在途请求收完、把计数器落进日志,而不是一句 os.Exit。

本节只讲装配、配置与启停;端到端的业务流转放到 17.2,压测数字放到 17.3。全部代码在 /tmp/mmobook/ch/ch17/(module 名 aegis),go vet ./... 与 go test ./... 均跑绿。

依赖顺序:proto → session → router → timing → actor → authority

aegis 的分层是自底向上的,每层只依赖它下面那层;箭头方向就是初始化顺序,也是 import 方向。

proto    帧格式 / 命令号(第 3、4 章)          ← 无依赖,谁都能引
  │
session  连接与会话表(第 7 章)                ← 依赖 proto
  │
router   命令号 → 处理函数的路由(第 8 章)     ← 依赖 session
  │
timing   周期任务 / 心跳清扫(第 9 章)         ← 依赖 session(活跃时间)
  │
aoi      兴趣网格(第 12 章)                   ← 依赖 session(位置)
  │
world    权威世界逻辑,接住以上所有层(第 15 章)

这张图里没有「actor」这一格,因为 actor 不是一层,而是一种执行方式:world 把整张图变成一个 actor——所有消息都进它的邮箱,由唯一一个 goroutine 串行消费,因此 sessions、byName、grid 这些状态一把锁都不用。第 10 章讲的邮箱与背压,在这里落地成了「世界 goroutine + 每连接一个发送队列」。

启动顺序因此是确定的六步:

1. 解析 flag        →  Config(先取 DefaultConfig,再被 flag 覆盖)
2. newStarter(cfg)  →  按 -role 建 World 或 Gateway
3. s.Listen()       →  绑定端口;失败立刻返回错误并退出
4. go s.Serve()     →  accept 循环 + 世界 goroutine
5. <-ctx.Done()     →  等 SIGINT / SIGTERM
6. s.Shutdown(ctx)  →  停止 accept、关闭连接、等 goroutine 收摊

第 3 步和第 4 步刻意分开:先把端口绑好,再开始服务。这样「端口被占用」是一个启动期错误,进程干净退出;如果把它混进 Serve(),就会出现「日志说启动了、实际 accept 一直失败」的半启动状态。

配置结构:一个结构体装下所有旋钮

配置集中在一个结构体里,每个字段都带注释说明它对应哪一章的机制。下面这段是 L1,go test ./internal/gateway/ 会连同本包其它文件一起编译并跑通:

// file: internal/gateway/config.go
// tier: L1
// verify: go test ./internal/gateway/

// Package gateway 把前 16 章的零件装配成一个能跑的最小 MMO 服务端 aegis。
//
// 装配顺序就是依赖顺序,自底向上:
//
//	proto   帧格式与命令号(第 3、4 章)——谁都不依赖
//	session 连接与会话表(第 7 章)——依赖 proto
//	router  命令号到处理函数的路由(第 8 章)——依赖 session
//	timing  周期性任务(第 9 章)——依赖 session(心跳清扫)
//	aoi     兴趣网格(第 12 章)——依赖 session(位置)
//	world   权威世界逻辑,把所有层接起来(第 15 章)
//
// 每个角色(world / gateway)由 cmd/aegis 按 -role 选择装配哪一套。
package gateway

import "time"

// Config 是一个 aegis 进程的全部可调参数。零值不可用,用 DefaultConfig 起步。
type Config struct {
	// Role 是进程角色:world 承载完整世界逻辑,gateway 只做字节中继。
	Role string
	// Listen 是本进程监听地址,例如 127.0.0.1:19001。端口 0 让内核分配。
	Listen string
	// Upstream 是 gateway 的上游 world 地址;world 角色留空。
	Upstream string
	// ReadTimeout 是连接最长静默时间,超过即被心跳清扫关闭。
	ReadTimeout time.Duration
	// Tick 是世界主循环的节拍,定时任务按它对齐。
	Tick time.Duration
	// AOIRadius 是兴趣半径,进入该半径的实体互相可见。
	AOIRadius float64
	// MaxSpeed 是移动速度上限(单位/秒),超过即判定为瞬移并拒绝。
	MaxSpeed float64
}

// DefaultConfig 返回一组能直接在本机跑起来的参数。
func DefaultConfig() Config {
	return Config{
		Role:        "world",
		Listen:      "127.0.0.1:19001",
		ReadTimeout: 30 * time.Second,
		Tick:        50 * time.Millisecond,
		AOIRadius:   30,
		MaxSpeed:    12,
	}
}

AOIRadius 同时也是网格的单元边长——这是第 12 章定下的约束:单元边长必须不小于兴趣半径,否则「查我周围 3×3 个单元」会漏掉第 2 圈的邻居。MaxSpeed 是第 15 章的权威边界,单位是「世界单位/秒」。

入口与优雅启停

cmd/aegis 只有 87 行,但把启停生命周期讲全了:一个 starter 接口让 world 与 gateway 共用同一套 Listen → Serve → Shutdown。下面这段是 L2,go build 后可直接起真进程:

// file: cmd/aegis/main.go
// tier: L2
// verify: go build ./cmd/aegis/

// Command aegis 是装配后的最小 MMO 服务端入口。同一个二进制用 -role 选择角色:
//
//	world    承载完整世界逻辑(会话、路由、定时、AOI、权威移动)
//	gateway  接入层,把客户端连接逐条转发给上游 world
//
// 启动顺序即依赖顺序:解析配置 -> 建世界/网关 -> Listen -> Serve ->
// 等信号 -> 优雅关停。把 Listen 与 Serve 分开,是为了让端口绑定失败能
// 在进入服务前就被发现(fail fast),而不是服务跑到一半才崩。
package main

import (
	"context"
	"flag"
	"fmt"
	"log"
	"net"
	"os"
	"os/signal"
	"syscall"
	"time"

	"aegis/internal/gateway"
)

// starter 抽象「一个可监听的 aegis 角色」,让 world 与 gateway 共用同一套启停流程。
type starter interface {
	Listen() error
	Addr() net.Addr
	Serve()
	Shutdown(ctx context.Context)
}

func newStarter(cfg gateway.Config) starter {
	if cfg.Role == "gateway" {
		return gateway.NewGateway(cfg)
	}
	return gateway.NewWorld(cfg)
}

func main() {
	cfg := gateway.DefaultConfig()
	var grace time.Duration
	flag.StringVar(&cfg.Role, "role", cfg.Role, "进程角色:world|gateway")
	flag.StringVar(&cfg.Listen, "listen", cfg.Listen, "监听地址,端口 0 表示由内核分配")
	flag.StringVar(&cfg.Upstream, "upstream", cfg.Upstream, "gateway 的上游 world 地址")
	flag.DurationVar(&cfg.ReadTimeout, "read-timeout", cfg.ReadTimeout, "连接最长静默时间")
	flag.Float64Var(&cfg.AOIRadius, "aoi-radius", cfg.AOIRadius, "兴趣半径")
	flag.Float64Var(&cfg.MaxSpeed, "max-speed", cfg.MaxSpeed, "移动速度上限(单位/秒)")
	flag.DurationVar(&grace, "shutdown-grace", 3*time.Second, "关停等待上限")
	flag.Parse()

	if cfg.Role != "world" && cfg.Role != "gateway" {
		fmt.Fprintf(os.Stderr, "unknown role %q (want world|gateway)\n", cfg.Role)
		os.Exit(2)
	}
	if cfg.Role == "gateway" && cfg.Upstream == "" {
		fmt.Fprintln(os.Stderr, "role gateway requires -upstream")
		os.Exit(2)
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	if err := run(ctx, cfg, grace); err != nil {
		log.Fatalf("[%s] %v", cfg.Role, err)
	}
}

// run 完成「装配 -> 监听 -> 服务 -> 关停」的完整生命周期。
func run(ctx context.Context, cfg gateway.Config, grace time.Duration) error {
	// 先 Listen:端口被占用时立刻失败,不留半启动的进程。
	s := newStarter(cfg)
	if err := s.Listen(); err != nil {
		return err
	}
	// 打印可解析的地址行,测试 harness 与运维脚本据此拿到真实端口。
	fmt.Printf("ADDR=%s ROLE=%s\n", s.Addr(), cfg.Role)

	go s.Serve()

	<-ctx.Done()
	log.Printf("[%s] 收到退出信号,开始优雅关停", cfg.Role)
	shutdownCtx, cancel := context.WithTimeout(context.Background(), grace)
	defer cancel()
	s.Shutdown(shutdownCtx)
	return nil
}

两个细节值得点出来。其一,Listen 与 Serve 分离后,ADDR= 那行打印的是内核分配的真实端口(-listen 127.0.0.1:0 时),脚本能据此拿到端口做后续断言。其二,关停有一个 shutdown-grace 上限,超时就放弃等待——优雅关停也要有兜底,否则一个卡住的连接能让进程永远退不掉。

真起一次进程,kill -TERM 后世界与网关各打印一行收摊计数:

2026/10/12 11:56:25 [world] 收到退出信号,开始优雅关停
2026/10/12 11:56:25 [world] 已关停: conns=3 frames_in=8 frames_out=17 aoi_add=6 aoi_remove=3 drops=0
2026/10/12 11:49:46 [gateway] 收到退出信号,开始优雅关停
2026/10/12 11:49:46 [gateway] 已关停: accepted=3

frames_in == frames_out 且 drops=0,说明三个客户端的每一帧都走了完整链路、没有一个被发送队列丢弃。

与站内既有文章的分工

既有文章它负责什么本节只做什么
游戏服务器网关与路由架构设计网关/路由的部署拓扑与选型取舍不聊拓扑,只把网关与世界的进程边界装配出来
游戏服务器场景服架构设计场景服如何切分、如何与网关对接不设计场景切分,只实现一个能跑的单场景
游戏服务器优雅关停关停的流量摘除、连接排空与发布流程不做发布编排,只实现进程内的启停生命周期
大型游戏世界里的 AOI 系统设计AOI 的选型、分层与容量取舍不比较方案,只实现均匀网格并接入世界循环

一句话:别人讲「这套服务该怎么切、怎么部署、怎么发版」,本节讲「把这些零件在一个进程里按什么顺序装起来、怎么安全地起和停」。

本节小结

装配的本质是把依赖顺序写死在初始化流程里:proto 无依赖、session 依赖 proto、router 与 timing 依赖 session、aoi 依赖 session 的位置、world 依赖全部。Config 把每章的旋钮收进一个结构体,cmd/aegis 用 Listen/Serve 分离保证 fail fast,用带超时的 Shutdown 保证进程一定退得掉。下一节把三个客户端接进来,看登录、移动与广播怎么在真进程里跑通。

阅读导航:上一节:目录 · 下一节:17.2 登录、移动与广播端到端 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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