248|代码质量与重构:SOLID、代码异味、静态检查、评审和渐进式重构

工程效能、测试与DDD约 12 分钟阅读6,441 字符
知识库文档 249 / 251

阅读时间:约 45 分钟 难度:中高级 前置知识:面向对象编程基础、至少一个主流语言的中型项目经验


1. 引言:代码质量的"隐形税"

文章配图
文章配图

两个团队,同样技术栈、同样基础设施、同样运行三年——

团队 A:每个"简单需求"需要 3-5 天。开发说"老王的模块没人敢动"。新人三个月才能独立干活。

团队 B:需求基本不超预估时间。新人看一遍单元测试就知道怎么扩展。出问题 10 分钟内定位。

差异在哪?代码质量的本质是可维护性——改代码的成本。

有三个趋势让代码质量变得前所未有的重要:

  1. 系统生命周期越来越长(5-10 年很常见),但最初写代码的人可能第二年就走了
  2. 微服务让"局部腐烂"的扩散速度更快
  3. AI 编码工具让代码产量暴增,但质量方差也在变大

技术债务 ≠ 坏代码

文章配图
文章配图

Ward Cunningham 在 1992 年创造"技术债务"一词时,本意是中性概念:有意识地先交付再优化。但现实中大多数技术债务是"不小心欠的"。Martin Fowler 的技术债务象限解释了区别:

鲁莽的(reckless) 审慎的(prudent)
故意的(deliberate) "没时间做设计" "先发布再优化"
无意的(inadvertent) "不知道什么是分层架构" "现在才理解该怎么设计"

审慎的债务用重构来还;鲁莽的债务需要先止血再重构。


2. SOLID 原则

文章配图
文章配图

SOLID 由 Robert C. Martin 整理推广。单独每条都不难理解,真正价值在于组合使用——五条原则互相支撑。

2.1 SRP:单一职责原则

一个类应该只有一个引起它变化的原因。

正确理解:一个类只对一个角色(Actor)负责——变化来源是"人"或"角色",不是"功能点"。

java
// ❌ 违反 SRP:对 CFO、COO、CTO 三个角色负责
class Employee {
    public double calculatePay() { ... }   // CFO 关注
    public void reportHours() { ... }      // COO 关注
    public void save() { ... }             // CTO 关注
}

CFO 改薪资规则时可能影响 COO 的工时报表逻辑。重构方式:将数据与行为分离,各角色对应独立的类。

粒度把握:内部工具脚本不需要严格 SRP;核心支付模块必须遵守。SRP 的粒度取决于变化频率。

2.2 OCP:开闭原则

对扩展开放,对修改关闭。

实用主义解读:新增功能时,优先通过新增代码而非修改既有代码来实现。

核心实现:策略模式 + 依赖注入。将可变行为抽象为接口,新增变体只需加实现类。

java
// ✅ 新增通知渠道只需实现接口并注册
interface MessageChannel { void send(String msg); }
class NotificationService {
    private final List<MessageChannel> channels;
    public void send(String msg) { channels.forEach(c -> c.send(msg)); }
}

什么时候不强求:一年只改一次的模块,为之设计扩展点是过度设计。OCP 保护的是高频变化点。

2.3 LSP:里氏替换原则

子类型应该能完全替换基类型,不破坏程序正确性。

这是五个原则中最"硬核"的——涉及语义正确性。经典反例是"矩形-正方形"问题:

java
class Rectangle {
    public void setWidth(int w) { this.width = w; }
    public void setHeight(int h) { this.height = h; }
}
class Square extends Rectangle {
    public void setWidth(int w) { this.width = w; this.height = w; } // 破坏了父类契约
}
// resizeRectangle(r) 传入 Square 时断言失败

生产环境中的 LSP 违反更隐蔽:子类抛父类未声明的异常、子类方法返回 null(父类契约承诺非 null)、子类加强了前置条件。防范方法:父类的测试用例套用到子类上也必须通过。

2.4 ISP:接口隔离原则

客户端不应被迫依赖它不使用的方法。

直指"胖接口"问题。如果一个 UserService 接口包含 20 个方法,不同 Controller 各自只用 2-3 个,当一个方法的签名变化时,所有 Controller 的编译单元都需要重新编译。

正确做法:按客户端角色拆分——UserQuery、UserProfileManager、PasswordManager、RoleManager 接口各自独立。

ISP 与 SRP 的区别:SRP 关注类的职责,ISP 关注接口的粒度。它们互相印证。

2.5 DIP:依赖倒置原则

高层模块不应依赖低层模块,两者都应依赖抽象。

这是 Spring 依赖注入的直接实践。关键洞察:接口应由高层模块(使用者)定义,而非低层模块(实现者)定义。

反例:OrderRepository 接口定义在 infrastructure 包里,domain 包引用它——依赖方向仍是 domain → infrastructure。

正例:接口定义在 domain 包里,infrastructure 提供实现——依赖倒置为 infrastructure → domain。


3. 代码异味

文章配图
文章配图

"代码异味"由 Kent Beck 提出、Martin Fowler 在《重构》中系统化。异味不是 bug,但暗示更深层的设计问题。

3.1 四大类异味速查

膨胀类(Bloaters):长方法(超 20 行且需要注释分段)、大类(字段/方法过多)、长参数列表(超 4 个)、数据泥团(某几个字段总一起出现)、基本类型偏执(用 String 表示电话号码)。

耦合问题(Couplers):依恋情结(方法大量用另一个类的数据)、不恰当亲密(互相访问私有成员)、过度耦合消息链(a.getB().getC().getD().doSomething())。

内聚问题(Cohesion Breakers):发散式变化(同一类因不同原因被修改,即违反 SRP)、霰弹式修改(改一个功能要动十几个类)、重复代码。

OO 滥用(OO Abusers):Switch 语句(基于类型码的多路分支,应用多态替代)、临时字段、拒绝的遗赠(子类不用父类多数方法,违反 LSP)、夸夸其谈未来性。

3.2 三个高危害异味深度剖析

文章配图
文章配图

长方法——最致命的异味

不只是"看着不舒服"。实际危害:难以测试(分支组合无法穷举)、难以复用(只能复制粘贴)、难以理解(读代码的人需在脑中维护完整状态栈)。

判断标准:如果一个方法需要注释解释"这一段在做什么",就该提取。

SLAP 原则(Single Level of Abstraction Principle):一个方法内的语句应处于同一抽象层级。如果一段代码在"累加订单金额",另一段在"调用第三方支付回调",它们就不该在同一个方法里。

霰弹式修改——架构腐化信号

每次加一个字段都要改 10 个类,说明模块边界划错了。典型表现:DTO、VO、Entity、Converter、Service、Controller、MQ 消息体、Excel 导出器——全都要同步修改。根本原因是数据定义和业务逻辑没有围绕同一个聚合根组织。

重复代码——看似简单,实则最难根治

注意"巧合的重复"——两段代码长得一样但变化原因不同。比如两个 Service 都有 validateUser(),一个是注册校验一个是登录校验。合并后一方的规则变化会破坏另一方。

三法则(Rule of Three):第一次直接写,第二次容忍但标记,第三次必须重构。在两个实例间强行抽象就是为"未来的相似性"买单。

3.3 处理优先级

  1. 立即处理:导致生产事故的问题
  2. 本次迭代处理:阻碍当前需求实现的问题
  3. 列入 backlog:其他异味,按影响范围排序

4. 静态检查

文章配图
文章配图

让评审者去检查"有没有用 == 比较 String"是浪费高级工程师的时间。机械性规则应交给工具。

4.1 Java 生态工具分层

层级 工具 关注点
L1 代码风格 Checkstyle 缩进、命名、import
L2 字节码缺陷 SpotBugs 空指针、资源泄漏、并发 bug
L3 设计质量 SonarQube 复杂度、重复、覆盖率、安全漏洞
L4 AI 增强 Qodo / Sonar AI 语义级问题、跨文件上下文

4.2 关键质量指标

文章配图
文章配图

指标 含义 健康阈值
认知复杂度 代码"读起来的难度",考虑嵌套和逻辑中断 方法 ≤ 15
圈复杂度 独立执行路径数 方法 ≤ 10
重复行率 重复代码占比 < 3%
技术债务比率 修复时间 ÷ 开发时间 < 5%
代码覆盖率 测试覆盖行数比例 行覆盖 ≥ 80%

认知复杂度 vs 圈复杂度:两个圈复杂度都为 2 的方法实际理解难度可能差异巨大:

java
// 圈复杂度 = 2,瞬间理解
if (user.isActive()) { sendNotification(user); }

// 圈复杂度也是 2,但嵌套流式调用打断了线性阅读流
user.getOrders().stream()
    .filter(o -> o.getStatus() == PAID)
    .flatMap(o -> o.getItems().stream())
    .filter(i -> i.getCategory() == ELECTRONICS)
    .map(Item::getPrice)
    .reduce(BigDecimal.ZERO, BigDecimal::add);

SonarQube 的认知复杂度额外考虑了嵌套深度和逻辑运算符打断线性阅读流的因素,更贴近"人读这段代码要费多少脑子"。

4.3 CI 门禁策略

文章配图
文章配图

code
Git Push → SpotBugs → Checkstyle → SonarQube Quality Gate
                                        │
                                ┌───────▼────────┐
                                │ Bug/Vuln → 阻断 │
                                │ 新增异味 → 阻断  │
                                │ 覆盖率下降 → 阻断 │
                                └────────────────┘

对存量问题:启用 Leak Period 模式,不对历史代码报错,但新增/修改代码必须满足门禁。在"不阻塞业务"和"不让质量下滑"之间务实平衡。


5. 代码评审

文章配图
文章配图

工具告诉你"圈复杂度 18",但不能告诉你"这个设计在业务场景下是否合适"。代码评审填补这个空白。

5.1 评审三个层次

层次一:风格与规范(10% 注意力)——命名、格式。这个层次应交给格式化工具。

层次二:正确性与健壮性(50% 注意力)——边界条件(null、空集合、0 值)、异常路径、并发安全、隐含假设。

层次三:设计与可维护性(40% 注意力)——代码是否在正确的位置(领域层 vs 基础设施层)、是否引入不必要耦合、三个月后接手的人能否理解。

5.2 高效评审实践

法则 说明
400 行上限 Cisco 研究发现超 400 行/次后缺陷发现率急剧下降
评审清单 有测试吗?异常路径处理了吗?API 向后兼容吗?迁移可回滚吗?
评论格式 &amp;amp;#91;类型]:&amp;amp;#91;具体问题] → &amp;amp;#91;建议],不说"这里有问题",说"这里若为 null 会 NPE,建议加 Optional"
不当语法警察 工具可以自动格式化的问题不在评审中讨论

5.3 作者最佳实践

  • PR 小而聚焦:一个 PR 只做一件事,不混入重构+新功能+bug 修复
  • 写好 PR 描述:背景、做了什么、没做什么、怎么测试
  • 自审一遍:提交前以评审者眼光过一遍 diff
  • 分离重构提交:重构和新功能分在不同 commit,方便评审者跳过低风险变更

6. 渐进式重构

"三个月重写这个模块"在多数业务团队中不可行;"永远不还技术债"同样不可持续。答案在渐进式重构。

6.1 重构的严格定义

重构是对软件内部结构的修改,不改变外部可观察行为,目的是提高可理解性并降低修改成本。 —— Martin Fowler

如果你在"重构"时改了 API 返回格式,那不是重构,是重写。

6.2 安全重构的前提:测试

没有测试的重构叫赌博。对遗留模块的正确顺序:

  1. 写特征测试(Characterization Tests):测试"代码现在实际做了什么",锁定当前行为
  2. 确认特征测试全通过
  3. 每次一小步重构,每步跑测试
  4. 重构完成后补充规范测试(明确"应该做什么")

6.3 绞杀者模式(Strangler Fig Pattern)

文章配图
文章配图

替换整个模块时最安全的方案——新旧系统并行运行:

code
请求 → [路由层] → 旧模块 + 新模块(并行)
                  路由规则:按接口/流量比例/租户灰度
                  → 逐步扩大新模块流量 → 旧模块下线

与"重写"的关键区别:重写是"关旧→开新"(无路可退);绞杀是"新老并行→渐进切换"(出问题随时切回)。

6.4 高频重构手法

每个开发者都应该熟练以下手法,它们是 Martin Fowler《重构》一书中最核心的工具:

场景 手法 一句话描述
方法太长 Extract Method 把一段逻辑提取成独立方法
参数太多 Introduce Parameter Object 把一组相关参数封装为值对象
类太大 Extract Class 把部分字段和方法移到新类
条件逻辑复杂 Replace Conditional with Polymorphism 用多态替代 if-else/switch
两个类耦合紧 Move Method / Move Field 把方法/字段移到真正归属的类
重复代码 Pull Up Method 将子类重复方法提升到父类
魔法数字 Replace Magic Number with Constant 用命名常量替换字面量
条件表达式复杂 Decompose Conditional if 条件提取为命名方法

关键警告:重命名类/方法/变量、提取方法、修改方法签名、移动类——这些操作必须用 IDE 自动化重构,不要手动做。IDE 通过完整调用图保证重构不会引入编译错误,而手动操作可能漏掉调用点。


7. 总结

  1. 代码质量是经济问题,不是审美问题。坏代码的花费不是"写它的时候",而是"每次读它/改它的时候"。
  2. SOLID 是思考框架,不是教条。一年改两次的代码,保持简单。
  3. 代码异味是早期预警。不要等"没人敢动"才开始行动。
  4. 工具做机械检查,人做判断。不用评审去讨论格式化。
  5. 重构是持续的、小步的。"三个月重写"几乎总比"每天重构 20 分钟"更危险。

最小可行实践路线

阶段 行动 预期效果
第 1 周 Checkstyle + SpotBugs 入 CI,仅查新增代码 阻止质量下滑
第 2-4 周 PR 评审机制,400 行上限,建立评审清单 知识分享 + 缺陷发现
第 2 个月 SonarQube Quality Gate,达成质量基线共识 可量化门禁
第 3 个月 标记 Top 3 技术债务,渐进式重构计划 主动治理

8. 面试官视角:高频追问

以下问题来自真实的大厂技术专家/架构师面试场景。

Q1:你遵循 SOLID,什么时候会刻意违反其中一条?举一个生产例子。

参考思路

  • ISP:系统只有 3-5 个接口时,严格隔离可能是不必要的复杂度
  • OCP:稳定、很少变化的模块中,直接修改比抽象的扩展点更清晰
  • SRP:简单 CRUD 应用中严格按 Actor 拆分会导致类爆炸

关键是说明你意识到了违反并权衡了利弊——这比盲目遵守更体现判断力。

Q2:接手 10 万行遗留系统,无测试。需两周内上线新功能且要改核心模块。怎么做?

参考思路

  1. 给核心路径写特征测试,锁定当前行为
  2. 用 IDE 自动化重构做零风险局部整理
  3. 实现新功能,用特征测试验证未破坏旧行为
  4. 记录技术债务,下个迭代排重构时间

绝不"顺手重构"不相关的代码——无测试保护下,任何非必要修改都是风险。

Q3:SonarQube 报告技术债务比率 8%,需要立刻处理吗?

参考思路

8% 跨过告警线,但还需知道:

  • 这 8% 在哪些模块?核心交易 vs 练手模块风险等级完全不同
  • 趋势?8% 稳定 vs 每月增长 1% 是两个问题
  • 交付压力?下月大促时不宜大面积重构

结论:不必立刻处理,但需冻结新增债务 + 审计高风险模块。

Q4:团队代码评审流于形式(全 LGTM),作为 TL 怎么改变?

参考思路

  1. 以身作则——TL 的评审评论是团队质量标准
  2. 机械检查交工具,评审聚焦设计和正确性
  3. 设定具体预期:"每个 PR 至少 3 个实质性评论"
  4. 正向激励:周会展示"最佳评审评论"
  5. 强制 400 行上限——PR 过大评审质量自然下降

Q5:有人提议"三个月重写订单模块",你支持吗?用什么标准判断?

参考思路

大规模重写是软件工程失败率最高的活动之一(参考 Netscape 教训)。

支持信号:技术栈 EOL、修改成本 > 重写成本、无法招聘到维护该栈的人。

反对信号:只因"代码风格不好"、重写期间需求快速变化、无测试覆盖现有行为。

更好的方案:渐进式绞杀——按功能边界逐块替换,新旧并行直到旧系统流量归零。

核心观点:代码可以烂,但它包含了无数你未发现的边界条件和业务规则——重写会丢失这些隐含知识。

Q6:DRY 被滥用时会导致什么问题?你怎么判断"是否应该复用"?

参考思路

DRY 滥用导致"错误的抽象"——把变化原因不同的两段代码强行合并。判断标准:

  • 变化原因相同 → 应该复用
  • 变化原因不同(即代码看起来一样但服务于不同业务规则) → 不应复用
  • 不确定 → 先不复用,等第三次出现再判断(Rule of Three)