《游戏服务端编程实践》19.2 模块职责说明

用一张模块清单把职责、对外接口、数据所有权和依赖方向写清楚,讨论模块边界与团队边界的对应关系、启动就绪关闭的生命周期,以及进程内接口、RPC 和事件的选择,并给出可测试的 Go 模块注册与逆序关闭实现。文章还分析关闭顺序反转导致刷盘失败的案例,说明依赖排序、失败回滚与就绪检查的落地方式。

一、模块边界先于代码边界

模块划分不是把文件挪进不同目录,而是先回答四个问题:这个模块对谁负责、暴露什么接口、拥有哪些数据、允许依赖谁。四个问题没有答案,代码目录再整齐也只是物理隔离。一个模块如果既能改角色属性、又能直接写排行榜,它的边界实际上不存在。

判断边界是否成立,有一个简单测试:把模块的实现整体替换掉,调用方是否需要修改。如果调用方依赖的是接口和事件,替换实现不需要改调用方;如果调用方直接读模块的表或全局变量,替换就无从谈起。模块化的收益来自“可替换”,而不是来自“文件分了很多包”。

模块清单应该是一份可以评审的文档,而不是代码生成后的目录树。每个模块至少写清楚:职责一句话、对外接口、拥有的数据、明确不做什么、依赖哪些模块。下面是一份示例清单,名字和划分方式要按项目实际调整。

模块对外接口拥有的数据明确不做
配置按版本读取配置、热更新通知配置版本、灰度规则不判断业务合法性
存储事务、查询、迁移状态连接池、表结构版本不包含业务 SQL 语义
账号登录、创角、封禁校验账号、角色列表、封禁记录不处理场景与战斗
世界进入场景、移动、AOI 查询地图、实体位置、场景状态不结算伤害与掉落
战斗开始战斗、释放技能、结算战斗实体、冷却、随机种子不持久化角色成长
社交好友、公会、聊天、邮件关系链、频道成员、邮件不修改角色数值
排行榜写分数、查名次、赛季切换分数、名次索引、赛季不接受客户端直接报分
后台补偿、封禁、公告、审计运营单据、审计日志不进入实时战斗路径

这张表的重点是“明确不做”和“拥有的数据”。职责描述容易写得漂亮,但数据所有权是硬约束:谁拥有数据,谁就拥有写权限;其他模块只能通过接口请求变更,不能绕过接口直接写库。

二、依赖方向与团队边界

模块依赖必须是有向无环图。配置和存储通常在最底层,账号、世界、战斗、社交、排行榜在中间,后台和网关在最上层。依赖只能从上往下,不能从下往上回调。底层模块如果需要通知上层,应该发事件,而不是直接引用上层的类型。

团队边界最好和模块边界对齐。一个团队拥有一个或几个模块,接口变更由拥有方评审,调用方不能私自修改接口语义。常见的问题是“公共模块”没有明确 owner,所有人都往里加代码,最后它依赖了所有业务模块,变成一个无法拆分的垃圾场。

边界方式优点风险适用
同包内拆分文件改动成本低容易绕过接口原型期、小模块
独立包 + 接口编译期约束依赖方向需要维护接口大多数业务模块
独立进程 + RPC故障和发布隔离网络、序列化、运维成本需要独立扩容或隔离
独立仓库 + 版本团队完全自治版本兼容和联调成本高多团队、长期产品

边界不是越细越好。拆得太细,一次业务变更要改五个模块、发四个版本,团队会把精力耗在联调上;拆得太粗,一个模块内部又回到共享全局状态。合理的粒度是“一个团队能独立发布,且一次业务变更主要落在一个模块里”。

三、生命周期:启动、就绪与关闭

模块的生命周期至少有三个阶段:启动、就绪、关闭。启动是分配资源、建立连接、加载配置;就绪是确认可以接流量;关闭是停止接收新请求、处理完在途请求、释放资源。三个阶段失败时的处理方式不同,不能用一个 Init 函数全部包办。

阶段做什么失败时的行为常见错误
启动建连、加载配置、启动协程失败则回滚已启动模块在启动里做全量数据迁移
就绪预热缓存、检查依赖、试读进程存活但不接流量把就绪检查写成永远返回成功
关闭摘流量、等待在途、释放逆序关闭,继续关闭其余模块先关数据库再关业务模块

启动顺序必须由依赖决定,不能靠 main 函数里手写调用顺序。手写顺序在模块少的时候没问题,模块一多就会出现“某个服务忘了启动”或“先启动世界再启动数据库”的问题。关闭顺序必须是启动顺序的逆序:后启动的模块可能依赖先启动的模块,先关掉底层会让上层在关闭过程中报错甚至丢数据。

就绪和启动要分开。进程启动成功不代表可以接流量:缓存还没预热、下游还没连上、配置版本还没同步,这时接流量会把错误放大。就绪检查应该验证关键依赖,但也不能每次请求都打一遍数据库,通常是启动后异步刷新状态,健康检查只读内存标记。

四、模块间通信:接口、RPC 与事件

模块间通信有三种基本方式:进程内接口调用、RPC、事件。选择依据不是“哪个更先进”,而是调用方是否需要立即得到结果、是否能接受失败、以及两个模块是否必须一起发布。

方式延迟耦合度失败语义适用场景
进程内接口纳秒到微秒编译期依赖同步返回错误同进程、强一致、低延迟
RPC毫秒级契约依赖超时、重试、幂等独立部署、独立扩容
事件异步只依赖事件结构至少一次、可能重复通知、审计、异步结算

进程内接口适合“必须立刻知道结果”的调用,例如战斗读取角色快照。RPC 适合跨进程但仍有明确请求响应的场景,例如跨服匹配。事件适合“发生了这件事,相关方自行处理”的场景,例如角色升级后刷新排行榜、发送邮件、写审计日志。

事件不是免费的。引入事件后要处理重复投递、顺序、积压和死信。如果一件事必须强一致完成,不要用事件来“解耦”,那只是把一致性问题推迟到消费者。反过来,如果一件事只是通知,用同步 RPC 会让上游被下游的故障拖死。

什么时候该把一个模块拆成独立进程?看三个信号:需要独立扩容、需要独立发布、故障必须隔离。三个信号都没有,就留在进程内,用接口约束边界。过早拆进程会让本地开发、调试和事务都变复杂,收益却要等到规模上来才出现。

五、失败场景:关闭顺序反了

某次停服维护,进程收到退出信号后,main 按注册顺序依次调用 Close:配置、存储、账号、世界、战斗、社交。存储排在第二,连接池先被关闭;世界和战斗在关闭时还需要把玩家状态刷回数据库,结果全部返回“连接已关闭”。更糟的是战斗模块的关闭逻辑在写失败后重试了三次,每次等待两秒,整个进程退出被拖到十几秒,编排系统直接发送了强制杀进程的信号。

这次维护丢了最近几分钟的战斗结算。事后看,问题不是某个模块写得不对,而是关闭顺序与依赖方向相反:数据库是所有业务模块的依赖,必须最后关闭;网关和战斗是最上层,必须最先停止接收流量并完成在途请求。

修复方案是把生命周期交给注册表:启动时按依赖排序,关闭时严格逆序;每个模块的 Stop 只负责自己的资源,不负责调用别人的 Stop;关闭过程中的错误要收集起来继续关闭其余模块,而不是在第一个错误处返回。最后再加一个总超时,超时后记录未关闭的模块名,便于下次修复。

六、Go 实现:注册、排序初始化与逆序关闭

下面的 module.go 用注册表管理模块。Register 只做注册和重名检查,Plan 负责依赖排序并检测缺失依赖和环,Start 按顺序初始化并在失败时回滚,Stop 严格逆序关闭并合并错误。Ready 作为可选接口,在所有模块启动成功后统一调用。

依赖排序使用 Kahn 算法,并对候选队列排序,保证同一组模块每次得到相同顺序。关闭时只关闭真正启动成功的模块,避免对没启动的模块调用 Stop。启动失败后的回滚也走同一套逆序逻辑,不会留下半初始化状态。

// file: ch19/module/module.go
// tier: L1
// verify: go test ./ch19/module/

package module

import (
	"context"
	"errors"
	"fmt"
	"sort"
	"strings"
	"sync"
)

var (
	ErrDuplicateModule   = errors.New("module: duplicate module")
	ErrUnknownDependency = errors.New("module: unknown dependency")
	ErrCycle             = errors.New("module: dependency cycle")
)

// Module 是一个可启动、可关闭的进程内模块。Name 在注册表内唯一,
// DependsOn 返回的是“必须先于本模块完成 Start”的模块名。
type Module interface {
	Name() string
	DependsOn() []string
	Start(ctx context.Context) error
	Stop(ctx context.Context) error
}

// Readier 是可选接口。启动完成不等于可以接流量,预热、建连、加载配置
// 都可能失败,注册表在全部 Start 成功后统一调用 Ready。
type Readier interface {
	Ready(ctx context.Context) error
}

// Registry 负责注册、依赖排序和生命周期编排。它不关心模块内部做什么,
// 只保证依赖先启动、后关闭。
type Registry struct {
	mu      sync.Mutex
	modules map[string]Module
	started []string
	stopped bool
}

func NewRegistry() *Registry {
	return &Registry{modules: make(map[string]Module)}
}

func (r *Registry) Register(m Module) error {
	if m == nil {
		return errors.New("module: nil module")
	}
	name := strings.TrimSpace(m.Name())
	if name == "" {
		return errors.New("module: empty module name")
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.modules[name]; ok {
		return fmt.Errorf("%w: %s", ErrDuplicateModule, name)
	}
	r.modules[name] = m
	return nil
}

// Plan 返回依赖优先的初始化顺序。它可以被单独调用,用于启动前检查配置。
func (r *Registry) Plan() ([]string, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.planLocked()
}

func (r *Registry) planLocked() ([]string, error) {
	indegree := make(map[string]int, len(r.modules))
	dependents := make(map[string][]string, len(r.modules))
	for name, m := range r.modules {
		seen := make(map[string]bool, len(m.DependsOn()))
		for _, dep := range m.DependsOn() {
			if dep == name {
				return nil, fmt.Errorf("%w: %s depends on itself", ErrCycle, name)
			}
			if _, ok := r.modules[dep]; !ok {
				return nil, fmt.Errorf("%w: %s -> %s", ErrUnknownDependency, name, dep)
			}
			if seen[dep] {
				continue
			}
			seen[dep] = true
			indegree[name]++
			dependents[dep] = append(dependents[dep], name)
		}
	}

	ready := make([]string, 0, len(r.modules))
	for name := range r.modules {
		if indegree[name] == 0 {
			ready = append(ready, name)
		}
	}
	sort.Strings(ready)

	order := make([]string, 0, len(r.modules))
	for len(ready) > 0 {
		name := ready[0]
		ready = ready[1:]
		order = append(order, name)
		next := append([]string(nil), dependents[name]...)
		sort.Strings(next)
		for _, dep := range next {
			indegree[dep]--
			if indegree[dep] == 0 {
				ready = append(ready, dep)
			}
		}
		sort.Strings(ready)
	}
	if len(order) != len(r.modules) {
		return nil, ErrCycle
	}
	return order, nil
}

// Start 按依赖顺序初始化全部模块。任一模块失败时,已经启动的模块会按
// 逆序关闭,避免留下半初始化的状态。
func (r *Registry) Start(ctx context.Context) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if len(r.started) > 0 {
		return errors.New("module: already started")
	}
	order, err := r.planLocked()
	if err != nil {
		return err
	}
	for _, name := range order {
		if err := r.modules[name].Start(ctx); err != nil {
			rollbackErr := r.stopReverseLocked(ctx)
			return errors.Join(fmt.Errorf("module: start %s: %w", name, err), rollbackErr)
		}
		r.started = append(r.started, name)
	}
	r.stopped = false
	return nil
}

// Ready 按启动顺序调用实现了 Readier 的模块。返回错误说明进程可以
// 继续运行,但还不应该接入流量。
func (r *Registry) Ready(ctx context.Context) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	for _, name := range r.started {
		rd, ok := r.modules[name].(Readier)
		if !ok {
			continue
		}
		if err := rd.Ready(ctx); err != nil {
			return fmt.Errorf("module: ready %s: %w", name, err)
		}
	}
	return nil
}

// Stop 按启动顺序的逆序关闭。单个模块关闭失败不会中断后续关闭,
// 所有错误会合并返回。
func (r *Registry) Stop(ctx context.Context) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if r.stopped {
		return nil
	}
	err := r.stopReverseLocked(ctx)
	r.stopped = true
	return err
}

func (r *Registry) stopReverseLocked(ctx context.Context) error {
	var errs []error
	for i := len(r.started) - 1; i >= 0; i-- {
		name := r.started[i]
		if err := r.modules[name].Stop(ctx); err != nil {
			errs = append(errs, fmt.Errorf("module: stop %s: %w", name, err))
		}
	}
	r.started = nil
	return errors.Join(errs...)
}

// Started 返回当前已成功启动的模块名,按启动顺序排列。
func (r *Registry) Started() []string {
	r.mu.Lock()
	defer r.mu.Unlock()
	return append([]string(nil), r.started...)
}

代码里有三个刻意设计。第一,Plan 可以单独调用,发布前就能检查依赖写错,而不必真的启动进程。第二,Start 失败时调用 stopReverseLocked,把已经启动的模块按逆序关掉,错误用 errors.Join 合并,既不丢原始错误也不阻断回滚。第三,Stop 是幂等的,重复调用不会再次触发关闭,这对信号处理和测试都更安全。

Started 返回的是副本,调用方无法通过修改返回值影响注册表内部状态。这个细节在并发排查时很重要:管理接口可能同时读取已启动模块列表,如果直接返回内部切片,追加或排序都会引发数据竞争。

七、测试覆盖哪些边界

测试用 fakeModule 记录每个生命周期事件,覆盖了七类行为:

  • 启动顺序满足依赖:数据库先于缓存,缓存先于世界,世界先于网关。
  • 同一组模块的启动顺序稳定,不随 map 遍历顺序变化。
  • 关闭顺序是启动顺序的严格逆序,网关最先关闭,数据库最后关闭。
  • 依赖不存在的模块时,Start 返回 ErrUnknownDependency。
  • 两个模块互相依赖时,Plan 返回 ErrCycle。
  • 某个模块启动失败时,已经启动的模块被逆序回滚,后续模块不再启动。
  • 重复注册同名模块返回 ErrDuplicateModule,就绪检查在所有启动完成之后才执行。

这些测试不启动真实网络服务,运行时间在毫秒级,适合放进每次提交的 CI。真实项目还应该补一类集成测试:用真实数据库和配置启动完整注册表,验证关闭时没有连接泄漏和未完成事务。

八、运行测试

cd .local/checks/gs-practice-lab && gofmt -l ch19/module && go vet ./ch19/module/ && go test ./ch19/module/ -count=1
ok  	gspractice/ch19/module	0.732s

gofmt -l 没有输出说明格式一致;go vet 通过;测试覆盖顺序、失败回滚、环检测、缺失依赖、重复注册和就绪时机。需要注意,这个注册表只保证单进程内的生命周期顺序,跨进程的启动依赖仍要靠部署编排和健康检查解决。

小结

模块边界要同时写清职责、接口、数据所有权和依赖方向,四者缺一不可。依赖必须单向,团队边界尽量与模块边界对齐,但不要为了“架构纯洁”过度拆分。生命周期分成启动、就绪和关闭:启动按依赖排序,就绪在所有启动完成后检查,关闭严格逆序并合并错误。通信方式按“是否需要立即结果”和“是否能接受异步失败”选择,不要用事件掩盖强一致需求。把这些规则写进注册表和测试,模块化才不是一句口号。

常见错误与边界

  • 模块之间互相引用,编译期就形成环,最后靠抽取“公共包”掩盖问题,公共包反而依赖所有业务。
  • 初始化函数里做全量数据迁移、预热所有缓存或同步调用下游,启动时间不可控且失败难以回滚。
  • 关闭顺序与初始化顺序相同,先关数据库再关业务模块,导致刷盘失败或数据丢失。
  • 关闭时遇到第一个错误就返回,其余模块没有释放,连接和文件描述符泄漏。
  • 就绪检查永远返回成功,或者每次请求都打数据库,前者掩盖故障,后者放大故障。
  • 用事件处理必须强一致的动作,重复投递和乱序直接造成重复发奖或状态错乱。
  • 模块边界只按目录划分,数据表却允许所有模块直接写,实际仍是共享数据库的单体。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist