7.SOLID原则案例汇

12 阅读21分钟

7.SOLID原则案例汇

目录介绍

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

1.一次SOLID争吵

1.1 团队三年之痛

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

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 灵魂五连问

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 一个职责的边界

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

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——业务规则方不违反

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。违反

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

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

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 段重构:

// 第 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 也有滥用症——「贫血神族」:

// 反面教材:拆得太碎
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:

「修改」型变更

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

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

「扩展」型变更

+ @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」长这样:

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

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

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 反例:

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); }
}

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

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)不能破坏(必须维持)
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 接口:

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 { ... }   // 主从异步, 可能读到旧值

调用方写:

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

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

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

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

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 个方法 ...
}

调用方有:

  • 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 { ... }

实现类仍然是一个(避免 §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。区别:

维度SRPISP
看的东西的职责接口的客户端视图
边界依据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 个 ...

每个 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 的最简化原理:

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 在类加载时生成子类:

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老接口对接不上新需求
工厂方法DIPnew 钉死了类型
桥接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. 每周可能调整阈值规则

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())) {
            ...
        }
        ...
    }
}

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 教训):

public interface RiskProvider {
    RiskScore evaluate(RiskRequest req);
}
public class TongdunProvider  implements RiskProvider { ... }
public class BangshengProvider implements RiskProvider { ... }

第 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();
    }
}

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 { ... }

新加策略 = 新加一个 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 { ... }

双跑需求最终落地:只新增 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 的三道思考题答案,也将在下一篇开头揭晓。