7.SOLID原则案例汇

7.SOLID原则案例汇

目录介绍
  • 1.一次SOLID争吵
    • [1.1 团队三年之痛](#1.1 团队三年之痛 "#11-%E5%9B%A2%E9%98%9F%E4%B8%89%E5%B9%B4%E4%B9%8B%E7%97%9B")
    • [1.2 五次反转排查](#1.2 五次反转排查 "#12-%E4%BA%94%E6%AC%A1%E5%8F%8D%E8%BD%AC%E6%8E%92%E6%9F%A5")
    • [1.3 灵魂五连问](#1.3 灵魂五连问 "#13-%E7%81%B5%E9%AD%82%E4%BA%94%E8%BF%9E%E9%97%AE")
  • 2.SRP真相揭秘
    • [2.1 一个职责的边界](#2.1 一个职责的边界 "#21-%E4%B8%80%E4%B8%AA%E8%81%8C%E8%B4%A3%E7%9A%84%E8%BE%B9%E7%95%8C")
    • [2.2 滥用现场扫描](#2.2 滥用现场扫描 "#22-%E6%BB%A5%E7%94%A8%E7%8E%B0%E5%9C%BA%E6%89%AB%E6%8F%8F")
    • [2.3 重构前后对比](#2.3 重构前后对比 "#23-%E9%87%8D%E6%9E%84%E5%89%8D%E5%90%8E%E5%AF%B9%E6%AF%94")
    • [2.4 误用反模式](#2.4 误用反模式 "#24-%E8%AF%AF%E7%94%A8%E5%8F%8D%E6%A8%A1%E5%BC%8F")
  • 3.OCP扩展真意
    • [3.1 修改与扩展之分](#3.1 修改与扩展之分 "#31-%E4%BF%AE%E6%94%B9%E4%B8%8E%E6%89%A9%E5%B1%95%E4%B9%8B%E5%88%86")
    • [3.2 真实演化案例](#3.2 真实演化案例 "#32-%E7%9C%9F%E5%AE%9E%E6%BC%94%E5%8C%96%E6%A1%88%E4%BE%8B")
    • [3.3 扩展点设计](#3.3 扩展点设计 "#33-%E6%89%A9%E5%B1%95%E7%82%B9%E8%AE%BE%E8%AE%A1")
    • [3.4 过度设计陷阱](#3.4 过度设计陷阱 "#34-%E8%BF%87%E5%BA%A6%E8%AE%BE%E8%AE%A1%E9%99%B7%E9%98%B1")
  • 4.LSP契约深挖
    • [4.1 子类型规则](#4.1 子类型规则 "#41-%E5%AD%90%E7%B1%BB%E5%9E%8B%E8%A7%84%E5%88%99")
    • [4.2 矩形正方形悖论](#4.2 矩形正方形悖论 "#42-%E7%9F%A9%E5%BD%A2%E6%AD%A3%E6%96%B9%E5%BD%A2%E6%82%96%E8%AE%BA")
    • [4.3 契约前后置](#4.3 契约前后置 "#43-%E5%A5%91%E7%BA%A6%E5%89%8D%E5%90%8E%E7%BD%AE")
    • [4.4 真实事故复盘](#4.4 真实事故复盘 "#44-%E7%9C%9F%E5%AE%9E%E4%BA%8B%E6%95%85%E5%A4%8D%E7%9B%98")
  • 5.ISP接口隔离
    • [5.1 胖接口的成本](#5.1 胖接口的成本 "#51-%E8%83%96%E6%8E%A5%E5%8F%A3%E7%9A%84%E6%88%90%E6%9C%AC")
    • [5.2 拆分的判据](#5.2 拆分的判据 "#52-%E6%8B%86%E5%88%86%E7%9A%84%E5%88%A4%E6%8D%AE")
    • [5.3 与SRP差异](#5.3 与SRP差异 "#53-%E4%B8%8Esrp%E5%B7%AE%E5%BC%82")
    • [5.4 拆过头的代价](#5.4 拆过头的代价 "#54-%E6%8B%86%E8%BF%87%E5%A4%B4%E7%9A%84%E4%BB%A3%E4%BB%B7")
  • 6.DIP依赖倒置
    • [6.1 倒置的方向](#6.1 倒置的方向 "#61-%E5%80%92%E7%BD%AE%E7%9A%84%E6%96%B9%E5%90%91")
    • [6.2 IoC容器原理](#6.2 IoC容器原理 "#62-ioc%E5%AE%B9%E5%99%A8%E5%8E%9F%E7%90%86")
    • [6.3 字节码织入](#6.3 字节码织入 "#63-%E5%AD%97%E8%8A%82%E7%A0%81%E7%BB%87%E5%85%A5")
    • [6.4 何时不要倒置](#6.4 何时不要倒置 "#64-%E4%BD%95%E6%97%B6%E4%B8%8D%E8%A6%81%E5%80%92%E7%BD%AE")
  • 7.SOLID关系图
    • [7.1 五原则相互依赖](#7.1 五原则相互依赖 "#71-%E4%BA%94%E5%8E%9F%E5%88%99%E7%9B%B8%E4%BA%92%E4%BE%9D%E8%B5%96")
    • [7.2 与设计模式的映射](#7.2 与设计模式的映射 "#72-%E4%B8%8E%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%E7%9A%84%E6%98%A0%E5%B0%84")
  • 8.综合案例实战
    • [8.1 风控接入需求](#8.1 风控接入需求 "#81-%E9%A3%8E%E6%8E%A7%E6%8E%A5%E5%85%A5%E9%9C%80%E6%B1%82")
    • [8.2 不用SOLID翻车](#8.2 不用SOLID翻车 "#82-%E4%B8%8D%E7%94%A8solid%E7%BF%BB%E8%BD%A6")
    • [8.3 SOLID演化版](#8.3 SOLID演化版 "#83-solid%E6%BC%94%E5%8C%96%E7%89%88")
    • [8.4 类图与时序](#8.4 类图与时序 "#84-%E7%B1%BB%E5%9B%BE%E4%B8%8E%E6%97%B6%E5%BA%8F")
    • [8.5 留下三道思考题](#8.5 留下三道思考题 "#85-%E7%95%99%E4%B8%8B%E4%B8%89%E9%81%93%E6%80%9D%E8%80%83%E9%A2%98")
  • 08.总结与下一篇

# 章节 主题 关键案例 关键图表
01 面向对象设计思想 范式之别 双 11 雪崩订单系统 网状 vs 线性流程图
02 面向对象的特性 封装/抽象/继承/多态 钱包余额对不上排查 vtable 内存布局图
03 接口vs抽象类比较 何时选哪个 日志器 47 次改动复盘 决策树、模板时序图
04 接口而非实现编程 依赖抽象不依赖具体 云迁移 6 万行连锁修改 三步重构演化图
05 多用组合和少继承 继承陷阱与组合优雅 企鹅会飞继承翻车 类爆炸 vs 正交组合
06 设计原则全景图 SOLID + 23 模式 8000 行上帝类失控 SOLID 关系图
07 SOLID 原则案例汇 SOLID 在战场上 团队三年 SOLID 之争 滥用现场扫描图
08 反模式与坏味道 30+ 嗅觉清单 PR 被驳回 17 次 坏味道地图
09 重构十二式实战 12 个最常用手法 1200 行结算函数救援 重构节奏循环
10 可测试性设计 OOP 的硬验收 95% 覆盖率上线即崩 测试金字塔
11 DDD 与战术建模 从代码到业务 需求文档 70% 失真 限界上下文图
12 综合实战图片框架 完整系统实现 图片处理系统实战 完整架构图

1.一次SOLID争吵

1.1 团队三年之痛

2023 年 4 月,某金融中台团队做 Code Review。一段 700 行的 RiskCheckProcessor 长出了三重 if 嵌套 + 9 个魔法字符串。会议室里 5 个工程师吵了 40 分钟:

css 复制代码
A 工程师:「这违反 SRP,做了风控判断+落库+发消息」
B 工程师:「不,违反的是 OCP,新加渠道得改这块代码」
C 工程师:「哪儿都没违反,就是写得有点长」
D 工程师:「拆开吧」
Tech Lead:「拆完万一线上炸了谁背?------下周再说」

「下周再说」之后的三年,这段代码长到了 3142 行 。其间每一次 PR 都被 Reviewer 留下「此处违反 SOLID 」的评论,但究竟违反哪一条,没人说得清 。最后变成谁都不敢碰的禁区------SOLID 反而成了团队不前进的借口

flowchart TD PR[每个 PR 都被打回]-->C{Reviewer 评论} C-->C1["违反 SRP?"] C-->C2["违反 OCP?"] C-->C3["违反 LSP?"] C-->C4["违反 ISP?"] C-->C5["违反 DIP?"] C1 & C2 & C3 & C4 & C5 -->X[无人能精确定位] X-->Y[代码不敢动] Y-->Z[新需求继续堆 if-else] Z-->PR

真正的麻烦不在「该不该遵守 SOLID」,而在「面对一段烂代码,工程师能否 30 秒内说清违反了哪一条 」。本篇要解决的就是这个精确诊断能力

1.2 五次反转排查

我们把当时 700 行的代码逐步剥洋葱,看「真正的根因」是什么:

反转 1:以为是 SRP ------把发消息、落库各拆出去后,核心 risk() 仍然 400 行,问题没解决。SRP 不是答案。

反转 2:以为是 OCP ------但产品反馈未来不会新增风控渠道(业务已稳定 2 年),扩展点是空中楼阁。OCP 也不是。

反转 3:以为是 LSP------查代码:根本没有继承体系,全是平铺过程式调用。LSP 不适用。

反转 4:以为是 ISP ------但调用方就一个 OrderService,没有「胖接口受害者」。ISP 也不是。

反转 5:真正的根因是 DIP 缺失 ------RiskCheckProcessor 直接 new MysqlConnectionnew HttpClientnew KafkaProducer。一切实现细节都钉死在这个类里,于是任何「拆」都拆不动------动一个分支就要动数据库、动消息、动 HTTP,没有哪条原则能"先"被遵守,因为底下没有抽象层托住。

flowchart LR A[症状: 代码没法拆] -->|你以为| B1[SRP 病] A -->|你以为| B2[OCP 病] A -->|你以为| B3[LSP 病] A -->|你以为| B4[ISP 病] A -->|实际| B5[DIP 病] B5 -->|后果| C[四原则全失效]

这个故事的真正寓意 :SOLID 五条原则不是平行罗列 ,它们之间有承载关系DIP 是地基,没有 DIP,其他四条都建在沙上------这是上一篇 06 §10 「认知跃迁」的工程印证。

1.3 灵魂五连问

scss 复制代码
Q1 ── SOLID 究竟解决什么问题?为什么不直接说"写好代码"?
       └─→ §06.1 五原则是熵增的对抗
Q2 ── 五条原则有先后吗?
       └─→ §06.1 因果关系图
Q3 ── 它们之间是否有冲突?
       └─→ §01.4 / §04.4 / §05.4 三处冲突现场
Q4 ── 何时该"刻意不遵守"SOLID?
       └─→ §02.4 / §05.4 反 OCP / 反 DIP 的合理边界
Q5 ── 为什么 99% 工程师只在面试时讨论 SOLID?
       └─→ §07 综合实战------把 SOLID 装回到肌肉记忆

2.SRP真相揭秘

2.1 一个职责的边界

教科书定义:「一个类应当只有一个引起它变化的原因」。但「一个原因」到底是几个原因?看这段代码:

java 复制代码
public class Invoice {
    public BigDecimal computeTotal()    { ... }   // 计算总金额
    public BigDecimal computeTax()      { ... }   // 计算税
    public void       sendToCustomer()  { ... }   // 发邮件
    public void       saveToDb()        { ... }   // 入库
    public String     toJson()          { ... }   // 序列化
}

直觉上------「不行,5 个职责」。但这是错的诊断 。SRP 真正问的是「变化的方向」:

方法 谁会请求修改它?
computeTotal() 业务/财务
computeTax() 业务/财务(税法)
sendToCustomer() 运营/邮件模板组
saveToDb() DBA/架构组
toJson() API 网关/对接方

5 个方法 = 5 个不同的 stakeholder------这才是 SRP 真正违反的地方。Robert Martin 在《Clean Architecture》里换了说法:

一个模块应当只对一个 actor 负责。

「actor」是一群有共同变更动机的人。职责的边界 = 变更动机的边界

flowchart LR Inv[Invoice 类] --> A1[财务: 改算法] Inv --> A2[运营: 改邮件] Inv --> A3[DBA: 改表结构] Inv --> A4[网关: 改 JSON] A1 & A2 & A3 & A4 -.同时盯着同一个文件.-> Conflict[Git 冲突, 部署连坐, 测试爆炸]

2.2 滥用现场扫描

下面三段代码,先自己判断:违反 SRP 吗?

【A 段】 :表面有 3 个 if,实际上只有 1 个 actor------业务规则方不违反

java 复制代码
public class OrderValidator {
    public ValidationResult check(Order order) {
        if (order.amount().compareTo(BigDecimal.ZERO) <= 0) return Invalid("amount");
        if (order.items().isEmpty())                       return Invalid("items");
        if (order.user() == null)                          return Invalid("user");
        return Valid();
    }
}

【B 段】 :看起来都是「用户操作」。实际uploadAvatar 改动频率属于「图片处理团队」,与「身份团队」是两个 actor。违反

java 复制代码
public class UserService {
    public User register(...)      { ... }
    public User login(...)         { ... }
    public Avatar uploadAvatar(...) { ... } // 含图片裁剪、CDN 上传、缩略图生成
}

【C 段】 :三个动词分别属于:报表统计团队、运营团队、运维团队 。三个 actor,严重违反

java 复制代码
public class ReportService {
    public Report build(Date d)    { ... }   // SQL 聚合 + 业务规则
    public void   email(Report r)  { ... }   // 模板渲染 + SMTP
    public void   archive(Report r){ ... }   // 写到 S3
}

自我诊断的关键问句:「如果同一份代码被三个不同的 actor 同时要求改,他们会不会打架?」如果会,就违反 SRP。

2.3 重构前后对比

把 C 段重构:

java 复制代码
// 第 1 步:按 actor 拆类
public class ReportBuilder  { public Report build(Date d) { ... } }
public class ReportMailer   { public void email(Report r)  { ... } }
public class ReportArchiver { public void archive(Report r){ ... } }

// 第 2 步:在更上层用「门面」组装(Facade)
public class ReportFacade {
    public ReportFacade(ReportBuilder b, ReportMailer m, ReportArchiver a) { ... }
    public void runDailyReport(Date d) {
        var r = builder.build(d);
        mailer.email(r);
        archiver.archive(r);
    }
}

关键变化:Git 冲突域物理隔离 ------三个 actor 现在各自维护自己的文件,改动互不打扰,又能在 Facade 协作。这是 SRP 最直接的工程红利。

2.4 误用反模式

但 SRP 也有滥用症------「贫血神族」:

java 复制代码
// 反面教材:拆得太碎
class OrderId        { String value; }
class OrderAmount    { BigDecimal value; }
class OrderItemList  { List<Item> items; }
class OrderUser      { User user; }
class OrderState     { Status state; }
class Order          { OrderId id; OrderAmount amt; OrderItemList items; OrderUser user; OrderState state; }

每个类只包一个字段,没有任何行为。SRP 是手段不是目的 。判断标准:拆出来的类是否有自己的 invariant(不变量)需要守护?如果没有,就退回去。

SRP 的极端是 Anemic(贫血),过度遵守反而违背了 OOP 的本意。 SRP 要回答的不是「字段拆不拆」,而是「变更动机是否同构」。


3.OCP扩展真意

3.1 修改与扩展之分

OCP 的两个动词「对扩展开放、对修改关闭」最容易被误读。看 git diff:

「修改」型变更

diff 复制代码
- if (channel.equals("alipay")) { return alipay(); }
+ if (channel.equals("alipay")) { return alipay(); }
+ if (channel.equals("wechat")) { return wechat(); }

原来的代码被改动了一行以上,引发回归测试。

「扩展」型变更

diff 复制代码
+ @Component class WechatPay implements PayMethod { ... }

原代码一行不动,只是新增了一个类。Spring 在装配时自动把它纳入。

OCP 真正的标准:「面对新需求,原有的代码是否需要打开 IDE 的现有文件?」如果不需要,就达到了 OCP

flowchart LR R[新需求来了] --> Q{要不要打开<br/>现有文件?} Q -->|要| M[修改型: 违反 OCP] Q -->|不要| E[扩展型: 遵守 OCP] M --> Risk[回归测试 + 上线风险] E --> Safe[只测新代码 + 灰度上线]

3.2 真实演化案例

某支付系统从 1 种支付演化到 7 种的真实路径,下面是 4 次大版本各自的 commit diff 行数:

版本 新增渠道 修改文件数 OCP 状态
v1.0 支付宝 创建文件 起点
v2.0 + 微信 改动 1 个核心文件 ,加 else if ❌ 违反
v2.1 重构:抽 PayMethod 接口 改动 1 个核心文件(最后一次) 转折点
v3.0 + 银联 新增 1 个文件 ✅ 遵守
v4.0 + Apple Pay 新增 1 个文件 ✅ 遵守
v5.0 + PayPal、Stripe、银行卡 新增 3 个文件 ✅ 遵守

v2.1 是关键转折点 。它本身是一次 SRP/OCP 双违反的回头修复------但回头一次以后,后续 5 个迭代再没有动过核心文件。

重构成本 :1 人 3 天。节省成本 :5 次迭代 × 3 天测试回归 = 15 天。ROI = 5 倍

3.3 扩展点设计

OCP 不是「所有地方都要预留扩展点 」,而是「在该开的地方开」。怎么判断?

flowchart TB Q[要不要在这开扩展点?] --> Q1{此处近 1 年<br/>变更过几次?} Q1 -->|0 次| N1[不要开] Q1 -->|1-2 次| Q2{是否预期未来还变?} Q2 -->|否| N2[不要开] Q2 -->|是| Y1[开] Q1 -->|3+ 次| Y2[必须开]

三个真实信号

  1. 过去信号:git log 里此处改动 ≥ 3 次。
  2. 现在信号:PM/产品文档明确说「未来还要接 X」。
  3. 未来信号 :该处对接的是外部第三方(外部就是变化源)。

满足任一,就值得开。否则就是「YAGNI 反模式」------见 §2.4。

3.4 过度设计陷阱

最常见的「过度 OCP」长这样:

java 复制代码
public interface UsernameValidator { boolean valid(String s); }
@Component class DefaultUsernameValidator implements UsernameValidator { ... }
@Component class UserRegisterService {
    @Autowired UsernameValidator validator;   // 永远只有一个实现
}

问题UsernameValidator 接口三年来只有一个实现。这个接口没有保护任何变化,只增加了:

  • 阅读成本(多跳一层 IDE)
  • 测试成本(mock 设置)
  • 包结构复杂度

YAGNI(You Aren't Gonna Need It) :在没有「过去/现在/未来」三个信号之一时,预留扩展点 = 浪费

OCP 要遵守,但只在该遵守的地方 。这正是 §0.3 Q4 的答案:何时刻意不遵守? 答:在没有变化源的地方


4.LSP契约深挖

4.1 子类型规则

Barbara Liskov 1987 年原话很数学:

若 S 是 T 的子类型,则 T 类型对象在程序中能被替换为 S 类型对象,且不影响程序的正确性。

「正确性」 才是关键。子类型能不能用作父类型,不是看编译器 (编译器只看签名),是看行为契约

java 复制代码
abstract class Bird { abstract void fly(); }
class Sparrow extends Bird { void fly() { ... } }
class Penguin extends Bird { void fly() { throw new Unsupported(); } } // ← 违反 LSP

Penguin 编译能过,但运行时会爆炸。LSP 防的就是这种「编译期通过、运行期违约」。

4.2 矩形正方形悖论

最著名的 LSP 反例:

java 复制代码
class Rectangle { int w, h; void setW(int w) { this.w = w; } void setH(int h) { this.h = h; } }
class Square extends Rectangle {
    @Override void setW(int w) { super.setW(w); super.setH(w); }
    @Override void setH(int h) { super.setH(h); super.setW(h); }
}

逻辑上「正方形是矩形」,但调用方写:

java 复制代码
void resize(Rectangle r) {
    r.setW(5); r.setH(10);
    assert r.area() == 50;   // ← Square 实例会让 area = 100
}

LSP 失败的数学根源

  • 矩形的契约setW(w); setH(h) 后,width=w 且 height=h
  • 正方形的重写 :破坏了「setW 不改 height」这条后置条件

LSP 不是讲类型层级的"是不是",是讲行为契约的"能不能替换"。

4.3 契约前后置

LSP 的三条机械可校验规则(来自 Bertrand Meyer 的「契约式编程」):

契约部分 子类规则
前置条件(Precondition) 不能加强(接受输入要更宽松)
后置条件(Postcondition) 不能削弱(保证输出要更严格)
不变量(Invariant) 不能破坏(必须维持)
java 复制代码
class Account {
    /** 前置: amount > 0 ; 后置: balance 增加 amount */
    public void deposit(BigDecimal amount) { ... }
}
class FrozenAccount extends Account {
    @Override public void deposit(BigDecimal amt) {
        // 加强前置条件: "且账户必须不冻结"
        // ← 违反 LSP! 调用方不知道还要查 frozen 状态
    }
}

修复办法:把「frozen」升格为前置条件 写在父类里,或者干脆不让 FrozenAccount 继承 Account------而用组合 :「Account 有一个 state 字段」。

4.4 真实事故复盘

某分布式 KV 存储有 Storage 接口:

java 复制代码
interface Storage {
    /** 后置: get(k) 返回最近一次 put(k, v) 的 v */
    void put(String k, byte[] v);
    byte[] get(String k);
}
class MemoryStorage   implements Storage { ... }   // 强一致
class RedisStorage    implements Storage { ... }   // 主从异步, 可能读到旧值

调用方写:

java 复制代码
storage.put("session:" + sid, sessionData);
// 100ms 后另一个请求
byte[] data = storage.get("session:" + sid);   // RedisStorage 偶尔读到 null!

RedisStorage 削弱了后置条件 (最终一致而非强一致)。事故表现:用户登录后偶尔被踢出。修复办法不是改 Redis,而是把接口契约重写为:

java 复制代码
interface Storage {
    /** 后置: 在最终一致的窗口期(<= 1s)内, get(k) 返回最近一次 put(k, v) 的 v */
    ...
}

契约写宽 ,让所有实现都能满足。调用方就被强制按"最终一致"的假设写代码------再也不会踩这个坑。

LSP 治理事故的方法论:契约要写在最弱的实现能满足的水位上


5.ISP接口隔离

5.1 胖接口的成本

java 复制代码
interface UserService {
    User get(String id);
    User register(...);
    void changePassword(...);
    void uploadAvatar(...);
    void exportData(...);     // GDPR 需求
    void deleteAccount(...);  // GDPR 需求
    void grantAdminRole(...); // 后台管理
    ... 47 个方法 ...
}

调用方有:

  • LoginController:只用 get + register + changePassword
  • AvatarController:只用 uploadAvatar
  • AdminController:只用 grantAdminRole

但他们全员依赖 UserService 整个接口。后果:

问题 解释
Mock 编写难 测试 LoginController 要 mock 47 个方法
重编译扩散 加一个 GDPR 方法,所有 Controller 重新编译
代码搜索噪声 uploadAvatar,IDE 报「47 处引用」, 实际只有 1 处真用

5.2 拆分的判据

ISP 的金科玉律按调用方的实际依赖差异拆,不按方法多寡拆。

java 复制代码
interface UserAuth     { User get(...); User register(...); void changePassword(...); }
interface UserProfile  { void uploadAvatar(...); }
interface UserGdpr     { void exportData(...); void deleteAccount(...); }
interface UserAdmin    { void grantAdminRole(...); }

class UserServiceImpl implements UserAuth, UserProfile, UserGdpr, UserAdmin { ... }

实现类 仍然是一个(避免 §4.4 的拆过头),但对调用方暴露的视图按照「角色」分割。

flowchart LR LC[LoginController] --> A[UserAuth] AC[AvatarController] --> P[UserProfile] GC[GDPR 服务] --> G[UserGdpr] AdC[AdminController] --> Ad[UserAdmin] A & P & G & Ad -.全部由.-> Impl[UserServiceImpl]

5.3 与SRP差异

很多人混淆 SRP 与 ISP。区别:

维度 SRP ISP
看的东西 的职责 接口的客户端视图
边界依据 actor 分组 caller 用法分组
拆分对象 类拆成多个类 接口拆成多个接口(实现可以仍是一个)
检验问题 "几个 actor 想改这个类?" "几种 caller 看这个接口的不同子集?"

SRP 是「写代码的人」视角,ISP 是「用代码的人」视角。同一个类可以 SRP 通过、ISP 失败------比如 UserServiceImpl 只服务一个 actor(用户中心团队),但暴露 47 个方法给 4 类 caller

5.4 拆过头的代价

java 复制代码
// 反面教材:1 个方法 1 个接口
interface CanRegister { User register(...); }
interface CanLogin    { User login(...); }
interface CanLogout   { void logout(...); }
interface CanChangePwd{ void changePassword(...); }
... 47 个 ...

每个 Controller 要 @Autowired 5+ 个接口,构造方法变成 100 行。拆得太散,Caller 要重新组装这些「功能微粒」 ,实际上又把胖接口的复杂度转嫁给了 Caller

判据:拆到「一个 caller 用一个接口」就停。再细分就是过度。


6.DIP依赖倒置

6.1 倒置的方向

最直观的解释:

flowchart LR subgraph 正常依赖[传统结构(依赖向下)] A1[OrderService] --> B1[MysqlOrderRepo] end subgraph 倒置依赖[DIP 结构(依赖倒置)] A2[OrderService] --> I[OrderRepo<br/>接口] I -.被实现.-> B2[MysqlOrderRepo] end

「倒置」二字的精髓:接口的所有权属于高层(OrderService 所在的 domain 包),实现属于低层(infra 包)。物理上:

  • 接口文件路径:com.mall.domain.OrderRepo
  • 实现文件路径:com.mall.infra.MysqlOrderRepo

包依赖箭头从 infra → domain------而不是反过来。这就是「倒置」一词的字面意义。

6.2 IoC容器原理

DIP 在静态层只是约定,运行时还需要一个「装配者」把接口和实现连起来。这就是 IoC 容器。Spring 的最简化原理:

java 复制代码
public class MiniContainer {
    Map<Class<?>, Object> beans = new HashMap<>();

    public void register(Class<?> iface, Object impl) { beans.put(iface, impl); }

    public <T> T resolve(Class<T> iface) { return (T) beans.get(iface); }

    public <T> T autowire(Class<T> clazz) {
        Constructor<?> ctor = clazz.getDeclaredConstructors()[0];
        Object[] args = Arrays.stream(ctor.getParameterTypes())
                              .map(this::resolve).toArray();
        return (T) ctor.newInstance(args);
    }
}
// 装配
container.register(OrderRepo.class, new MysqlOrderRepo(dataSource));
OrderService svc = container.autowire(OrderServiceImpl.class);   // 接口依赖被自动注入

20 行代码就把 Spring 核心机制讲完了。Spring 多出来的是生命周期管理、AOP、scope 控制、循环依赖检测------但 DIP 装配本质就这么简单。

6.3 字节码织入

DIP 还有更隐蔽的实现路径:AOP 代理 。Spring 给 OrderService@Transactional 时不是修改源码,而是用 CGLIB 在类加载时生成子类:

kotlin 复制代码
原 class:        OrderService
加载后实际跑的:  OrderService$$EnhancerByCGLIB$$abc123
                ↓ 子类的 placeOrder 长这样
                public Order placeOrder(...) {
                    txManager.begin();
                    try { result = super.placeOrder(...); txManager.commit(); }
                    catch (Throwable t) { txManager.rollback(); throw t; }
                    return result;
                }

这是「反向控制(IoC) 」的字节码层物理表达------你以为自己在调 OrderService,运行时调的是它的代理。这种控制权的反转就是 DIP 在动态层的体现。

6.4 何时不要倒置

DIP 也有反例:

场景 是否需要 DIP 原因
业务层依赖 ORM/HTTP/Cache ✅ 必须 这些是「外部不稳定边界」
工具类依赖 String/Math/Collections ❌ 不需要 标准库稳定,倒置只会增加复杂度
算法库依赖数据结构 ❌ 不需要 内聚高、性能敏感、抽象增加间接层开销
Controller 依赖 Service ✅ 倾向需要 Service 实现可能切换(比如本地/远程)
Service 内部的 helper 方法 ❌ 不需要 同包内聚力,不抽象不影响测试

判据 :「这层依赖未来可能换吗? 」+「是否影响可测试性?」。两个都说「不」,就别 DIP------不必为没有变化的稳定依赖加抽象层。


7.SOLID关系图

7.1 五原则相互依赖

回到 §0.2 的真相:SOLID 不是平行五条,而是有承载关系:

flowchart TD DIP[DIP 依赖倒置<br/>地基: 给抽象一个家] --> SRP[SRP 单一职责<br/>边界: 类的轴线] DIP --> OCP[OCP 开闭<br/>扩展: 业务的弹性] SRP --> OCP SRP --> ISP[ISP 接口隔离<br/>视角: 调用方的舒适] OCP --> LSP[LSP 里氏替换<br/>护栏: 替换不出错] ISP --> LSP LSP -.闭环回到.-> DIP
  • DIP 是地基:没有抽象层,其他四条无处生根(开篇 §0.2 排查结论)。
  • SRP 是骨架:定义类的边界。SRP 不立,OCP 谈何「扩展点」。
  • OCP 是肌肉:让系统在业务变化下能伸展。
  • ISP 是皮肤:让 caller 看到的视图舒服。
  • LSP 是免疫系统:替换不破坏,OCP 才安全(不然「扩展点」就是地雷)。

五原则互为因果,不能孤立讨论。这就是 §0.3 Q1/Q2 的回答

7.2 与设计模式的映射

23 种 GoF 设计模式可以按「主治哪条 SOLID」归类(取代表性几个):

模式 主治 SOLID 病灶举例
策略模式 OCP 算法分支膨胀
模板方法 OCP + LSP 流程相同细节不同
装饰器 OCP + ISP 切面加在调用链上
适配器 DIP 老接口对接不上新需求
工厂方法 DIP new 钉死了类型
桥接 DIP + OCP 抽象与实现独立变化
观察者 OCP + DIP 变化源 → 多消费者
命令 SRP + OCP 把动词封装为对象
责任链 OCP 顺序处理可插拔
访问者 OCP(双分派) 数据结构稳定,操作变化

设计模式不是 23 个独立招式,而是 SOLID 在不同场景的"模板解"


8.综合案例实战

主线接力------06 篇我们用 SOLID 武装了 OrderManager,现在压力测试:接入第三方风控

8.1 风控接入需求

PM 给的需求:

markdown 复制代码
1. 选 5 家供应商之一: 同盾/邦盛/猛犸/百融/腾讯天御
2. 所有供应商接口都不同,但功能相似
3. 风控失败要写日志、发告警、阻断订单
4. 不同业务线(商城/票务/二手)有不同的风控阈值
5. POC 完成后会选定 1-2 家长期合作,但允许"双跑"
6. 每周可能调整阈值规则

8.2 不用SOLID翻车

工程师 A 第一版:

java 复制代码
public class RiskCheckService {
    public RiskResult check(Order order) {
        // 阶段 1: 业务线判别
        if ("MALL".equals(order.bizLine())) {
            // 阶段 2: 调风控供应商
            String body = httpClient.post("https://tongdun.com/api/v1/check",
                                          buildTongdunBody(order));
            // 阶段 3: 解析、判定
            JsonNode resp = json.read(body);
            if (resp.get("score").asInt() > 80) {
                logger.error("风控拦截: " + order);
                kafkaProducer.send("risk-alert", order.toString());
                return RiskResult.BLOCKED;
            }
        } else if ("TICKET".equals(order.bizLine())) {
            // 票务用邦盛 + 阈值不同
            ...
        } else if ("RESALE".equals(order.bizLine())) {
            ...
        }
        ...
    }
}

3 周后,需求方说「双跑------同盾和邦盛同时调,对比结果」。这一句话需要:

  • 改 14 处 if 分支
  • 把 HTTP 调用全部 try-catch 加重试
  • 发邮件给两家供应商对账

工程师当场崩溃。5 条 SOLID 全员违反

flowchart LR Code[RiskCheckService] -->|做了 5 件事| SRP违反 Code -->|加供应商要改 if| OCP违反 Code -->|没有抽象,无法替换| DIP违反 Code -->|无替换体系| LSP不适用 Code -->|无接口分层| ISP违反

8.3 SOLID演化版

第 1 步·DIP 打地基(必须先做,§0.2 教训):

java 复制代码
public interface RiskProvider {
    RiskScore evaluate(RiskRequest req);
}
public class TongdunProvider  implements RiskProvider { ... }
public class BangshengProvider implements RiskProvider { ... }

第 2 步·SRP 划职责

java 复制代码
public class RiskCheckService {
    public RiskCheckService(RiskProviderRouter router,
                            RiskDecisionEngine decider,
                            RiskAuditor auditor,
                            RiskAlertPublisher alerter) { ... }

    public RiskResult check(Order order) {
        RiskScore score = router.choose(order).evaluate(toRequest(order));
        Decision  d     = decider.decide(order, score);
        auditor.audit(order, score, d);
        if (d.blocked()) alerter.alert(order, score);
        return d.toResult();
    }
}

5 件事 → 4 个协作者 + 1 个编排器。

第 3 步·OCP 开扩展点(针对「双跑」需求):

java 复制代码
public interface RiskProviderRouter {
    RiskProvider choose(Order order);     // 单跑
    List<RiskProvider> chooseAll(Order order);  // 双跑
}
public class WeightedRouter         implements RiskProviderRouter { ... }
public class DualRunRouter          implements RiskProviderRouter { ... }
public class BizLineSpecificRouter  implements RiskProviderRouter { ... }

新加策略 = 新加一个 Router 类,原代码一行不动。

第 4 步·LSP 守契约

RiskProvider.evaluate 的契约必须是「纯函数 + 幂等 」,新加的 BaironProvider 如果偷偷做了缓存写库,就是 LSP 违反------必须重写文档。契约文档不是装饰,是法律

第 5 步·ISP 拆视角

java 复制代码
// 给 Controller 看到的接口
public interface RiskCheckApi {
    RiskResult check(Order order);
}
// 给运维看到的接口
public interface RiskOps {
    void hotReloadRules();
    Map<String, Long> stats();
}
// 给 BI 看到的接口
public interface RiskAuditQuery {
    List<RiskAudit> queryByOrder(OrderId id);
}
// 一个实现实现三个接口,但三类调用方互不知情
public class RiskCheckServiceImpl implements RiskCheckApi, RiskOps, RiskAuditQuery { ... }

双跑需求最终落地 :只新增 DualRunRouter 一个文件 + 一行 Bean 注册。5 条 SOLID 的复利效应

8.4 类图与时序

classDiagram class RiskCheckService { +check(order) RiskResult } class RiskCheckApi { <<interface>> +check(order) RiskResult } class RiskOps { <<interface>> +hotReloadRules() } class RiskAuditQuery { <<interface>> +queryByOrder(id) } class RiskProvider { <<interface>> +evaluate(req) RiskScore } class RiskProviderRouter { <<interface>> +choose(order) +chooseAll(order) } RiskCheckService ..|> RiskCheckApi RiskCheckService ..|> RiskOps RiskCheckService ..|> RiskAuditQuery RiskCheckService --> RiskProviderRouter RiskProviderRouter <|.. WeightedRouter RiskProviderRouter <|.. DualRunRouter RiskProviderRouter --> RiskProvider RiskProvider <|.. TongdunProvider RiskProvider <|.. BangshengProvider RiskProvider <|.. BaironProvider

然后看一下时序图

sequenceDiagram participant C as OrderController participant S as RiskCheckService participant R as DualRunRouter participant P1 as TongdunProvider participant P2 as BangshengProvider participant D as RiskDecisionEngine C->>S: check(order) S->>R: chooseAll(order) R-->>S: [TongdunProvider, BangshengProvider] par 并行评估 S->>P1: evaluate(req) P1-->>S: score1 and S->>P2: evaluate(req) P2-->>S: score2 end S->>D: decide(order, [score1, score2]) D-->>S: Decision S-->>C: RiskResult

8.5 留下三道思考题

答案在第 08 篇开头揭晓。

🟢 易 :§7.3 第 5 步把同一个 RiskCheckServiceImpl 实现了 3 个接口。有人会说这违反 SRP------你怎么反驳?提示:actor 与 caller 的分别,§4.3。

🟡 中DualRunRouter.chooseAll() 返回 List<RiskProvider>,但两家供应商的延时差 200msRiskCheckService.check 是同步等所有结果,还是只等更快的那个?这两种语义会导致什么不同的问题?提示:与第 11 篇 DDD 的「最终一致」相关。

🔴 难 :风控规则每周调整。如果用「配置中心 + DSL 表达式 」让运营自己配,不再编译------你会绕过 OCP (因为运营每周确实要"修改"规则配置)。这种「配置即代码」算违反 OCP 吗?SOLID 在「应用代码 」与「配置/数据」之间的边界在哪?提示:这道题就是第 08 篇「过度设计 vs 不足设计」要回答的核心矛盾。


9.总结与下一篇

一句话总结 SOLIDSOLID 不是写好代码的"五条规则",而是面对烂代码时"五种诊断工具"------SRP 看 actor、ISP 看 caller、OCP 看 diff、LSP 看契约、DIP 看抽象层归属。

与第 06 篇的关系------

flowchart LR P06[06.设计原则全景图<br/>什么是 SOLID] --> P07[07.SOLID 案例汇<br/>SOLID 在生产环境] P07 --> P08[08.反模式与坏味道大全<br/>违反 SOLID 的具体气味]

06 篇告诉你「好代码的样子 」,本篇告诉你「坏代码到底坏在哪条 」。但精确诊断 还有上层------先得有"嗅觉"。光知道五条原则,还不够。在线上 Code Review 里,你需要在 3 秒内闻出「这段代码不对劲」,然后才轮到 SOLID 来确诊。

下一篇 08.反模式与坏味道------把 30+ 种代码坏味道映射到 SOLID,让你的"代码嗅觉"成为肌肉记忆。本篇 §7.5 的三道思考题答案,也将在下一篇开头揭晓。

相关推荐
杨充1 小时前
2.面向对象的特性
设计模式
杨充1 小时前
8.反模式与坏味道
设计模式·开源·代码规范
梦梦代码精1 小时前
基于ThinkPHP6 + Vue3的家政预约系统全解析:从LBS定位到自动派单的完整实现
java·docker·开源·php·代码规范
杨充1 小时前
3.接口vs抽象类比较
设计模式
yunwei372 小时前
eBPF 教程:检查 exec 后真正安装的可执行镜像
linux·开源
码上解惑2 小时前
2026 年智能体开发平台怎么选:从开源产品、云厂商到私有化平台
人工智能·ai·开源·智能体·spring ai
Kel2 小时前
Node.js 没那么复杂
人工智能·node.js·全栈