模块化与包结构:拆的依据与依赖治理
包结构不是审美问题,它是把「边界」和「依赖方向」变成编译期可见的约束。Go 有个先天优势:同一个模块内出现 import 环会直接编译失败(import cycle not allowed),所以包级的循环依赖不会活到线上。但代价是——环会以更隐蔽的形态转移阵地:跨模块环、通过接口/注册表形成的逻辑环、以及一个什么都往里塞的 common 包。
1. 拆包的三个依据
只用一个问题判断该不该有独立包:它是否拥有一块独立的、需要被保护的内部状态或规则。 细化为三条判据:
| 判据 | 拆的信号 | 不拆的信号 |
|---|---|---|
| 变更原因 | 两部分因不同原因、不同频率变化(促销规则周更 / 税制年更) | 总在同一批提交里一起改 |
| 不变量 | 有内部状态和必须守住的不变量,需要封装 | 只有数据结构,无规则 |
| 复用方式 | 会被多个调用方按不同方式使用 | 只有一条调用路径,拆了也只有转发方法 |
再往下是内聚与耦合的具体形态,目标很明确:只保留功能内聚与数据耦合。
| 形态 | 例子 | 评价 |
|---|---|---|
| 功能内聚(Functional) | pricing 包里全是定价规则 | 目标形态 |
| 时序内聚(Temporal) | init/setup 包按启动顺序堆放 | 可接受但不该长期存在 |
| 逻辑内聚(Logical) | utils、helper、common | 应拆分 |
| 巧合内聚(Coincidental) | misc、base | 应解散 |
| 耦合形态 | 例子 | 处理 |
|---|---|---|
| 内容耦合 | 直接改对方的非导出字段、直接读对方表 | 禁止 |
| 公共耦合 | 共用全局变量、共享可变单例 | 改为显式传参 |
| 控制耦合 | Process(order, isRefund bool) | 拆成两个方法 |
| 数据耦合 | 传值对象(Money、OrderID) | 目标形态 |
2. 稳定依赖原则与依赖方向
稳定依赖原则(Stable Dependencies Principle, SDP):依赖箭头应指向更稳定的包。不稳定度用 I = Ce / (Ca + Ce) 度量(Ca 被外部依赖数,Ce 依赖外部数)。抽象度 A = 抽象类型数 / 类型总数,理想落在主序列 A + I ≈ 1 上。
| 包 | Ca | Ce | I | 期望抽象度 A | 说明 |
|---|---|---|---|---|---|
domain/order | 高 | 低(≈0) | ≈0(最稳定) | 高:接口、值对象、聚合 | 变化慢,被广泛依赖 |
app/order | 低 | 高 | ≈1(最不稳定) | 低:具体编排代码 | 变化快,无人依赖它 |
adapter/repo | 低 | 高 | ≈1 | 低:具体实现 | 可随时替换 |
common(反例) | 极高 | 极高 | 数值无意义 | — | 人人都依赖、它也依赖人人 → 环的温床 |
无环依赖原则(Acyclic Dependencies Principle, ADP)在 Go 里的落地要点是:编译器的检查只覆盖 import 图,覆盖不了逻辑环。三种常见变形要人工盯:
- 跨模块环:
moduleA依赖moduleB,某天moduleB为了一个工具函数反过来加依赖。 - 逻辑环:A 定义接口、B 实现,但 B 又回调 A 的其他功能——import 图无环,运行时是环。
- 万能
types包:为了绕开环,把所有结构体抽到common/types,结果它同时依赖两边。
3. Go 的包命名与 internal/
internal/
├── order/ # 限界上下文边界(上下文即顶级目录)
│ ├── domain/ # 模型 + 仓储接口:不 import 任何基础设施
│ │ ├── order.go
│ │ └── repository.go
│ ├── app/ # 应用服务:用例编排、事务、事件发布
│ ├── adapter/
│ │ ├── http/ # 入站适配:绑定参数、DTO 转换
│ │ └── repo/ # 出站适配:GORM 实现仓储接口
│ └── event/ # 对外的集成事件契约(其他上下文只能看这个包)
├── inventory/ # 另一个上下文,结构对称
└── platform/ # 公共基础设施:logx、config、db
├── logx/
└── db/
命名规则(Go 社区共识,比框架约定更重要):
- 包名小写、单数、单词,不用下划线(
order,不是order_service)。 - 名字表达角色或领域概念,不表达层次前缀(
pricing,不是pricingService、pricingHelper)。 - 禁止
util/helper/common/base/misc/models——这些名字不包含任何边界信息。 - 避免与标准库同名(
url、strings、io),否则调用方要起别名,可读性立刻下降。
internal/ 的语义:只有以 internal 的父目录为根的子树内的代码能 import 它。多级 internal 更严格,例如 internal/order/internal/rule 只有 internal/order/ 子树能 import——这是免费获得的边界保护,跨上下文越界会直接编译失败。
| 情况 | 结论 |
|---|---|
| 新代码只有 1~3 个文件、与调用方同步变更 | 先放在调用方包里,别急着拆 |
| 拆出的包里所有方法都只是转发给另一个包 | 合并回去 |
| 两个包必须同时 import 才能完成一件事 | 高概率应合并 |
| 需要隐藏内部状态、阻止外部直接改写 | 拆包(配 internal/ 更硬) |
| 想让依赖方向被工具和 CI 强制 | 拆包 + 层目录命名固定(domain/app/adapter) |
4. 用 go list 检查依赖与方向
# 1) 每个业务包的直接依赖(Imports 是 Go 切片字面量)
go list -f '{{.ImportPath}} {{.Imports}}' ./internal/...
# 2) 谁依赖了 order/domain(排查反向依赖与环的入口)
go list -f '{{.ImportPath}} {{.Importers}}' ./internal/... | grep 'order/domain'
# 3) 依赖闭包:domain 层是否意外引入基础设施
go list -deps ./internal/order/domain/... | grep 'myapp/internal'
# 4) 跨模块依赖图,检查 module 之间的环
go mod graph | grep 'myapp'
# 5) 编译期检查环(同模块内必然报错)
go build ./... 2>&1 | grep -i 'import cycle'
把方向约束固化成 CI 脚本,比写在文档里可靠:
#!/usr/bin/env bash
set -euo pipefail
# domain 层不允许依赖 app / adapter / platform
if go list -deps ./internal/order/domain/... | grep -E 'internal/order/(app|adapter)|internal/platform'; then
echo "违反依赖方向:domain 层出现了对外依赖"; exit 1
fi
# 各上下文只能通过 event 包互相引用
if go list -deps ./internal/inventory/... | grep -E 'internal/order/(domain|app|adapter)'; then
echo "违反上下文边界:inventory 直接依赖了 order 内部包"; exit 1
fi
更完整的方案:golangci-lint 的 depguard、或者 go-arch-lint 这类工具把允许的边写成配置,在 PR 上卡住违规。
5. 落地检查项
- 每个上下文顶级目录下的
domain包不 import 任何外部/基础设施包(CI 校验)。 - 跨上下文只能引用对方的
event或公开 API 包,不能引用内部实现包。 - 不存在
common/utils/helper这类无边界包;公共设施放在platform且不反向依赖业务。 - 新增包能说出它的变更原因与受保护的不变量,而不只是「代码太多了」。
- 依赖方向有一条可执行断言(脚本或 lint 配置),而不是口头约定。
6. 常见误区
- 按技术分层切包(
controllers/services/models三个大包):一个功能要改五个包,上下文边界在代码里完全不可见,而且最容易出环。 common包救场:把两个包都要用的东西搬过去,短期解环,长期让所有包互相耦合。- 用接口藏环:A 定义接口、B 实现、B 又依赖 A 的功能,import 图干净了,逻辑环还在,只是没人看得见。
- 包越细越有架构:拆成三十个两文件小包,阅读成本高于收益;判据是变更原因和不变量,不是文件数。
- 忘了 module 边界也是边界:Go 可以为每个上下文建独立
go.mod,但跨模块版本管理成本会上升,团队没准备好就别拆(第 08 章讨论物理形态的取舍)。