2773 字
约 9 分钟
15
S18-X08 · 设计模式七大原则在真实代码里的取舍

SOLID 加迪米特、合成复用,七条原则常被讲成行为规范。真实项目里它们互相打架:单一职责拆出的类让调用方串五个对象,接口隔离拆出的接口让一个方法注三个依赖,开闭原则预留的扩展点三年没被扩展过。

这篇不讲定义,讲每条原则在什么条件下成立、违反它的成本谁承担。最后给一段同时"过度设计"和"欠设计"的代码。


1. 单一职责:判据是"变化的理由",不是"代码行数"

public class OrderService {                 // 一个类里三个变化的理由
    public Order create(CreateCmd cmd) { }            // 业务规则会变
    public String renderHtml(Order o) { }             // 展示模板会变
    public void exportCsv(List<Order> list) { }       // 报表口径会变
}

判据:问"谁会来改这个类"。 如果答案是"产品经理改规则、设计师改模板、财务改口径"——三个不同的角色、三个不同的节奏,就该拆。如果三个变化都来自同一个需求(比如"支付渠道接入"),拆开只是让一次提交变成三个文件的提交。

信号 该拆 不该拆
修改动机 不同角色/不同发布节奏 同一需求的不同侧面
依赖 一半方法要 DB,一半方法只要内存 所有方法用同一批依赖
测试 测 A 行为必须构造 B 的依赖 一个测试能覆盖全部
命名 类名里出现 And、Manager 名字准确描述一件事

代价:拆完的 OrderService、OrderRenderer、OrderExporter 要调用方去协调。成本落在调用方,收益落在维护者——只有一个调用方时两者是同一批人,拆不拆都行;有 20 个调用方时收益立刻超过成本。


2. 开闭原则:不是所有扩展点都值得预留

public interface DiscountStrategy { BigDecimal apply(Order o); }   // 一年前预留
public class NoDiscount implements DiscountStrategy { }            // 一年后仍只有 1 个实现

真实情况是:预留扩展点要付三次成本——多一个接口、多一层间接、多一份文档。收益只有"未来加需求时不用改调用方",而那个"未来"经常不来。

判断预留要三条同时成立:变化方向已知(能说清下一个实现长什么样,如支付渠道、存储后端)、变化频率高(半年内会加第二个实现)、调用方多(改调用方的成本大于加抽象的收益)。三条齐了才值得开。"配置里可能有多个值"不算变化方向已知——那是数据差异,不是行为差异,用策略模式处理数据差异是错的工具。

真正需要开闭的地方往往很土:加一个支付方式要改 5 处 switch,这比"策略接口"更值得改——前者是真实重复修改,后者是想象出来的。


3. 里氏替换:违约都发生在"前置条件"和"异常"上

class Account { void withdraw(BigDecimal amount) { } }   // 前置:amount > 0

class FrozenAccount extends Account {
    @Override void withdraw(BigDecimal amount) {
        if (amount.compareTo(new BigDecimal("1000")) > 0)   // 加了新前置条件
            throw new IllegalStateException("限额 1000");
        super.withdraw(amount);
    }
}

三处违约,都在运行时才炸:

违约形式 例子 后果
加强前置条件 子类要求 amount ≤ 1000 用父类类型调用的代码突然抛异常
削弱后置条件 子类 withdraw 不实际扣减 上层算出错误余额
抛出父类未声明的异常 UnsupportedOperationException 调用方 catch 不到,直接 500

Collections.unmodifiableList() 返回的 List 就是"宽接口 + 运行时违约":add 抛 UnsupportedOperationException,类型系统看不出来。所以**"能不能替换"不该靠继承表达,该靠更窄的接口**——只读场景声明 ReadOnlyList<E>,写操作根本不在接口里。继承只用于"行为增强且契约不变";一旦要加前置条件,说明这不是 is-a,是角色不同。


4. 接口隔离:胖接口什么时候拆

public interface UserRepository {     // 胖接口
    User findById(Long id);  List<User> search(Query q);
    void save(User u);       void batchImport(List<User> users);
    void rebuildIndex();     byte[] exportExcel();     // 只有运维脚本用
}

rebuildIndex 和 exportExcel 只有运维脚本用,但所有实现类(含 5 个测试 mock)都必须实现它们。拆分时机:

症状 处理
实现类里有大量 throw new UnsupportedOperationException() 立刻拆
不同调用方只用其中 1–2 个方法 按调用方拆
所有实现都完整实现全部方法 不拆,拆了反而要注两个 Bean

拆完是 UserQueryRepo + UserWriteRepo + UserMaintenanceRepo。不要按"方法数量"拆,按"调用方群体"拆——否则会出现注入了两个接口却只用一个。


5. 依赖倒置:为了 DI 而 DI 是负收益

典型症状:interface IdGenerator { String next(); } 只有一个实现 return UUID.randomUUID().toString(),永远不会换。加了它之后多一个文件、多一个 Bean、断点要多跳一层。判据是**"是否有第二个实现,或者是否需要在不启动容器的情况下测试"**。

该抽:有第二个实现、需要在单测里替换(时间/随机/外部 HTTP)、被多模块复用。不必抽:只有一个实现且是纯计算,static 即可;只有一个实现但有外部 IO,仍建议抽,测试替身省事。

依赖倒置真正的价值不在"解耦",在测试可控:把 Clock、IdGenerator、FileUploader 抽出来,直接收益是测试不用 sleep、不用 mock 静态方法。


6. 迪米特法则与链式调用的冲突

迪米特的典型反例是 order.getCustomer().getAddress().getCity()——一句话穿了三层结构;符合的写法是 order.deliveryCity()。但链式调用(Builder、Stream、Optional)本质就是连续跨越对象。冲突的实质是:迪米特针对的是"导航式访问"(暴露结构),不针对"流式 API"(返回同一抽象)。

调用形式 是否违反迪米特 理由
a.getB().getC().doX() 是 暴露了 a→b→c 的结构
builder.x(1).y(2).build() 否 每步返回同一类型,无结构暴露
list.stream().filter(..).toList() 否 同一抽象的连续变换

判据一句话:链上每一步的返回类型是否暴露出"内部结构"。是 → 违反;否 → 只是风格。要消除导航,不是禁止链式,而是在 Order 上提供 deliveryCity() 这样的语义方法——这也是"封装"和"迪米特"的交点。


7. 合成复用优先于继承:具体改法

继承的写法是 class SmsNotifier extends HttpClientNotifier {}——为复用父类 20 行 send;合成是把它变成字段 private final HttpClient http。三个改法步骤:

步骤 做法 收益
1. 找"被复用的行为" 是工具方法、还是完整对象 工具方法用组合没必要,静态导入即可
2. 判断生命周期 被复用者是否独立创建/关闭 独立生命周期必须组合
3. 看是否要替换 运行时换实现 组合 + 接口,继承做不到

继承唯一无可替代的场景:需要模板方法(父类定流程,子类填步骤)且流程稳定。一旦子类开始 override 流程方法而不是步骤方法,说明流程不稳,该换成组合 + 策略。


8. 原则打架时怎么排优先级

冲突 优先 理由
单一职责 vs 可读性 可读性 拆成 6 个类没人能顺着读完,等于把复杂度换了个位置
开闭 vs 简单 简单 预留的扩展点大概率不用,多一层是确定成本
迪米特 vs 流式 API 流式 API 禁止链式会让代码退化成 20 个临时变量

总排序:可读性 > 正确性(里氏、契约) > 可测试性 > 可扩展性。可扩展性排最后不是不重要,而是最容易高估——三年后要扩展的地方,和今天预留的地方通常不是同一个。


9. 一段代码:两侧都有问题

// 欠设计:改一处怕碰三处,且无校验、无日志、无异常边界
public void handle(Order o) {
    if (o.getType() == 1) { /* 40 行 */ } else if (o.getType() == 2) { /* 40 行 */ }
}

// 过度设计:泛型体操 + 抽象模板,为"将来可能"付出确定成本
public interface OrderHandler<T extends OrderContext> { <R> R handle(T ctx, Class<R> resultType); }
public abstract class AbstractOrderHandlerTemplate<T, R> implements OrderHandler<T> { }

正确的中间态:一个策略接口 + 三个实现 + 一个工厂 Map。

public interface OrderHandler {
    OrderType type();
    void handle(OrderContext ctx);
}

@Component
public class OrderHandlerRegistry {
    private final Map<OrderType, OrderHandler> map;
    public OrderHandlerRegistry(List<OrderHandler> handlers) {
        this.map = handlers.stream().collect(toMap(OrderHandler::type, identity()));
    }
    public void dispatch(Order o) {
        OrderContext ctx = OrderContext.of(o);
        map.get(o.getType()).handle(ctx);   // 未注册时给明确异常
    }
}

它只加了真正需要的扩展点(订单类型会增加,这是已知的变化方向),没有泛型体操、模板方法、六层横切。代价是一条 if 换成一个 Map 查找。


10. 常见坑

现象 原因 处理
拆完调用链 6 层 单一职责拆过头 按"变化的理由"合并回去
扩展点三年没实现类 开闭原则用错地方 删掉接口,直接写实现
子类 override 后行为不一致 里氏违约 改为组合或独立类型
构造器 8 个参数 接口隔离后遗症 合并回胖接口,或聚合依赖对象

判断"该不该抽接口"最快的办法:问测试里要不要替换它。不用替换就不抽——这条比任何原则表述都好用。


11. 边界:原则不适用的场景

场景 说明
一次性脚本、数据迁移 可读性优先,抽象是负担
性能敏感的循环内部 多一层调用多一次虚分派,热路径要算清
团队只有 2 人、代码 5000 行 抽象成本摊不开,直觉式写法更快

12. 自测

  1. 单一职责的实操判据是什么,为什么"代码行数"不是判据?
  2. 开闭原则预留扩展点要付哪三次成本?
  3. 里氏替换的违约最常出现在哪两处?
  4. 接口隔离该按"方法数量"拆还是按什么拆?
  5. 迪米特法则为什么不禁 Builder 与 Stream?
  6. 原则冲突时的总排序,可扩展性为什么排最后?
S18-X08 · 设计模式七大原则在真实代码里的取舍
https://www.clxhxhhr.top/posts/5723/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。