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 限界上下文就是用业务语言回答这个问题的方法。