主题
六大设计原则:反例与量化取舍
一句话:设计原则约束的是「依赖往哪流、变更影响多大」。每条原则都能翻译成一个可测指标——改动文件数、回归测试范围、调用方依赖的类型数;说不清指标,原则就只是口号。
为什么需要
代码能跑但不敢改,通常不是代码量的问题,而是变更的影响范围不可控:改一个 if 要回归二十个用例,加一个渠道要改五处 switch,换一个存储要动半个项目。设计原则的作用是把这些影响范围变成可计算、可评审的东西。
| 原则 | 约束对象 | 核心指标 |
|---|---|---|
| SRP 单一职责 | 类 | 一个类有几个变更来源 |
| OCP 开闭 | 扩展点 | 新增一种变体要改几个已有文件 |
| LSP 里氏替换 | 继承契约 | 父类契约测试能否覆盖所有子类 |
| ISP 接口隔离 | 接口 | 实现类被迫实现多少个用不到的方法 |
| DIP 依赖倒置 | 依赖方向 | 高层模块编译期依赖的是抽象还是具体 |
| LoD 最少知识 | 对象协作 | 调用方直接依赖多少个类型 |
核心机制
SRP 单一职责:一个类应该只有一个被修改的理由
java
class Employee {
void calculatePay(){} // 财务规则
void saveToDatabase(){} // 持久化
void generateReport(){} // 报表格式
}三个变更来源,任何一个变化都改同一个文件。设一个类有 s 个变更来源,单次变更要重跑的测试范围约为该类测试集全部;拆成 s 个类后,单次变更只影响一个,按测试量均分估算约 1/s,代价是新增 s-1 个类型和调用点。判断阈值:s ≥ 2 且来源由不同角色驱动(财务/运维/业务)就拆;两个职责总是一起变,拆了只多一层跳转。
OCP 开闭原则:对扩展开放,对修改关闭
java
void pay(String channel) {
if ("alipay".equals(channel)) { /* ... */ }
else if ("wechat".equals(channel)) { /* ... */ }
// 加渠道 = 改这个方法,所有调用点一起回归
}比较「新增一种变体要改几个已有文件」:if-else 版本 = 1 个核心文件 × 所有分支位置;策略版本 = 0 个已有文件 + 1 个新实现类。代价是多一个接口、一个注册表和一次 Map.get。判断阈值:变体超过 5 种,或过去两个季度新增/修改过 2 次以上,抽象才划算;只有两个稳定枚举值,if-else 更易读。
LSP 里氏替换原则:子类必须能替换父类,且不破坏契约
java
class Rectangle {
protected int w, h;
void setW(int w) { this.w = w; }
void setH(int h) { this.h = h; }
int area() { return w * h; }
}
class Square extends Rectangle {
@Override void setW(int w) { this.w = this.h = w; }
@Override void setH(int h) { this.w = this.h = h; }
}
public class LspDemo {
public static void main(String[] args) {
Rectangle r = new Square();
r.setW(5); r.setH(3);
System.out.println("area=" + r.area()); // 9,按 Rectangle 契约应为 15
}
}运行 java LspDemo.java,输出 area=9。调用方按 Rectangle 契约写 setW(5); setH(3); 期望 15,传入 Square 得到 9。契约测试的数量是 父类契约用例数 × 子类数;不愿为每个子类跑完整契约测试,就不该用继承复用。正方形应持有边长,不该继承长方形;代价是失去父类类型,收益是契约不靠约定维持。
ISP 接口隔离原则:不强迫实现类依赖用不到的方法
java
interface Machine { void print(); void fax(); void scan(); }
class SimplePrinter implements Machine {
public void print() { /* ... */ }
public void fax() { throw new UnsupportedOperationException(); }
public void scan() { throw new UnsupportedOperationException(); }
}设胖接口有 m 个方法,某实现类平均使用 k 个,则 m-k 个方法是空实现或抛异常。按职责拆分后,实现类只依赖用到的部分,调用方编译期依赖面从 m 降到 k;代价是接口数量增加。jdeps 可以看模块/包的依赖面变化,比凭感觉判断可靠。
DIP 依赖倒置原则:高层和低层都依赖抽象
java
class OrderService {
private final MySQLOrderRepository repo = new MySQLOrderRepository(); // 绑死具体存储
}比较两件事:跑一次业务单测是否需要外部资源,替换实现要改几处。反例版本每个测试都要起数据库或 mock 静态构造,换存储要改所有 new 位置;DIP 版本用构造器注入接口,单测传内存 fake,替换实现只改组合根一处。测试耗时取决于你的环境,先测一次再下结论;可测性是 DIP 最直接的收益。
LoD 最少知识原则:只和直接朋友说话
java
a.getB().getC().getD().doSomething();调用方 a 直接依赖 B、C、D 三个类型;B 把 C 换成 E,a 也要改。链深为 d 时,调用方依赖 d 个类型;在 B 上提供转发方法可把依赖降到 1,代价是多一次方法调用和一层封装。用 ArchUnit 的 noClasses().should().accessClassesThat() 或 JDepend 的 afferent/efferent coupling 量化。
实战场景
一、OrderService 3000 行:一次计价改动引发通知重复
现象:计价规则调整只改了 OrderService 里 20 行,上线后短信重复发送三倍。复盘发现该类同时承担校验、计价、落库、发通知、风控上报五件事,createOrder() 内部被三个私有方法互相调用,改动时顺手把“重试计数”复用了通知的循环变量。回归范围被迫覆盖整个下单链路,测试耗时 2 天。
排查:SRP 的判据不是行数,而是“有几个被修改的理由”,且每条都能量化。
| 指标 | 怎么测 | 本次数值 | 健康口径 |
|---|---|---|---|
| 变更原因数 | 近半年该文件的提交按需求归类,看来自几个业务方向 | 5 个(计价、风控、通知、履约、报表) | 1 个 |
| 单次改动波及文件数 | 需求单关联的 diff 文件数 | 8(同类问题反复出现) | 与需求边界一致 |
| 回归范围 | 改动后必须重跑的测试用例数 | 全量下单回归 | 只跑受影响模块 |
| 方法内部协作对象数 | 一个方法里 new/调用的下游种类 | 6 类职责混在一起 | 编排而非实现 |
| 缺陷来源集中度 | 缺陷单指向的代码区域 | 同一文件占 40% | 分散 |
对症做法:
java
// 按“变更原因”拆分:每个类只因一件事被改
class OrderApplicationService { // 只编排:校验 → 计价 → 持久化 → 发事件
public OrderId place(PlaceOrder cmd) {
validation.assertCanPlace(cmd);
PricingResult price = pricing.price(cmd);
OrderId id = orders.save(Order.of(cmd, price));
events.publish(new OrderPlaced(id, price.total())); // 通知由订阅者负责
return id;
}
}
class PricingService { ... } // 计价规则变更只改这里
class OrderNotifier { ... } // 通知逻辑变更只改这里,且不参与计价text
1. 应用层只做编排,不写业务规则,也不写基础设施细节;
2. 通知、上报这类副作用改由领域事件驱动,去掉“在主流程里顺手发一条”的写法;
3. 拆分后给每个类补一句“它因什么而变”的注释——写不出来说明拆得不对。复盘要点:原则要落到可测指标:变更原因数、单次改动波及文件数、回归用例数。这三个数在拆分前后要有对比,否则“符合 SRP”只是感受。重复通知还暴露另一个事实:主流程里的循环变量被副作用复用,是“一个方法承担多职责”的典型并发症。
二、子类返回 null:里氏替换被破坏后的批量 NPE
现象:PriceCalculator 接口有多个实现,新加的 GroupBuyCalculator 在无活动时返回 null,而既有调用方都按“返回金额,无值时用 0”的约定写。灰度 5% 流量后,订单详情接口 NPE 激增,日均 200 余次(经验值,需校准),影响面覆盖所有调用点。
排查:LSP 的破坏几乎都发生在契约层,而不是类型层。
| 检查项 | 取证据 | 判定 |
|---|---|---|
| 返回值约定 | 接口注释/测试里是否规定“空值用 Money.zero” | 实现返回 null → 契约被破坏 |
| 异常约定 | 子类是否抛出父类未声明的运行时异常 | 抛出 UnsupportedOperationException → 不可替换 |
| 前置条件 | 子类是否增加了父类没有的参数限制 | 参数校验更严 → 调用方按旧约定会失败 |
| 后置条件 | 子类是否削弱了保证(如不再保证非负) | 返回负数破坏调用方的展示逻辑 |
| 行为不变式 | 状态类实现是否破坏不变式 | 缓存实现返回过期值即为破坏 |
对症做法:
java
// 1. 契约用类型表达,而不是靠注释:无值是一个合法状态
public interface PriceCalculator {
PricingResult calculate(Cart cart); // 返回值非空,用 NONE 表示无优惠
}
public record PricingResult(Money amount, PricingKind kind) {
public static final PricingResult NONE = new PricingResult(Money.zero(), PricingKind.NONE);
}
// 2. 契约测试基类:所有实现都必须通过同一组用例,CI 强制
abstract class PriceCalculatorContractTest {
protected abstract PriceCalculator create();
@Test void neverReturnsNull() { assertThat(create().calculate(emptyCart())).isNotNull(); }
@Test void amountIsNonNegative() { assertThat(create().calculate(cart("100")).amount().signum()).isNotNegative(); }
@Test void unknownChannelNoThrow() { assertThatCode(() -> create().calculate(cartWithUnsupported())).doesNotThrowAnyException(); }
}
class GroupBuyCalculatorTest extends PriceCalculatorContractTest { ... }
// 3. 新增实现无法通过契约测试时,用组合加策略注册表,而不是强行继承java
// 4. 调用点也要收敛:把“无优惠”的分支固定在一处,避免每个调用点自己猜
Money payable = pricing.amountOr(PricingResult.NONE, cart).value();复盘要点:判据是“实现类必须通过同一套契约测试”,且这套测试与实现同时存在于仓库里;新增实现不过测试就不能合并。子类替换父类的成本如果在调用点体现(到处加 if (x != null)),说明继承关系已经不可用,改用组合与策略更便宜。
三、20 个方法的渠道接口:新渠道要改 12 处
现象:PayChannel 接口有 20 个方法(支付、退款、代扣、对账、签约、扫码、分期……)。接入一个只支持支付和退款的渠道,实现类里写了 15 个 throw new UnsupportedOperationException();三个月后有人调用了其中的 sign(),线上直接报错。同时新增渠道要在工厂、路由、配置、监控四处登记,改动 12 个文件。
排查:胖接口 + 具体依赖会同时放大两处成本。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 实现里的空方法数 | 统计 UnsupportedOperationException 出现次数 | 占比高 → 接口未按能力隔离(违反 ISP) |
| 调用方依赖的类型 | 调用方 import 的是 PayChannel 还是某个具体渠道 | 依赖具体类 → 换实现要改调用点 |
| 新增实现的改动文件数 | 接入一个新渠道需要改几处 | 大于 2 处 → 扩展点缺失(违反 OCP) |
| 能力协商方式 | 是否有 supports(Capability) 之类显式声明 | 靠异常表达不支持,等于把错误推到运行期 |
| 空实现的风险 | 是否有代码路径能调到未实现方法 | 有线上异常即为可触发 |
对症做法:
java
// 1. 按能力拆接口:实现只依赖用得到的部分
public interface PaymentApi { PayResult pay(PayRequest req); }
public interface RefundApi { RefundResult refund(RefundRequest req); }
public interface SigningApi { SignResult sign(SignRequest req); }
// 2. 能力用显式声明,而不是靠抛异常
public interface PayChannel extends CapabilityAware {
Set<Capability> capabilities();
}
// 路由时先过滤,不支持就不会被选中
channels.stream().filter(c -> c.capabilities().contains(Capability.SIGNING)).findFirst();
// 3. 注册表替代散落的 if/switch:新增渠道只加一个实现 + 一处注册
@Component
class AlipayChannel implements PayChannel, PaymentApi, RefundApi {
public Set<Capability> capabilities() { return EnumSet.of(PAY, REFUND, QUERY); }
}
// 工厂收集所有 PayChannel Bean,按渠道码建表,新增渠道不改工厂java
// 4. 高层依赖抽象:渠道选择服务只依赖接口与注册表
class ChannelRouter {
private final Map<String, PayChannel> registry; // 由配置/SPI 注入
public PayChannel route(String channelCode, Capability need) {
PayChannel c = registry.get(channelCode);
if (c == null || !c.capabilities().contains(need)) throw new ChannelNotCapableException(channelCode, need);
return c;
}
}复盘要点:三条指标可以直接进评审:新增一个实现需要改动的文件数(目标 ≤ 2)、实现类里空方法数(目标 0)、调用点依赖具体类的数量(目标 0)。第二个场景里 sign() 的线上异常属于必然结果——用异常表达“不支持”,就是把建模缺口转成运行时风险。ISP 解决“被迫依赖用不到的方法”,DIP 解决“换实现要改调用方”,两者在这里同时缺位。
最小上手
bash
java LspDemo.java # 输出 area=9,契约已被破坏
git log --since="6 months" --name-only --pretty=format: -- src/main/java \
| sort | uniq -c | sort -rn | head -20第一条命令把「子类破坏父类契约」变成可观察输出;把期望值改成 15 写成断言,就是一条会失败的契约测试,也是重构的起点。第二条找高变更热点:热点文件同时承担多个变更来源就优先拆;只是被频繁修改但职责单一,可能只是核心域,不要为了指标而拆。
生产动作:误用与代价
设计模式属于学习型内容,这里不列生产检查清单,只写每条原则被滥用时的代价。
| 原则 | 过度应用的代价 | 更简单的替代 |
|---|---|---|
| SRP | 类数量膨胀,一个用例跨五个类;事务边界被切碎 | 按变更来源拆,不按方法拆;总一起变就保留 |
| OCP | 为两个稳定枚举值建策略体系,多一层注册和查找 | 变体少且稳定时用 if-else,分支集中在一个方法 |
| LSP | 为了复用强行继承,子类靠抛异常表达「不支持」 | 组合优先;不能替换就不用继承 |
| ISP | 接口碎到每个方法一个,调用方要组合多个接口 | 按调用方视角拆,不按方法数量拆 |
| DIP | 每个类都抽接口,包括没有替换需求的内部类 | 只给跨层、需替换、需测试的边界抽接口 |
| LoD | 每个字段访问都包一层转发,代码变成 getter 接力 | 只对跨模块、跨层的深链做封装 |
边界
- 原则之间会冲突:SRP 拆得越细,调用方要认识的类型越多,和 LoD 有张力。按当前最痛的一项取舍。
- 原则不是评审打分表:违反 SRP 但半年没改过的类,优先级低于职责单一但天天改的核心类。
- DIP 不等于必须用 DI 框架:构造器传入实现就是 DIP,Spring 只是把装配自动化。
- LSP 只约束可替换性:子类可以增加方法,但不能收紧前置条件、放宽后置条件、抛出父类未声明的受检异常。
讲清楚
六条原则都在回答「变更会波及多大」。SRP 让一个类只有一个变更来源;OCP 让新增变体不改旧代码;LSP 保证子类替换父类不破坏契约;ISP 让实现类不依赖用不到的方法;DIP 让高层和低层都依赖抽象;LoD 让调用方不深入对方内部。每条都能量化:改动文件数、要重跑的契约测试数、被迫实现的方法数、单测是否需要外部资源、调用方直接依赖的类型数。量化之后会发现,原则不是越多越好:拆太细增加类型和跳转,抽象太早为不变的东西付成本。判断标准是变更频率和影响面。
初稿 2025-06 | 最后修订 2026-10