一、目录结构是依赖边界的投影
目录结构不是审美问题。它决定了三件事:代码能不能被独立测试、依赖会不会形成环、新人能不能在十分钟内找到入口。一个看起来整齐但所有包都互相引用的项目,比一个朴素但边界清晰的项目更难维护。
好的目录结构通常满足四个条件:
- 入口唯一:每个可执行程序有一个明确的
main,启动逻辑不散落。 - 依赖单向:业务层不反向依赖传输层,基础设施不被业务细节污染。
- 职责可命名:目录名能说明“这里放什么”,而不是“什么都放这里”。
- 可独立验证:核心逻辑不启动网络也能测试。
后文以一个 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/platform | internal/logic |
| internal/logic | 玩家、战斗、房间等业务规则 | internal/shared、internal/platform | internal/gateway |
| internal/platform | 配置、日志、指标、存储适配 | pkg、标准库 | 具体业务包 |
| internal/shared | 协议帧、错误码、时钟等无状态类型 | 标准库、pkg | internal/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,拆包困难 | 用接口或事件解耦,保持平级 |
| 业务逻辑放 cmd | main.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 同时承担了数据结构、配置读取和日志上下文三种职责。
修复过程分三步:
- 把纯数据结构下沉到
internal/shared/protocol,不依赖任何基础设施。 - 把配置读取上移到
internal/platform/config,由cmd注入,而不是让模型主动读取。 - 日志上下文改为显式传参或使用独立的上下文字段,切断
logging对业务模型的依赖。
这次事故的教训不是“不要用 model 包”,而是“每个包只能有一个变化原因”。当一个包既定义协议、又读配置、又记日志时,它一定会成为依赖枢纽,最终把整个项目缠在一起。
十、从零落地顺序
如果要从空仓库建立结构,建议按下面的顺序,而不是一次性建出所有目录。
- 先写
cmd/<service>/main.go,只做参数解析、配置加载和依赖装配。 - 把第一条业务路径放进
internal/<domain>,同时写同包单元测试。 - 抽出
internal/platform,统一配置、日志、指标和存储连接。 - 需要跨服务共享的协议放进
api/,生成代码与手写代码分离。 - 数据库变更进入
migrations/,脚本进入scripts/,并在 CI 中验证。 - 部署描述进入
deploy/,运行手册与架构说明进入docs/。 - 最后再决定哪些包值得放进
pkg/,没有外部使用者就不要放。
每加一个目录,都问一句:它的职责能否用一句话说清?它允许导入谁、禁止导入谁?如果答不上来,说明目录只是模仿了别人的形状,还没有形成自己的边界。
小结
目录结构的核心不是“看起来像大厂”,而是让依赖方向可见、让测试可运行、让新人能找到入口。单仓多服务适合早期快速协作,多仓适合强边界与独立发布;服务内部用 cmd 做装配、internal 放业务与基础设施、pkg 放稳定公共库、api 放协议、migrations 放结构变更、deploy 放部署描述。配置与密钥分离,迁移可回滚,测试分层,testdata 有明确范围。最后,用依赖检查把“不应导入”从口头约定变成 CI 规则。
常见错误与边界
- 把目录结构当架构:目录整齐不代表依赖正确,
internal之间仍可能互相引用。 - util 包无限膨胀:没有归属的公共代码会成为循环依赖的温床。
- 业务逻辑塞进 cmd:入口难以测试,多个服务重复实现同一规则。
- 过早使用 pkg:一旦被外部引用,修改成本会显著上升。
- 配置散落在各包:默认值不一致,启动行为不可预测。
- 密钥写进配置文件:仓库、镜像和日志都会成为泄露渠道。
- 迁移只有 up 没有 down:出问题时无法快速回退。
- testdata 与生产数据混放:可能被运行时误读,也可能带来隐私风险。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。