主题
界面
主题
《软件工程与开发流程》讲了软件工程要"驯服复杂度";本节给出最实用的五条设计规则:
SOLID 不是审美偏好,它直接决定「改需求时要动几处代码」 ✓★ 本节每个原则都配一个"违反版 / 遵守版",并把差别量化成"改动点数量" ✓
先看一个没有 SOLID 的真实代价 ✗:
某项目的支付逻辑写在一个 if-else 里(支持 3 种渠道)。
半年来加了 8 种渠道 → 这个方法变成 300 行、嵌套 5 层 ✗
某次为了加"分账",改动了公共分支 → 影响了另外 7 种渠道 → 线上故障 ✗✗★ 本节的实测会把"改动成本"量化 ✓:
加一种支付方式:
违反 OCP 版 → 改 1 个已有方法 ✗(且要重跑所有老功能的测试)
遵守 OCP 版 → 加 1 个新类 ✓✓(老代码零改动)★ 关键差别在"是否动到老代码" ✓✓:
改老代码 = 引入回归风险 = 所有老测试都要重跑 ✗
加新代码 = 隔离风险 = 老测试不用动 ✓✓
✗ 违反版:OrderService 同时干四件事 ✓:
| 它负责 | 谁会让它变化 |
|---|---|
| 计算价格 | 定价规则变化 |
| 存数据库 | 表结构变化 |
| 发短信 | 短信通道更换 |
| 生成 PDF | 报表格式调整 |
★ 后果:四个不同团队的需求都会改同一个文件 ✗✗ —— 合并冲突、测试互相影响、一处改动波及全部 ✓
✓ 遵守版:拆成 PricingCalculator / OrderRepository / SmsNotifier / PdfGenerator ✓
★ 判断方法(一句话) ✓✓:
"描述这个类时,你需要用「并且」吗?" ✓
"它负责下单并且发短信" → 该拆 ✓✓
★ 但要避免过度拆 ✗:
一个 30 行、内聚性很强的类就让它待着 ✓
拆成 5 个类、每个 6 行,只会让调用链变长 ✗
✗ 违反版(实测代码里的 BadPaymentService):
String pay(String type, BigDecimal amount) {
if ("alipay".equals(type)) { ... }
else if ("wechat".equals(type)) { ... }
else if ("bank".equals(type)) { ... }
throw new IllegalArgumentException("不支持的支付方式:" + type);
}✓ 遵守版:注册式工厂 + 实现类 ✓
PaymentService service = new PaymentService();
service.register(new AlipayChannel());
service.register(new WechatChannel());
service.register(new UnionPayChannel()); // ★ 加新渠道:不改任何已有代码★ 实测的对比 ✓:
| 要做的改动 | 违反 OCP | 遵守 OCP |
|---|---|---|
| 加一种支付方式 | 改 1 个已有方法 ✗ | 加 1 个新类 ✓ |
| 老功能的回归测试 | 要重跑(动过代码) ✗ | 不用跑 ✓✓ |
| 多人同时加渠道 | 改同一个文件(冲突) ✗ | 各加各的(无冲突) ✓✓ |
★★ 第三行是"团队协作层面的收益",常被忽略但极其真实 ✓✓:
两个人同时加渠道,一个改文件 A、一个加文件 B —— 零冲突 ✓
而在 if-else 版里,两人改同一个方法 → 必然冲突 ✗
★ OCP 的正确理解 ✓✓:
它不是"永远不许改代码" ✗
而是"把可预期的变化设计成扩展点" ✓✓
判断"可预期"的方法:这个 if-else 半年内加过几次分支?加过就说明它在变 ✓
经典反例(本节实测) ✓:
class Rectangle {
void setWidth(int w) { this.width = w; }
void setHeight(int h) { this.height = h; }
int area() { return width * height; }
}
class Square extends Rectangle {
@Override void setWidth(int w) { this.width = w; this.height = w; } // ✗ 悄悄改了 height
@Override void setHeight(int h) { this.width = h; this.height = h; }
}★ 实测结果 ✓✓:
用 Rectangle 的契约(设置宽高后算面积):
Rectangle(5,4).area() = 20 ✓(符合预期)
Square 当 Rectangle 用 → area() = 16
✗✗ 期望 20,实际 16 —— 契约被破坏了★ 问题出在哪 ✓✓:
Rectangle的契约是"宽高互不影响" ✓
Square加强了前置条件(改一个必须改另一个) ✗✗所以它不能替换 Rectangle ✓
★ 正确建模 ✓:不要让 Square 继承 Rectangle,而是各自实现 Shape 接口 ✓
★ 工程上更隐蔽的 LSP 违反 ✓:
| 违反形式 | 例子 |
|---|---|
| 子类抛父类没声明的异常 ✗ | 父类说"不抛",子类抛了 IllegalStateException |
| 把查询做成了副作用 ✗ | 父类 getX() 是纯查询,子类里顺手改状态 |
| 返回 null 而父类承诺非 null ✗ | 调用方到处 NPE |
| 子类收窄了参数范围 ✗ | 父类接受任意正整数,子类只接受偶数 |
★ 自查方法 ✓✓:"任何用父类的地方,换成子类后行为还对吗?" ✓
✗ 违反版:
interface Device {
void print(); void scan(); void fax(); void staple(); void copy();
}★ 后果 ✓:
| 后果 | 说明 |
|---|---|
| 简单打印机被迫实现 4 个用不到的方法 ✗ | 只能抛 UnsupportedOperationException |
| 改接口会波及所有实现类 ✗ | 哪怕它不用改的那个方法 |
✓ 遵守版:按能力拆小接口 ✓(Printer / Scanner / Fax)✓
★ 判断信号 ✓✓:
"实现类里有方法只能抛
UnsupportedOperationException" → 接口太胖 ✗
★ 但别走另一个极端 ✗:
"每个方法一个接口"也是灾难 ——调用方要注入五个接口 ✗
平衡点:按"谁会一起变化"分组 ✓✓
✗ 违反版:
class OrderService {
private MySQLOrderRepository repo = new MySQLOrderRepository(); // ✗ 写死具体实现
}✓ 遵守版:
class OrderService {
private final OrderRepository repo; // ← 接口
OrderService(OrderRepository repo) { this.repo = repo; } // ← 注入
}★ 三层收益(按重要性排序) ✓✓:
| # | 收益 | 说明 |
|---|---|---|
| ① | 可测试 ★★★ | 单元测试注入内存实现,不需要数据库 ✓✓ |
| ② | 可替换 ★★ | 换数据库/换第三方只改装配处 ✓ |
| ③ | 可并行开发 ★★ | 接口定好后,上下游各自实现 ✓ |
★ 第一层收益最实在 ✓✓:
本项目 T1 系列的全部单元测试都靠它 —— 没有 DIP,测试就只能连真实数据库:慢、脆弱、跑不起来 ✗✗
而有了 DIP,测试可以在毫秒级跑完,还能随便造边界数据 ✓✓
★ DIP 的边界 ✓:
不是"每个类都要有接口" ✗
只有"会有多个实现"或"需要被替身替换"的依赖才需要接口 ✓✓
给一个永远不会变的工具类抽接口,只是噪音 ✗
| 缩写 | 全称 | 它回答的问题 |
|---|---|---|
| SRP ★★★ | 单一职责 | 这个类需要同时被几个原因改动? |
| OCP ★★★ | 开闭原则 | 加新类型要改几处老代码? |
| LSP ★★★ | 里氏替换 | 换成子类后行为还对吗? |
| ISP ★★ | 接口隔离 | 实现类有被迫抛异常的方法吗? |
| DIP ★★★ | 依赖倒置 | 业务类认识具体实现吗? |
★ 最后一条重要的话 ✓✓:
SOLID 是"降低改动成本"的手段,不是目的 ⚠️
它的收益是长期的(三个月后改需求时体现); 它的代价是当下的(更多类、更多接口、更多注入)✗
所以正确的态度是:在"确实会变"的地方应用它,其余地方保持简单 ✓✓
// ✗ 每加一种类型都改这里
if (type == A) { ... } else if (type == B) { ... }
// ✓ 每种类型一个实现,新增不改老代码
interface Handler { void handle(); }
Map<String, Handler> handlers = ...; // 注册进来
handlers.get(type).handle();// 生产:注入 MySQL 实现
new OrderService(new MySQLOrderRepository(dataSource));
// 测试:注入内存实现(毫秒级、不连库、能造任意边界数据)
new OrderService(new InMemoryOrderRepository());// ✗ 一个只有一行逻辑的格式化工具,也抽出接口 + 实现 + 工厂
interface FormatterFactory { Formatter create(); }★ 判据:"这里未来真的会有第二种实现吗?" ——答案不确定就先不抽 ✓✓
代码:labs/design-patterns/src/main/java/cn/kb/learn/dp/SolidLesson.java
运行:
cd labs
.\mvnw.cmd -pl design-patterns compile
java "-Dstdout.encoding=UTF-8" -cp design-patterns/target/classes cn.kb.learn.dp.SolidLesson实测输出(LSP 与 OCP 的关键部分):
用 Rectangle 的契约(设置宽高后算面积):
Rectangle(5,4).area() = 20 ✓(符合预期:5*4=20)
Square 当 Rectangle 用 → area() = 16
✗✗(期望 20,实际 16 —— 契约被破坏了)
加一种支付方式:
违反 OCP → 改 1 个已有方法 ✗
遵守 OCP → 加 1 个新类 ✓★ 两条可落地的结论 ✓:
| # | 结论 | 依据 |
|---|---|---|
| ① | "子类能替换父类"是继承的前提,不是自动成立的 | 正方形替换长方形后行为变了 ✗ |
| ② | 把"会变的 if-else"换成"注册 + 多态" | 老代码零改动,且多人并行无冲突 ✓ |
UnsupportedOperationException:接口太胖 ✓;| # | 错误 | 说明 |
|---|---|---|
| 1 | 把 SOLID 当教条,到处抽接口 ✗✗ | 过度设计的代价同样真实 ✓ |
| 2 | 为了"复用"让子类继承父类,却破坏了契约 ✗✗ | LSP(5.3)✗ |
| 3 | 以为 OCP 是"永远不许改代码" ✗ | 是"把可预期的变化做成扩展点" ✓ |
| 4 | 一个接口塞进所有方法 ✗ | ISP(5.4)✗ |
| 5 | 业务类里 new 具体实现 ✗✗ | DIP,且导致无法单元测试 ✓ |
| 6 | 拆得过细导致调用链冗长 ✗ | SRP 过度 ✗ |
| 7 | 只看"当前需求",不给任何扩展点 ✗ | 半年后改动成本爆炸 ✓ |
目标:亲手把一段"违反 OCP"的代码改成"遵守 OCP"。
步骤:
SolidLesson,确认 LSP 的 20 vs 16、OCP 的改动点对比 ✓;验收方式:能说出"你改的那个 if-else,加新类型从改几处变成了改几处"。
扩展任务:设计一个"可扩展的优惠券系统":
class UserService {
public void register(String name, String email) {
// 1. 校验邮箱格式
// 2. 插入数据库
// 3. 发送欢迎邮件
// 4. 写审计日志
}
}这个类违反了哪个原则?怎么改? 5. (设计)把上题的 UserService 重构:给出拆分后的类与它们之间的依赖方向,并说明"为什么依赖方向是这样"。
EmailValidator、UserRepository、WelcomeMailSender、AuditLogger ✓UserService 依赖四个接口,由外部注入实现 ✓ —— 依赖方向从"具体实现"倒转为"抽象",这样 UserService 可以独立测试 ✓✓《设计模式:创建型》:对象"怎么造"的三种套路,以及单例的六种写法与两个攻击面。