除錯舊程式碼 - Debug Legacy Code 實戰範例
本範例將展示如何運用 Superpowers 的除錯與驗證 skills 來處理 legacy code 中難以追蹤的 bug。Legacy code 通常缺乏測試、結構複雜、商業邏輯隱晦,是開發者最頭痛的挑戰之一。透過系統化的方法,我們可以把「猜測式除錯」轉變為「根因分析」。
專案概述
情境:接手一個 5 年歷史的電商後端專案,使用者回報「偶爾訂單金額計算錯誤」的問題,但 QA 團隊始終無法穩定重現。
目標:找出根因、修復 bug、建立回歸測試避免再次發生。
技術棧:Node.js + Express + MongoDB + Jest
核心挑戰:
- 訂單計算邏輯分散在 5 個檔案、12 個函式中
- 沒有單元測試,只有少數整合測試
- 浮點數運算混合貨幣處理
- 折扣、稅金、運費的計算順序不明確
完整除錯流程
第 1 步:使用 Systematic Debugging Skill 建立除錯計畫
觸發:
使用 systematic-debugging skill 處理這個訂單金額錯誤的問題
AI 回應摘要:
鐵律:在沒有找到根因之前,不修復任何程式碼。
## 第 1 階段:重現問題
- 蒐集錯誤日誌與訂單樣本
- 找出能穩定重現的觸發條件
- 建立最小的重現案例(MRE)
## 第 2 階段:隔離問題
- 使用二分法縮小範圍
- 檢視最近的相關 commit
- 對照「正常」與「錯誤」訂單的差異
## 第 3 階段:提出假設
- 根據觀察提出可驗證的假設
- 每個假設都要有明確的驗證方式
- 排序假設,從最可能、最容易驗證的開始
## 第 4 階段:實作修復
- 確認根因後才動手修
- 修復需伴隨回歸測試
- 修復後需重新驗證原問題已解決
第 2 步:重現問題-蒐集證據
我們與 QA 合作蒐集了一週的訂單資料,發現錯誤訂單有以下共同特徵:
錯誤訂單特徵分析:
- 訂單包含 3 個以上商品:佔錯誤案例的 87%
- 有套用折扣碼:佔錯誤案例的 92%
- 建立時間落在 23:50 ~ 00:10 之間:佔錯誤案例的 64%
- 商品價格含小數(如 $19.99):佔錯誤案例的 78%
最小重現案例(MRE):
// reproduce-bug.test.js
const { calculateOrderTotal } = require('./order-calculator');
test('重現金額錯誤:跨日折扣 + 多商品 + 小數價格', () => {
const order = {
items: [
{ sku: 'A', price: 19.99, qty: 2 },
{ sku: 'B', price: 5.50, qty: 1 },
{ sku: 'C', price: 12.75, qty: 3 },
],
discount: { code: 'NIGHT10', type: 'percentage', value: 10 },
createdAt: '2024-01-15T23:58:00Z', // 跨日時段
};
const result = calculateOrderTotal(order);
// 預期:79.74 - 7.97 (折扣) + 0.8 (稅) = 72.57
expect(result.total).toBe(72.57);
// 實際:有時回傳 72.56、有時 72.58,偶爾 NaN
});
第 3 步:隔離問題-提出假設
根據蒐集到的證據,我們提出並排序假設:
假設 1(最可能):浮點數精度遺失
- 證據:錯誤案例集中在小數價格
- 驗證方式:將所有運算改用整數分(cents)計算,看問題是否消失
假設 2:跨日時區問題
- 證據:錯誤集中於 00:00 前後
- 驗證方式:固定時區測試、檢查
Date用法
假設 3:折扣碼套用順序錯亂
- 證據:錯誤案例大多有折扣
- 驗證方式:檢查
applyDiscount何時被呼叫、是否與並發有關
第 4 步:驗證假設 1
// 驗證浮點數假設
function demonstrateFloatIssue() {
// 原本的計算方式
const sum = 19.99 * 2 + 5.50 + 12.75 * 3;
console.log(sum); // → 79.73200000000001
console.log(sum * 0.9); // → 71.75880000000001
// 改用整數分計算
const sumCents = 1999 * 2 + 550 + 1275 * 3;
console.log(sumCents); // → 7972(分)= 79.72 元(無精度誤差)
const discountedCents = Math.round(sumCents * 0.9);
console.log(discountedCents); // → 7175(分)= 71.75 元
}
觀察發現:浮點數誤差確實存在,但量大小不足以解釋 0.01 ~ 0.02 的誤差。這表示假設 1 是「部分原因」但非「主要根因」。我們繼續驗證假設 2。
第 5 步:驗證假設 2-找到根因
檢查 order-calculator.js 中使用日期的程式碼:
// 問題程式碼
function applyDiscount(order, discount) {
const now = new Date(); // ❌ 使用本地時間
const expiry = new Date(discount.expiresAt); // ❌ 字串解析時區不一致
if (now > expiry) return order; // 過期不折扣
// ... 計算折扣
}
根因確認:
- 伺服器時區設定為 UTC,但
new Date()在某些 deployments 使用本地時區 - 折扣碼的「跨日邊界」會導致
now > expiry在毫秒之間翻盤 - 當折扣「有時套用、有時不套用」時,金額就會出現誤差
第 6 步:使用 TDD 修正
觸發:
使用 test-driven-development skill 修復這個跨日折扣計算問題
先寫測試(紅燈):
// order-calculator.test.js
describe('折扣碼跨日計算', () => {
test('UTC 與本地時區應有相同結果', () => {
const baseOrder = { items: [...], total: 79.72 };
const discount = {
code: 'NIGHT10',
type: 'percentage',
value: 10,
expiresAt: '2024-01-16T00:00:00Z',
};
// 模擬不同時區的「同一時刻」
process.env.TZ = 'UTC';
const resultUTC = calculateOrderTotal({ ...baseOrder, createdAt: '2024-01-15T23:59:59Z' }, discount);
process.env.TZ = 'Asia/Taipei';
const resultTW = calculateOrderTotal({ ...baseOrder, createdAt: '2024-01-15T23:59:59Z' }, discount);
expect(resultUTC.total).toBe(resultTW.total);
// 目前:紅燈(兩者不同)
});
test('過期邊界應精確到毫秒', () => {
// 過期前 1 毫秒:應套用折扣
// 過期後 1 毫秒:不應套用折扣
});
});
實作修復(綠燈):
// order-calculator.js(修復後)
function applyDiscount(order, discount) {
// 統一使用 UTC時間戳比較
const nowMs = Date.now(); // ✅ 時區無關
const expiryMs = Date.parse(discount.expiresAt); // ✅ ISO 8601 含時區
if (nowMs > expiryMs) return order;
// 改用整數分計算,避免浮點誤差
const totalCents = Math.round(order.total * 100);
const discountCents = Math.round(
totalCents * (discount.value / 100)
);
return {
...order,
total: (totalCents - discountCents) / 100,
};
}
第 7 步:使用 Verification Skill 確認修復
觸發:
使用 verification-before-completion skill 驗證這次的修復是否完整
驗證清單:
✅ 根因確認
- 已隔離到時區比較邏輯 ✓
- 已驗證為主要誤差來源 ✓
- 假設 1(浮點數)為次因,一併修復 ✓
✅ 測試覆蓋
- 新增「跨時區一致性」測試 ✓
- 新增「過期邊界」測試 ✓
- 新增「整數分計算」測試 ✓
- 既有整合測試全數通過 ✓
✅ 回歸驗證
- 用蒐集到的歷史錯誤訂單重算:結果正確 ✓
- 監控一週,未再出現金計算錯誤告警 ✓
✅ 程式碼品質
- 無新的 lint 警告 ✓
- 無新的 TypeScript 錯誤 ✓
- 新增註解說明時區陷阱 ✓
✅ 文件更新
- 更新 discount 邏輯的 ADR ✓
- 在 commit message 中記錄根因分析 ✓
關鍵學習點
1. 不要急著改程式碼
Legacy code 的 bug 往往「症狀簡單、根因複雜」。看到 total 算錯,直覺是去改 order-calculator.js 的計算公式,但真正的問題在「時間比較」。Systematic Debugging skill 的鐵律「未找到根因前不修復」是這個案例能成功收斂的關鍵。
2. 證據驅動而非直覺驅動
如果憑直覺「看程式碼找錯」,可能會把心力耗在 calculateTotal 的浮點數問題而錯過時區這個關鍵。透過蒐集大量錯誤样本 → 找出共同特徵 → 推導假設,才能讓根因浮現。
3. 修復要包含防護網
單純改掉一段程式碼不算完成。真正的修復包含:
- 回歸測試:確保同類問題不再發生
- 根因文件:下一位維護者能理解脈絡
- 監控機制:佈署後驗證問題真的消失
工具使用摘要
| Skill | 用途 | 在本例的作用 |
|---|---|---|
| Systematic Debugging | 系統化除錯 | 建立重現→隔離→假設→修復的流程 |
| Test-Driven Development | 測試先行 | 為修復建立回歸測試 |
| Verification Before Completion | 完成前驗證 | 確認根因已解決、無副作用 |
總結
處理 legacy code 的核心在於把「未知的未知」轉變為「已知的已知」:
- 透過蒐集證據,把「偶爾出錯」變成「可控重現」
- 透過假設排序,把「胡亂排查」變成「有序驗證」
- 透過 TDD,把「猜測式修復」變成「有防護的改良」
- 透過驗證清單,把「自以為修好了」變成「可證明的修復」
下次遇到 legacy code 的玄學 bug,請記得:不要急、找證據、排序假設、修復含測試。 Superpowers 的除錯 skills 並非萬能,但能在混亂中提供紀律。