26.6 模块化与包设计
一句话
模块化是把 26.1–26.5 的依赖规则下沉到包一级:包是最小的可独立演进单元,好的包高内聚、依赖无环、只暴露必要的东西——判据是 Martin 的三条内聚原则 + 三条耦合原则,落地手段是 Go 的包边界、
internal与 CI 检查。
三个粒度:包 / 模块 / 服务
| 粒度 | 边界形式 | 破坏成本 | 跨边界成本 | 什么时候用 |
|---|---|---|---|---|
| 包 (package) | 编译期 import | 低(改 import 即可) | 一次函数调用(纳秒) | 默认。绝大多数边界应该是包 |
| 模块 (Go module / 多模块仓库) | 版本、发布周期 | 中(版本升级、兼容承诺) | 版本对齐 + 依赖治理 | 需要独立发布、被多个仓库复用 |
| 服务 (process) | 网络 + 部署 | 高(数据一致性、运维、故障传播) | RPC(毫秒级)+ 分布式事务 | 独立伸缩、独立团队、故障隔离(30 章) |
🔑 先用尽包边界,再考虑服务边界。包的边界成本几乎为 0,服务的边界成本是网络调用 + 数据一致性 + 值班。 很多团队跳过包边界直接拆服务,结果得到"分布式单体"(30.3)。
内聚:一个包该装什么
| 原则 | 一句话 | 什么时候优先 | 反例 |
|---|---|---|---|
| REP 复用/发布等价 | 复用的粒度 = 发布的粒度:一个包要么整体被复用,要么整体不用 | 做基础库 / SDK | 为了"省一个 import"把工具函数塞进业务包 |
| CCP 共同闭包 | 因为同一个原因变化的类,放同一个包 | 业务包(最常用) | 一个需求改 6 个包(按技术分包) |
| CRP 共同复用 | 不一起被使用的类,不要放进同一个包 | 公共库 / 平台包 | util 包:用 A 的人被迫依赖 B、C、D |
三者天然冲突:REP + CCP 让包变大(好复用、好改),CRP 让包变小(好裁剪)。取舍依据是谁在改:
- 业务代码:CCP 优先——"一个需求进来,改动落在几个包?" 1 个 → 好;4 个 → 你按技术分层了。
- 平台/SDK 代码:REP + CRP 优先——用户只想要其中一个能力时,不该被迫拖进整个依赖树。
耦合:包之间怎么依赖
| 原则 | 一句话 | 违反的症状 |
|---|---|---|
| ADP 无环依赖 | 包依赖图必须是 DAG | import cycle not allowed;只能靠"抽接口"硬绕 |
| SDP 稳定依赖 | 依赖要指向更稳定的包(被依赖多的 = 更稳定) | 核心领域包 import 了一个天天改的工具包 |
| SAP 稳定抽象 | 越稳定的包越应该抽象 | 一个被 10 个包依赖的包,全是具体函数 |
度量公式(Martin 的包度量):
| 符号 | 含义 | 计算 |
|---|---|---|
| Ca | 入度:有多少个包依赖我 | 数入边 |
| Ce | 出度:我依赖了多少个包 | 数出边 |
| I | 不稳定度 | I = Ce / (Ca + Ce),0 = 最稳定,1 = 最不稳定 |
| A | 抽象度 | 抽象类型数 / 总类型数 |
| D | 与主序列的距离 | D = abs(A + I − 1),0 = 在主序列上 |
主序列的含义:稳定的包(I 小)应该抽象(A 大);不稳定的包(I 大)可以具体(A 小)。 落在 A + I ≈ 1 这条线上算健康。
代码:把度量算出来
go run main.go(度量的价值在"看趋势和异常",不在精确值):
package main
import (
"fmt"
"math"
"sort"
)
// 一个包的度量输入:抽象类型数、具体类型数、依赖了哪些内部包
type pkg struct {
name string
abstract, concrete int
deps []string
}
func main() {
pkgs := []pkg{
{"domain", 6, 4, nil},
{"ports", 8, 0, []string{"domain"}},
{"app", 2, 10, []string{"domain", "ports", "util"}},
{"adapter/mysql", 1, 9, []string{"domain", "ports", "util"}},
{"adapter/http", 1, 7, []string{"app", "ports", "util"}},
{"infra/config", 0, 3, nil},
{"util", 0, 12, []string{"infra/config"}},
}
// Ca:有多少个内部包依赖我(入度)
ca := map[string]int{}
for _, p := range pkgs {
for _, d := range p.deps {
ca[d]++
}
}
type row struct {
name string
ce, ca int
i, a, d float64
}
rows := make([]row, 0, len(pkgs))
for _, p := range pkgs {
ce := len(p.deps)
total := p.abstract + p.concrete
a := 0.0
if total > 0 {
a = float64(p.abstract) / float64(total) // 抽象度
}
i := 0.0
if ce+ca[p.name] > 0 {
i = float64(ce) / float64(ce+ca[p.name]) // 不稳定度
}
rows = append(rows, row{p.name, ce, ca[p.name], i, a, math.Abs(a + i - 1)})
}
sort.Slice(rows, func(x, y int) bool { return rows[x].d > rows[y].d })
fmt.Printf("%-14s %3s %3s %6s %6s %6s\n", "包", "Ce", "Ca", "I", "A", "D")
for _, r := range rows {
flag := ""
if r.d > 0.5 {
flag = " ← 偏离主序列"
}
fmt.Printf("%-14s %3d %3d %6.2f %6.2f %6.2f%s\n", r.name, r.ce, r.ca, r.i, r.a, r.d, flag)
}
}
输出(按 D 从大到小):
包 Ce Ca I A D
infra/config 0 1 0.00 0.00 1.00 ← 偏离主序列
util 1 3 0.25 0.00 0.75 ← 偏离主序列
domain 0 3 0.00 0.60 0.40
ports 1 3 0.25 1.00 0.25
adapter/http 3 0 1.00 0.12 0.12
adapter/mysql 3 0 1.00 0.10 0.10
app 3 1 0.75 0.17 0.08
读这张表:
| 包 | 读法 | 判断 |
|---|---|---|
util (D=0.75) | 被 3 个包依赖(稳定),却 0 抽象、12 个具体类型 | 真问题:所有人依赖它,它却随时会改 |
infra/config (D=1.00) | 被 1 个包依赖,全是常量/具体类型 | 假问题:它本质是叶子常量包,稳定且具体是合理的 |
app (D=0.08) | 依赖 3 个、被 1 个依赖,比较不稳定、比较具体 | 健康:应用层本来就该随用例变化 |
ports (D=0.25) | 被 3 个依赖,全抽象 | 健康:稳定的东西抽象,符合 SAP |
⚠️ D 是探照灯,不是判决书。
infra/config的 D=1.0 不代表要改,util的 D=0.75 才需要动手(拆包、下沉、或把具体工具改成按能力分包)。度量用来找"该看一眼的地方",判断仍要看包的职责。
包的切法:按业务垂直切,不按技术水平切
同一个系统,两种切法:
❌ 按技术分层(水平切):一个需求改 4 个包
shop/
├── controller/
│ ├── order.go
│ ├── payment.go
│ └── user.go
├── service/
│ ├── order.go
│ └── payment.go
├── dao/
│ ├── order.go
│ └── payment.go
└── model/
├── order.go
└── payment.go
✅ 按业务模块(垂直切):一个需求改 1 个包
shop/
├── internal/
│ ├── order/ ← 一个模块一个包
│ │ ├── handler.go
│ │ ├── service.go
│ │ ├── repo.go
│ │ ├── order.go
│ │ └── service_test.go
│ ├── payment/
│ └── inventory/
├── platform/ ← 技术设施,被业务依赖
│ ├── db/
│ ├── config/
│ └── observability/
└── cmd/
└── api/main.go
"给订单加优惠券叠加"这个需求,改动范围:
| 切法 | 要改的包 | 影响面 |
|---|---|---|
| 按技术分层 | controller、service、dao、model(4 个包) | 每次改动都要跨 4 个目录 review,合并冲突高 |
| 按业务模块 | order(1 个包,多改几个文件) | 单包内完成,review 集中,边界清楚 |
🔑 包名应该是业务名词(
order、payment、settlement),不是技术词(controller、service、dao、models)。看到models包就该警惕:它通常会长成"所有实体的上帝包",最终谁都不敢改。
模块内部还要不要分层? 要,但用文件而不是子包:小模块里 handler.go / service.go / repo.go / order.go 四类文件足够;只有当某一层复杂到需要独立测试与复用时(比如 order/pricing 有 20 个文件),才拆子包。层数不变,只是层的载体从目录变成了文件。
Go 的边界工具:让编译器帮你守
1. internal/:编译器强制的可见性
shop/
├── internal/
│ ├── order/ ← 只有 shop/... 能 import
│ └── billing/
└── api/ ← 可以被外部仓库 import 的公开 API
internal 的规则由 Go 工具链强制执行:shop/internal/order 只能被 shop 前缀的包 import。这是唯一不需要额外工具、也不会被绕过的边界,应该用它装"实现细节";而对外的 SDK / 共享契约要放在 internal 之外。
2. 导出面最小化
package order
// 端口导出:外部要提供实现,所以接口必须可见
type Repo interface {
Find(ctx context.Context, id string) (*Order, error)
Save(ctx context.Context, o *Order) error
}
// 结构体导出、字段不导出:外部改不了内部状态,只能通过方法
type Service struct {
repo Repo
seq int
}
func NewService(r Repo) *Service { return &Service{repo: r} }
三条经验:
| 做法 | 理由 |
|---|---|
构造函数返回 *Service(struct),不返回 interface | 让使用方定义它需要的接口(26.2),而不是被实现方规定 |
| 字段不导出 | 外部无法把对象改成非法状态(seq 只能由方法维护) |
| 导出的符号越少越好 | 每个导出符号都是一份对外承诺,改名就是破坏性变更 |
3. CI 里检查依赖方向
Go 没有内置的"架构规则"检查,用 go list 一行就能做:
# 领域层只能依赖标准库:出现任何内部依赖都算违规
if go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... \
| grep '^shop/internal/domain ' \
| grep -Eq 'shop/internal/(order|payment|adapter|infra)'; then
echo "违规:domain 依赖了外层"; exit 1
fi
echo "OK:domain 没有向外依赖"
需要更细的规则("adapter 不许互相 import"、"app 不许 import gorm")时,用 golangci-lint 的 depguard(配置字段随版本演进,下面是最小示例,以你所用版本的文档为准):
version: "2"
linters:
settings:
depguard:
rules:
domain-pure:
files:
- "**/internal/domain/**"
deny:
- pkg: "gorm.io/**"
desc: "领域层不得依赖 ORM"
adapter-isolation:
files:
- "**/internal/adapter/**"
deny:
- pkg: "shop/internal/adapter/**"
desc: "适配器之间不许互相依赖,只能依赖核心"
🔑 边界只有两种状态:被编译器/CI 强制,或者靠自觉。靠自觉的边界在第一次赶工期时就会破。
循环依赖:Go 编译失败是礼物
Go 在包级检测循环 import,直接编译错误。四种破法的优先级:
| 破法 | 做法 | 什么时候用 | 风险 |
|---|---|---|---|
| 下沉共享类型 | 把双方共用的类型抽到第三个包(domain、kernel) | 循环来自"共享数据结构" | 抽成 common 垃圾桶 |
| 接口倒置 | 一方定义接口,另一方实现(26.2) | 双方职责清晰、只是互需能力 | 接口里镜像对方全量字段 = 假解耦 |
| 事件解耦 | 一方发事件,另一方订阅 | 反应式流程(下单 → 扣库存) | 同步变异步,一致性变最终一致 |
| 合并包 | 承认它们是一个模块 | 循环来自职责切错 | 包变大,要重新找内聚边界 |
⚠️ 循环依赖的最强信号是"两个包互相需要对方的数据结构",这通常意味着边界画在了错误的位置——不是按变化原因切,而是按名词切(
order和user都可能需要"地址",地址该下沉到共享内核,而不是让两个包互相依赖)。
包大小与数量:经验值
| 指标 | 经验值 | 超过时怎么办 |
|---|---|---|
| 单包文件数 | 5–20 个 | > 50 考虑按子能力拆(如 order/pricing) |
| 目录深度 | ≤ 3–4 层 | 更深通常是按技术分层嵌套了 |
| 单包导出符号 | ~20 个以内 | 拆分或收敛导出面 |
| 单文件行数 | 300–500 行 | 拆文件(同包内),不是拆包 |
| 包数量 | 与团队人数同量级,几十个 | 上百个小包通常是"一文件一包"过度拆分 |
💡 这些是启发式,不是标准。包的边界服务于"谁能独立改、谁能独立测",不服务于目录美观。
与 27 章的衔接:模块化单体
该不该用单体、什么时候必须拆,见 27.1 单体架构的适用性;27.2 模块化单体的前提就是本节的包边界:一个模块 = 一个包(或一组包),模块间只通过导出的端口通信,不共享数据库表、不互相 import 内部实现。
🔑 判断能不能拆服务,先看包边界能不能守住:包边界守得住,拆服务只是把包搬到进程外;守不住,拆完就是分布式单体(30.3)。
反模式
| 反模式 | 后果 |
|---|---|
util / common / helpers 包 | 变成垃圾桶:所有人依赖它、它随时会改,D 值长期偏高 |
models 包装所有实体 | 上帝包,任何表结构变更都触发全仓库重编译与 review |
| 按技术分包(controller/service/dao) | 一个需求横跨 4 个包,改动无法独立 review,模块边界不存在 |
| 一文件一包 | 包数量爆炸,导入路径冗长,跳转成本高 |
用 interface 硬绕循环依赖,接口里放对方全部字段 | 耦合从显式变隐式,编译器不再报警 |
internal 放本该公开的 SDK 类型 | 外部用户无法使用,只能复制代码 |
包名与目录名不一致(package models 放在 entity/) | import 时看到的名字和路径对不上,认知负担 |
| 过早拆 Go module(一个仓库 20 个 module) | 版本对齐、replace 指令、CI 复杂度全面上升 |
| 导出的 struct 字段全部公开 | 外部可构造非法状态,所有字段变更都是破坏性变更 |
| 认为"包隔离 = 拆服务" | 白付网络与一致性成本,拿到一个更慢的单体 |
自检
Q1 一个团队把代码切成 controller/、service/、dao/、model/ 四个包,说"我们是分层清晰的分层架构"。从模块化角度看,这有什么问题?
▸ 答案:这是按技术水平切,违反 CCP:一个业务需求(比如"订单加优惠券")要同时改 4 个包,四个包之间没有任何一个能独立演进——它们的变化原因完全相同,却被拆成四份。分层没有错,错在把"层"做成了顶层目录:正确结构是"按业务模块垂直切(order/、payment/),模块内部再用文件分层"。判据:改动一个需求,落在几个包?超过 2 个就该重新审视切法。
Q2 度量显示 util 包 D=0.75、infra/config 包 D=1.00。你先改哪个?为什么另一个可能不用改?
▸ 答案:先改 util。它被 3 个包依赖(稳定、Ca 高),却 0 抽象、12 个具体类型——稳定的位置放易变的具体实现,任何改动都会波及所有调用方,这是真问题(拆成按能力的包,或把其中真正稳定的契约抽象出来)。infra/config 的 D=1.0 来自"稳定 + 全具体",但它只是常量和配置读取,稳定且具体对叶子包是合理的,强行抽象只会加一层无意义的接口。D 只提示"看一眼",不替代职责判断。
Q3 你的 order 包和 payment 包互相 import:order 要调 payment.Charge,payment 要读 order.Order。你抽了一个接口放在 order 里,让 payment 实现,编译通过了。这个方案什么时候是错的?
▸ 答案:当接口里出现 payment 需要的 order 全量结构(FindFullOrder() (*Order, error),Order 有 15 个字段)时就是错的。这时 payment 仍然依赖 order 的数据形状(虽然 import 方向变了),order 改字段 payment 就得改,耦合只是从显式变隐式。正确做法通常是把 payment 真正需要的少量信息定义成自己的参数(Charge(ctx, orderID, amount, currency)),或把共享的稳定类型下沉到 domain/kernel 包。接口能反转依赖方向,不能反转数据形状。
Q4 领导说:"我们只有 6 个人,先别搞包边界了,等拆微服务时一起做。"怎么回应?
▸ 答案:包边界是现在最便宜的保险:改一个 import 就能修,成本接近 0;而等到拆服务时,边界不清的代码会以"分布式单体"的形式固化——那时每次跨服务调用都要处理网络、重试、数据一致性,成本是包边界的几个数量级。且 6 人团队恰恰最需要边界:人少意味着每个人都在改多个模块,没有边界的冲突成本更高。合理妥协是只守三条:领域包不依赖 ORM、模块间只通过导出端口通信、CI 跑一条依赖方向检查。
Q5 为什么"按名词切包"(order、user、address)比"按技术切包"好,但仍然可能出错?
▸ 答案:按名词切至少让"一个需求一个包"成立,符合 CCP,比按技术切好。但它仍可能出错,因为名词边界不等于变化边界:order 和 settlement 可能因为同一个计价规则一起变,user 和 address 可能永远一起变(该合并)。更可靠的做法是按变化原因和生命周期切——问"这两部分代码,会不会因为不同原因、在不同时间被改?"会 → 拆;不会 → 合。领域驱动设计里的29.1 限界上下文就是用业务语言回答这个问题的方法。