跳到正文

六大设计原则:反例与量化取舍 ​

一句话:设计原则约束的是「依赖往哪流、变更影响多大」。每条原则都能翻译成一个可测指标——改动文件数、回归测试范围、调用方依赖的类型数;说不清指标,原则就只是口号。

为什么需要 ​

代码能跑但不敢改,通常不是代码量的问题,而是变更的影响范围不可控:改一个 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

把一门技术讲透到既懂也会用