设计模式六大原则
一个架构评审引发的血案
上周我们团队做架构评审,实习生小王写了一个 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 原则冲突时的决策框架
【面试官心理】
面试中问到设计原则,我最想听到的不是候选人背诵定义,而是他能否说清楚:在什么场景下你主动选择违反了一个原则?是因为什么权衡?代价是什么? 能回答这个问题的,基本都是 P7 以上的候选人了。
八、生产避坑清单
8.1 SRP 违反的典型信号
- 一个类超过 500 行(经验值)
- 一个方法超过 50 行
- 修改一个类时,git diff 显示涉及多个完全无关的功能
- 类名包含"Utils"、"Manager"、"Helper"等模糊词
8.2 OCP 违反的典型信号
- 代码里出现大量
if-else 或 switch-case 判断类型
- 每加一个功能就要改多个文件
- 需要修改测试过的、已经上线的代码
8.3 排查工具
九、工程代价评估
原则不是教条,遵循它们是为了让软件更容易应对变化。但过度遵循原则同样是一种反模式。知道什么时候遵守,什么时候打破,才是真正的架构能力。