一、模块边界先于代码边界
模块划分不是把文件挪进不同目录,而是先回答四个问题:这个模块对谁负责、暴露什么接口、拥有哪些数据、允许依赖谁。四个问题没有答案,代码目录再整齐也只是物理隔离。一个模块如果既能改角色属性、又能直接写排行榜,它的边界实际上不存在。
判断边界是否成立,有一个简单测试:把模块的实现整体替换掉,调用方是否需要修改。如果调用方依赖的是接口和事件,替换实现不需要改调用方;如果调用方直接读模块的表或全局变量,替换就无从谈起。模块化的收益来自“可替换”,而不是来自“文件分了很多包”。
模块清单应该是一份可以评审的文档,而不是代码生成后的目录树。每个模块至少写清楚:职责一句话、对外接口、拥有的数据、明确不做什么、依赖哪些模块。下面是一份示例清单,名字和划分方式要按项目实际调整。
| 模块 | 对外接口 | 拥有的数据 | 明确不做 |
|---|---|---|---|
| 配置 | 按版本读取配置、热更新通知 | 配置版本、灰度规则 | 不判断业务合法性 |
| 存储 | 事务、查询、迁移状态 | 连接池、表结构版本 | 不包含业务 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 通过;测试覆盖顺序、失败回滚、环检测、缺失依赖、重复注册和就绪时机。需要注意,这个注册表只保证单进程内的生命周期顺序,跨进程的启动依赖仍要靠部署编排和健康检查解决。
小结
模块边界要同时写清职责、接口、数据所有权和依赖方向,四者缺一不可。依赖必须单向,团队边界尽量与模块边界对齐,但不要为了“架构纯洁”过度拆分。生命周期分成启动、就绪和关闭:启动按依赖排序,就绪在所有启动完成后检查,关闭严格逆序并合并错误。通信方式按“是否需要立即结果”和“是否能接受异步失败”选择,不要用事件掩盖强一致需求。把这些规则写进注册表和测试,模块化才不是一句口号。
常见错误与边界
- 模块之间互相引用,编译期就形成环,最后靠抽取“公共包”掩盖问题,公共包反而依赖所有业务。
- 初始化函数里做全量数据迁移、预热所有缓存或同步调用下游,启动时间不可控且失败难以回滚。
- 关闭顺序与初始化顺序相同,先关数据库再关业务模块,导致刷盘失败或数据丢失。
- 关闭时遇到第一个错误就返回,其余模块没有释放,连接和文件描述符泄漏。
- 就绪检查永远返回成功,或者每次请求都打数据库,前者掩盖故障,后者放大故障。
- 用事件处理必须强一致的动作,重复投递和乱序直接造成重复发奖或状态错乱。
- 模块边界只按目录划分,数据表却允许所有模块直接写,实际仍是共享数据库的单体。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。