5.1 可验证的小步重构
重构是在保持外部可观察行为不变的前提下改善代码内部结构。每一个小变化都需要证据说明行为仍然一致。“这次只是整理代码”可以表达意图,不能代替测试结果。
先定义哪些行为不能变
外部行为不只包括返回值,还可能包括:
- 抛出的异常类型和时机;
- 持久化结果与事务边界;
- 发送的事件、顺序和次数;
- 日志、指标或审计记录;
- API 状态码、字段和兼容行为;
- 性能与资源占用中已写入契约的部分。
若需求本身也要变化,先把结构变化和行为变化分开,让每一步的意图和失败原因可辨认。
没有测试时先建立观察点
遗留代码不一定适合立即补细粒度单元测试。可以从系统当前可观察边界建立 characterization test(特征测试):
下面是 JUnit 5 测试方法片段,legacyCalculator、MatchScore、Outcome 和 ScoreResult 来自遗留项目夹具。+16/-16 表示假设观察到的同分选手胜负结果,不是通用积分公式,也不是平局。运行前须接入真实夹具。
@Test
void preservesEqualRatingWinDelta() {
ScoreResult result = legacyCalculator.calculate(
new MatchScore(1200, 1200, Outcome.WIN));
assertEquals(new ScoreResult(+16, -16), result);
}特征测试记录“现在实际做什么”,不自动证明该行为正确。如果发现明显缺陷,应单独确认期望、增加一个先失败的测试,再作为行为变更修复。不要在纯重构中悄悄把缺陷一起改掉。
当代码难以测试时,先在现有边界做较粗测试,再用最小接缝切开时间、文件系统、数据库或网络依赖。为了测试而增加一个清晰端口,常常也是设计改善。
识别变化阻力,而不是按长度判罪
“坏味道”是调查信号,不是自动判决:
| 信号 | 要追问的问题 | 常见方向 |
|---|---|---|
| 长函数 | 是否混合多个抽象层次或变化原因? | 提炼函数、以查询替代临时变量 |
| 长参数列表 | 参数是否总成组出现,顺序是否易错? | 参数对象、保留完整对象 |
| 重复代码 | 两段代码是否真的会因同一原因变化? | 提炼共享概念,而非只消除文本重复 |
| 霰弹式修改 | 一个业务变化为何散落多处? | 移动函数/字段、集中策略 |
| 发散式变化 | 一个模块是否承担多个无关职责? | 分解模块、分离阶段 |
| 基本类型偏执 | 字符串或数字是否隐藏单位和规则? | 值对象、枚举、受约束类型 |
| 过多注释解释流程 | 能否用名称和结构表达意图? | 重命名、提炼函数;保留“为什么” |
十行函数也可能混乱,五十行顺序清晰的转换流程也可能无需拆分。目标是降低理解和修改成本,不是满足行数阈值。
示例:先把计算从编排中分离
原始方法同时读取数据、计算积分、写库和通知:
下面两个 complete 是 Java 21 服务方法的替代片段,matches、scores、ratingFormula 和 notifications 是已有协作者;这里的 Match 接口与 4.1 的小状态机不同。本例假定 ratingFormula 确定且无副作用,协作者限于单线程使用,并只讨论普通调用失败;JVM 退出与分配失败不在练习范围内。
public void complete(String matchId, String winnerId) {
Match match = matches.getRequired(matchId);
if (!match.isInProgress()) throw new IllegalStateException();
if (!match.hasPlayer(winnerId)) throw new IllegalArgumentException();
String loserId = match.otherPlayer(winnerId);
int winnerScore = scores.get(winnerId);
int loserScore = scores.get(loserId);
int winnerDelta = ratingFormula.winnerDelta(winnerScore, loserScore);
int loserDelta = -winnerDelta;
scores.update(winnerId, winnerScore + winnerDelta);
scores.update(loserId, loserScore + loserDelta);
match.complete(winnerId);
matches.save(match);
notifications.send(winnerId, "score +" + winnerDelta);
notifications.send(loserId, "score " + loserDelta);
}第一步可以只提炼纯计算,不改变事务、调用顺序或失败行为:
record RatingChange(int winnerScore, int loserScore,
int winnerDelta, int loserDelta) {}
private RatingChange calculateChange(int winnerScore, int loserScore) {
int winnerDelta = ratingFormula.winnerDelta(winnerScore, loserScore);
return new RatingChange(
winnerScore + winnerDelta,
loserScore - winnerDelta,
winnerDelta,
-winnerDelta);
}将 RatingChange 和 calculateChange 放入同一服务类,再用下面的方法替换 complete。读取、写入、状态变更与通知保持原顺序:
public void complete(String matchId, String winnerId) {
Match match = matches.getRequired(matchId);
if (!match.isInProgress()) throw new IllegalStateException();
if (!match.hasPlayer(winnerId)) throw new IllegalArgumentException();
String loserId = match.otherPlayer(winnerId);
int winnerScore = scores.get(winnerId);
int loserScore = scores.get(loserId);
RatingChange change = calculateChange(winnerScore, loserScore);
scores.update(winnerId, change.winnerScore());
scores.update(loserId, change.loserScore());
match.complete(winnerId);
matches.save(match);
notifications.send(winnerId, "score +" + change.winnerDelta());
notifications.send(loserId, "score " + change.loserDelta());
}再运行特征测试。之后才考虑移动通知到提交后事件、增加事务或改变失败恢复;这些会改变可观察语义,属于后续设计变更,不应伪装成提炼函数。
不能只比较最终分数。夹具令 1200/1200 的积分变化为 16 时,两版都应依次写入 1216 和 1184、完成并保存比赛,再发送相同的两条消息。还要在这些边界注入失败:
| 失败位置 | 两版应保留的行为 |
|---|---|
| 比赛未开始 / 获胜者不在参赛者中 | 读取或写入积分前拒绝 |
| 第二次积分写入失败 | 第一次写入已发生;不完成比赛,不发通知 |
| 第一条通知发送失败 | 两次积分写入和比赛保存已发生;不调用第二条通知 |
这些部分完成的结果可能暴露设计缺陷。先在提炼中保持它们,再单独调整事务与恢复契约。Java int 计算也保留原有的溢出回绕行为;悄悄改成 Math.addExact 会新增异常路径。
常用重构手法的前置判断
提炼函数
适合一段代码有独立意图时。名称应描述“为什么做”,参数和返回值揭示数据依赖。如果提炼后需要十几个参数,先检查职责和数据归属。
引入参数对象
参数总是一起出现且构成一个概念时,用类型固定它们的关系。下面的 Java 21 声明需已有 TournamentId、PlayerId 值类型,并导入 Instant:
import java.time.Instant;
public record RegistrationCommand(
TournamentId tournamentId,
PlayerId playerId,
Instant requestedAt) {}不要创建一个无语义的 Params 袋子,把所有值塞进去只为缩短签名。
移动函数
如果函数频繁读取另一个对象的数据,可能属于那个对象或其领域服务。移动后仍要检查依赖方向:为了“数据离得近”把数据库、HTTP 和领域规则全塞进实体,同样会恶化设计。
以多态替代条件
只有当分支代表稳定、可扩展的行为族时才值得引入策略对象。一个短小、封闭且清楚的 switch 往往比五个只含一行代码的类更易维护。
安全循环
每一步都保持可构建、可运行:
- 明确不变行为与当前验证手段;
- 让测试先处于绿色;
- 做一个有名字的结构变换;
- 运行相关测试和静态检查;
- 查看 diff,确认没有混入行为变化;
- 保存一个可独立回退的变更;
- 重复下一小步。
IDE 自动重命名、移动和内联工具能理解语法与引用关系,通常比全文替换安全;它们仍不能替你判断反射、配置、序列化名称或外部 API 是否受影响。
大规模替换:通过抽象渐进迁移
替换数据库客户端、消息库或外部供应商时,不必长期维护一个巨大分支:
调用者 -> 自有端口 -> 旧实现
-> 新实现先让调用者经过自有抽象,再增加新实现,通过配置或流量切换逐步验证,最后删除旧实现和临时兼容层。这种 Branch by Abstraction 让系统在迁移期间仍可集成和发布。
双写或影子读取会引入额外一致性与成本风险,必须定义比对、隐私、故障和回滚策略,不能只加一个开关。
何时停止
当代码已经足以安全完成当前变化,继续抽象可能只是在猜未来。可以停止的信号包括:
- 目标变化已局部化;
- 名称和边界能解释意图;
- 测试能快速定位回归;
- 新抽象没有明确的第二个用例;
- 下一步会扩大范围,却不降低已知风险。
下一节用指标、静态分析和代码评审辅助判断,但不会把任何单一数字当成代码质量本身。