项目启动时有人提了一句"以后可能要换掉这个存储",于是先抽了三层接口:Repository、RepositoryAdapter、RepositoryPort。第一年换了零次实现,每个 bug 的定位路径却多跳了两次。
抽象是用当下确定的成本,换未来不确定的收益。这笔交换有时极划算(隔离外部依赖),有时纯亏损(只有一个实现的接口)。区别不在品味,而在几个能数出来的判据。
1. 抽象到底花掉了什么
| 成本项 | 具体形态 | 什么时候最痛 |
|---|---|---|
| 阅读跳转 | 读一处逻辑要跳 2–4 个文件 | 新人接手、代码评审 |
| 调试断点 | 栈里一半帧是包装层 | 线上问题复现 |
| 参数透传 | 每加一个参数,改 3 层签名 | 需求变化 |
| 变更扩散 | 改一处行为,动 5 个类 | 小改动变大 PR |
| 隐式约定 | 谁管事务、谁管重试,看不出来 | 并发与一致性 |
| 收益项 | 具体形态 | 成立条件 |
|---|---|---|
| 隔离变化 | 换实现只改一个地方 | 变化真的会发生 |
| 复用 | 同一份逻辑服务多处 | 有 ≥ 2 个真实调用点 |
| 可测 | 可以在边界上替换成替身 | 抽象位于正确的接缝 |
| 表达领域 | 代码里出现业务词汇 | 术语在团队内有共识 |
关键的不对称:成本确定且立刻发生,收益有概率且延迟兑现。所以不该问"抽象好不好",该问"这个收益的兑现概率有多高"。
2. "以后可能要换"是最贵的假设
判据一:现在有几个实现?
0 个(连替代方案都没有) → 不抽,等第二个实现出现再抽
1 个(只有当前实现) → 不抽;抽了也只是把"没有第二个实现"写成接口
2 个(已有真实替代) → 抽,且此时抽象的形状已经被现实约束住了
判据二:上一个"以后可能要换"的假设,兑现了没有?
记录兑现率;0/5 兑现的团队,第六次预测的权重应当接近零
这类假设的问题不是它错,而是无法验证,因而无法被否证。不能被证伪的假设会永久占据代码结构,且没有机制清理它。
做法:把假设写进 ADR 并带触发条件——"若需要第二种存储,则在 X 处引入接口"。触发条件没到的抽象不做。
3. 三种动机与各自的成立条件
| 动机 | 典型例子 | 成立条件 | 不成立的表现 |
|---|---|---|---|
| 隔离外部依赖 | 支付网关、对象存储 SDK | 外部 API 会变、且难以在测试中启动 | 包了一层仍然到处 import SDK 的类型 |
| 消除重复 | 三个服务里同样的重试逻辑 | 重复且确定同向演化 | 抽完之后各处加 if 开关,最后比复制还长 |
| 表达领域概念 | Order、Refund |
术语稳定、团队共识 | 概念是新造的,业务方不用这个词 |
检验方式各不相同:隔离外部依赖看接缝是否只有一个入口;消除重复看抽出来的东西是否还在变;表达领域看非技术同事能否看懂这个名字。最常见的错配,是拿消除重复的动机做了隔离变化的抽象——两段本就该分化的代码被合并成带 mode 参数的类。
4. 重复三次再抽象,以及它的反例
规则:第一次直接写,第二次复制并标注,第三次才抽象。
| 情形 | 该不该抽 | 理由 |
|---|---|---|
| 三次重复,且三次的演化方向一致 | 抽 | 变化是"同一件事的不同数据" |
| 三次重复,但每次都在往不同方向长 | 不抽 | 抽象会把分叉固化成 if 分支 |
| 两次重复,但代码是纯计算且极短 | 抽 | 合并成本低于阅读成本 |
| 两次重复,但涉及不同的失败语义 | 不抽 | 错误处理不同意味着不是同一概念 |
反例的核心:重复是事实,共性只是猜测。第三次出现前,样本不足以判断哪些部分稳定、哪些各自演化。
# 判据可部分机械化:同一段逻辑的调用点数量
$ grep -rn "calculateRetryDelay" --include=*.java | wc -l
3 # ≥3 且演化同向 → 考虑抽象;=1 → 先别动
5. 抽象层级与调用深度
| 深度 | 读者的体验 | 可接受条件 |
|---|---|---|
| 1 层(直接调用实现) | 一眼到底 | 默认形态 |
| 2 层(接口 + 实现) | 需要跳一次 | 有第二个实现或需要替身 |
| 3 层(接口 + 抽象基类 + 实现) | 开始需要工具和耐心 | 必须有明确的分层理由(如框架回调边界) |
| ≥ 4 层 | 没人读完,改的时候靠猜 | 几乎总是设计事故 |
深度不是罪,深度乘分叉数才是。3 层但每层一条路,读起来仍线性;2 层但每层 5 个实现加条件装配,路径数已爆炸。评估要数"从入口到执行经过的条件分支数",不是数类。
6. 接口的宽度
| 取向 | 特征 | 优点 | 代价 |
|---|---|---|---|
| 窄接口(1–3 个方法) | 每个接口一件事 | 易组合、易替换、易测 | 调用方要拼装多个接口 |
| 宽接口(10+ 方法) | 一个接口包一切 | 调用方简单 | 任何实现都要实现全部方法;替换成本极高 |
// 窄:调用方按需组合,替身只需实现用到的那一个
interface PriceLookup { Money priceOf(Sku sku); }
interface StockLookup { int available(Sku sku); }
// 宽:任何替身都得实现 12 个方法,其中 11 个是 UnsupportedOperationException
interface CatalogService { /* 12 methods */ }
判断方法:写出替身时要实现几个方法。出现 throw new UnsupportedOperationException(),说明接口太宽,把不相关能力绑在了一起。
7. 配置化与抽象的界限
配置是运行期抽象:同一份代码,不同参数走不同路径。它比代码抽象便宜,代价是静态检查失效——错误从编译期推迟到运行期。
# 配置化:分支存在但不可见,读代码时看不到有哪些取值
retry:
strategy: exponential # 合法值?谁校验?默认值错了会怎样?
| 手段 | 变化发生在 | 静态可检查 | 适合 |
|---|---|---|---|
| 代码抽象(接口/策略) | 编译期 | 是 | 实现差异大、数量少 |
| 配置开关 | 运行期 | 否 | 同一实现的参数差异 |
| 特性开关 | 运行期 | 否 | 灰度与回滚,有生命周期 |
界限:两个分支的差异超过几行,配置化就是用 if 隐藏两个类;差异只是一个数字,写两个类就是浪费。判定线是"分支里的代码能否独立测试"——能则是类,不能则是参数。
8. 过度抽象的识别信号
| 信号 | 检出方式 | 说明 |
|---|---|---|
| 只有一个实现的接口 | grep -r "implements XxxPort" 只命中一处 |
抽象没有第二实现,也没有测试替身需求 |
| 只被调用一次的类 | 调用点计数 | 拆出来没有复用收益 |
| 参数原样透传的包装 | 方法体只有一行 return delegate.x(...) |
纯厚度,无信息 |
| 抽象基类只有一个子类 | 继承树宽度 | 为"未来扩展"预留,实际从未扩展 |
| 每次改动都要改三层签名 | 变更历史 | 抽象的接缝位置错了 |
注意:测试替身也算第二个实现。如果一个接口唯一的用途是被 mock,那不是抽象失败,而是抽象成功了——但前提是这个接缝确实需要被替换。
9. 抽象撤销的成本
抽错后的三条路都不便宜:保留(每次改动多一层,无人敢删)、内联回调用方(一次中等重构,撞并行开发)、改接缝位置(语义漂移,测试重写)。
抽错的代价高于不抽,因为删除接口比添加接口难:删要确认所有调用方、所有替身、所有装配配置。所以"先抽了再说"在组织上是不对称的坏决定——加的人收益归自己,删的人承担全部成本。
10. 测试对抽象的约束
抽象应当让测试更容易。要 4 个以上替身才测得到一个纯函数,或替身必须模拟调用顺序、断言依赖调用次数,都说明接缝切在了业务逻辑中间:
// 抽象让测试变难:为了测一个纯函数,必须启动 5 个替身
@Test void testFee() {
var svc = new FeeService(mockRepo(), mockClock(), mockTax(), mockFx(), mockAudit());
// 真正的逻辑只有一行加法
}
11. 抽象泄漏
泄漏不可避免:非平凡抽象迟早会暴露下层细节(性能、事务边界、异常类型)。关键不是消灭,而是泄漏有没有被文档化、有没有逃生口。
| 泄漏类型 | 例子 | 缓解 |
|---|---|---|
| 性能特征 | "批量接口比循环单条快 50 倍" | 文档写明批量语义,提供批量入口 |
| 异常语义 | 包装后丢失原始错误码 | 保留 cause 与错误码映射表 |
| 事务边界 | 抽象内部各自开事务 | 在接口契约上写明事务归属 |
| 资源生命周期 | 连接何时归还不可见 | 显式 close 或 try-with-resources |
有逃生口的意思是:保留一个"绕过抽象直接操作"的合法途径,并写清何时该用。没有逃生口的抽象,会在第一个性能问题上被推倒重来。
12. 排错:抽象上线后出现的问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 改一个字段要动 5 个文件 | 分层过深或 DTO 层层转换 | 合并相邻层,去掉纯透传的映射 |
| 线上栈里一半是框架帧 | 包装层过多 | 按用例收拢接缝,减少转发 |
| 换实现时发现接口不匹配 | 抽象按当前实现的形状写 | 接口按调用方需求定义 |
| 测试替身越写越复杂 | 接口太宽或接缝太细 | 收窄接口,改按用例切分 |
| 新人读不懂调用链 | 深度 ≥ 4 或不必要的中间层 | 删层,而不是补文档 |
13. 边界:这些地方不要抽象
| 场景 | 理由 |
|---|---|
| 一次性代码(脚本、迁移工具、demo) | 生命周期短于学习成本 |
| 探索期原型 | 需求形状未定,抽象会把错误固化 |
| 性能极敏感的热路径 | 每次调用多一次间接跳转,JIT 未必能内联掉 |
| 语言/框架已有稳定抽象 | 再包一层只是方言 |
| 团队没有维护约定的能力 | 抽象要求"改接口的人负责全部实现" |
14. 自测
- 抽象的成本和收益在时间上的不对称,如何影响判断顺序?
- "现在有几个实现"作为判据,为什么比"以后可能要换"可靠?
- 重复三次才抽象,这条经验法则在什么情况下应当被推翻?
- 评估抽象深度时,为什么数条件分支比数类更准?
- 写出替身需要
UnsupportedOperationException,说明接口有什么问题? - 抽象泄漏无法消灭,那么"可接受"的标准是什么?