跳到内容

第 6 卷 / 第 5 章 / 第 1 课

5.1 可验证的小步重构 ​

重构是在保持外部可观察行为不变的前提下改善代码内部结构。每一个小变化都需要证据说明行为仍然一致。“这次只是整理代码”可以表达意图,不能代替测试结果。

先定义哪些行为不能变 ​

外部行为不只包括返回值,还可能包括:

  • 抛出的异常类型和时机;
  • 持久化结果与事务边界;
  • 发送的事件、顺序和次数;
  • 日志、指标或审计记录;
  • API 状态码、字段和兼容行为;
  • 性能与资源占用中已写入契约的部分。

若需求本身也要变化,先把结构变化和行为变化分开,让每一步的意图和失败原因可辨认。

没有测试时先建立观察点 ​

遗留代码不一定适合立即补细粒度单元测试。可以从系统当前可观察边界建立 characterization test(特征测试):

下面是 JUnit 5 测试方法片段,legacyCalculator、MatchScore、Outcome 和 ScoreResult 来自遗留项目夹具。+16/-16 表示假设观察到的同分选手胜负结果,不是通用积分公式,也不是平局。运行前须接入真实夹具。

java
@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 退出与分配失败不在练习范围内。

java
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);
}

第一步可以只提炼纯计算,不改变事务、调用顺序或失败行为:

java
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。读取、写入、状态变更与通知保持原顺序:

java
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:

java
import java.time.Instant;

public record RegistrationCommand(
        TournamentId tournamentId,
        PlayerId playerId,
        Instant requestedAt) {}

不要创建一个无语义的 Params 袋子,把所有值塞进去只为缩短签名。

移动函数 ​

如果函数频繁读取另一个对象的数据,可能属于那个对象或其领域服务。移动后仍要检查依赖方向:为了“数据离得近”把数据库、HTTP 和领域规则全塞进实体,同样会恶化设计。

以多态替代条件 ​

只有当分支代表稳定、可扩展的行为族时才值得引入策略对象。一个短小、封闭且清楚的 switch 往往比五个只含一行代码的类更易维护。

安全循环 ​

每一步都保持可构建、可运行:

  1. 明确不变行为与当前验证手段;
  2. 让测试先处于绿色;
  3. 做一个有名字的结构变换;
  4. 运行相关测试和静态检查;
  5. 查看 diff,确认没有混入行为变化;
  6. 保存一个可独立回退的变更;
  7. 重复下一小步。

IDE 自动重命名、移动和内联工具能理解语法与引用关系,通常比全文替换安全;它们仍不能替你判断反射、配置、序列化名称或外部 API 是否受影响。

大规模替换:通过抽象渐进迁移 ​

替换数据库客户端、消息库或外部供应商时,不必长期维护一个巨大分支:

text
调用者 -> 自有端口 -> 旧实现
                    -> 新实现

先让调用者经过自有抽象,再增加新实现,通过配置或流量切换逐步验证,最后删除旧实现和临时兼容层。这种 Branch by Abstraction 让系统在迁移期间仍可集成和发布。

双写或影子读取会引入额外一致性与成本风险,必须定义比对、隐私、故障和回滚策略,不能只加一个开关。

何时停止 ​

当代码已经足以安全完成当前变化,继续抽象可能只是在猜未来。可以停止的信号包括:

  • 目标变化已局部化;
  • 名称和边界能解释意图;
  • 测试能快速定位回归;
  • 新抽象没有明确的第二个用例;
  • 下一步会扩大范围,却不降低已知风险。

下一节用指标、静态分析和代码评审辅助判断,但不会把任何单一数字当成代码质量本身。

参考资料 ​

Built with VitePress | Software Systems Atlas