软件项目开发全流程 · 第 30 课 · 技术债与重构
1、"先这样,回头再改"——那个回头永远没来
为了赶上线,你把一段逻辑硬编码进去,心里想:"先能跑,回头再改。" 三个月后,这段代码扩散成了 5 个地方,没人敢改、没人记得为什么这么写,而"回头"始终没来。
这就是技术债(Technical Debt)——一个由 Ward Cunningham 提出的绝妙比喻:为了短期速度而做的妥协,会在未来收取"利息"。 本课讲:什么是技术债、怎么识别、怎么还,以及重构的正确姿势。
2、技术债:一个借贷的比喻
借债:现在拿到快(提前上线)
利息:以后每次改动都更慢、更容易出错
本金:最终需要花时间做的重构/补齐关键洞察:技术债不全是坏的。
| 类型 | 例子 | 评价 |
|---|---|---|
| 审慎的(Deliberate) | "我知道这里简化了,为了抢市场先发" | ✅ 有意识、有计划地借 |
| 无意的(Inadvertent) | "当时不懂,这么写其实有坑" | ⚠️ 学习成本,可接受 |
| 鲁莽的(Reckless) | "不懂也不问,乱写一通" | ❌ 危险 |
借债本身不是问题,"借了不还"和"不知道自己借了"才是问题。
有时故意借债是正确决策——比如为了验证 MVP 快速上线。关键是:记账、有计划地还。
3、技术债都有哪些"面"
技术债不只是"代码写得烂":
| 类型 | 表现 |
|---|---|
| 代码债 | 重复代码、超长函数、魔法数字、命名混乱 |
| 设计/架构债 | 循环依赖、耦合过重、抽象层级混乱 |
| 测试债 | 没有自动化测试、测试脆弱、覆盖率低 |
| 文档债 | 没 README、没接口文档、注释过时 |
| 依赖债 | 依赖版本老旧、用了停止维护的库 |
| 基础设施债 | 手工部署、环境不一致、无监控 |
| 知识债 | 只有一个人懂某模块("巴士因子 = 1") |
识别信号(出现这些,说明债在收利息了):
- 改一个小功能要动 10 个文件
- 没人敢碰某个模块
- 每次发布都心惊胆战
- 新人上手要三个月
- 同样的问题反复出现
- 测试跑得又慢又经常"意外红"4、"技术债台账":把债显性化
态度:不追求零技术债(不可能),而是让它可见、可控、可规划。
做法:建一个技术债台账(Debt Register / Backlog):
| ID | 位置 | 描述 | 影响 | 利息 | 估计修复 | 优先级 |
|----|------|------|------|------|----------|--------|
| D1 | order.py | 结算逻辑硬编码税率 | 新地区无法上线 | 高 | 2 天 | P1 |
| D2 | api/ | 无接口测试 | 改动易回归 | 中 | 5 天 | P1 |
| D3 | utils.py | 重复的日期处理 | 维护成本 | 低 | 0.5 天 | P3 |什么时候还债:
- 顺手还:碰到就改一点(童子军规则:离开时比来时更干净)。
- 专项还:每个迭代留固定比例(如 20%)用于还债。
- 不还:低利息、无人碰、即将退役的模块——主动决定"不还",比假装没债强。
5、重构:改变结构,不改变行为
重构(Refactoring) 的严格定义:
在不改变代码外部行为的前提下,改善其内部结构。
铁律:行为不变。 重构不是"顺便加个功能",也不是"顺便修个 bug"——那是另外的事。混在一起,测试就失去了判定基准,风险陡增。
重构的保障:自动化测试。没有测试的重构,等于闭着眼睛拆炸弹。所以:
安全重构的前提顺序:
① 先给要重构的代码补测试(哪怕先是"特征测试"characterization test)
② 小步重构,每步跑测试
③ 常用 IDE 的自动重构(改名、提函数、内联)常见重构手法(Martin Fowler《重构》的名录):
| 坏味道(Smell) | 重构手法 |
|---|---|
| 重复代码 | 提炼函数 / 上移方法 |
| 超长函数 | 提炼函数、以查询取代临时变量 |
| 超长参数列表 | 引入参数对象 |
| 嵌套过深 | 卫语句(提前 return)、提炼函数 |
| 魔法数字 | 用常量/枚举命名 |
| 发散式变化 | 拆分职责(单一职责) |
| 霰弹式修改 | 内聚相关代码 |
| 依恋情结 | 搬移方法 |
# 重构前:嵌套深、魔法数字、职责混杂
def calc(order):
total = 0
for i in order.items:
if i.type == 1: # 1 是什么?
if i.qty > 10:
total += i.price * i.qty * 0.9 # 0.9 是什么?
else:
total += i.price * i.qty
else:
total += i.price * i.qty
return total
# 重构后:有名有姓、职责清晰
VIP_TYPE = 1
VIP_BULK_THRESHOLD = 10
VIP_BULK_DISCOUNT = 0.9
def item_price(item):
base = item.price * item.qty
if item.type == VIP_TYPE and item.qty > VIP_BULK_THRESHOLD:
return base * VIP_BULK_DISCOUNT
return base
def calc(order):
return sum(item_price(i) for i in order.items)6、"重构" vs "重写":别动不动推倒
重写(Rewrite) 是从头换一套实现。它听起来很爽,实际极其危险:
重写吞噬项目(Second System Effect):
- 老系统所有隐藏的边界处理、兼容逻辑会"丢失"
- 新系统要重新踩所有旧坑
- 期间老系统还得维护(双倍成本)
- 需求还在变,目标不断移动
- 常见结局:重写永远不会"完成"更稳的路径(第 29 课讲过):绞杀者模式 + 渐进重构——旧系统不动,新功能新写法,一块块蚕食替换。
Joel Spolsky 的名言:把重写当成"打扫",是这个行业最容易犯的严重错误之一。"代码是资产,重写往往把它当负债扔掉。"
7、把还债纳入正常节奏
别把还债当"额外负担"。健康的做法:
- 定义完成的含金量(Definition of Done):包含"测试通过、无新增明显坏味道"。
- 每次迭代留容量(如 20%)给重构和债。
- 代码评审拦债:不让明显坏味道轻易合入。
- 自动化护栏:Lint、格式、覆盖率门禁,让"变烂"没那么容易。
- 技术债可见:进 backlog,和产品需求一起排优先级(而非永远垫底)。
8、常见误区
| 误区 | 问题 | 正解 |
|---|---|---|
| 技术债 = 全是坏事 | 因噎废食,不敢快速迭代 | 审慎借债可接受,关键在记账与还 |
| 重构时顺手改功能 | 测试基准混乱、风险高 | 行为不变;改功能另开一次 |
| 没测试就重构 | 等于赌博 | 先补测试(特征测试) |
| 一出问题就重写 | 丢知识、无限延期 | 渐进重构 + 绞杀者 |
| 债永远垫底 | 利息滚雪球 | 固定容量还债 + 进 backlog |
| 追求零技术债 | 不现实、浪费 | 可见、可控、按优先级还 |
9、动手实验 🎯
任务:为贯穿项目「团队任务清单」做一次技术债清理。
- 建台账:通读你的项目,列出 ≥8 条技术债,填入台账(含影响、利息、估计修复、优先级)。
- 补测试:选一个"没测试、不敢改"的函数,先写特征测试(哪怕它行为不完美,先固化现状)。
- 小步重构:用"提炼函数 + 命名常量 + 卫语句",改进一段嵌套深/魔法数字多的代码,每步跑测试。
- 验证行为不变:重构前后,用同一组输入对比输出,确认完全一致。
- 写一份"债务计划":未来 3 个迭代,每期还哪些债(用 20% 容量)。
- 识别一个"巴士因子=1"的模块,想想怎么降低知识债(结对、文档)。
- 反思:你项目里的债,哪些是"审慎借的",哪些是"无意欠下的"?处置方式该有什么不同?
下一课:数据迁移与数据库演进。
10、今天你带走的三句话
- 技术债是借贷:审慎借债可接受,关键是记账、有计划地还,不是追求零债务。
- 重构 = 不改行为、只改结构;前提是有测试,且小步走。
- 别动不动重写:用渐进重构 / 绞杀者模式,把还债纳入每个迭代的固定节奏。
下一课:数据迁移与数据库演进。