17 模块化与解耦:耦合度、依赖倒置、事件驱动

开篇提问

先给你一个真实的工程困境,你来感受一下:

你的系统有 20 个模块。某天要改一个小功能,你以为改一个模块就够了——结果改完发现另外 6 个模块都受影响了,测试跑挂了一片。

这种”牵一发而动全身”的痛苦,几乎每个工程师都经历过。但请往深里想:

“改一个地方要动六个地方”这件事,本质是什么在作祟?为什么有些系统改起来很轻松,有些系统改起来像拆炸弹?

答案藏在一个词里:耦合(Coupling)。

而这一章要讲的核心洞察是:耦合不可能被消除——它是模块之间”必须协作”的必然产物。你能做的只有”管理它”:把耦合放在正确的地方、用正确的形式。


子主题一:耦合与内聚——模块设计的两个维度

模块设计有两个核心指标,它们总是一起出现:

内聚(Cohesion):模块内部的元素,彼此相关程度有多高?

  • 高内聚:一个模块只做一件事、且内部元素都为这件事服务。好。
  • 低内聚:一个模块里塞了乱七八糟不相关的东西。坏。

耦合(Coupling):模块之间的依赖有多强?

  • 低耦合(松耦合):模块之间依赖弱,改一个不太影响另一个。好。
  • 高耦合(紧耦合):模块之间缠绕,改一个要动一片。坏。

经典的设计目标就八个字:高内聚,低耦合。

但这里有个必须澄清的重要认知:耦合本身不是坏事。

为什么?因为模块之间必须协作,而协作必然产生耦合。如果完全没有耦合,模块之间就没有任何关系——那它们就不构成”一个系统”,只是”一堆独立程序”。

所以真正的问题不是”有没有耦合”,而是:

  1. 耦合的强度有多大?(改 A 会不会强制改 B)
  2. 耦合的方向对不对?(依赖该依赖的东西)
  3. 耦合的形式合不合适?(通过什么机制耦合)

子主题二:耦合的层次——从最坏到最好

耦合有强弱之分,我们把常见的耦合形式从”最坏”排到”最好”:

1. 内容耦合(最坏):一个模块直接修改另一个模块的内部数据。

  • 比如:A 直接读写 B 的私有变量
  • 危害:任何内部改动都可能破坏对方,几乎无法维护

2. 公共耦合:多个模块共享同一个全局变量/全局状态。

  • 危害:任何模块都能改全局状态,出问题你不知道是谁改的
  • 这是大型系统里最隐蔽的 bug 来源之一

3. 控制耦合:一个模块通过”控制信号”(flag)控制另一个模块的内部逻辑。

  • 比如:process(data, mode=1),mode 决定内部走哪条分支
  • 危害:调用方需要知道被调方的内部逻辑

4. 标记耦合:通过复杂的数据结构传递,但只用其中一部分。

  • 危害:数据结构变了,即使没用到那部分的模块也可能受影响

5. 数据耦合(较好):只通过参数传递必要的数据。

  • 这是”正常”的耦合,可接受

6. 消息/事件耦合(最松):模块之间不直接调用,只通过消息/事件通信,甚至不知道对方存在。

  • 这是最松的耦合形式(第 10 章的异步解耦)

实践要点:追求”数据耦合”和”事件耦合”,避免”公共耦合”和”内容耦合”。


子主题三:依赖倒置——把依赖方向反过来

这是管理耦合最重要的一招,值得单独讲。

先看坏的依赖方向:

高层模块(业务逻辑)→ 依赖 → 低层模块(数据库、具体实现)

问题:业务逻辑依赖了具体实现。换数据库(MySQL → PostgreSQL),业务逻辑要改。这是”高层依赖低层”,而低层(具体实现)恰恰是最容易变的东西。

依赖倒置原则(DIP, Dependency Inversion Principle) 说:

1. 高层模块不应该依赖低层模块,两者都应该依赖抽象。 2. 抽象不应该依赖细节,细节应该依赖抽象。

依赖倒置之后:

高层模块(业务逻辑)→ 依赖 → 抽象接口(Repository)
                                    ↑
低层模块(MySQL实现)→ 实现 → 抽象接口

关键变化:依赖的”方向”反转了。 低层实现现在”依赖”(实现)高层定义的接口。

为什么这样更好?

  • 业务逻辑不再依赖易变的具体实现
  • 换数据库时,只需要写一个新的实现类,业务逻辑一行不改
  • 测试时可以用 mock 实现替换掉真实数据库

依赖倒置的本质洞察:

让”不稳定的东西”依赖”稳定的东西”,而不是反过来。

抽象(接口)是稳定的,具体实现是易变的。所以应该让”易变的实现”去依赖”稳定的接口”。

这一招在 Go 里的体现特别自然:Go 的接口是隐式实现的,且推荐”由消费者定义接口”——使用方定义”我需要什么能力”,提供方去满足它。这天然就是依赖倒置。


子主题四:解耦的几种武器

武器一:接口 / 抽象(依赖倒置)——上面讲过,最基础的一招。

武器二:依赖注入(DI) 不是在模块内部 new 一个依赖,而是从外部传入。

  • 坏:func NewService() { db := mysql.Connect() }(内部硬编码依赖)
  • 好:func NewService(db DB) { ... }(依赖从外部注入)
  • 价值:依赖可替换、可测试、模块不知道具体实现是什么

武器三:事件驱动 / 消息(第 10 章) 模块 A 做完一件事,发一个事件,不关心谁会处理。

  • 价值:A 完全不知道 B 的存在——这是能达到的最松耦合
  • 代价:流程变隐式(难以追踪”这个事件触发了什么”),需要可观测性配套

武器四:中间人 / 中介者 模块之间不直接通信,都通过一个中介。

  • 比如:消息队列、API 网关、服务注册中心
  • 价值:N×N 的网状依赖变成 N×1 的星形依赖

武器五:配置驱动 把”谁依赖谁”从代码里抽出来,变成配置。

  • 比如:插件系统、策略模式 + 配置选择

武器六:约定优于配置 用命名/位置约定代替显式依赖声明。

  • 价值:减少显式的依赖描述
  • 代价:约定是隐式的,新人难懂,且重构时容易漏

子主题五:解耦的代价——过犹不及

讲了这么多解耦的好处,必须泼一盆冷水:解耦不是免费的,过度解耦是灾难。

代价一:间接层增加,理解成本上升。 为了解耦 A 和 B,你引入了接口 I、工厂 F、配置 C、事件 E……现在要理解”A 和 B 怎么协作”,你得看懂 5 个东西而不是 2 个。

代价二:流程变隐式,调试变难。 事件驱动解耦后,“这个操作会触发什么”变得不直观。你需要可观测性(第 14 章)来补,否则排查故障时就是在迷宫里找路。

代价三:过度设计,YAGNI 违反。 “你不会需要它(You Ain’t Gonna Need It)“——为了”将来可能换数据库”而引入一堆抽象,结果永远没换,白白付了复杂度。

代价四:分布式单体的陷阱。 把系统拆成微服务(物理解耦),但服务之间依然紧耦合(共享数据库、同步调用、一起发版)——结果是”分布式的单体”,同时承受了分布式的复杂度和单体的耦合,是最糟的组合。

判断”该不该解耦”的标准:

  1. 这里真的会变吗? 如果一个依赖三年没变过,为它做抽象可能不值。
  2. 变化的成本有多高? 如果这个依赖变了要改 50 个地方,那值得解耦。
  3. 解耦的收益 > 间接层的成本吗? 每加一层抽象,都要问这句。

一条实用的经验法则(“三振出局”):

第一次写代码,直接写;第二次遇到类似需求,复制;第三次重复了,再抽象。

不要在第一次就抽象——因为你还没真正理解需求的全貌,过早的抽象往往是错的抽象。而错误的抽象,比没有抽象更糟——因为你被它锁死了。


子主题六:耦合的六种形态逐个解剖——从”直接接管子偷邻居家的水”到”只寄一封信”

子主题二给了你一张排序表,但排序只是结论,看不懂”它长什么样”,你就永远改不掉它。这一节我们把每一种耦合掰开来看:它到底长什么样、为什么坏(或不坏)、以及怎么破。

先抛一段真实到扎心的代码,你来找问题:

// user 包
package user
 
type User struct {
    ID      int64
    Balance float64 // 单位:元
    credit  int     // 私有
}
 
// order 包
package order
 
var CurrentTenant *Tenant // 全局当前租户
 
func Pay(u *user.User, amount float64) {
    u.Balance -= amount             // 直接改别人对象的内部字段
    v := reflect.ValueOf(u).Elem().FieldByName("credit")
    // 用 unsafe 绕过只读限制,把私有字段也改了
    log(CurrentTenant.ID)           // 顺手从全局变量取点东西
}

反问一句:如果 user 包明天把 Balance 从”元”改成”分”,或者加上”余额变动必须走流水校验”的约束,order 包会怎样?

它不会报错。它只会悄悄地算错钱。

这就是耦合强度最直观的体感:

耦合越强,变化传播得越隐蔽、被发现得越晚、修复得越贵。

下面逐个来。分类沿用 Yourdon 与 Constantine 在《Structured Design》(1979) 中系统化的经典层次(Glenford Myers 在 1978 年的《Composite/Structured Design》里给出了几乎相同的分层),并补上后来面向对象/事件驱动语境下才加入的”消息耦合”。

1. 内容耦合(Content Coupling)——最坏

定义:一个模块直接访问或篡改另一个模块的内部实现(私有数据、内部语句、控制流、内存布局),完全绕过对方提供的接口。

三种典型长相:

  • 直接读写别人的私有/内部数据(上面的 u.Balance -= amount 就是,哪怕字段是 public)
  • 直接跳转或分支进入别人内部(早期 FORTRAN 的 ALTER、跨模块的 goto;现代的等价物是 monkey patch、反射改私有字段、字节码增强)
  • 依赖对方的实现细节与内存布局(C 的指针偏移、结构体字节序、序列化后的字段顺序)

坏在哪(三条,一条比一条狠):

  1. 不变量守不住。user 想加”余额不能为负”的校验,改不动——因为全代码库有 300 个地方在直接写这个字段,你加在哪一个里?
  2. 变化毫无信号。字段语义变了(元→分),编译器一声不响。你的单元测试也不会响,因为测试也在直接写字段,于是测试和你一起错。
  3. 责任彻底消散。钱算错了,是 order 的错还是 user 的错?“谁都可能写这个字段”意味着”谁都不对它负责”。

生活化类比:你不按门铃、不走大门,而是从邻居家墙里接了根管子直接抽他家的水。他家换了水管布局,你家的水就变脏了——而且你俩都不觉得是自己的问题。

怎么破:

  • 字段私有化 + 暴露有语义的行为方法:u.Debit(amount, reason),让”不变量”守在拥有数据的那一侧。这正是 Parnas 1972 年那篇《On the Criteria To Be Used in Decomposing Systems into Modules》里说的信息隐藏。
  • 行为方法还有个名字叫 Tell, Don’t Ask(告诉,别打听):别把数据取回来自己算,而是让拥有数据的对象去做这件事。

一个必须点破的误解:把字段改成私有、再配一对 getter/setter,并不解决内容耦合——那只是”从徒手摸内脏”变成”戴手套摸内脏”。setter 不是封装,行为方法才是。 你的 User 类有 40 个字段、80 个 setter,它其实是个穿着面向对象外衣的结构体。

2. 公共耦合(Common Coupling)——最隐蔽

定义:多个模块共享同一份可变的全局状态:全局变量、有状态单例、共享内存、Redis 里那个谁都能写的 key、数据库里那张谁都能 update 的配置表。

var CurrentTenant *Tenant
 
func CreateOrder(...) { tid := CurrentTenant.ID; ... }
func AuditLog(...)    { tid := CurrentTenant.ID; ... }
func SendEmail(...)   { tid := CurrentTenant.ID; ... }
func CalcTax(...)     { tid := CurrentTenant.ID; ... }

坏在哪:

  1. 可写 + 全局可见 = 谁都能改,没人负责。出问题时你的排查方式是”全代码库 grep 谁改了它”。
  2. 时序耦合 + 并发地雷。单线程时它工作得好好的,一上并发/多租户/异步任务,就开始随机出错。
  3. 测试互相污染。每个单测都要 defer reset(),一旦并行跑测试就 flaky,于是你关掉并行,CI 慢三倍。
  4. 隐式依赖。函数签名上写着 func AuditLog(msg string),实际上它还需要一个”当前租户”——签名骗了你。

类比:办公室里只有一块白板,谁都能上去擦改。某天上面写着”周五不上班”——你不知道是行政写的、老板写的、还是保洁阿姨擦错了。白板没有主人,所以白板上的信息不可信。

怎么破:

  • 依赖显式传参(退化成数据耦合),或用 request-scoped 的上下文对象沿着调用链显式传递。
  • 关键判据要记牢:问题出在”可变”,不是”全局”。
    • const MaxRetry = 3 —— 全局但不可变,完全没问题。
    • 无状态单例(线程池、日志器)——安全。
    • 有状态单例(当前用户、运行时开关表)——就是公共耦合。

3. 外部耦合(External Coupling)——最常被漏掉的一类

定义:多个模块共享一个外部强加的数据格式/通信协议/接口约定——文件格式、通信协议、数据库 schema、第三方 API 契约。

-- 订单服务这么写
UPDATE orders SET status = 'PAID' WHERE id = ?;
-- 报表服务、风控服务、客服后台都在读同一张表,并且依赖 status 的取值语义

和公共耦合的区别(这条经常考):

  • 公共耦合共享的是可变状态——谁偷偷改了没人知道。
  • 外部耦合共享的是格式/契约——它通常是显式的、可版本化的、可被测试的。

所以外部耦合的危害明显更小,甚至在很多场景下是不可避免的(你总得和第三方系统说话)。但它依然真实存在:格式一变,所有参与者一起改,跨团队排期就此产生。

怎么破:把外部格式关进一个门里——只有 owner 模块负责解析/写入这个格式,其他模块通过它的 API 或它发布的事件访问;对外接脏系统时,在边界上架一层防腐层(ACL),把外部 schema 的变化挡在门外。

4. 控制耦合(Control Coupling)

定义:调用方传递一个”控制信号”(flag / 枚举 / 字符串命令),用来决定被调方内部走哪条逻辑分支。

func ProcessOrder(o *Order, mode string) error {
    switch mode {
    case "normal":     return pay(o)
    case "retry":      return payForce(o)   // 跳过风控
    case "compensate": return refund(o)     // 这压根是另一件事
    case "dryrun":     return validate(o)
    }
}

坏在哪:

  1. 被调方把”决策权”外包给了调用方。调用方必须知道被调方”有哪几条分支、每条分支意味着什么内部逻辑”——这是赤裸裸的知识泄漏。
  2. 内聚度崩塌。每加一种模式 switch 就膨胀一次,函数变成一个”万能入口”。这在经典分类里叫逻辑内聚:几件不相关的事被一个 flag 硬捆在一起。
  3. 测试成本爆炸:模式数 × 输入组合,而且每条分支的错误处理还不一样。

怎么破:拆成多个语义明确的函数或命令对象——Pay() / Refund() / Compensate()。让”做什么”由调用方通过选择调用谁来决定(这是多态,是健康的),而不是让被调方靠 flag 在内部 if-else。

一个精细的判别问题:WithRetry(true) 这种 option 参数算不算控制耦合?

判据:这个参数有没有改变函数的”业务语义”。 只改执行方式(重试、超时、日志级别、是否异步)→ 不算,那是配置。 改了”到底做哪件事”(pay / refund / dryrun)→ 算,这是控制耦合。

同理,策略模式注入一个 PaymentStrategy 不是控制耦合——因为策略是”整体替换一个算法”,而 flag 是”在被调方内部挑一条路”。区别在于”被调方还知不知道自己有几种走法”。

5. 标记耦合(Stamp Coupling / 特征耦合)

定义:传递一个复合数据结构(struct / record / 对象 / map),但被调方只用其中一小部分字段。

func CalcDiscount(u *User) float64 {  // User 有 40 个字段
    return rateTable[u.Level]          // 只用了一个 Level
}

坏在哪:

  1. 依赖面被放大。签名说”我需要一个用户”,实际说”我需要一个等级”——读代码的人被骗了。
  2. 无谓的编译期/二进制依赖。在 Java/C# 这类按类做二进制依赖的语言里,这意味着 User 加个字段,所有依赖 User 的模块都要重新编译、重新验证、甚至重新部署。
  3. 可测试性下降。为了测一个”算折扣”,你得造一个 40 字段的 User——于是引入 mock 框架,于是测试变复杂。

怎么破:只传需要的(数据耦合),或者由消费者定义窄接口(Go 的隐式接口在这里特别香):

type DiscountSubject interface{ Level() int }
 
func CalcDiscount(s DiscountSubject) float64 { return rateTable[s.Level()] }

这就是依赖倒置的微型版本:消费者只声明”我需要什么能力”,提供方去满足它。

但也要泼一盆冷水:把参数机械拆成一堆裸基本类型(CalcDiscount(string, int, bool, bool))会掉进”参数列表过长”和”布尔陷阱”(CalcDiscount(true, false) 鬼知道是什么意思)。真正的做法是按概念分组传值对象(CalcDiscount(Level, Money)),而不是散装参数。

6. 数据耦合(Data Coupling)——健康、够用、但不是”最好”

定义:模块之间只通过参数传递完成工作所必需的数据,且每一项都是明确的数据项。

func CalcDiscount(level Level, amount Money) float64

评价:这是”正常”的耦合,是绝大多数代码应该待的地方。注意措辞:它是”够用且便宜”,不是”最松”。

一个常见误读要澄清:数据耦合也可能长歪——传 12 个参数。类型上它是数据耦合,可读性上它是灾难。解决办法是引入参数对象(Parameter Object),但要注意别把参数对象做成”什么都装的大袋子”,那会直接退化成标记耦合。

7. 消息耦合(Message Coupling)——最松

注意准确性:消息耦合并不在 Yourdon & Constantine 1979 年的原始清单里,它是后来在面向对象与事件驱动语境下被补充进来的第 7 类。但这不妨碍它成为当下最重要的一类。

// 订单模块:只发布事件,不 import 任何下游模块
bus.Publish(OrderPaid{OrderID: id, Amount: amt, PaidAt: now})
 
// 积分模块:自己订阅,订单模块完全不知道它存在
bus.Subscribe(func(e OrderPaid) { addPoints(e.OrderID, e.Amount) })

为什么它能做到最松:

  • 双向依赖变成单向依赖一个契约(事件类型);
  • 零个接收方时发送方照样正常工作 → 加新订阅者不改发送方一行代码(这是 OCP 的教科书实现);
  • 编译期不依赖 → 可以独立部署、独立演化。

代价(而且代价和收益出自同一个根因):

  • 正因为”不知道谁在听”,才必须靠可观测性(分布式追踪、事件血缘图)把隐式流程重新显性化;
  • 事件 schema 变成了公共契约,改它要向前兼容。

这里必须把一句最关键的话刻进脑子里:

解耦不会消灭耦合,只会把耦合搬个家。 模块间依赖消失了,事件契约上的”外部耦合”出现了。你以为你在消除耦合,其实你只是在选择耦合的位置与形式。

一张可操作的判据表:用”三问”测耦合强度

别再用”感觉上紧不紧”来判断了,问这三个问题:

  1. 改 A 的内部实现,B 需要重新编译吗?(编译期依赖)
  2. 改 A 的行为语义,B 会静默出错吗?(语义依赖——这其实是 LSP 问题,见下一节)
  3. 改 A 的契约,B 必须同步上线吗?(部署依赖 / 版本锁)

三问答”是”越多,耦合越强。

而这正是”分布式单体”的根源:把模块拆成微服务,只解决了第 1 问的一部分(编译期不再依赖了),第 2 问和第 3 问一个都没解决——甚至因为跨了网络,第 2 问变得更致命(静默出错变成了静默的数据不一致)。

顺带补上内聚的层次(耦合有层次,内聚也有,从最坏到最好):

内聚类型含义举例
偶然内聚元素之间毫无关系,只是恰好放在一起那个叫 utils 的包
逻辑内聚做几件相似的事,靠 flag 选一件ProcessOrder(o, mode)
时间内聚因为”同一时刻要执行”才凑一起启动时的一堆 init()
过程内聚按固定步骤顺序组织,但做的事不同源一段 200 行的 handleRequest
通信内聚操作同一份数据,但职责不同一个函数里既读又写又删用户
顺序内聚前一个的输出是后一个的输入管道式的处理步骤
功能内聚(最好)所有元素共同完成且仅完成一件事SortInts()

判据极其好用:给一个模块/类写一句话描述”它做什么”,如果你写出来的句子里有”和""以及""同时也”——它的内聚一定不是功能内聚。


子主题七:SOLID 五原则——每一条都在为”让变化变便宜”铺路

先抛个问题:SOLID 讲了快三十年为什么还没过时?

因为它回答的从来不只是”一个类怎么写”,而是那个更本质的问题:

怎样让一次需求变化,改动的地方最少、最局部、最不容易出错?

把 SOLID 翻译成一句话是这样的:

用抽象把”稳定的东西”和”易变的东西”隔开,并让易变的东西去依赖稳定的东西。

(顺带一个史实:SOLID 这个缩写是 Michael Feathers 提出来的,由 Robert Martin 在 2000 年代初系统化并推广;几条原则本身则分散来自 1980-90 年代:Meyer 的开闭原则 1988、Liskov 的替换原则 1987/1994。)

SRP 单一职责——它管的是”边界”

定义:Robert Martin 的原话是 “A class should have one, and only one, reason to change”——一个类应该只有一个引起它变化的原因。

请注意那个关键词:变化的原因(reason to change),不是”只做一件事”。这两者差得很远。

反问一个具体场景:UserService 里同时有”注册”、“改密码”、“发欢迎邮件”,违反 SRP 吗?

看变化源:注册规则(业务方要改)、密码策略(安全合规部要改)、邮件模板(市场部要改)——三个不同的角色、三条不同的变化节奏。那就是三个职责。

判断 SRP 的实操问法:把所有”会要求改这个模块的人”列出来。如果这些人来自不同的业务诉求/不同的部门/不同的变化节奏,就该拆。

与解耦的关系:SRP 管的是内聚。内聚不够的东西,边界必然模糊;边界模糊,耦合就无处安放。一个混着”计算折扣”和”写数据库”的 Service,会让任何折扣规则的改动都连带触碰持久化代码——你把一个易变的东西(业务规则)和一个稳定的东西(存储机制)焊死在同一个修改面上了。

OCP 开闭——它管的是”变化被吸收的方式”

定义(Bertrand Meyer, 《Object-Oriented Software Construction》1988):对扩展开放,对修改关闭。加新功能应该通过”加代码”实现,而不是”改已有代码”。

// 违反 OCP:每加一种支付方式,就要改这个函数(if-else 无限膨胀)
func Pay(method string, amount Money) error {
    switch method {
    case "alipay": ...
    case "wechat": ...
    }
}
 
// 符合 OCP:加支付方式 = 新增一个实现,Pay 一行不动
type PaymentMethod interface{ Charge(Money) error }
 
func Pay(m PaymentMethod, amount Money) error { return m.Charge(amount) }

这里必须点破一个幻觉:

你不可能对所有变化都关闭。

Robert Martin 自己称之为 strategic closure(战略性关闭)——你只能选择性地对”已经能看到的、最可能发生的那几个变化方向”关闭。因为未来无法预测,所以每一次为 OCP 做的抽象,本质都是一次押注。

于是这里出现了本章最真实的张力:

  • 盲目为所有可能的变化预留扩展点 → 过度设计,违反 YAGNI;
  • 完全不为任何变化预留 → 每次需求都要改老代码,回归测试面越来越大。

解药是什么?是”变化历史”。 用 git 记录看哪些地方真的反复变过——那些才是值得押注的方向(具体做法见子主题十三)。押注要押在历史告诉你会发生的变化上,而不是你脑子里想象出来的变化上。

LSP 里氏替换——它管的是”抽象这道墙有没有洞”

定义:Liskov 在 1987 年 OOPSLA 的主题演讲《Data Abstraction and Hierarchy》中提出,1994 年她与 Jeannette Wing 在 TOPLAS 上给出了”行为子类型(behavioral subtyping)“的严格定义。通俗说法:子类型必须能够替换掉它的父类型,而不改变程序的正确性。

经典反例(用 Go 写出来更直观):

type Rectangle struct{ W, H float64 }
func (r *Rectangle) SetWidth(w float64)  { r.W = w }
func (r *Rectangle) SetHeight(h float64) { r.H = h }
func (r *Rectangle) Area() float64       { return r.W * r.H }
 
type Square struct{ Rectangle }
func (s *Square) SetWidth(w float64)  { s.W = w; s.H = w } // 维持"正方形"不变量
func (s *Square) SetHeight(h float64) { s.W = h; s.H = h }
 
func areaTest(r *Rectangle) {
    r.SetWidth(5)
    r.SetHeight(4)
    assert(r.Area() == 20) // 传进 *Square → 16,测试挂了
}

LSP 与解耦的关系(这是很多人从没想过的一层):

LSP 是”抽象屏障”的守门人。

如果子类不遵守父类的行为契约,调用方迟早得写出这种代码:

if sq, ok := shape.(*Square); ok {
    // 特判:正方形不能单独设宽
} else {
    shape.SetWidth(5)
}

一旦出现对具体类型的判断,抽象就当场失效了——高层被迫知道低层有哪些具体实现,依赖倒置被一枪击穿。

违反 LSP = 抽象的墙上破了个洞,耦合会从洞里漏进来。

再强调一次判据:LSP 检查的不是”编译能不能过”(那是语法子类型),而是行为契约:前置条件不能更强、后置条件不能更弱、不变量必须保持。所以 func (c *ReadOnlyCache) Save(u *User) error { return ErrNotSupported } 是对 Repository 接口最直白的 LSP 违反——Save 的后置条件被削弱成了”什么都没做”。

ISP 接口隔离——它管的是”依赖面有多宽”

定义:客户端不应该依赖它不需要的接口;多个专门的接口优于一个通用的胖接口。Robert Martin 最早是在 Xerox 的打印系统项目上被这个问题教育到的。

// 胖接口:一个只读缓存被迫实现一堆它用不到的方法
type UserRepository interface {
    Get(id int64) (*User, error)
    Save(*User) error
    Delete(id int64) error
    Query(q Query) ([]*User, error)
    BeginTx(ctx context.Context) (Tx, error)
}
 
// 只读缓存实现:4 个方法只能写 panic("not supported") —— 顺手把 LSP 也违反了

与解耦的关系:多余的依赖就是多余的耦合面。 哪怕你只调用 Get,只要接口里躺着 5 个方法,你就依赖了 5 个方法的签名——它们任何一个变化,你的代码都要重新编译、重新验证。而且胖接口会诱使实现方勉强实现,最终导向 LSP 违反。

ISP 的实操口诀:按调用方(消费者)切接口,不要按实现方/数据表切接口。

type UserReader interface{ Get(id int64) (*User, error) }
type UserWriter interface{ Save(*User) error }

是不是很眼熟?“消费者定义接口”——这又是依赖倒置的同一种味道。 SOLID 不是五个独立的规矩,它是同一套思想在五个方向上的投影。

DIP 依赖倒置——它管的是”依赖的方向”

(子主题三已详述,这里只补三个容易被讲错的点。)

第一,“倒置”倒的到底是什么?

传统结构化设计的分层方向是”高层策略 → 低层机制”:主程序调子程序,子程序调更底层的 I/O。DIP 让低层实现去依赖高层定义的接口,于是出现了一种奇妙的重排:实现方向仍然是”上→下”(高层调用低层干活),但依赖方向变成了”下→上”(低层实现依赖高层抽象)。 这就是”倒置”两个字的字面意思。

第二,DIP ≠ DI——这两个经常被混为一谈。

// 这是 DI(依赖从外部注入),但不是 DIP(注入的仍是具体类型)
func NewOrderService(db *MySQLRepo) *OrderService { return &OrderService{db: db} }

换数据库照样要改 OrderService 的签名。

DIP 是目的地(依赖抽象),DI 是运输工具(把依赖从外面送进来)。有了车不等于到了地方。

第三,DIP 不等于”所有东西都加接口”。 为一个三年来从未变过、且你完全拥有、变化节奏与业务同频的依赖强加一层接口,净收益为负。

把五条串成一条流水线

一次需求变化发生时,理想路径是这样的:

  1. 边界清楚(SRP/内聚)→ 我知道该改哪个模块;
  2. 该模块内部有扩展点吸收变化(OCP)→ 不用改老代码;
  3. 替换进来的实现遵守契约(LSP)→ 高层无感,不用写类型特判;
  4. 依赖面窄(ISP)→ 改动不外溢到无关模块;
  5. 依赖方向正确(DIP)→ 替换实现不影响业务策略。

哪一条失守,都会卡住整条流水线。 所以有个很实用的诊断法:

原则管什么失守后的典型症状
SRP内聚 / 边界一个需求要改好几处互不相关的代码;同名类里塞着两个业务概念
OCP变化被吸收的方式加功能必改老代码;switch/case 到处复制;回归测试面越来越大
LSP抽象的可信度到处是 instanceof / 类型断言 / panic("not supported")
ISP依赖面的宽度接口一改一堆模块重编译;实现方被迫写空方法
DIP依赖的方向换底层实现要改业务逻辑;业务代码里写满了 sql.Open

子主题八:依赖注入的三种姿势,以及”容器”到底该不该用

DIP 说”要依赖抽象”。但有个问题它没回答,而这个问题恰恰是工程上最麻烦的:

运行时,到底由谁来决定”用哪个实现”?

反问一句:如果 OrderService 在自己内部 new MySQLRepository(),那它依赖的究竟是谁?

——它依赖的是 MySQLRepository 这个具体类。那个漂亮的 Repository 接口只是个摆设。

“谁来创建依赖”,决定了”谁真正知道实现”。 这就是依赖注入的全部出发点。

Martin Fowler 在 2004 年的经典文章《Inversion of Control Containers and the Dependency Injection pattern》里,把注入归纳为三种形式。下面逐个看。

形式一:构造函数注入(Constructor Injection)

type OrderService struct {
    repo  Repository
    clock Clock
}
 
func NewOrderService(repo Repository, clock Clock) *OrderService {
    if repo == nil || clock == nil {
        panic("dependencies required") // 不变量在构造时一次性保证
    }
    return &OrderService{repo: repo, clock: clock}
}

优点:

  • 强制性:不给依赖就造不出对象,编译期就挡住;
  • 不变性:创建后依赖不可变,没有并发与时序隐患;
  • 依赖一眼看全:看构造函数就知道它需要什么,新人友好;
  • 循环依赖在构造期暴露,不会拖到运行时;
  • 天然利于不可变对象与线程安全。

缺点:可选依赖(可插拔的埋点、可选的缓存)也得传,参数列表会变长;某些框架要求无参构造(Android 的 Activity、部分 Java 框架),用不了。

结论:默认首选,没有理由时就用它。

形式二:Setter 注入(Setter Injection / 属性注入)

func (s *OrderService) SetMetrics(m Metrics) { s.metrics = m }

优点:可选依赖很自然;可以在运行期重新配置;便于框架做反射式装配。

缺点:

  1. 时间耦合(temporal coupling):对象在”被装配完成之前”是个半成品,任何方法调用都可能 nil panic。这是一类错误的性质恶化——从”编译期能查出来”变成”运行时偶发”。
  2. 依赖可以被随意改,不变量守不住。想补救就得加 sync.Once、init 标记位,代码反而更啰嗦。
  3. 依赖是可选的,也就意味着你不知道调用这个方法时它到底在不在。

适用场景:真正的可选依赖;框架约束下的妥协;运行期可替换的插件。

形式三:接口注入(Interface Injection)

// 被注入方声明:"我能接受哪些依赖"
type RepositoryInjectable interface {
    InjectRepository(r Repository)
}
 
func (s *OrderService) InjectRepository(r Repository) { s.repo = r }
 
// 装配方(容器/框架)对所有实现了该接口的类型统一处理
if x, ok := obj.(RepositoryInjectable); ok {
    x.InjectRepository(myRepo)
}

思路:连”注入”这个动作本身也被抽象成一个接口,于是装配逻辑可以对任意实现了该接口的类型统一处理——不依赖构造函数签名,也不依赖反射字段名。

现状:这是实践中最少见的一种。Fowler 举的例子是 Apache Avalon。为什么用得少?

代价是”被注入方要为实现注入而写代码”——侵入性强、样板代码多,而收益和 setter 注入几乎一样。

了解它的价值,是让你看懂”注入”这个概念的完整版图(注入 ≠ 构造函数传参),而不是让你日常使用它。

三个必须澄清的概念混淆

  1. IoC ≠ DI。控制反转是更大的概念:把”谁掌控流程”反转了。库是你调它,框架是它调你——这就是好莱坞原则(Don’t call us, we’ll call you)。模板方法、回调、事件循环、DI 都是 IoC 的不同表现。DI 特指”依赖的获取方式”被反转。
  2. DI ≠ 工厂模式。工厂解决”复杂创建逻辑的封装”,DI 解决”依赖从哪来”。两者常一起用(工厂作为依赖的提供方),但不是一回事。
  3. DI ≠ 用容器。手动 new 出来再传进去,也是 DI,而且往往更好(见下文 Pure DI)。

DI 容器:便利的代价

容器做什么:读配置/注解 → 构建依赖图 → 决定生命周期(singleton / scoped / transient)→ 注入 → 回收。

好处(快讲,因为大家都会说):装配集中在一处(Composition Root,组合根);依赖图自动求解,省掉几百行 new;生命周期统一管理;AOP/拦截器可在装配点插入;测试时可整体替换实现。

争议(重点,逐条给机制):

① 依赖关系变隐式。 看 OrderService 的源码,你只知道它要 Repository,不知道运行时到底是谁、是单例还是每次新建、有没有被代理包了三层。答案藏在配置、注解和扫描规则里。这叫 action at a distance(隔着老远的暗箱操作)。这是真实的成本,尤其体现在新人 onboarding 和半夜排查故障时。

② 编译期保障被削弱。 XML 装配时代,配错一个 bean 名要等运行时才报错(“启动即崩溃”还算幸运的,懒加载的就是半夜炸弹)。业界正在往回补这个洞:

  • Dagger(Java)、Wire(Go) → 用编译期代码生成替代反射,装配错误在编译期暴露;代价是多一层构建步骤与生成代码。
  • Spring 从 XML 走向注解 + 构造器注入 + 编译期索引;Quarkus/Micronaut 这类新框架干脆把 DI 挪到编译期——本质上就是”用工具生成你本该手写的 new 代码”。

一个非常实在的判据:装配错误能不能在”编译或启动的那一瞬间”暴露? 能,则容器的风险可控;不能,则它把一类廉价错误变成了昂贵错误。

③ Service Locator 反模式(最狠的一条)。

// 看起来"解耦"了,不用构造函数传了
repo := Container.Get(RepositoryKey).(Repository)

为什么这是反模式?

  • 依赖关系从函数签名里消失了 → 比直接 new 更难发现;
  • 业务代码依赖了容器本身 → 基础设施侵入业务代码;
  • 容器是全局可访问的 → 这就是公共耦合,只不过披着 DI 的外衣;
  • 测试必须先初始化容器 → 测试变慢、变脆。

Service Locator 比直接 new 更糟——因为它用”看起来解耦”换来了”实际上更难发现的耦合”。

类比:把垃圾从客厅扫到地毯底下。客厅确实干净了。

Mark Seemann 在《Dependency Injection in .NET》里把它明确列为反模式,并强调 DI 的正确位置只有一个:组合根,一处集中装配。

④ 注解涂满代码库。 把 @Inject / @Autowired 撒在几百个类上,等于把”如何装配”这个策略涂满了整个代码库。后果是:所有模块从此依赖这个 DI 框架——换框架要改几百个文件。

多讽刺:你用 DI 来解耦业务模块,却把自己和 DI 框架牢牢焊死了。这本身就是一种耦合:对基础设施的耦合。

⑤ 生命周期错配(captive dependency)。 一个单例 Service 里注入了”当前请求的用户”(scoped 依赖),表现是:第一个请求之后,所有用户看到的都是第一个人的数据。这类 bug 在生产上极其致命且极难查。容器把生命周期变成隐式配置,也就让这类错误变得容易犯。手动写 Pure DI 时,“谁活得长”是明明白白写在代码里的。

那到底用不用容器?给三条判据

  • Pure DI(手写 new):依赖图浅、生命周期简单、生产只有一个实现(测试用替身除外)→ 手写就够了,而且更好读、更好调试、零框架耦合。Go/Rust 生态普遍这么干,Java/.NET 的新一代框架也在往这个方向回退。
  • 用容器:依赖图深且规则统一(几十上百个组件)、生命周期多样(request/session/singleton)、需要 AOP/拦截器/插件体系、需要按环境切换整组实现。
  • 永远避免:Service Locator、到处 Container.Get、为了”将来可能换”而给每个类都配一个接口 + 一个注入点。

最后送一句狠的,用来判断任何 DI 用法的好坏:

DI 的价值不是”消除耦合”,而是”把耦合集中到一个地方管理”。 手写在组合根,是集中;用容器,也是集中;用 Service Locator,是把耦合打散到每个角落。

判断标准只有一句:现在我能不能在一个文件(或一个函数)里看到完整的依赖图?


子主题九:模块到底怎么切——按功能、按领域,还是按”谁坐在一起”

先做个选择题。给你一个电商系统,模块你会怎么切?

  • A 方案:controller / service / dao / model / utils
  • B 方案:商品 / 订单 / 库存 / 支付 / 物流 / 用户

现在产品提一个需求:“下单时如果库存不足,允许预售。” 你要动几个包?

  • A 方案:controller(加字段)→ service(改下单逻辑)→ dao(加查询)→ model(加字段)→ utils(加工具函数)。一个业务变化,横切了所有技术层。
  • B 方案:主要在”订单”和”库存”两个模块内部完成。

这里藏着一个根本性的错配:

技术分层按”代码的技术角色”分组,而需求变化按”业务能力”发生。 分组的轴和变化的轴不一致,于是每一次变化都是一次横切。

反问:那分层错了吗?——分层没错,错在把分层当成了唯一的、最外层的切分方式。

视角一:按技术角色划分(Technical / Layered Partitioning)

长什么样:presentation / application / domain / infrastructure,或 MVC 三件套,或前端的 components / pages / utils / store。

优点:新人五分钟就能找到代码位置;技术关注点隔离(改 UI 不动持久化);作为模块内部的组织方式,它是良好的默认。

缺点:

  • 业务能力被打散,一个业务变化横切所有层;
  • 领域知识无处安放——业务逻辑要么散在 XxxService 里,要么塞进贫血的 Xxx 数据类里(贫血领域模型),最后 OrderService 长到三千行,变成上帝类;
  • 层与层之间容易形成”万能 Service”,任何改动都要碰它。

适用:模块内部;以及业务逻辑稀薄的 CRUD 型系统。

视角二:按领域划分(DDD 的限界上下文)

这里的三个核心概念必须讲准:

① 限界上下文(Bounded Context) 一个边界,边界内的领域模型(概念、术语、规则)是自洽的。重点是:同一个词在不同上下文里可以有不同含义,而且都是对的。

拿”商品”举例:

上下文”商品”在这里意味着什么
商品上下文标题、主图、类目、上下架状态、价格
订单上下文下单瞬间的快照(当时的价格与名称,之后永不改变)
物流上下文重量、体积、是否易碎、是否需冷链
财务上下文税率、成本、收入确认规则

反问:如果用一个 Product 类贯穿全部场景会怎样?

每个新场景都往 Product 上加字段、加 if。最终它变成一个谁都不敢动的巨型对象,并且任何一个上下文的规则改动,都可能踩到别的上下文。

“用一个模型统一全世界”的代价,就是让全世界共享所有变化。

② 统一语言(Ubiquitous Language) 团队(包括产品与业务方)在同一个上下文内用同一套词说话,且代码里的命名和这套词一致。

检验方法极其好用:开会时业务方嘴里说出来的词,能不能在代码里找到同名的类或方法? 找不到,说明模型和现实已经脱节了。

③ 上下文映射(Context Map) 上下文之间怎么协作:合作关系(Partnership)、客户方-供应方(Customer-Supplier)、防腐层(Anti-Corruption Layer, ACL)、共享内核(Shared Kernel)、顺从者(Conformist)、各行其道(Separate Ways)。

最实用的一条是 ACL:当下游必须对接一个”模型很脏、很多变”的外部系统(老 ERP、第三方支付、别人的 OpenAPI),在边界上架一层翻译,把外部模型和自己的领域模型隔开,脏东西不进家门。这是解耦在集成场景下的标准答案。

相反,共享内核(两个团队共用一小块模型)要极度谨慎——它是一份被两个人共同拥有的代码,是最容易退化成”公共耦合”的地方。只有在双方沟通成本极低、变化同步时才划算。

与解耦的关系,一句话说清:

限界上下文就是”最有资格成为模块边界”的那条线——因为边界内的东西倾向于一起变(业务同源),跨边界的东西倾向于不同步变。

视角三:康威定律(组织结构的引力)

原文(Melvin Conway, 1968, 《How Do Committees Invent?》):

设计系统的组织,其产出的设计必然是该组织沟通结构的副本。 (organizations which design systems are constrained to produce designs which are copies of the communication structures of these organizations)

为什么成立?这不是玄学,机制很清楚:

跨团队的改动需要跨团队的沟通,而沟通是昂贵的。

于是系统里”跨边界的协作”会自发减少——因为做起来最费劲。反过来讲:任何不能被组织沟通结构承载的设计,都会在实施过程中被持续扭曲,直到它顺从组织为止。

两条推论:

  1. 反向康威策略(Inverse Conway Maneuver):想要什么样的架构,就把团队拆成什么样。想让模块边界稳定,就让团队边界与它对齐,并给团队端到端的职责(you build it, you run it)。
  2. 它是约束,不是宿命。康威定律不禁止你设计跨团队的边界,只是警告你:那条边界的维护成本会一直很高,高到组织结构追上来为止。

反面教材:按职能切团队(前端组 / 后端组 / DBA 组 / 测试组)→ 天然产出按技术分层的架构,且任何需求都要跨四组协调,交付周期被沟通成本吃干净。

这就是为什么”按业务能力组建跨职能团队”和”按限界上下文划分模块”总是一起出现——它们是同一件事的两面。

三个视角怎么结合:一个能落地的四层切法

  1. 最外层(模块之间):按业务能力 / 限界上下文切。 判据三连:这块能独立回答一个完整的业务问题吗?它有自己专属的数据吗?它的变化频率是否与同一批业务方一致?
  2. 模块内部:按技术角色分层(接口层 / 应用服务 / 领域模型 / 基础设施)。 分层是”模块内部的卫生习惯”,不是系统的骨架。
  3. 模块之间:只走显式契约(API + 领域事件 + ACL)。 默认用异步事件,只把”必须同步拿到结果”的那部分留给 API。
  4. 用组织结构校验边界: 能不能让一个不超过 8-9 人的跨职能团队独占这个上下文、独立发版?如果某个上下文需要三个团队共同维护,那它其实不是上下文,是一坨。
  5. 用变化历史校验边界(呼应子主题十三): 统计过去半年”一个需求改动了哪些模块”。若某两个模块 80% 的需求都一起改,边界画错了,它们该合并;反之若一个模块里两类需求从不一起改,它该被拆开。

一句狠类比收尾:

按技术角色切模块,就像把厨房布置成”所有刀放一间、所有锅放一间、所有食材放一间”——收拾起来整齐,做一道菜却要跑三个房间。 按领域切,是每个灶台自带一套刀锅铲料,一个厨师在自己的工位上就能出一道菜。


子主题十:模块化单体——当”微服务”这剂药比病还猛

先看两个公开的、被反复引用的业界回退案例(注意准确理解它们,别被标题党带偏):

  • Segment,2018 ——《Goodbye Microservices: From 100s of problem children to 1 superstar》。他们把数百个微服务合并回一个单体,交付速度反而上去了。
  • Amazon Prime Video,2023 —— 公开博客讲他们把音视频监控服务从”分布式 serverless 组件”合并为单体,成本下降约 90%。原文特意强调:微服务和 serverless 是工具,不是银弹;在某些场景下单体更简单、更划算。

反问一句:如果连 Amazon 都在某些场景下回退,你凭什么认为你的团队一定需要微服务?

但请注意这两个案例的真正含义:

它们不是说”单体赢了”,而是说”边界画错了,而分布式把这个错误的成本放大了一个数量级”。 微服务没有创造耦合,它只是让错误边界的代价从”改几个包”变成”跨团队的、带版本兼容的迁移”。

为什么很多团队拆微服务后悔了(八条代价,逐条给机制)

  1. 网络让失败模式从两种变成三种。进程内调用只有”成功/异常”;远程调用多了超时——你不知道对端到底做没做。于是必须补上幂等、重试、去重、超时传递、熔断、降级。
  2. 可用性被乘法吃掉。5 个服务串联,每个 99.9% → 端到端 0.999⁵ ≈ 99.5%。数学是无情的。
  3. 延迟被加法吃掉。跨 7 跳的调用,p99 是各段 p99 的叠加,而尾延迟的叠加比平均值更凶。
  4. 一致性从”一个事务”变成”一个分布式系统问题”。本地事务没了;2PC 太重且可用性差;Saga 补偿要把”撤销”写进业务逻辑;最终一致要处理乱序、重复、丢失。
  5. 可观测性从”锦上添花”变成”生存必需品”。没有分布式追踪,你连”这个请求经过了哪些服务”都不知道。
  6. 开发与测试环境成本暴涨。本地起 15 个服务?只能用云环境或 mock 集群,于是反馈周期从秒变分钟。
  7. 重构成本暴涨。跨服务移动一个方法 = 一次跨团队的、带版本兼容的迁移;而在单体里,这是 IDE 里的一次 Extract/Inline 重构。
  8. 组织前提。没有成熟的 CI/CD、监控告警、on-call 文化与自动化伸缩,微服务的运维负担会直接压垮小团队。

反问:那为什么还有那么多团队拆成功了?

因为他们通常是先有了一个边界已经很清晰的系统(往往就是模块化单体),再把它物理拆开。 而不是从一坨泥球直接跳到分布式。顺序反了,就是灾难。

模块化单体是什么

一句话:

一个部署单元,多个强边界模块。

  • 部署:一个进程 / 一个 war / 一个镜像。
  • 边界:模块之间只通过显式公开的 API(外观 / 应用服务接口)+ 领域事件交互;禁止跨模块访问对方的私有实现和数据库表。
  • 关键中的关键:边界必须是机器强制的,不是口头约定的。

边界怎么做到”机器强制”(具体手段清单)

1. 编译期可见性

  • Java:Maven/Gradle 多模块,只让 order-api 被别人依赖,order-impl 对外部不可见;更强的用 Java 9+ 的 JPMS(module-info.java)做强封装。
  • Go:internal/ 目录的天然规则——order/internal/repo 只能被 order/... 引用。
  • Python / C#:__all__、独立分发包、assembly 边界。

2. 架构测试(architecture fitness function)写进 CI——这一步最容易被忽略,也最值钱

  • Java:ArchUnit(noClasses().that().resideInAPackage("..order..").should().dependOnClassesThat().resideInAPackage("..payment.internal.."))
  • .NET:NetArchTest;Python:import-linter;JS/TS:dependency-cruiser / eslint-plugin-boundaries

判据:任何一条非法依赖,都应该在 CI 里红掉,而不是在 code review 里被”善意提醒”。

3. 依赖图与禁环检查 用 go mod graph、JDepend、Structure101、madge 等生成模块依赖图,CI 里检查无环——循环依赖是”边界失效”最硬的指标。

4. 数据所有权(最关键、也最常被跳过的一步)

  • 每个模块的表有自己的 schema / 表前缀;每张表有且只有一个写方;
  • 跨模块读:走 API(少量、强一致)或数据副本(大量、最终一致);
  • 先做逻辑分离(schema 分离、禁止跨 schema join),再视情况物理分离(独立库/实例)。

为什么这是关键? 如果数据还共享,所谓”模块”只是包名的艺术。 一旦数据分开了,将来真要拆微服务,剩下的就只是运维工作量,而不再是架构问题。 这句话就是模块化单体的全部核心价值。

5. 模块间通信:进程内事件总线 发事件是同步还是异步?在单体内先同步发(同一事务内,简单可靠),等真的要拆出去时再改成异步消息。

过早异步化是另一种过度设计——你现在就付了最终一致性的税,却还没拿到独立部署的收益。

保留单体,你放弃了什么(诚实清单)

  • 共享运行时:一个模块的内存泄漏 / 死循环 / 慢 SQL 会拖垮整个进程(可用资源隔离、bulkhead、优雅降级缓解,但无法根除);
  • 技术栈被统一;
  • 只能整体伸缩,不能”只扩支付模块”。

反问:这些代价对你真的成立吗?你的支付模块一天的请求量真是商品模块的 50 倍吗?你的团队是 3 个人还是 300 个人?

绝大多数业务从未触及这些限制。 承认这一点,能省掉一整个团队的运维噩梦。

该走向分布式的”真实信号”(而不是”看起来先进”)

  1. 独立部署节奏:某模块每周发 10 次,另一个每月发 1 次,且互相阻塞。
  2. 独立伸缩需求:某模块的负载曲线与其余部分显著不同,且扩容成本敏感。
  3. 故障域隔离的硬需求:支付挂了不能让商品浏览挂掉,且这是业务级别 SLA 的要求。
  4. 组织规模:团队多到在单体上互相阻塞(沟通成本 > 技术成本),且每个团队能端到端 own 一块。
  5. 技术栈 / 合规隔离:某模块必须用另一种语言,或数据必须物理隔离(合规、多租户)。

以上五条一条都不满足时,分布式带来的收益通常是负的。

最后一句类比:

微服务是给系统”分家产”,模块化单体是”先分房间、立家规”。 家产还没分清就分家,你只会得到一屋子为了抢插座吵架的兄弟。


子主题十一:分布式单体——服务拆了,耦合没拆

先给一个一票否决的自测:

“改一个业务需求,你是否经常需要同时修改并发布两个以上的服务?” 如果答案是”经常”,那你就是分布式单体,不用再辩解。

下面逐个症状,每个都按”表现 → 机制 → 破法”三层讲。

症状一:共享数据库(最常见、最致命)

表现:多个服务直连同一个库、同一批表。orders 表被订单服务写、被风控服务读、被报表服务 join、被客服后台 update。

机制:数据库成了事实上的全局可变状态——这是公共耦合,而且是最难治的那种,因为它藏在 SQL 里,代码扫描和依赖分析都看不到它。同时 schema 又变成了所有人的外部耦合契约,改个字段要跨团队排期。

破法:

  1. 先划分表的所有权:每张表有且只有一个写方。
  2. 跨服务读:少量走 API(强一致),大量走数据副本——订阅对方的领域事件,在自己库里维护一份只读投影(读模型)。
  3. 跨服务写:绝对禁止。改成”调用 owner 的 API”或”发命令给它处理”。
  4. 迁移期:先用视图 / 只读副本把”读”解耦,再动”写”;双写 + 结果比对 + 逐步切流。

症状二:同步调用链过深(链式依赖)

表现:下单 = 订单 → 风控 → 库存 → 价格 → 优惠 → 会员 → 支付,串成 7 跳的链。

机制(四条,全是硬事实):

  • 可用性:0.999⁷ ≈ 99.3%,一年多停机约 60 小时。乘法是残酷的。
  • 延迟:p99 是各段 p99 的叠加,而深链会放大尾延迟(慢请求更容易在长链上被放大)。
  • 故障传播 + 重试风暴:任一节点慢或挂,整条链失败;上游一重试,下游压力成倍放大。
  • 更隐蔽的一条:链上每一跳都让上游”知道”下游的业务语义,于是依赖关系在语义层面纠缠不清。

破法:

  1. 区分”查询”和”决策”。把链上大部分调用替换成”本地数据副本 + 事件更新”,让服务在回答问题时不必依赖别人在线。这是提升自治性最有效的一步。
  2. 读路径外置。BFF / 聚合层 / 物化视图 / CQRS 读模型——把多源数据的组装放到调用链顶端的一次显式编排里,而不是让服务互相追问。
  3. 编排显式化(呼应下一节的 Unix 哲学):让一个显式的编排者(流程服务 / Saga 编排器)描述”下单要经历哪些步骤”,服务只负责”我负责的那件事”。

    不要让服务互相调用来隐式实现流程——那样的流程谁都画不全。

  4. 打散重试:重试只在最外层做一次、带抖动退避、下游配熔断与配额。

症状三:一起发版(部署耦合)

表现:改 A 必须同时发 B、C;发布要拉群排期;有”发布窗口”和”全链路联调”。

机制:契约不兼容(API / 事件 / 共享库语义变了),导致版本被锁死。本质上是编译期耦合换了个马甲。

破法:

  1. 契约向后兼容作为硬纪律:只加字段、不删不改语义;枚举扩展视为新增。
  2. 消费者驱动契约测试(CDC):用 Pact 一类工具在 CI 里保证”提供方没有破坏消费者的期望”。
  3. 事件 schema 入 registry:Avro / Protobuf + Schema Registry,强制做向前/向后兼容检查。
  4. 两阶段迁移:先新老并存(v1/v2 同时提供、事件双发),消费方逐个迁移,最后下线旧版。
  5. 度量目标:“独立部署率”——多少比例的需求能单服务发布?这个数字不改善,拆分就没有成功。

症状四:分布式事务

表现:跨服务用 2PC / XA,或自己写”先扣库存、再扣余额、再发券,失败就补偿”。

机制:2PC 的问题是协调者单点 + 跨网络长时间持锁 + 吞吐低,而且很多中间件在故障时的真实行为并不像文档承诺得那么干净(这是运维噩梦的主要来源)。自研补偿则把”业务撤销”写进了每个服务,复杂度随服务数指数上升。

破法:

  1. 先问能不能不要分布式事务——能不能把这段逻辑放进同一个聚合 / 同一个服务?

这条判据极其实用:如果两段数据必须强一致才不会出错,它们大概率属于同一个边界。 大量分布式事务其实是边界画错的产物。

  1. 用 Saga(Hector Garcia-Molina 与 Kenneth Salem 1987 年提出的模型;编排式或协同式)替代:每个步骤是本地事务 + 一个补偿动作,接受最终一致。 注意:补偿不是回滚——中间态是可见的,必须能被业务接受(“已扣款、待发货”)。
  2. 幂等 + 去重表 + 唯一约束,让重试变得安全。
  3. 对账兜底:分布式系统的最后一道防线永远是”对账 + 自动/人工修复”,而不是”保证不出错”。

可以直接拿去开会的诊断清单(八问)

  1. 改一个需求要动几个服务?(经常 >1 = 边界画错)
  2. 有几张表被一个以上的服务写入?(>0 = 数据所有权没划清)
  3. 一次用户请求的调用链深度与扇出是多少?(深度 >4、扇出 >6 就该警惕)
  4. 有多少比例的发布是”单服务独立发布”?(<80% = 部署耦合)
  5. 是否存在跨服务的分布式事务 / 两阶段提交?(有 = 边界很可能画错了)
  6. 是否存在所有服务共享的 common / domain 库,里面装着业务模型?(有 = 把耦合从网络搬回了编译期,而且更隐蔽)
  7. 是不是”改一个服务要跑全链路回归”?(是 = 契约不可信)
  8. 团队边界和服务边界一致吗?(不一致 = 康威定律正在持续拆你的架构)

最后一句反问,值得贴在工位上:

如果你的所有服务都必须同时上线,那它们和一个单体有什么区别? ——区别只是:你的单体现在会网络超时了。


子主题十二:Unix 哲学 vs 现代微服务——同一个梦想,不同的介质

1978 年,Doug McIlroy(Unix 管道的发明者)为《贝尔系统技术期刊》的 Unix 特刊写下了那段被反复引用、后来经 Peter Salus 整理流传的四条总结:

  1. 让每个程序做好一件事。 要完成新任务,就重新写一个,而不是给老程序加一堆”新特性”让它变复杂。
  2. 预期每个程序的输出,都会成为另一个(尚不为人知的)程序的输入。 不要在输出里塞无关信息。避免严格的列式或二进制输入格式。不要坚持交互式输入。
  3. 尽早(最好在几周内)把软件甚至操作系统拿来试用。 不要犹豫丢掉笨拙的部分,重写它们。
  4. 在编程任务上,优先用工具而不是不熟练的人力来减轻负担——哪怕你得绕道去先造这个工具,并且预期用完之后会扔掉其中一些。

后来 Mike Gancarz 在《The Unix Philosophy》里把它扩展成九条戒律,最常被引用的几句:小即是美;让每个程序做好一件事;尽快做原型;可移植性优于效率;数据存成纯文本文件;利用软件的杠杆效应;避免”俘虏式”用户界面;让每个程序都成为过滤器。

为什么说 Unix 是模块化设计的”教科书级范例”

① 最小的功能内聚。 wc 只数数,sort 只排序,grep 只匹配,uniq 只去重。一个程序、一个职责、一个变化原因——这是 SRP 的字面实现。

② 统一的接口契约:文本字节流。这是全部精髓。

find . -name '*.go' | xargs grep -l 'TODO' | xargs wc -l | sort -n | tail -20
  • 这实现了消息耦合的极致:grep 完全不知道前面是 find 还是 ls,也不知道后面是 wc 还是什么都没有。
  • 管道就是那个”中间人”:把 N×N 的互相认识,变成 N×1 的”只认识管道”。
  • 它同时也是依赖倒置的极致:所有程序都只依赖”文本流”这一个抽象,没有任何程序依赖另一个程序的具体实现。

③ 组合优于集成。 能力来自组合,而不是在单个程序里堆功能。加一个新能力 = 加一个新工具或换一种组合,老程序一行不改——这是 OCP 的完美示范。

④ 无状态 + 单向数据流。 过滤器读进来、吐出去,不保留会话状态。所以一个程序崩溃 = 这条流水线失败,重启即可,无副作用、无中间态、无补偿。

⑤ 背压是免费的。 管道有内核缓冲区,写满时写端自然阻塞。下游慢,上游就慢——流量控制由操作系统提供,你一行代码都不用写。

关键对照:同样的哲学,为什么搬到微服务上就变味了

维度Unix 管道微服务
传输介质内核管道缓冲(同机、内存级)网络(跨机、不可靠)
失败模式只有”程序挂了”,无中间态成功 / 失败 / 超时(最毒的第三种)
契约文本行(弱类型、无模式)JSON / Protobuf(有模式,但必须版本化)
组合成本几乎为零毫秒级 + 序列化 + 重试/超时/熔断
状态无状态过滤器有状态服务,跨服务产生中间态
背压内核免费提供要自己实现(队列、限流、熔断、反压协议)
编排位置shell 脚本 / 人——显式在最外层常藏在服务互相调用里——隐式在中间
组合者人,几秒钟试一次,错了重敲代码,上线周期以天计,错了要回滚

这一节最该记住的一句话:

Unix 哲学和微服务信奉的是同一个梦想——用”小的、单一职责的、通过标准契约组合的单元”取代庞大的单体。区别只在于”组合的介质”。 介质一从内核管道换成网络,你就必须额外缴纳分布式系统的全部税:部分失败、延迟、一致性、可观测性、运维。 Unix 之所以能那么优雅,是因为这笔税它一分没交。

反问:那能不能让微服务也像管道一样便宜?

不能免税,但可以减少缴税次数:

  • 减少跳数——合并那些边界画错的碎服务;
  • 用异步事件替代同步链——消除”必须等对方在线”的依赖;
  • 让编排显式化——像 shell 脚本那样把流程写在最外层,而不是嵌进服务间的互相调用里;
  • 让服务尽量”过滤器化”——无状态、幂等、输入输出明确;
  • 用本地数据副本消除查询链路——让服务在决策时不必问别人。

也别忘了 Unix 哲学的代价(批判视角,别神化它)

  • 文本流 = 弱契约。没有类型、没有模式校验,字段改名要 grep 全仓库,错误常在运行期才暴露。微服务用 Schema Registry + 契约测试,补的正是这一课。
  • 解析开销。每行都要 split / 正则,性能敏感场景(数据库引擎、图像处理)绝不会用管道把字节在进程间传来传去。
  • 通用契约 = 最低公分母。文本能表达一切,也因此无法表达”必须遵守的结构”。这和”用 JSON 打通所有服务”面临的是同一个问题:通用性换来的是约束的缺失。
  • 组合的自由,也意味着组合的正确性无人负责。管道接错了没人拦你,只有结果不对。同理,微服务里”事件发出去没人处理”也永远不会报错。

一句类比收尾:

Unix 管道像乐高积木——卡扣标准、随手拼、拼错了拆掉重来,成本几乎为零。 微服务像用集装箱搭乐高——还是那个”组合”的梦,但每次拼装都要调度吊车、报关、买保险。 别因为乐高好用,就以为集装箱也该这么玩。


子主题十三:给”大泥球”做解耦——一条能落地的渐进式路径

场景:你接手了一个 50 万行的系统,所有包互相 import,到处是 util、common、helper,改一行要回归三天。

两个典型错误答案:

  • 错误答案 A:重写(big bang rewrite)。 Joel Spolsky 在 2000 年的《Things You Should Never Do, Part I》里说得很直白:重写是丢掉几年积累下来的、用血泪换来的领域知识(那些只有老代码才知道的边界情况),是软件公司能犯的最糟的战略错误之一。
  • 错误答案 B:直接上微服务,拆成 20 个服务。 这是把重构成本乘以一个数量级——因为跨服务的改动比跨包的改动贵十倍以上,而你还没搞清边界在哪。

正确的顺序只有三步,而且顺序不能换:

先识别边界 → 再在单体内部抽接口、立边界 → 最后才考虑拆部署。

第一步:识别边界——别拍脑袋,让数据和历史告诉你

方法 1:从数据写入关系找边界(最可靠)

  • 把所有表列出来,标注”哪些代码会写它”(INSERT/UPDATE),而不是”谁会读它”。
  • 写操作聚集的地方,通常是同一个聚合 / 同一个限界上下文。 因为写意味着”对数据的不变量负责”,而不变量的边界天然就是模块的边界。
  • 反过来是一条粗暴但极准的判据:

如果两张表必须在一个事务里同时更新才不会出错,它们几乎肯定属于同一个边界。

方法 2:从变化历史找边界(行为代码分析)

  • 统计版本历史里”经常被同一次提交一起修改的文件”——经验软件工程里叫 逻辑耦合 / 变更耦合(logical coupling)。
  • 原理简单到近乎粗暴,却极其诚实:

一起改 = 事实上的耦合。 设计文档会骗人,架构图会过期,但”过去两年里这两百次提交都同时改了 A 和 B”不会骗人。

  • 工具:Adam Tornhill 的 CodeScene / Code Maat(把 git 历史与代码结构叠加分析,《Software Design X-Rays》),开源方案有 git-of-theseus,或者就用 git log --name-only 自己写个脚本粗筛。
  • 产出热点图(hotspot):把”改动频率”与”代码复杂度”叠在一起,只重构”既热又烂”的部分。
    • 改动频繁但代码简单 → 不值得动;
    • 代码很烂但半年没人碰 → 不值得动;
    • 又热又烂 → 这才是 ROI 最高的地方。

方法 3:从需求与组织找边界

  • 把过去半年的需求列出来,看哪些需求总成对出现(改了 A 必然改 B);
  • 看组织结构:哪些模块被同一个人/同一个小组反复改?人脑里装着的上下文,往往就是真实的边界。

方法 4:从依赖图找”病根”

生成包/类级依赖图,重点找三样:

  1. 循环依赖(A→B→C→A)——边界失效的硬指标;
  2. 被所有人依赖的 common / util 包——事实上的全局耦合点;
  3. 扇入/扇出异常高的类(上帝类)。

其中有一条最刺眼的检查项,值得单独贴出来:

你的 common 包里有没有业务代码? 有,就说明耦合正在泄漏。——一旦业务规则混进 util,它就从”工具”变成了跨所有模块的内容耦合。

这一步的产出物(必须有,否则等于没做):

  • 现状依赖图 + 热点标注;
  • 目标边界图(候选限界上下文清单);
  • 一份按 ROI 排序的重构清单:优先”高变化率 × 高耦合”的模块。

第二步:抽接口、立边界——先不动部署

目标:让边界在单体内部先真实存在,并且被机器强制。这一步做完,你已经拿到模块化单体的大部分收益,而风险极低。

按这个顺序做,每一步都单独可发布、可回滚:

1. 给模块定”公开面”。 为每个模块定义对外暴露的应用服务接口 / Facade(例如 OrderAPI),把所有跨模块调用改为调用 Facade。 先只加不改——老的内部调用先留着,别一上来就大改。

2. 堵住后门。 把实现类降为模块私有(internal/、包可见性、JPMS),让非法调用编译失败;编译期做不到的语言,用架构测试在 CI 里红掉。

这一步的关键心态:边界靠”自觉遵守”一定会被侵蚀。 人会图快,review 会漏,只有 CI 不会累。这就是破窗效应——第一扇破窗没人修,很快整条街都是破的。

3. 消除循环依赖。 三选一:

  • ① 用接口反转:A 依赖 IB,B 实现 IB(接口放在 A 侧或独立契约包)——这本来就是 DIP 的真身;
  • ② 用事件把同步调用变成异步通知;
  • ③ 把双方共用的部分下沉为独立模块,或上提到一个共同父模块。 ⚠️ ③ 最省事,也最容易造出新的”垃圾桶模块”,谨慎使用。

4. 拆数据(最关键、最常被跳过的一步,也是真正的分水岭)。

  • 先逻辑分离:按模块划分 schema / 表前缀,禁止跨模块的 join 与事务;
  • 再物理分离:独立库 / 独立实例;
  • 原本靠 join 拿到的数据,改成 ① 调对方 API(少量、强一致),或 ② 用领域事件在自己库里维护一份只读投影(大量、最终一致)。

这一条是分水岭:数据没分开,前三步都只是”整理包结构”;数据分开了,模块才真正拥有自治权,未来拆服务就只剩运维工作量。

5. 消除时间耦合。 把”先调 Init() 再调 Run()”这种隐式约定,改成构造期保证(构造函数注入 + 不变量校验)。

6. 补齐测试网。 边界立起来之后,为每个模块建立可独立运行的测试。 顺带补两个《Working Effectively with Legacy Code》(Michael Feathers, 2004)里的概念,它们在无测试的老系统里是救命的:

  • 特征测试(characterization test):先写测试记录当前实际行为(哪怕那个行为是错的),拿到安全网之后再动手改;
  • 接缝(seam):在难以测试的地方找一个”可以替换行为的切口”(接口、函数指针、可覆盖的方法),先把依赖从这里塞进去,测试才有着落。

一个隐藏的度量指标:单独跑通某个模块测试的耗时。能独立跑通 = 依赖真的干净了。

每一步的验收标准(不能度量,就是自我感动):

  • 跨模块非法依赖数 → 归零并保持;
  • 依赖图无环;
  • 改一个典型需求的**改动文件数(change footprint)**是否下降;
  • 单个模块测试能否独立运行、耗时多少;
  • 新功能有多大比例能只在一个模块内完成。

第三步:拆部署——最后一步,且只在有真实收益时

前置条件(不满足就别拆,拆了是给自己找罪受):

  • 数据已分离、契约已稳定并版本化;
  • 有分布式追踪 / 日志 / 指标的可观测性底座;
  • 有自动化部署 + 一键回滚 + 灰度能力;
  • 有 on-call 与被认领的服务所有权(你建的,你运维);
  • 有子主题十里那 5 条真实收益信号中的至少一条。

拆的顺序:

  1. 先拆”依赖最少 + 变化最频繁”的模块。 反问:为什么不先拆最大的?——最大的那个通常耦合最深、风险最高,而它带来的收益(省下的改动成本)未必最大。
  2. 先拆读,再拆写。 把查询 / 报表 / 聚合读路径先外置(BFF、物化视图、读库)——读路径无副作用,是最容易安全迁移的部分。
  3. 同步改异步。 把链上的查询型调用换成”本地副本 + 事件”;把流程控制换成显式编排(Saga 编排器或流程服务)。
  4. 绞杀者模式(Strangler Fig,Martin Fowler 2004)灰度迁移。 新入口作为代理,按流量 / 用户 / 租户灰度切到新服务;旧路径保留一段时间,随时可回滚;先影子流量对比、结果一致后再放量。
  5. 回滚开关保留足够久。 分布式系统的回滚窗口不是”上线后一小时”,而是”一个完整业务周期”(一个大促周期、一次对账周期)。

什么时候该停:用结果指标而不是”服务数量”来判断——《Accelerate》(2018) 提出的 DORA 四指标:部署频率、变更前置时间、变更失败率、平均恢复时间,以及”一个需求要动几个服务”这个数字。

如果拆完之后这些数字没变,那你只是把耦合搬了个家,还多付了一笔网络税。

一张路线图

现状:大泥球
  │
  ├─ ① 画依赖图 + git 热点分析        → 找到边界与优先级
  ├─ ② 加 Facade 接口(只加不改)      → 建立公开面
  ├─ ③ 收紧可见性 + CI 架构测试        → 边界由机器强制
  ├─ ④ 消除循环依赖                   → 依赖图无环
  ├─ ⑤ 拆分数据(逻辑 → 物理)         ← ★ 真正的分水岭
  └─ ⑥ 模块级独立测试                 → 可独立验证
  ▼
模块化单体   ← 大部分收益已到手,风险极低
  │
  └─ ⑦ 仅当出现真实收益信号时:绞杀者模式灰度拆出服务
  ▼
边界正确的分布式系统(可选,不是终点)

这一节最重要的一句话,也是整章的落点:

先让边界在代码里成立,再让它在网络里成立。 顺序反了,你得到的就不是分布式系统,是分布式泥球。


演进脉络

  • 1960s-70s:结构化编程、模块化;常数增长的软件危机催生”软件工程”。
  • 1968:康威定律提出——系统架构是组织沟通结构的副本;同一时期 Unix 与管道诞生(McIlroy 1978 年总结出 Unix 哲学),成为”小单元 + 标准契约 + 自由组合”的第一个大规模成功范例。
  • 1970s:Parnas 的信息隐藏(1972);Yourdon & Constantine 与 Glenford Myers 系统化了耦合/内聚的层次分类。
  • 1990s:面向对象鼎盛,设计模式(GoF)系统化;SOLID 原则被总结。
  • 2000s:依赖注入框架(Spring)流行;同时开始反思”过度设计”,敏捷和 YAGNI 兴起。
  • 2010s:微服务把”解耦”从代码层推到架构层;但也暴露了”分布式单体”问题。
  • 现代:领域驱动设计(DDD)的限界上下文成为划分模块的主流方法论;模块化单体(Modular Monolith)作为”微服务过度拆分”的回调方案重新受到重视;架构测试(ArchUnit、import-linter 等)让”边界”第一次可以被 CI 机器强制,而不只是靠文档与自觉。
  • 2020s:业界从”微服务 vs 单体”的二元对立,转向”耦合才是问题,部署形态只是选项”——Segment(2018)与 Amazon Prime Video(2023)的公开回退是可观测性驱动下的理性回调,而不是”单体反攻”。

主线:解耦的演进,是从”消除耦合”的幻想,走向”管理耦合”的成熟。人们逐渐认识到:耦合无法消除,只能被放置在正确的地方、以正确的形式存在。而”解耦”本身不是目的,“让变化变得便宜”才是。


本章思考题

【思考题】

  1. 为什么说”耦合不可能被消除”?那我们做模块设计时,真正在管理的是什么?

  2. 依赖倒置原则(DIP)是什么?为什么”让不稳定的依赖稳定的”是关键?

  3. 解耦的六种武器分别是什么?请说明”事件驱动”为什么能达到最松的耦合。

  4. 过度解耦有哪些代价?什么是”三振出局”原则?

  5. 内容耦合与公共耦合有什么区别?为什么说公共耦合在大型系统里”更隐蔽、更难查”?请各举一个具体形态,并说明”判据是可变而非全局”这句话的含义。

  6. “外部耦合”共享的东西和”公共耦合”有什么不同,为什么危害更小?此外,用消息/事件做解耦之后,耦合去了哪里?

  7. 内聚也有层次。请说出从最坏到最好的七种内聚类型,并给出一个”一句话判定内聚度”的实操方法。

  8. SOLID 五原则分别服务于”让变化变便宜”的哪一环?请各用一句话说清它管的是什么。

  9. 里氏替换原则(LSP)与解耦有什么关系?为什么”违反 LSP”会让依赖倒置被当场击穿?

  10. DIP 和 DI 是一回事吗?请举例说明”用了 DI 却没有 DIP”的代码,并说明”倒置”到底倒的是什么方向。

  11. 依赖注入有哪三种形式?各自的优缺点与适用场景是什么?哪一种在实践中用得最少、为什么?

  12. DI 容器的主要批评有哪些?什么是 Service Locator 反模式,为什么说它比直接 new 更糟?请给出”判断一个 DI 用法好不好”的单一标准。

  13. 按技术分层划分模块与按限界上下文划分模块,各自的优缺点是什么?康威定律在这件事上起什么作用?三个视角该怎么结合?

  14. 什么是模块化单体?为什么”先理边界、后拆服务”的顺序如此重要?相比微服务,它保留了什么、放弃了什么?

  15. 分布式单体有哪四个典型症状?请给出可量化的诊断指标,并说明为什么”数据所有权”是破解的关键。

  16. Unix 哲学与现代微服务信奉的是同一个梦想,为什么后者的代价高得多?请说明”组合介质”的差异带来了哪些具体的分布式税。

  17. 给一个大泥球系统做解耦,渐进式路径分哪三步?为什么”拆数据”是真正的分水岭?请说明每一步的可度量验收标准。

【参考答案】

  1. 因为模块之间必须协作才能构成系统,而协作必然产生耦合;完全无耦合意味着模块之间毫无关系,那就不是”一个系统”而是”一堆独立程序”。所以我们真正管理的不是”有没有耦合”,而是三件事:耦合的强度(改 A 是否会强制改 B)、耦合的方向(是否依赖了不该依赖的东西)、耦合的形式(通过什么机制耦合,直接调用 vs 事件)。

  2. DIP:高层模块不依赖低层模块,两者都依赖抽象;抽象不依赖细节,细节依赖抽象。依赖方向反转后,易变的具体实现去依赖稳定的接口。关键是”让不稳定的依赖稳定的”——抽象(接口)稳定、具体实现易变,若高层依赖低层(稳定依赖不稳定),则低层一变高层就改;反过来(不稳定依赖稳定),则换实现时高层一行不改。

  3. 六种武器:接口/抽象(依赖倒置)、依赖注入(从外部传入依赖而非内部 new)、事件驱动/消息、中间人/中介者(N×N 变 N×1)、配置驱动、约定优于配置。事件驱动能达到最松耦合,因为发送方发完事件就走,完全不知道谁会处理、甚至不知道有没有人处理——双方不直接引用彼此,只依赖事件这个”契约”,因而可以独立演化、独立部署。

  4. 代价:间接层增加导致理解成本上升(理解 A 和 B 的协作要看 5 个东西而非 2 个);流程变隐式、调试变难(需要可观测性配套);过度设计违反 YAGNI(为”将来可能换”引入抽象却永远没换,白付复杂度);分布式单体陷阱(物理拆分但逻辑仍紧耦,同时承受两种代价)。三振出局:第一次直接写、第二次复制、第三次重复了才抽象——不要在第一次就抽象,因为此时还没理解需求全貌,过早的抽象往往是错的,而错误的抽象比没有抽象更糟(会被它锁死)。

  5. 内容耦合是”直接触碰并篡改另一个模块的内部实现”(私有数据、内部分支、内存布局),例如 u.Balance -= amount 直接改别人的字段、用反射/unsafe 改私有字段、monkey patch;危害是不变量守不住(想加”余额不能为负”的校验无处可加)、语义变化编译器一声不响(元改成分)、责任彻底消散。公共耦合是”多个模块共享同一份可变的全局状态”(全局变量、有状态单例、谁都能写的配置表);危害是谁都能改、没人负责、时序耦合与并发地雷、测试互相污染和隐式依赖。公共耦合更难查,因为它藏在函数签名之外——AuditLog(msg) 看上去只依赖 msg,实际还依赖一个全局当前租户,调用点根本看不出来,排查方式是”全代码库 grep 谁改了它”。“判据是可变而非全局”指:const MaxRetry = 3 全局但不可变,无”谁改的”问题,安全;无状态单例(线程池、日志器)安全;只有”全局 + 可变”才构成公共耦合。

  6. 公共耦合共享的是可变状态(谁偷偷改了没人知道,且无契约、无测试可依);外部耦合共享的是外部强加的格式/协议(文件格式、通信协议、数据库 schema、第三方 API 契约)。后者危害更小,因为格式通常是显式的、可版本化的、可被测试的——格式一改参与者都得改,但至少”改哪儿”是清楚的,且能用防腐层(ACL)关在一个门里。事件/消息解耦之后,模块间的直接依赖确实消失了,但耦合没有被消灭,而是搬到了事件 schema 这个公共契约上(一种外部耦合)。所以解耦的实质是选择耦合的位置与形式,而不是消除耦合。

  7. 从最坏到最好:偶然内聚(元素毫无关系,只是恰好放在一起,典型是 utils 包)、逻辑内聚(做几件相似的事由 flag 选一件,如 ProcessOrder(o, mode))、时间内聚(因为同一时刻执行才凑一起,如启动时的一堆 init)、过程内聚(按固定步骤组织但不同源,如 200 行的 handleRequest)、通信内聚(操作同一份数据但职责不同)、顺序内聚(前一个的输出是后一个的输入,如管道式步骤)、功能内聚(所有元素共同完成且仅完成一件事,如 SortInts)。一句话判定法:给这个模块写一句话描述”它做什么”,如果句子里出现了”和""以及""同时也”,它的内聚一定不是功能内聚。

  8. 总纲是”用抽象把稳定的东西和易变的东西隔开,并让易变的东西依赖稳定的东西”。分条:SRP 管内聚与边界(一个模块只应有一个引起它变化的原因,判据是”会要求改它的人是否来自同一业务诉求”);OCP 管变化被吸收的方式(加功能靠加代码而非改老代码,且只能战略性地对最可能的变化关闭);LSP 管抽象这道墙有没有洞(子类型必须能替换父类型而不破坏正确性);ISP 管依赖面的宽度(客户端不依赖它不需要的方法,应按调用方而非实现方切接口);DIP 管依赖的方向(让易变的实现去依赖稳定的抽象)。五条串成一条流水线:边界清楚→知道改哪一个;有扩展点→不改老代码;实现守契约→高层无感;依赖面窄→改动不外溢;方向正确→替换实现不影响策略。

  9. LSP 是”抽象屏障”的守门人。若子类型不遵守父类型的行为契约,调用方迟早要写 if x, ok := obj.(*Square); ok {...} 这类特判——一旦出现对具体类型的判断,抽象就当场失效,高层被迫知道低层有哪些具体实现,依赖倒置被一枪击穿。所以”违反 LSP = 抽象的墙上破了个洞,耦合从洞里漏进来”。还要注意 LSP 检查的不是”编译能否通过”(那是语法子类型),而是行为契约:前置条件不能更强、后置条件不能更弱、不变量必须保持——所以 func (c *ReadOnlyCache) Save(u *User) error { return ErrNotSupported } 是对 Repository 接口最直白的 LSP 违反,它把 Save 的后置条件削弱成了”什么都没做”。

  10. 不是一回事:DIP 是原则(依赖抽象),DI 是实现手法(把依赖从外部送进来)——DI 是运输工具,DIP 是目的地。“用了 DI 却没有 DIP”的例子:func NewOrderService(db *MySQLRepo) *OrderService,依赖确实从外部注入了,但注入的仍是具体类,换数据库照样要改签名。“倒置”倒的是依赖方向相对传统分层结构的方向:传统是”高层策略 → 低层机制”(主程序调子程序、子程序调 I/O);DIP 之后,实现方向仍是”上→下”(高层调用低层干活),但依赖方向变成”下→上”(低层实现去实现/依赖高层定义的接口),两者方向相反,故曰倒置。

  11. 三种:① 构造函数注入——优点:强制性(不给依赖造不出对象)、不变性(无并发与时序隐患)、依赖一眼看全、循环依赖在构造期暴露、利于不可变与线程安全;缺点:可选依赖也得传、参数列表变长、要求无参构造的框架(如 Android Activity)用不了;默认首选。② Setter 注入——优点:可选依赖自然、可运行期重配置、便于框架反射装配;缺点:时间耦合(装配完成前是半成品,任何调用都可能 nil panic,把编译期错误变成运行时偶发)、依赖可随意篡改、不变量守不住;适用于真正的可选依赖与框架妥协。③ 接口注入——被注入方实现形如 InjectRepository(r Repository) 的注入接口,装配方对所有实现该接口的类型统一处理,不依赖构造签名也不依赖反射字段名;实践中用得最少,因为代价是”被注入方要为实现注入而写代码”,侵入强、样板多,而收益与 setter 注入相当(Fowler 举的例子是 Apache Avalon)。

  12. 主要批评:① 依赖关系变隐式(action at a distance:看类本身不知道运行时是谁、什么生命周期、有没有被代理,答案藏在配置/注解/扫描规则里);② 编译期保障被削弱(配错 bean 名要等运行时才报错,补救方向是 Dagger/Wire 的编译期代码生成、Spring 转向构造器注入与编译期索引);③ Service Locator 反模式;④ 注解涂满代码库(把”如何装配”这个策略撒进几百个类,导致所有模块依赖 DI 框架,换框架要改几百个文件——用 DI 解耦业务,却把自己和 DI 框架焊死);⑤ 生命周期错配(captive dependency)(单例持有 scoped 依赖,表现为”第一个请求之后所有用户看到第一个人的数据”);外加启动变慢、堆栈变深。Service Locator(Container.Get(RepositoryKey))比直接 new 更糟,因为:依赖从函数签名里消失(比 new 更难发现)、业务代码依赖容器本身、容器全局可访问→本质就是公共耦合、测试必须先初始化容器——它用”看起来解耦”换来了”实际上更难发现的耦合”,等于把垃圾从客厅扫到地毯底下。判断标准只有一句:现在我能不能在一个文件(或一个函数)里看到完整的依赖图? 能,就是集中管理;不能,耦合已被打散。

  13. 按技术分层(controller/service/dao):优点是新人五分钟找到代码、技术关注点隔离,是模块内部的良好默认;缺点是分组的轴(技术角色)与变化的轴(业务能力)不一致,一个业务变化横切所有层,领域知识无处安放,容易长出三千行的上帝 Service。按限界上下文:边界内模型自洽、同一个词在不同上下文含义不同(“商品”在商品/订单/物流/财务四个上下文完全是四回事)、有统一语言与上下文映射(含防腐层 ACL);因为”边界内的东西倾向于一起变”,它是最有资格成为模块边界的那条线。康威定律(1968)说系统设计是组织沟通结构的副本,机制是”跨团队的改动需要跨团队沟通,而沟通昂贵”,所以任何不能被组织沟通结构承载的设计都会在实施中被持续扭曲;推论是反向康威策略(想要什么架构就把团队拆成什么样),它只是约束而非宿命。结合方式:① 最外层按业务能力/限界上下文切;② 模块内部按技术角色分层(分层是”模块内的卫生习惯”,不是系统骨架);③ 模块间只走显式契约(API + 事件 + ACL);④ 用组织校验(一个 ≤9 人的跨职能团队能否独占并独立发版);⑤ 用变化历史校验(两个模块 80% 的需求都一起改 → 该合并;一个模块里两类需求从不一起改 → 该拆开)。

  14. 模块化单体 = 一个部署单元 + 多个强边界模块:模块间只通过显式公开的 API(Facade/应用服务接口)+ 领域事件交互,禁止跨模块访问对方的私有实现与数据库表,且边界由机器强制(编译期可见性如 internal//JPMS/maven 多模块、以及 CI 里的架构测试如 ArchUnit / NetArchTest / import-linter / dependency-cruiser、依赖图禁环检查)。顺序重要的原因:微服务不创造耦合,只把”边界画错”的代价放大一个数量级——在单体里纠正边界是 IDE 的一次重构,跨服务则是带版本兼容的跨团队迁移(Segment 2018、Amazon Prime Video 2023 的回退都属于这一类教训)。所以正确顺序是”先让边界在代码里成立,再让它在网络里成立”。保留:单进程内调用(无网络延迟与序列化、本地事务一致、调试与重构便宜)+ 边界清晰可演进。放弃:故障域隔离(一个模块 OOM 拖垮整体)、技术栈自由、按模块独立伸缩。是否该走向分布式看五条真实信号(独立部署节奏、独立伸缩需求、故障域隔离的硬性 SLA、组织规模、技术栈/合规隔离),一条都不满足时收益通常为负。

  15. 四症状与量化指标:① 共享数据库——指标是”被一个以上服务写入的表数 > 0”,它是藏在 SQL 里的公共耦合 + schema 上的外部耦合,破法是划清表所有权、跨服务读走 API 或事件维护的只读投影、跨服务写绝对禁止;② 同步调用链过深——指标是调用链深度 >4、扇出 >6、端到端可用性 0.999⁷ ≈ 99.3%(一年多停机约 60 小时),破法是查询与决策分离(本地副本 + 事件,让服务回答问题时不必依赖别人在线)、读路径外置(BFF/物化视图/CQRS 读模型)、编排显式化、重试只在最外层做一次;③ 一起发版——指标是”单服务独立发布率 < 80%“,破法是契约向后兼容 + 消费者驱动契约测试(Pact)+ Schema Registry + 两阶段迁移;④ 分布式事务——先问”能否把这段逻辑放进同一个聚合/同一服务”(必须强一致的两段数据,大概率属于同一个边界),否则用 Saga(补偿不是回滚,中间态必须能被业务接受)+ 幂等去重 + 对账兜底。数据所有权是关键:数据没分开,所谓”模块”只是包名的艺术;数据一分开,模块才真正拥有自治权,将来拆服务就只剩运维工作量而不再是架构问题。

  16. 同一个梦想:都用”小的、单一职责的、通过标准契约组合的单元”取代庞大单体(Unix 里是过滤器 + 文本流,微服务里是服务 + API/事件)。代价差异全部来自组合介质从”内核管道缓冲”换成”网络”,由此产生分布式税:失败模式从两种(成功/异常)变成三种(多了最毒的超时——不知道对端做没做);可用性被乘法吃掉(0.999⁷ ≈ 99.3%);延迟被加法吃掉(p99 叠加,尾延迟更凶);状态从”无状态过滤器”变成”有状态服务”,于是出现中间态与补偿问题;背压从内核免费提供变成要自己实现(限流/熔断/队列);编排从”shell 脚本显式写在最外层”变成”藏在服务互相调用里的隐式流程”;组合者从”人,几秒试一次、错了重敲”变成”代码,上线周期以天计、错了要回滚”。Unix 之所以优雅,是因为这笔税它一分没交。减税办法:减少跳数、异步替代同步链、编排显式化、服务”过滤器化”(无状态幂等)、本地数据副本消除查询链路。

  17. 三步:① 识别边界(不拍脑袋)——从数据写入关系找(写操作聚集处即上下文;“必须同一事务更新的两张表几乎肯定属于同一边界”)、从 git 变化历史找(逻辑耦合/变更耦合,“一起改 = 事实上的耦合”,用 CodeScene/Code Maat 找”又热又烂”的热点优先动)、从需求与组织找、从依赖图找(循环依赖、被所有人依赖的 common/util、common 里有没有混进业务代码);产出依赖图 + 热点图 + 按 ROI 排序的清单。② 抽接口立边界(不动部署)——加 Facade 公开面(只加不改)→ 收紧可见性 + CI 架构测试让非法依赖红掉(边界靠自觉遵守一定会被侵蚀,这是破窗效应)→ 消除循环依赖(接口反转/事件/下沉上提)→ 拆分数据(逻辑 → 物理)→ 消除时间耦合 → 模块级独立测试(含特征测试与找接缝)。验收标准要可度量:非法依赖数归零并保持、依赖图无环、改一个需求的改动文件数(change footprint)下降、单模块测试可独立运行、新功能有多大比例能只在一个模块内完成。③ 拆部署(只在有真实收益信号时)——先拆”依赖最少 + 变化最频繁”的模块(不是最大的,最大通常耦合最深且收益未必最大),先拆读再拆写,同步改异步,用绞杀者模式灰度(影子流量比对 → 逐步切流 → 回滚开关保留一个完整业务周期)。“拆数据”是分水岭:之前三步只是整理包结构,之后模块才拥有真正的自治权,未来拆服务只剩运维工作量。何时停止看 DORA 四指标(部署频率、变更前置时间、变更失败率、平均恢复时间)与”一个需求要动几个服务”,而不是看服务数量。