Skip to content

元数据卡

  • 前置知识:第4章(测试);有 2000 行以上的项目经验
  • 预计时间:45 分钟
  • 核心难度:进阶
  • 完成标志:能识别代码坏味道,能用安全的重构手法改进代码

你的进度

你在工匠之都的工坊里翻出三个月前自己锻造的一个模块。说明书上的字迹模糊——“这里为什么要用三个齿轮嵌套?这个连杆变量为什么叫 tmp2?”

你翻开工坊的登记簿,上面写着:“三个月前,学徒小杨亲手打造。”是你自己干的。

铁匠师傅路过:“代码能跑和代码能改,是两回事。不重构的话,三个月后你自己都看不懂。” 你的任务

你不是看不懂代码,是代码本身有问题——坏味道。你已经写了足够多的代码,进入了第二个阶段:从"能不能跑"到"是否好读"。代码首先是写给读的,其次才是写给机器执行的。这章要你掌握一套系统化的手法:识别代码里的坏味道、用安全的步骤重构它、用量化指标衡量质量。

本章分层

  • 必读:代码坏味道识别、核心重构手法、圈复杂度概念
  • 选读:SonarQube/PMD 静态分析集成
  • 进阶:大规模重构的策略(分支与骨干模式)

本章不会要求你掌握

  • 重构所有可能的模式

破局 · 溯源

你接手了师兄留下的一个模块——积分计算引擎。300 行代码,一个函数,没有测试,变量名是 abctmp1tmp2。一个 if 嵌套了四层,最后两个 else 分支在做什么,你不确定。你想改它,但不敢——万一改错了积分系统就乱了。

这不是你的错。但你现在有能力让代码恢复秩序。在工匠之都的锻造铺里,有一个专门的工序叫"退火重锻"——不改变武器的外形(不改行为),只改进内在组织结构(改代码结构)。这叫重构。

第一层:代码坏味道——闻一闻你的代码

好的工匠靠嗅觉判断锻造火候到了没有。你靠这些信号判断代码需要重构。以下是最常见的坏味道:

1. 过长函数

一个函数超过 20 行就是一个危险信号。超过 50 行,几乎肯定做了不止一件事。

java
// 坏味道:过长函数
public void processMatchResult(Match match, String winner) {
    // 验证
    if (match == null) throw new IllegalArgumentException("...");
    if (winner == null || winner.isBlank()) throw new IllegalArgumentException("...");
    if (!match.getPlayers().contains(winner)) throw new IllegalArgumentException("...");
    if (match.getStatus() != MatchStatus.IN_PROGRESS) throw new IllegalArgumentException("...");

    // 计算积分变更
    String loser = match.getPlayers().stream()
        .filter(p -> !p.equals(winner))
        .findFirst().orElseThrow();
    int winnerScore = scoreRepo.getScore(winner);
    int loserScore = scoreRepo.getScore(loser);
    int winnerDelta = calculateDelta(winnerScore, loserScore, true);
    int loserDelta = calculateDelta(winnerScore, loserScore, false);

    // 更新积分
    scoreRepo.updateScore(winner, winnerScore + winnerDelta);
    scoreRepo.updateScore(loser, loserScore + loserDelta);

    // 更新对战记录
    match.setWinner(winner);
    match.setStatus(MatchStatus.COMPLETED);
    matchRepo.save(match);

    // 发送通知
    notificationService.send(winner, "你赢了!积分 +" + winnerDelta);
    notificationService.send(loser, "你输了。积分 " + loserDelta);
}

修复:提取函数,把每段独立逻辑拆出来。

2. 过长的参数列表

一个函数带着 5 个以上的参数时,调用方已经记不住顺序了。

java
// 坏味道
public void createAdventurer(String name, int level, String weapon,
                              String armor, int hp, int mp, int exp)

修复:引入参数对象。

java
public class AdventurerProfile {
    private final String name;
    private final int level;
    private final String weapon;
    // ...
}

public void createAdventurer(AdventurerProfile profile)

3. 霰弹式修改

改一个需求要跑遍 5 个文件,每个文件只改一行。比如你想改"赢家积分加多少",发现分数计算在 MatchService、排行榜在 ScoreService、通知在 NotificationService——每个地方都有一个硬编码的数字 10

修复:把魔数提取为常量,统一引用。

java
public static final int WIN_SCORE_BONUS = 10;

4. 依恋情结

一个方法更多地使用了其他类的数据而不是自己的:

java
// 在 MatchService 里
public int calculateReward(Match match) {
    int base = match.getLevel() * 10;
    int bonus = match.getPlayers().size() > 2 ? 20 : 0;
    // match 的数据用得远比自己的字段多
    return base + bonus;
}

修复:把这个方法搬到 Match 类里去。

5. Switch 语句(容易散布)

同一个 switch 出现在多个地方:

java
// MatchService.java
switch (match.getStatus()) {
    case READY: ...
    case IN_PROGRESS: ...
}

// ScoreService.java
switch (match.getStatus()) {
    case READY: ...
    case IN_PROGRESS: ...
}

修复:用多态替代 switch(前移到第7章)。

6. 基本类型偏执

用 String 和 int 表示本应该是自定义类型的值:

java
// 坏味道:用 String 表示状态
String status = "IN_PROGRESS";  // 拼错了也不报错

修复:用枚举或 ADT。

java
enum MatchStatus { READY, IN_PROGRESS, COMPLETED, CANCELLED }

第二层:核心重构手法——安全的操作

重构的核心法则:不改行为。 每一步重构操作后,运行测试套件——测试全过,说明你没改坏。

以下是最常用的几种手法。

手法一:提炼函数(Extract Method)

把一段逻辑独立成有名字的函数。这是最常用的重构——没有之一。

java
// 重构前
public void printScoreBoard(List<Adventurer> players) {
    System.out.println("===== 积分榜 =====");
    for (Adventurer p : players) {
        System.out.println(p.getName() + ": " + p.getScore());
    }
    System.out.println("==================");
}

// 重构后
public void printScoreBoard(List<Adventurer> players) {
    printHeader();
    printScores(players);
    printFooter();
}

private void printHeader() {
    System.out.println("===== 积分榜 =====");
}

private void printScores(List<Adventurer> players) {
    for (Adventurer p : players) {
        System.out.println(p.getName() + ": " + p.getScore());
    }
}

private void printFooter() {
    System.out.println("==================");
}

提炼的核心原则:如果一个函数需要注释来解释它在做什么,把这个描述变成函数名

手法二:引入常量

数字魔法值直接出现在代码里:

java
// 重构前
if (score < 100) {
    return "青铜";
} else if (score < 500) {
    return "白银";
}

// 重构后
private static final int BRONZE_THRESHOLD = 100;
private static final int SILVER_THRESHOLD = 500;

手法三:移动方法

一个方法用另一个类的字段比自己类的多——把它搬过去。

java
// 重构前(在 MatchService 中)
public int calculateScore(Match match, int baseScore) {
    return baseScore + match.getLevel() * 10;
}

// 重构后(在 Match 中)
public int calculateScore(int baseScore) {
    return baseScore + this.level * 10;
}

手法四:引入参数对象

很多参数总是一起出现:

java
// 重构前
public Tournament createTournament(String name, int maxPlayers,
                                    int minLevel, Date startTime, Date endTime)

// 重构后
public record TournamentConfig(String name, int maxPlayers,
                                int minLevel, DateRange period) {}

public Tournament createTournament(TournamentConfig config)

手法五:分解条件表达式

大型 if-else 的可读性杀手:

java
// 重构前
if (player.getLevel() >= tournament.getMinLevel()
    && !tournament.isFull()
    && tournament.getStatus() == TournamentStatus.OPEN) {
    // 报名
}

// 重构后
if (playerMeetsRequirements(player, tournament)) {
    registerPlayer(player, tournament);
}

private boolean playerMeetsRequirements(Adventurer p, Tournament t) {
    return p.getLevel() >= t.getMinLevel()
        && !t.isFull()
        && t.getStatus() == TournamentStatus.OPEN;
}

每个重构手法的操作流程都一样:

  1. 确保这段代码有测试覆盖
  2. 应用一个小变换(不改行为)
  3. 运行测试,确认全过
  4. 提交/暂存

第三层:圈复杂度——量化代码的混乱程度

除了靠嗅觉,你可以用量化的指标来衡量代码质量。

圈复杂度(Cyclomatic Complexity) 衡量一个函数的"有多少条独立路径"。计算规则很简单:

圈复杂度 = 1 + (if/while/for/case 的条件数)

你的积分引擎函数:

java
public int calculateRank(int score, String tier) {
    int rank = 0;
    if (tier.equals("bronze")) {           // +1
        rank = score * 2;
    } else if (tier.equals("silver")) {    // +1
        rank = score * 3;
    } else {                               // +1(默认分支)
        rank = score;
    }
    if (rank > 1000) {                     // +1
        rank = 1000;
    }
    return rank;
}

圈复杂度 = 1 + 4 = 5。

业界经验:圈复杂度在 1-10 之间表示函数可维护;11-20 表示复杂,需要关注;21+ 表示非重构不可。

bash
# 用 PMD 检查圈复杂度
pmd check -f text -R category/java/design.xml/CyclomaticComplexity \
  -d src/main/java/

第四层:代码评审——改完有人看

你把代码重构得漂漂亮亮,发了一个 Pull Request。同事看完给了三个评论:

  1. "这个变量名没有体现业务含义"
  2. "这段逻辑放在这里,其他模块调用还得绕一圈"
  3. "这行没用的 import 删掉"

你的第一反应是"这些又不影响运行"。但代码评审的意义不在于抓运行时 bug——那交给测试和静态检查。代码评审的意义在于知识传播和设计改进

好的评审习惯:

  • 评审者:先读整体设计(文件名、文件结构),再读变更。先问"为什么这样做"而不是"为什么不这样做"。
  • 提交者:PR 描述写清楚背景,标注哪些是纯重构、哪些是改逻辑。把大的 PR 拆成小的(200 行以内)。
text
# 好的 PR 描述
## 背景
积分规则的"获胜加分"当前硬编码为 10,需要改为可配置。

## 变更
1. ScoreConfig 类:加载 YAML 配置
2. MatchService:从 ScoreConfig 读取 WIN_BONUS
3. 新增单元测试覆盖三种配置场景

## 测试
./gradlew test -> 全部通过

评审者能从这个描述里一眼看出:这是安全重构(测试已覆盖)。


常见陷阱

陷阱一:重构和加功能放在同一个 commit。 你重构了一段代码,顺便加了一个参数。出了 bug——你不知道是新功能搞出来的还是重构搞出来的。原则:一次只做一件事。重构单独 commit,加功能单独 commit。

陷阱二:没有测试就重构。 重构前没有测试覆盖的代码,你敢改吗?不敢。第一步永远是先加测试——即使只是描述当前行为(characterization test),把"现在它做什么"写下来。

陷阱三:过度重构。 一个变量只在两个地方用到,但你把它抽成了一个常量类。一个函数 10 行但你分了 5 个函数。重构不是让代码越短越好——是让代码更好读。

陷阱四:追求 0 警告。 PMD 和 Checkstyle 可以配置几百条规则。但有些规则(比如"所有方法必须小于 15 行")有损可读性。团队要一起决定哪些规则打开、阈值设多少。


通关挑战

  • 热身:找一段你写过的、超过 50 行的代码,识别其中至少 3 个坏味道,写下来。
  • 挑战:选一个坏味道最严重的,用本章的"提炼函数"和"分解条件"手法重构它。确保重构前后测试都通过。
  • 观察:在你的项目上运行 PMD 或 SonarQube,查看圈复杂度报告。挑一个圈复杂度最高的函数,重构它。

旅人笔记

重构不是重写——是在有测试的安全网下,一小步一小步地改善代码结构。你认识坏味道、使用系统化的手法、用圈复杂度量化质量、通过代码评审捕获设计缺陷——这些合在一起,让你的代码在时间的长河里保持健康。


下一站预告

你的代码在本地已经完美了——测试全过、重构干净。但本地不是战场。代码需要部署到服务器、和别人的代码合并、自动化地验证。下一章——CI/CD 与 DevOps。

Built with VitePress | Software Systems Atlas