《游戏服务端编程实践》附录 C:示例项目目录结构

讲清单仓多服务与多仓的取舍、Go 服务内部 cmd/internal/pkg/api/migrations/deploy 的分层职责与依赖方向,给出可直接对照的完整目录树、配置密钥迁移脚本文档的位置约定、测试与 testdata 组织方式,以及 util 大杂烩、internal 互相引用和业务逻辑塞进 cmd 等反模式。

一、目录结构是依赖边界的投影

目录结构不是审美问题。它决定了三件事:代码能不能被独立测试、依赖会不会形成环、新人能不能在十分钟内找到入口。一个看起来整齐但所有包都互相引用的项目,比一个朴素但边界清晰的项目更难维护。

好的目录结构通常满足四个条件:

  1. 入口唯一:每个可执行程序有一个明确的 main,启动逻辑不散落。
  2. 依赖单向:业务层不反向依赖传输层,基础设施不被业务细节污染。
  3. 职责可命名:目录名能说明“这里放什么”,而不是“什么都放这里”。
  4. 可独立验证:核心逻辑不启动网络也能测试。

后文以一个 Go 单仓多服务项目为例。语言不同,目录名可以调整,但分层与依赖方向的原则是通用的。

二、单仓多服务 vs 多仓

单仓(monorepo)和多仓(multirepo)没有绝对答案。游戏服务端早期通常服务少、团队小、协议频繁变化,单仓的协作成本更低;当服务数量、团队规模和发布频率上升到一定程度,多仓的权限与发布隔离更有优势。

维度单仓多服务多仓
代码复用直接引用,重构方便需要发版或使用私有模块
依赖版本统一管理,容易保持一致各仓库独立,容易出现版本漂移
发布节奏可以按服务独立发布,但 CI 容易互相影响发布边界清晰,权限隔离好
权限控制粗粒度,靠目录与代码评审可按仓库授权
CI 时间全量构建可能变慢,需要按变更路径触发单仓构建快,但跨仓联调麻烦
跨服务重构一次提交完成,容易验证需要协调多个仓库与版本
故障隔离代码同仓不等于运行同进程,仍需部署隔离仓库隔离感更强,但不等于故障隔离
团队规模小到中等团队更舒服多团队、强边界时更合适
迁移成本后期拆分需要理清依赖后期合并需要统一工具链

一个常见的折中是“单仓 + 按服务独立部署”:代码放在同一个仓库,但每个服务有自己的 cmd、配置、镜像和发布流水线。这样既保留跨服务重构的便利,又不会把整个仓库打成一个巨大的二进制。真正需要拆仓时,再按依赖边界迁移,而不是一开始就为未来付出复杂度。

三、服务内部分层

Go 项目常见的顶层目录包括 cmd、internal、pkg、api、migrations、deploy、configs、scripts、docs、test。它们的职责不同,不能混用。

推荐的依赖方向如下:

cmd/gateway ─┐
cmd/logic ───┼──> internal/gateway、internal/logic ──> internal/platform ──> pkg
cmd/admin ───┘                │
                              └──> internal/shared(协议、时钟等无状态基础类型)

规则:
1. cmd 只做装配与启动,不写业务规则。
2. internal/gateway 与 internal/logic 平级,不互相引用。
3. internal/platform 提供配置、日志、指标、存储等基础设施。
4. pkg 只放确实需要被外部引用的稳定库,且不得反向引用 internal。
5. internal/shared 只放无状态、无业务规则的公共类型。

需要特别注意:Go 的 internal 规则只阻止模块外的代码导入,并不阻止模块内不同 internal 子包互相引用。也就是说,internal/logic 导入 internal/gateway 在编译器看来完全合法,但架构上已经越界。目录结构只能表达意图,真正的约束要靠代码评审、依赖检查或分层测试。

四、完整目录树

下面是一份可以直接对照的示例。它不是唯一正确答案,但每一层都有明确职责,适合中小型游戏服务端起步。

game-server/
├── cmd/
│   ├── gateway/
│   │   └── main.go
│   ├── logic/
│   │   └── main.go
│   └── admin/
│       └── main.go
├── internal/
│   ├── gateway/
│   │   ├── conn.go
│   │   ├── handler.go
│   │   ├── heartbeat.go
│   │   └── router.go
│   ├── logic/
│   │   ├── player/
│   │   │   ├── service.go
│   │   │   └── repository.go
│   │   ├── battle/
│   │   │   ├── service.go
│   │   │   └── rules.go
│   │   └── room/
│   │       ├── manager.go
│   │       └── state.go
│   ├── platform/
│   │   ├── config/
│   │   │   └── config.go
│   │   ├── logging/
│   │   │   └── logger.go
│   │   ├── metrics/
│   │   │   └── metrics.go
│   │   └── storage/
│   │       ├── mysql.go
│   │       └── redis.go
│   └── shared/
│       ├── protocol/
│       │   ├── frame.go
│       │   └── errors.go
│       └── clock/
│           └── clock.go
├── pkg/
│   └── wire/
│       ├── codec.go
│       └── codec_test.go
├── api/
│   ├── proto/
│   │   └── game.proto
│   └── openapi/
│       └── admin.yaml
├── configs/
│   ├── base.yaml
│   ├── gateway.yaml
│   └── logic.yaml
├── migrations/
│   ├── 0001_init.up.sql
│   ├── 0001_init.down.sql
│   └── 0002_add_player_index.up.sql
├── scripts/
│   ├── build.sh
│   ├── migrate.sh
│   └── loadtest/
│       └── gateway.js
├── deploy/
│   ├── docker/
│   │   └── Dockerfile
│   ├── k8s/
│   │   ├── gateway.yaml
│   │   └── logic.yaml
│   └── caddy/
│       └── Caddyfile
├── docs/
│   ├── architecture.md
│   ├── runbook.md
│   └── adr/
│       └── 0001-use-grpc.md
├── test/
│   ├── integration/
│   │   └── login_test.go
│   └── e2e/
│       └── smoke_test.go
├── testdata/
│   └── fixtures/
│       └── player_seed.json
├── go.mod
├── go.sum
├── Makefile
└── README.md

目录树只是骨架。真正决定可维护性的是“谁可以导入谁”,以及每个目录的变更频率是否与它的职责匹配。

五、逐目录职责与依赖方向

目录职责可以导入不应导入
cmd/gateway组装网关依赖、读取配置、启动监听internal/gateway、internal/platform业务规则、具体存储实现
cmd/logic组装逻辑服依赖、启动服务internal/logic、internal/platform网关连接细节
cmd/admin运营后台入口internal 中的管理接口玩家战斗逻辑
internal/gateway连接管理、心跳、协议路由internal/shared、internal/platforminternal/logic
internal/logic玩家、战斗、房间等业务规则internal/shared、internal/platforminternal/gateway
internal/platform配置、日志、指标、存储适配pkg、标准库具体业务包
internal/shared协议帧、错误码、时钟等无状态类型标准库、pkginternal/logic、internal/gateway
pkg可被外部引用的稳定库标准库internal 下的任何包
api协议定义与接口描述无(生成代码另行放置)业务实现
migrations数据库结构变更无应用代码
deploy容器、编排、网关配置无业务代码
configs分环境配置模板无密钥明文
scripts构建、迁移、压测脚本无业务逻辑
docs架构、运行手册、决策记录无生成的 API 文档
test跨包集成与端到端测试被测包生产代码反向导入
testdata测试固件与黄金文件无生产运行时读取

表格里的“不应导入”不是编译器强制,而是架构约定。为了让约定可执行,可以在 CI 中加一条依赖检查:禁止 internal/logic 出现 internal/gateway 的导入路径,禁止 pkg 出现 internal/。这类检查很便宜,却能在几个月后阻止一次痛苦的拆包。

六、配置、密钥、迁移、脚本、文档

这些内容经常被塞进 internal 或根目录,最后变成“只有作者知道在哪”。建议给它们固定位置,并明确“能不能进仓库”。

内容推荐位置是否进仓库约定
非敏感配置模板configs/是按环境拆分,字段有默认值与校验
密钥与令牌环境变量或密钥管理服务否仓库只保留变量名与示例占位
数据库迁移migrations/是成对 up/down,按序命名,可重复执行
构建与迁移脚本scripts/是失败即退出,不依赖交互输入
压测脚本scripts/loadtest/是与生产协议一致,默认指向测试环境
部署描述deploy/是镜像、资源、探针、滚动策略齐全
架构与运行手册docs/是架构图、故障处理、常见问题
决策记录docs/adr/是一条决策一个文件,写清背景与取舍

配置示例应当只包含结构与占位,不包含真实地址和密码。

service: gateway
env: production
listen:
  addr: ":9000"
  max_connections: 50000
timeouts:
  read: "30s"
  write: "10s"
  idle: "120s"
storage:
  dsn_env: "GAME_DB_DSN"
  max_open: 50
  max_idle: 10
logging:
  level: "info"
  format: "json"
metrics:
  enabled: true
  addr: ":9090"

配置的优先级也要固定,例如“命令行参数 > 环境变量 > 环境配置文件 > 默认值”,并在文档里写明。优先级不固定时,同一个参数在不同部署方式下会读到不同的值,排查起来非常耗时。配置加载完成后打印一次脱敏后的最终值,能让“到底用了哪份配置”这个问题当场结束。

启动时应当校验必填字段,并在缺失时直接失败,而不是用空值继续运行。密钥只通过环境变量或密钥管理服务注入,配置文件里出现 password: 明文就视为缺陷。迁移文件建议成对出现,且遵循“先扩展、再迁移、后收缩”的兼容顺序,避免新旧版本同时运行时互相破坏。

七、测试目录与 testdata

测试的组织方式决定了它能不能长期运行。推荐把测试分成三层,并让每层有明确的运行时机。

层级位置运行时机依赖目标
单元测试与被测代码同包,_test.go每次提交无外部服务验证规则与边界
集成测试test/integration/合并前或夜间数据库、缓存、队列验证适配与事务
端到端测试test/e2e/发布前完整环境验证关键玩家路径

testdata 的放置要区分范围:只被某个包使用的黄金文件放在该包下的 testdata/;跨服务共享的种子数据放在仓库根的 testdata/fixtures/。不要把大体积二进制、生产数据导出或含个人信息的样本放进仓库。测试固件应该可读、可生成、可清理,而不是“从线上拖下来的一份神秘数据”。

集成测试要能重复运行。每次开始前清理或使用独立 schema,结束后释放连接;不要在测试之间共享可变状态。端到端测试要覆盖登录、进场景、关键战斗、支付回调等路径,但不要把全部业务规则复制一遍,否则维护成本会超过收益。

八、常见反模式目录

反模式典型症状后果替代做法
util 大杂烩internal/util 里同时有字符串、时间、加密、重试无明确负责人,成为循环依赖的枢纽按能力拆成 platform/ 下的包,或就近放置
internal 互相引用网关导入逻辑,逻辑又导入网关编译期形成 import cycle,拆包困难用接口或事件解耦,保持平级
业务逻辑放 cmdmain.go 里写战斗结算、掉落规则无法单测,多个入口重复实现cmd 只装配,规则下沉到 internal
pkg 过早公开把内部类型放进 pkg 供别的服务引用对外承诺 API,后续无法自由修改先放 internal,确有外部使用者再提升
配置散落各包直接读环境变量,默认值不一致启动行为不可预测统一在 platform/config 解析并校验
生成代码手改api/proto 生成物被直接编辑下次生成即丢失,协议漂移改源文件,生成物只读
一个 service 包包打天下internal/game 包含所有业务包体膨胀,测试与评审困难按领域拆 player、battle、room
测试数据放生产目录运行时误读测试固件数据污染与安全问题统一放 testdata,运行时不可访问

这些反模式的共同点是“短期方便,长期收税”。它们不会让项目立刻失败,但会让每一次修改都更慢、更危险。

九、失败场景:一次拆包引发的循环依赖

某项目最初只有一个 cmd/server 和 internal/model。为了快速开发,网关和逻辑都直接引用 model;后来 model 需要读取配置,又引用了 internal/config;配置包为了记录日志,引用了 internal/logging;日志包为了标注玩家 ID,引用了 model。四条边一连,编译直接报 import cycle。

团队的第一反应是把类型复制一份,结果两份模型逐渐不一致;第二反应是把所有东西塞进一个 internal/common,循环消失了,但依赖边界也消失了。真正的问题是:model 同时承担了数据结构、配置读取和日志上下文三种职责。

修复过程分三步:

  1. 把纯数据结构下沉到 internal/shared/protocol,不依赖任何基础设施。
  2. 把配置读取上移到 internal/platform/config,由 cmd 注入,而不是让模型主动读取。
  3. 日志上下文改为显式传参或使用独立的上下文字段,切断 logging 对业务模型的依赖。

这次事故的教训不是“不要用 model 包”,而是“每个包只能有一个变化原因”。当一个包既定义协议、又读配置、又记日志时,它一定会成为依赖枢纽,最终把整个项目缠在一起。

十、从零落地顺序

如果要从空仓库建立结构,建议按下面的顺序,而不是一次性建出所有目录。

  1. 先写 cmd/<service>/main.go,只做参数解析、配置加载和依赖装配。
  2. 把第一条业务路径放进 internal/<domain>,同时写同包单元测试。
  3. 抽出 internal/platform,统一配置、日志、指标和存储连接。
  4. 需要跨服务共享的协议放进 api/,生成代码与手写代码分离。
  5. 数据库变更进入 migrations/,脚本进入 scripts/,并在 CI 中验证。
  6. 部署描述进入 deploy/,运行手册与架构说明进入 docs/。
  7. 最后再决定哪些包值得放进 pkg/,没有外部使用者就不要放。

每加一个目录,都问一句:它的职责能否用一句话说清?它允许导入谁、禁止导入谁?如果答不上来,说明目录只是模仿了别人的形状,还没有形成自己的边界。

小结

目录结构的核心不是“看起来像大厂”,而是让依赖方向可见、让测试可运行、让新人能找到入口。单仓多服务适合早期快速协作,多仓适合强边界与独立发布;服务内部用 cmd 做装配、internal 放业务与基础设施、pkg 放稳定公共库、api 放协议、migrations 放结构变更、deploy 放部署描述。配置与密钥分离,迁移可回滚,测试分层,testdata 有明确范围。最后,用依赖检查把“不应导入”从口头约定变成 CI 规则。

常见错误与边界

  • 把目录结构当架构:目录整齐不代表依赖正确,internal 之间仍可能互相引用。
  • util 包无限膨胀:没有归属的公共代码会成为循环依赖的温床。
  • 业务逻辑塞进 cmd:入口难以测试,多个服务重复实现同一规则。
  • 过早使用 pkg:一旦被外部引用,修改成本会显著上升。
  • 配置散落在各包:默认值不一致,启动行为不可预测。
  • 密钥写进配置文件:仓库、镜像和日志都会成为泄露渠道。
  • 迁移只有 up 没有 down:出问题时无法快速回退。
  • testdata 与生产数据混放:可能被运行时误读,也可能带来隐私风险。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 B:性能调优 Checklist
  3. 《游戏服务端编程实践》附录 A:常用框架与工具清单(按语言分类)