元数据卡
- 前置知识:第4章(测试);有 2000 行以上的项目经验
- 预计时间:45 分钟
- 核心难度:进阶
- 完成标志:能识别代码坏味道,能用安全的重构手法改进代码
你的进度
你在工匠之都的工坊里翻出三个月前自己锻造的一个模块。说明书上的字迹模糊——“这里为什么要用三个齿轮嵌套?这个连杆变量为什么叫 tmp2?”
你翻开工坊的登记簿,上面写着:“三个月前,学徒小杨亲手打造。”是你自己干的。
铁匠师傅路过:“代码能跑和代码能改,是两回事。不重构的话,三个月后你自己都看不懂。” 你的任务
你不是看不懂代码,是代码本身有问题——坏味道。你已经写了足够多的代码,进入了第二个阶段:从"能不能跑"到"是否好读"。代码首先是写给人读的,其次才是写给机器执行的。这章要你掌握一套系统化的手法:识别代码里的坏味道、用安全的步骤重构它、用量化指标衡量质量。
本章分层
- 必读:代码坏味道识别、核心重构手法、圈复杂度概念
- 选读:SonarQube/PMD 静态分析集成
- 进阶:大规模重构的策略(分支与骨干模式)
本章不会要求你掌握
- 重构所有可能的模式
破局 · 溯源
你接手了师兄留下的一个模块——积分计算引擎。300 行代码,一个函数,没有测试,变量名是 a、b、c、tmp1、tmp2。一个 if 嵌套了四层,最后两个 else 分支在做什么,你不确定。你想改它,但不敢——万一改错了积分系统就乱了。
这不是你的错。但你现在有能力让代码恢复秩序。在工匠之都的锻造铺里,有一个专门的工序叫"退火重锻"——不改变武器的外形(不改行为),只改进内在组织结构(改代码结构)。这叫重构。
第一层:代码坏味道——闻一闻你的代码
好的工匠靠嗅觉判断锻造火候到了没有。你靠这些信号判断代码需要重构。以下是最常见的坏味道:
1. 过长函数
一个函数超过 20 行就是一个危险信号。超过 50 行,几乎肯定做了不止一件事。
// 坏味道:过长函数
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 个以上的参数时,调用方已经记不住顺序了。
// 坏味道
public void createAdventurer(String name, int level, String weapon,
String armor, int hp, int mp, int exp)修复:引入参数对象。
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。
修复:把魔数提取为常量,统一引用。
public static final int WIN_SCORE_BONUS = 10;4. 依恋情结
一个方法更多地使用了其他类的数据而不是自己的:
// 在 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 出现在多个地方:
// 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 表示本应该是自定义类型的值:
// 坏味道:用 String 表示状态
String status = "IN_PROGRESS"; // 拼错了也不报错修复:用枚举或 ADT。
enum MatchStatus { READY, IN_PROGRESS, COMPLETED, CANCELLED }第二层:核心重构手法——安全的操作
重构的核心法则:不改行为。 每一步重构操作后,运行测试套件——测试全过,说明你没改坏。
以下是最常用的几种手法。
手法一:提炼函数(Extract Method)
把一段逻辑独立成有名字的函数。这是最常用的重构——没有之一。
// 重构前
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("==================");
}提炼的核心原则:如果一个函数需要注释来解释它在做什么,把这个描述变成函数名。
手法二:引入常量
数字魔法值直接出现在代码里:
// 重构前
if (score < 100) {
return "青铜";
} else if (score < 500) {
return "白银";
}
// 重构后
private static final int BRONZE_THRESHOLD = 100;
private static final int SILVER_THRESHOLD = 500;手法三:移动方法
一个方法用另一个类的字段比自己类的多——把它搬过去。
// 重构前(在 MatchService 中)
public int calculateScore(Match match, int baseScore) {
return baseScore + match.getLevel() * 10;
}
// 重构后(在 Match 中)
public int calculateScore(int baseScore) {
return baseScore + this.level * 10;
}手法四:引入参数对象
很多参数总是一起出现:
// 重构前
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 的可读性杀手:
// 重构前
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;
}每个重构手法的操作流程都一样:
- 确保这段代码有测试覆盖
- 应用一个小变换(不改行为)
- 运行测试,确认全过
- 提交/暂存
第三层:圈复杂度——量化代码的混乱程度
除了靠嗅觉,你可以用量化的指标来衡量代码质量。
圈复杂度(Cyclomatic Complexity) 衡量一个函数的"有多少条独立路径"。计算规则很简单:
圈复杂度 = 1 + (if/while/for/case 的条件数)你的积分引擎函数:
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+ 表示非重构不可。
# 用 PMD 检查圈复杂度
pmd check -f text -R category/java/design.xml/CyclomaticComplexity \
-d src/main/java/第四层:代码评审——改完有人看
你把代码重构得漂漂亮亮,发了一个 Pull Request。同事看完给了三个评论:
- "这个变量名没有体现业务含义"
- "这段逻辑放在这里,其他模块调用还得绕一圈"
- "这行没用的 import 删掉"
你的第一反应是"这些又不影响运行"。但代码评审的意义不在于抓运行时 bug——那交给测试和静态检查。代码评审的意义在于知识传播和设计改进。
好的评审习惯:
- 评审者:先读整体设计(文件名、文件结构),再读变更。先问"为什么这样做"而不是"为什么不这样做"。
- 提交者:PR 描述写清楚背景,标注哪些是纯重构、哪些是改逻辑。把大的 PR 拆成小的(200 行以内)。
# 好的 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。