编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 面向对象设计

    • README
    • 面向对象设计思想
    • 面向对象特性思考
    • 接口vs抽象类比较
    • 接口而非实现编程
    • 多用组合和少继承
    • 设计原则的全景图
    • SOLID原则案例汇
      • 1.一次SOLID争吵
        • 1.1 团队三年之痛
        • 1.2 五次反转排查
        • 1.3 灵魂五连问
      • 2.SRP真相揭秘
        • 2.1 一个职责的边界
        • 2.2 滥用现场扫描
        • 2.3 重构前后对比
        • 2.4 误用反模式
      • 3.OCP扩展真意
        • 3.1 修改与扩展之分
        • 3.2 真实演化案例
        • 3.3 扩展点设计
        • 3.4 过度设计陷阱
      • 4.LSP契约深挖
        • 4.1 子类型规则
        • 4.2 矩形正方形悖论
        • 4.3 契约前后置
        • 4.4 真实事故复盘
      • 5.ISP接口隔离
        • 5.1 胖接口的成本
        • 5.2 拆分的判据
        • 5.3 与SRP差异
        • 5.4 拆过头的代价
      • 6.DIP依赖倒置
        • 6.1 倒置的方向
        • 6.2 IoC容器原理
        • 6.3 字节码织入
        • 6.4 何时不要倒置
      • 7.SOLID关系图
        • 7.1 五原则相互依赖
        • 7.2 与设计模式的映射
      • 8.综合案例实战
        • 8.1 风控接入需求
        • 8.2 不用SOLID翻车
        • 8.3 SOLID演化版
        • 8.4 类图与时序
        • 8.5 留下三道思考题
      • 9.总结与下一篇
    • 反模式与坏味道
    • 重构十二式的实战
    • 可测试性实战设计
    • DDD与战术的建模
    • 综合实战图片框架
  • 常见设计原则

  • 巧学设计模式

  • 系统架构设计

  • 编程
  • 面向对象设计
杨充
2022-04-19
目录

SOLID原则案例汇

# 第一卷第7章:SOLID原则案例汇

# 目录介绍

  • 1.一次SOLID争吵
    • 1.1 团队三年之痛
    • 1.2 五次反转排查
    • 1.3 灵魂五连问
  • 2.SRP真相揭秘
    • 2.1 一个职责的边界
    • 2.2 滥用现场扫描
    • 2.3 重构前后对比
    • 2.4 误用反模式
  • 3.OCP扩展真意
    • 3.1 修改与扩展之分
    • 3.2 真实演化案例
    • 3.3 扩展点设计
    • 3.4 过度设计陷阱
  • 4.LSP契约深挖
    • 4.1 子类型规则
    • 4.2 矩形正方形悖论
    • 4.3 契约前后置
    • 4.4 真实事故复盘
  • 5.ISP接口隔离
    • 5.1 胖接口的成本
    • 5.2 拆分的判据
    • 5.3 与SRP差异
    • 5.4 拆过头的代价
  • 6.DIP依赖倒置
    • 6.1 倒置的方向
    • 6.2 IoC容器原理
    • 6.3 字节码织入
    • 6.4 何时不要倒置
  • 7.SOLID关系图
    • 7.1 五原则相互依赖
    • 7.2 与设计模式的映射
  • 8.综合案例实战
    • 8.1 风控接入需求
    • 8.2 不用SOLID翻车
    • 8.3 SOLID演化版
    • 8.4 类图与时序
    • 8.5 留下三道思考题
  • 08.总结与下一篇

本篇是「面向对象设计」系列第 07 篇。
上一篇 06.设计原则全景图 把 SOLID 五条原则一次性铺出来。
但工程师面对的不是定义,而是滥用、误用、过度设计——本篇就把每条原则的真实战场翻给你看。


# 1.一次SOLID争吵

# 1.1 团队三年之痛

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

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

「下周再说」之后的三年,这段代码长到了 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
1
2
3
4
5
6
7
8
9
10
11

真正的麻烦不在「该不该遵守 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 MysqlConnection、new HttpClient、new KafkaProducer。一切实现细节都钉死在这个类里,于是任何「拆」都拆不动——动一个分支就要动数据库、动消息、动 HTTP,没有哪条原则能"先"被遵守,因为底下没有抽象层托住。
flowchart LR
    A[症状: 代码没法拆] -->|你以为| B1[SRP 病]
    A -->|你以为| B2[OCP 病]
    A -->|你以为| B3[LSP 病]
    A -->|你以为| B4[ISP 病]
    A -->|实际| B5[DIP 病]
    B5 -->|后果| C[四原则全失效]
1
2
3
4
5
6
7

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

# 1.3 灵魂五连问

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 装回到肌肉记忆
1
2
3
4
5
6
7
8
9
10

# 2.SRP真相揭秘

# 2.1 一个职责的边界

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

public class Invoice {
    public BigDecimal computeTotal()    { ... }   // 计算总金额
    public BigDecimal computeTax()      { ... }   // 计算税
    public void       sendToCustomer()  { ... }   // 发邮件
    public void       saveToDb()        { ... }   // 入库
    public String     toJson()          { ... }   // 序列化
}
1
2
3
4
5
6
7

直觉上——「不行,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 冲突, 部署连坐, 测试爆炸]
1
2
3
4
5
6

# 2.2 滥用现场扫描

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

【A 段】

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();
    }
}
1
2
3
4
5
6
7
8

表面有 3 个 if,实际上只有 1 个 actor——业务规则方。不违反。

【B 段】

public class UserService {
    public User register(...)      { ... }
    public User login(...)         { ... }
    public Avatar uploadAvatar(...) { ... } // 含图片裁剪、CDN 上传、缩略图生成
}
1
2
3
4
5

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

【C 段】

public class ReportService {
    public Report build(Date d)    { ... }   // SQL 聚合 + 业务规则
    public void   email(Report r)  { ... }   // 模板渲染 + SMTP
    public void   archive(Report r){ ... }   // 写到 S3
}
1
2
3
4
5

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

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

# 2.3 重构前后对比

把 C 段重构:

// 第 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);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

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

# 2.4 误用反模式

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

// 反面教材:拆得太碎
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; }
1
2
3
4
5
6
7

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

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


# 3.OCP扩展真意

# 3.1 修改与扩展之分

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

「修改」型变更:

- if (channel.equals("alipay")) { return alipay(); }
+ if (channel.equals("alipay")) { return alipay(); }
+ if (channel.equals("wechat")) { return wechat(); }
1
2
3

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

「扩展」型变更:

+ @Component class WechatPay implements PayMethod { ... }
1

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

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

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

# 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
2
3
4
5
6
7

三个真实信号:

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

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

# 3.4 过度设计陷阱

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

public interface UsernameValidator { boolean valid(String s); }
@Component class DefaultUsernameValidator implements UsernameValidator { ... }
@Component class UserRegisterService {
    @Autowired UsernameValidator validator;   // 永远只有一个实现
}
1
2
3
4
5

问题: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 类型对象,且不影响程序的正确性。

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

abstract class Bird { abstract void fly(); }
class Sparrow extends Bird { void fly() { ... } }
class Penguin extends Bird { void fly() { throw new Unsupported(); } } // ← 违反 LSP
1
2
3

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

# 4.2 矩形正方形悖论

最著名的 LSP 反例:

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); }
}
1
2
3
4
5

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

void resize(Rectangle r) {
    r.setW(5); r.setH(10);
    assert r.area() == 50;   // ← Square 实例会让 area = 100
}
1
2
3
4

LSP 失败的数学根源:

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

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

# 4.3 契约前后置

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

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

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

# 4.4 真实事故复盘

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

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 { ... }   // 主从异步, 可能读到旧值
1
2
3
4
5
6
7

调用方写:

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

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

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

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

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


# 5.ISP接口隔离

# 5.1 胖接口的成本

interface UserService {
    User get(String id);
    User register(...);
    void changePassword(...);
    void uploadAvatar(...);
    void exportData(...);     // GDPR 需求
    void deleteAccount(...);  // GDPR 需求
    void grantAdminRole(...); // 后台管理
    ... 47 个方法 ...
}
1
2
3
4
5
6
7
8
9
10

调用方有:

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

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

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

# 5.2 拆分的判据

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

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 { ... }
1
2
3
4
5
6

实现类仍然是一个(避免 §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]
1
2
3
4
5
6

# 5.3 与SRP差异

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

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

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

# 5.4 拆过头的代价

// 反面教材:1 个方法 1 个接口
interface CanRegister { User register(...); }
interface CanLogin    { User login(...); }
interface CanLogout   { void logout(...); }
interface CanChangePwd{ void changePassword(...); }
... 47 个 ...
1
2
3
4
5
6

每个 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
1
2
3
4
5
6
7
8

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

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

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

# 6.2 IoC容器原理

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

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);   // 接口依赖被自动注入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

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

# 6.3 字节码织入

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

原 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;
                }
1
2
3
4
5
6
7
8
9

这是「反向控制(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
1
2
3
4
5
6
7
8
  • 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 给的需求:

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

# 8.2 不用SOLID翻车

工程师 A 第一版:

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())) {
            ...
        }
        ...
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

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

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

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

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

# 8.3 SOLID演化版

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

public interface RiskProvider {
    RiskScore evaluate(RiskRequest req);
}
public class TongdunProvider  implements RiskProvider { ... }
public class BangshengProvider implements RiskProvider { ... }
1
2
3
4
5

第 2 步·SRP 划职责:

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();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

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

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

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 { ... }
1
2
3
4
5
6
7

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

第 4 步·LSP 守契约:

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

第 5 步·ISP 拆视角:

// 给 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 { ... }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

双跑需求最终落地:只新增 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 8.5 留下三道思考题

答案在第 08 篇开头揭晓。

  • 🟢 易:§7.3 第 5 步把同一个 RiskCheckServiceImpl 实现了 3 个接口。有人会说这违反 SRP——你怎么反驳?提示:actor 与 caller 的分别,§4.3。
  • 🟡 中:DualRunRouter.chooseAll() 返回 List<RiskProvider>,但两家供应商的延时差 200ms。RiskCheckService.check 是同步等所有结果,还是只等更快的那个?这两种语义会导致什么不同的问题?提示:与第 11 篇 DDD 的「最终一致」相关。
  • 🔴 难:风控规则每周调整。如果用「配置中心 + DSL 表达式」让运营自己配,不再编译——你会绕过 OCP(因为运营每周确实要"修改"规则配置)。这种「配置即代码」算违反 OCP 吗?SOLID 在「应用代码」与「配置/数据」之间的边界在哪?提示:这道题就是第 08 篇「过度设计 vs 不足设计」要回答的核心矛盾。

# 9.总结与下一篇

一句话总结 SOLID:

SOLID 不是写好代码的"五条规则",而是面对烂代码时"五种诊断工具"——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 的具体气味]
1
2
3

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

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

下一篇 08.反模式与坏味道

上次更新: 2026/06/17, 11:43:57
设计原则的全景图
反模式与坏味道

← 设计原则的全景图 反模式与坏味道→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式