设计模式六大原则

一个架构评审引发的血案

上周我们团队做架构评审,实习生小王写了一个 2000 行的类,方法之间跳来跳去,耦合得像一盘意大利面。

CTO 翻了翻代码,问:"你知道 SOLID 原则吗?"

小王说:"知道,单一职责、开闭原则..."

CTO 打断他:"那你这 2000 行是在干嘛?"

会议室陷入了尴尬的沉默。

这个小故事背后,是一个所有架构师都会追问的问题:你懂设计原则吗?你知道为什么要遵守它们吗?你知道违反它们的代价是什么吗?

今天这篇文章,不是教你背六大原则,而是帮你从架构师视角理解它们的本质——每个原则在解决什么问题,违反后会有什么代价,如何在面试中用它来说服面试官。

【架构权衡】 设计原则不是教条,是经验教训的沉淀。每一个原则的背后,都是生产环境中踩过的坑、付出过的代价。理解"为什么"比记住"是什么"重要一万倍。


一、单一职责原则(SRP)🔴

1.1 原则定义

一个类应该只有一个引起它变化的原因。

这句话听起来简单,但 90% 的工程师写代码时都在违反它。

1.2 真实翻车案例

// ❌ 违反 SRP:一个类干了三件事
class UserManager {
    // 1. 用户数据管理
    public void saveUser(User user) { /* ... */ }
    public User getUser(String id) { /* ... */ }

    // 2. 发送通知
    public void sendWelcomeEmail(User user) { /* ... */ }

    // 3. 日志记录
    public void logOperation(String msg) { /* ... */ }
}

这个类的问题在于:三种不同原因的变化都会导致这个类被修改

  • 邮件服务商换了?改 UserManager
  • 日志格式变了?改 UserManager
  • 用户数据结构变了?还是改 UserManager
// ✅ 符合 SRP:职责分离
class UserRepository {
    public void save(User user) { /* ... */ }
    public User find(String id) { /* ... */ }
}

class EmailService {
    public void sendWelcome(User user) { /* ... */ }
}

class AuditLogger {
    public void log(String operation) { /* ... */ }
}

1.3 面试中的 SRP

面试官问:"你如何判断一个类是否违反 SRP?"

低端回答:看这个类有没有多个方法。

高端回答:看引起这个类变化的原因是否只有一个。具体来说,问自己三个问题:这个类会因为谁的变化而变化(业务需求?技术变更?外部依赖?),如果只有一个答案,说明职责是清晰的;如果有多个答案,说明需要拆分。

【面试官心理】 我问他 SRP,其实想看两件事:第一,他有没有在真实项目中因为违反 SRP 踩过坑;第二,他有没有能力在代码写之前就识别出潜在的 SRP 违反。很多候选人能说出"要拆分",但说不出"怎么判断什么时候该拆"。

1.4 SRP 的代价

SRP 不是免费的午餐。过度拆分会导致:

  • 类数量爆炸:一个功能拆成 10 个类
  • 理解成本上升:追踪一个业务流程需要跳 10 个文件
  • 过度工程:在需求还不稳定时就急着拆分
💡

SRP 的最佳实践是:等到需要变化的时候再拆分,而不是提前预拆。YAGNI(You Aren't Gonna Need It)原则告诉我们,不要为可能永远不会来的变化提前付出代价。


二、开闭原则(OCP)🔴

2.1 原则定义

软件实体应该对扩展开放,对修改关闭。

这是六大原则中最难理解的一条。"对扩展开放"好理解,"对修改关闭"却让很多人困惑——不修改怎么扩展?

2.2 从一个支付场景说起

// ❌ 违反 OCP:每加一种支付方式都要改这个类
class PaymentService {
    public void pay(String type, double amount) {
        if ("alipay".equals(type)) {
            // 支付宝支付
        } else if ("wechat".equals(type)) {
            // 微信支付
        } else if ("unionpay".equals(type)) {
            // 银联支付
        } else if ("creditcard".equals(type)) {
            // 信用卡
        }
        // 每加一个都要改这里
    }
}

这个代码的味道:每加一个支付渠道,就要修改这个方法。线上已经运行的代码,每次改动都是风险。

// ✅ 符合 OCP:策略模式 + 依赖倒置
interface PaymentStrategy {
    void pay(double amount);
}

class AlipayStrategy implements PaymentStrategy {
    public void pay(double amount) { /* ... */ }
}

class WechatPayStrategy implements PaymentStrategy {
    public void pay(double amount) { /* ... */ }
}

class PaymentService {
    private final Map<String, PaymentStrategy> strategies;

    public void pay(String type, double amount) {
        strategies.get(type).pay(amount);
    }

    // 新增支付方式?加一个新类,实现接口,注入进来
    // 原有代码?一个字都不用改
}

2.3 OCP 的核心机制

OCP 的实现靠的是多态依赖注入

不修改原有代码  →  通过继承/实现新类来改变行为

              需要提前定义好扩展点(接口/抽象类)

              新功能通过实现接口接入,而不是修改原代码

【架构权衡】 OCP 的关键在于识别"不变"和"易变"的部分。不变的部分抽象成稳定接口,易变的部分封装成可插拔的实现。这个识别能力,是 P6 和 P7 的分水岭。

⚠️

OCP 不是万能药。过度抽象会导致系统难以理解,扩展点设错位置会导致后期改动困难。每次设扩展点,都是在赌"未来这里会变化"——赌错了,代码复杂度就白增加了。


三、里氏替换原则(LSP)🔴

3.1 原则定义

子类型必须能够替换其基类型而不改变程序的正确性。

这句话的意思是:所有使用父类的地方,换成子类后程序依然正确

3.2 经典翻车案例:正方形不是矩形的子类

// 基类
class Rectangle {
    protected double width;
    protected double height;

    public void setWidth(double w) { width = w; }
    public void setHeight(double h) { height = h; }
    public double area() { return width * height; }
}

// 子类
class Square extends Rectangle {
    @Override
    public void setWidth(double w) {
        // 正方形:宽高相等
        width = w;
        height = w;
    }

    @Override
    public void setHeight(double h) {
        width = h;
        height = h;
    }
}

这个设计看起来合理,但会导致问题:

void testArea(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    // Rectangle: 5 * 4 = 20
    // Square: setWidth(5) 后宽高都变成 5,setHeight(4) 后宽高都变成 4,结果是 16
    assert r.area() == 20; // Square 会失败!
}

正方形违背了 LSP,因为不是所有能用 Rectangle 的地方都能用 Square 替换

3.3 LSP 在设计中的体现

LSP 告诉我们一个重要的设计准则:子类不能强化前置条件,不能弱化后置条件,不能改变父类的不变式

// ❌ 违反 LSP:子类加强了前置条件
class Animal {
    public void eat(Food food) {
        // 动物吃食物
    }
}

class Dog extends Animal {
    @Override
    public void eat(Food food) {
        // 狗只吃肉,吃蔬菜就抛异常 —— 强化了前置条件
        if (food.type != FoodType.MEAT) {
            throw new IllegalArgumentException("Dog only eats meat");
        }
    }
}

// 调用方代码
void feedAnimal(Animal animal, Food food) {
    animal.eat(food); // 传 Dog + Vegetable → 崩溃
}

【面试官心理】 LSP 是我判断候选人有没有真正理解继承的试金石。知道"子类要重写父类方法"是 60 分,知道"子类不能改变父类约定"是 80 分,能举出生产中的 LSP 违反案例是 90 分以上。


四、接口隔离原则(ISP)🔴

4.1 原则定义

客户端不应该依赖它不需要的方法。

4.2 一个不该存在的"胖接口"

// ❌ 违反 ISP:把所有方法塞进一个大接口
interface IWorker {
    void work();
    void eat();
    void sleep();
    void code();
    void attendMeeting();
}

class RobotWorker implements IWorker {
    public void work() { /* ... */ }
    public void eat() { /* 机器人不需要吃饭 */ }
    public void sleep() { /* 机器人不需要睡觉 */ }
    public void code() { /* ... */ }
    public void attendMeeting() { /* ... */ }
}

class HumanWorker implements IWorker {
    public void work() { /* ... */ }
    public void eat() { /* ... */ }
    public void sleep() { /* ... */ }
    public void code() { /* ... */ }
    public void attendMeeting() { /* ... */ }
}

RobotWorker 被强迫实现了它不需要的方法。这就是"接口污染"。

4.3 正确的拆分方式

// ✅ 符合 ISP:按职责拆分接口
interface Workable {
    void work();
}

interface Feedable {
    void eat();
}

interface Sleepable {
    void sleep();
}

interface Coder {
    void code();
}

// 人类需要所有能力
class Human implements Workable, Feedable, Sleepable, Coder {
    public void work() { /* ... */ }
    public void eat() { /* ... */ }
    public void sleep() { /* ... */ }
    public void code() { /* ... */ }
}

// 机器人只需要工作
class Robot implements Workable, Coder {
    public void work() { /* ... */ }
    public void code() { /* ... */ }
}
💡

接口拆分的关键是站在使用者(客户端)的角度思考。机器人是 work 的客户端,它只需要 work 能力,不应该被迫依赖 eat、sleep 等无关方法。


五、依赖倒置原则(DIP)🔴

5.1 原则定义

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

5.2 控制反转的魔力

// ❌ 违反 DIP:高层依赖低层
class OrderService {
    private MySQLDatabase database = new MySQLDatabase(); // 硬依赖

    public void saveOrder(Order order) {
        database.insert(order);
    }
}

这个代码的问题是:如果要把数据库换成 MongoDB,整个 OrderService 都要改。

// ✅ 符合 DIP:依赖抽象
interface DataRepository {
    void save(Order order);
}

class MySQLRepository implements DataRepository {
    public void save(Order order) { /* MySQL 实现 */ }
}

class MongoDBRepository implements DataRepository {
    public void save(Order order) { /* MongoDB 实现 */ }
}

class OrderService {
    private final DataRepository repository; // 依赖抽象

    public OrderService(DataRepository repository) {
        this.repository = repository; // 注入进来
    }

    public void saveOrder(Order order) {
        repository.save(order);
    }
}

这就是依赖注入(DI) 的核心思想:不要在类内部创建依赖,而是从外部注入

5.3 DIP 与框架设计

Spring 框架的核心就是 DIP:

// 你写的业务代码 —— 高层
@Service
class OrderService {
    @Autowired
    private DataRepository repository; // 依赖抽象接口

    // ...
}

// Spring 容器 —— 负责注入实现 —— 低层细节
// 你不需要知道用的是 MySQL 还是 MongoDB

【架构权衡】 DIP 是微服务、Spring、DDD 等架构背后的核心原则。理解 DIP,你就能理解为什么"面向接口编程"如此重要——它让系统从"铁板一块"变成"可插拔"。


六、合成复用原则(CRP)🔴

6.1 原则定义

优先使用对象组合(has-a),而不是类继承(is-a)。

6.2 继承的陷阱

// ❌ 滥用继承:脆弱的继承层次
class Vehicle {
    void start() { /* ... */ }
}

class Car extends Vehicle {
    void drive() { /* ... */ }
}

class FlyingCar extends Car {
    // 飞行汽车同时是 Car 和 Vehicle
    // 如果 Vehicle 新增了方法,FlyingCar 可能需要重写
    // 如果 Car 新增了方法,FlyingCar 也可能需要调整
}

class AmphibiousCar extends Car {
    // 水陆两用车...继承关系越来越复杂
}

继承的问题:子类和父类的耦合太紧了。父类一变,子类可能全部遭殃。

6.3 组合优于继承

// ✅ 使用组合
class Car {
    private Engine engine;
    private AudioSystem audio;

    void drive() {
        engine.start();
        // ...
    }
}

class FlyingCar {
    private Engine engine;
    private Wing wing;
    private AudioSystem audio;

    void fly() {
        engine.start();
        wing.deploy();
    }
}

FlyingCar 不再是 Car 的子类,而是组合了引擎和翅膀。这样 Car 的变化不会影响 FlyingCar。

⚠️

不是说继承不能用。继承适合"is-a"关系非常稳定、且子类不会频繁变化的情况。比如 JDK 中的 Exception 体系,继承是合理的。滥用继承是新手最容易犯的错误。


七、六大原则的综合运用

7.1 原则之间的关系

SRP(单一职责)  →  类的数量增多

OCP(开闭原则)  →  依赖抽象接口

LSP(里氏替换)  →  子类不能破坏父类约定

ISP(接口隔离)  →  接口按需拆分,不喂胖接口

DIP(依赖倒置)  →  高层依赖抽象

CRP(合成复用)  →  组合优于继承

这六个原则最终指向同一个目标:让软件更容易变化、更容易扩展、更容易理解

7.2 原则冲突时的决策框架

场景冲突决策
SRP vs 适度内聚拆太细导致类爆炸先合用,变化发生时再拆(YAGNI)
OCP vs 简单直接抽象带来复杂度预测变化方向,在确定会变的地方设扩展点
DIP vs 性能反射/依赖注入有开销关键路径用直接依赖,非关键路径用 DI

【面试官心理】 面试中问到设计原则,我最想听到的不是候选人背诵定义,而是他能否说清楚:在什么场景下你主动选择违反了一个原则?是因为什么权衡?代价是什么? 能回答这个问题的,基本都是 P7 以上的候选人了。


八、生产避坑清单

8.1 SRP 违反的典型信号

  • 一个类超过 500 行(经验值)
  • 一个方法超过 50 行
  • 修改一个类时,git diff 显示涉及多个完全无关的功能
  • 类名包含"Utils"、"Manager"、"Helper"等模糊词

8.2 OCP 违反的典型信号

  • 代码里出现大量 if-elseswitch-case 判断类型
  • 每加一个功能就要改多个文件
  • 需要修改测试过的、已经上线的代码

8.3 排查工具

工具用途
IDE 重构功能提取方法、拆分类、提取接口
SonarQube自动检测代码味道和原则违反
ArchUnit在 Java 中用单元测试检查架构规则
PMD静态代码分析,检测过长方法和类

九、工程代价评估

原则应用成本收益适用场景
SRP拆分类需要理解业务边界变化隔离、可测试性提升业务复杂的核心模块
OCP需要预判变化方向并设计扩展点新功能无需改旧代码稳定接口 + 频繁扩展的场景
LSP需要仔细设计继承层次继承体系安全可用强继承关系的领域建模
ISP接口数量增多客户端只依赖需要的框架设计、API 设计
DIP需要 DI 容器/手动注入模块解耦、可测试性提升一切需要可替换实现的地方
CRP需要识别组合关系灵活组装、继承层次浅复杂对象组装场景

原则不是教条,遵循它们是为了让软件更容易应对变化。但过度遵循原则同样是一种反模式。知道什么时候遵守,什么时候打破,才是真正的架构能力。