模块化与包结构:拆的依据与依赖治理

包结构不是审美问题,它是把「边界」和「依赖方向」变成编译期可见的约束。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 上。

包CaCeI期望抽象度 A说明
domain/order高低(≈0)≈0(最稳定)高:接口、值对象、聚合变化慢,被广泛依赖
app/order低高≈1(最不稳定)低:具体编排代码变化快,无人依赖它
adapter/repo低高≈1低:具体实现可随时替换
common(反例)极高极高数值无意义—人人都依赖、它也依赖人人 → 环的温床

无环依赖原则(Acyclic Dependencies Principle, ADP)在 Go 里的落地要点是:编译器的检查只覆盖 import 图,覆盖不了逻辑环。三种常见变形要人工盯:

  1. 跨模块环:moduleA 依赖 moduleB,某天 moduleB 为了一个工具函数反过来加依赖。
  2. 逻辑环:A 定义接口、B 实现,但 B 又回调 A 的其他功能——import 图无环,运行时是环。
  3. 万能 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 章讨论物理形态的取舍)。

小结:包结构的唯一目标是让边界和依赖方向可编译、可校验;按「变更原因 + 不变量」拆包,用 internal/ 和固定层名把方向写死,再用 go list 与 CI 断言守住它。

笔记加载中…