魔鬼的 Clean Code:从迁移一个20年历史的遗留项目中得到的教训
I’m happy to translate the article for you, but I need the full text of the article (the content you’d like translated) in order to do so. Could you please paste the article’s body here? I’ll keep the source line exactly as you provided it and translate the rest into Simplified Chinese while preserving all formatting, markdown, and technical terms.
TL;DR
- 编写测试。 在尝试为代码编写单元测试之前,你很难真正意识到代码有多混乱。
- 了解你的注解。 当只需要
@Getter和@Setter时,不要使用@Data。 - 不要害怕重构。 如果有机会重写并简化代码,就去做。
过去的架构幽灵
在 2025 年,我接受了一个艰巨的挑战:将一个已有 20 年历史的 JSP 项目迁移到现代微服务架构。
为了理清业务逻辑,我不得不深入古老的 JSP 文件,试图破解二十年前的开发者想要实现的目标。客户最初的需求听起来很简单,却成了噩梦:
“把模型复制到新项目中,并把 Bean 转换为 Service。”
现实却是一团糟。我发现数百个模型类分散在 dozens(数十)个领域包中。每个包里至少有一个 utility 类,里面堆满了用于操作 DAO 和 DTO 的静态方法。继承树像套娃一样——类继承类,再继承其他类,直到最初的目的荡然无存。
在快速推进的压力下,我们犯了一个经典错误:把旧结构直接复制粘贴到新服务中。我们以为只要把有状态的变量改为无状态的方法参数就足够了。结果我们错了。
对抗 “Bugfest”
一旦代码迁移到它的新“现代”环境后,我们用 SonarQube 进行扫描。结果是一场灾难。
报告显示了一场史诗级的 “bugfest”:SQL 注入漏洞、非标准命名约定以及大量重复代码块。我的日常工作变成了循环的挫败感:咒骂原始开发者、质疑自己的职业选择,最终卷起袖子去清理这堆烂摊子。
在清理阶段结束时,我已经:
- 删除了近 2,000 个未使用的类。
- 消除了 30 % 的重复代码。
- 修复了数百个 bug 并抑制了遗留警告。
这个 “微服务” 终于开始像个微服务一样,规模降至 900 个类和 55 000 行代码。现在是迎战最终 BOSS 的时刻:80 % 的测试覆盖率。
为“恶魔的干净代码”编写测试
为你没有编写的遗留代码编写测试是一种独特的挑战。我在生成测试脚手架时大量依赖 AI 助手,这显著加快了进度。然而,当我审视所测试的逻辑时,意识到自己在阅读《恶魔版》Clean Code。
我遇到了:
- 缩进地狱: 方法中出现 8–10 层嵌套的
if和for循环。 - 条件疲劳:
if语句中包含 10–15 种不同的逻辑门。 - 死木: 大量不可达代码和未使用的变量。
测试迫使我直面这些问题。如果某个分支在测试中根本不可能到达,它就不应该出现在代码中。
Source: …
我学到的(血的教训)
1. 测试是诊断工具
测试是了解他人代码的最佳方式。它们能精准指出“意大利面条”所在的位置。如果一个方法太难测试,这就意味着代码需要重写,而不是仅仅打补丁。
// BEFORE: The "Pyramid of Doom" (deeply nested logic)
public void processLegacyRuo(RuoDir ruoDir) {
if (ruoDir != null) {
if ("ACTIVE".equals(ruoDir.getTPRuo())) {
if (ruoDir.getRuoArr() != null) {
for (RuoItem item : ruoDir.getRuoArr()) {
if (item.getGG() > 0) {
// Business logic buried 5 levels deep
performOperation(item);
}
}
}
}
}
}
// AFTER: Flattened logic using guard clauses and streams
public void processRuo(RuoDir ruoDir) {
// 1. Exit early if the object is invalid or not in the right state
if (ruoDir == null || !"ACTIVE".equals(ruoDir.getTPRuo())) {
return;
}
// 2. Handle null collections gracefully
if (ruoDir.getRuoArr() == null) {
return;
}
// 3. Use functional programming to handle the collection
ruoDir.getRuoArr().stream()
.filter(item -> item.getGG() > 0)
.forEach(this::performOperation);
}
2. Lombok 陷阱
我最大的 “啊哈” 时刻之一与 Lombok 有关。我们最初在所有模型上使用 @Data 来省事,结果发现分支覆盖率大幅下降。
为什么? 因为 @Data 会自动生成 @EqualsAndHashCode 和 @ToString。这些方法会产生大量隐藏的逻辑分支,需要额外的测试才能达到高覆盖率。改为只使用具体的 @Getter 和 @Setter 注解后,去掉了不必要的复杂度,覆盖目标也变得可实现。
// BEFORE: @Data generates equals, hashCode, and toString, adding hidden branches
@Data
public class UserDto {
private Long id;
private String name;
private String email;
}
// AFTER: Explicit annotations only for what we actually use
@Getter
@Setter
@NoArgsConstructor
public class UserDto {
private Long id;
private String name;
private String email;
}
3. 不要害怕重写
老代码可能让人望而却步,但不应因此回避修改它。如果你理解了代码的意图,就可以重写以提升可读性。未来的自己(以及团队)会感激你的决定。
// BEFORE: A maintenance nightmare with 20+ conditions
if ((input.getDtVersAA() == null || input.getDtVersAA().trim().isEmpty()) &&
(input.getDtVersMM() == null || input.getDtVersMM().trim().isEmpty()) &&
// ... imagine 15 more lines of this ...
(input.getCodFisc() == null || input.getCodFisc().trim().isEmpty())) {
// Do something if everything is empty
}
// AFTER: Using method references and streams for clarity
List<Supplier<String>> fieldsToCheck = Arrays.asList(
input::getDtVersAA,
input::getDtVersMM,
// ... other getters ...
input::getCodFisc
);
boolean allEmpty = fieldsToCheck.stream()
.map(Supplier::get)
.allMatch(value -> value == null || value.trim().isEmpty());
if (allEmpty) {
// Do something if everything is empty
}
要点
- 尽早编写测试 – 它们可以揭示隐藏的复杂性。
- 明智地使用 Lombok – 仅使用你需要的注解。
- 积极重构 – 干净的代码库更易于测试和维护。
通过将测试视为诊断工具,裁剪不必要的 Lombok 生成代码,并且不畏惧重写,即使是最错综复杂的遗留系统也能转变为可维护、经过充分测试的微服务。
重构代码示例
// Collect all the getters we want to check
List<Supplier<String>> fieldsToCheck = List.of(
input::getDtVersMM,
// ... add all 15 more lines ...
input::getCodFisc
);
// We use a helper method (checkCampoVuoto) and allMatch to verify the state
boolean allFieldsEmpty = fieldsToCheck.stream()
.allMatch(fieldGetter -> checkCampoVuoto(fieldGetter.get()));
if (allFieldsEmpty) {
// Logic is now readable and easy to extend
}
最终思考
迁移遗留系统不仅仅是把代码从一个地方搬到另一个地方;更是把旧的理念转化为现代标准的过程。这是一个混乱、令人沮丧,但最终令人满意的过程。
你曾经迁移过的最糟糕的遗留代码是什么?让我们在评论区交换恐怖故事吧!