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. 自测
- 单一职责的实操判据是什么,为什么"代码行数"不是判据?
- 开闭原则预留扩展点要付哪三次成本?
- 里氏替换的违约最常出现在哪两处?
- 接口隔离该按"方法数量"拆还是按什么拆?
- 迪米特法则为什么不禁 Builder 与 Stream?
- 原则冲突时的总排序,可扩展性为什么排最后?