🎯 什麼情境該想到我

當你在除錯,或者想讓下一次除錯更容易的時候。

章導言引家具師傅 Jeff Miller:「你也许会对木匠那毫无差错的工作印象深刻。但我向你保证,事实不是这样的。真正的高手只是知道如何亡羊补牢。」書中指出除錯真正的問題是無法預估時間——可能一小時,也可能一週。

習慣良策站內的深入版本
記錄問題解決日誌維護一個問題及解決方案的日誌—
警告就是錯誤把警告當成錯誤工具-破窗理論與軟體熵
對問題各個擊破把問題與周圍隔離開工具-系統化除錯
報告所有的異常處理或向上傳播所有的例外工具-Go錯誤該包還是該轉
提供有用的錯誤信息展示有用的錯誤訊息,但別讓使用者陷入細節工具-Go現代錯誤處理

出處:Subramaniam & Hunt《高效程式設計師的 45 個習慣》第 7 章。書中範例是 C# 與 Java(例外);Go 用回傳 error,對應寫法另加註。引文保留原書簡體字。

⚙️ 怎麼用(逐項)

記錄問題解決日誌

Keep a Solutions Log 什麼時候會冒出來:同一個怪問題又踩了一次/上次是怎麼解的完全忘記了/想把踩過的坑記下來給以後查/團隊裡重複在解一樣的疑難雜症

魔鬼的誘惑:「以前解决过的问题,现在还是可以解决掉的。」

良策:「维护一个问题及其解决方案的日志。保留解决方案是修复问题过程的一部分。」(旁註:不要在同一個地方摔倒兩次)

書中重點:

  • 常見的情況:熟悉的問題又出現了,卻忘了上次怎麼修的。上網搜尋也要花時間,而且常常只找到一堆意見而不是解法。
  • 日誌可以記錄的內容:問題發生的日期、問題簡述、解決方案的詳細說明、參考的文章或網址、相關的程式碼片段、設定或截圖。
  • 用可搜尋的格式存放,用關鍵字查找;放在共享的地方(網路硬碟、Wiki),鼓勵其他人使用與更新。
  • 書中的日誌範例:「04/27/2006: If you use KQED version 6 or earlier, you have to rename the base directory to _kqed6 to avoid a conflict with the in-house Core library」——具體、有版本、有解法。

切身感受:「对于某些类似但是有差异的问题,也能从中获得修复的指引。」

平衡的藝術:

  • 記錄問題的時間不能超過解決問題的時間;保持輕量,不需要對外發布的品質。
  • 用足夠的關鍵字,才找得到以前的解法。
  • 搜尋網路發現沒有人遇過同樣的問題——也許是你的搜尋方式有問題。
  • 記錄問題發生時的版本:同樣的問題在不同版本可能表現不同。
  • 記錄團隊做重大決策的原因:否則 6~9 個月後很難回顧,容易互相指責。

在今天的樣子:

  • 團隊層級:runbook、內部知識庫、事故報告的「已知問題」清單。
  • 個人層級:這個知識庫本身就是一個問題解決日誌。
  • 最後一條「記錄決策原因」,就是架構決策記錄(ADR)。
  • 下一次遇到問題時,先查日誌再開始除錯(見 對問題各個擊破 的平衡的藝術)。

警告就是錯誤

Warnings Are Errors 什麼時候會冒出來:編譯輸出一堆警告都直接無視/警告多到看不出哪個是真問題/想把警告直接設成建置失敗/靜態分析的報告從來沒人處理

魔鬼的誘惑:「编译器的警告信息只不过是给过分小心和过于书呆子气的人看的……干脆忽略它们就是了。」

良策:「将警告视为错误。签入带有警告的代码,就跟签入有错误或者没有通过测试的代码一样,都是极差的做法。」

書中重點:

  • Venkat 在一個客戶那裡發現開發中的應用程式有 300 多個警告,其中一個被忽略的是:「條件式中的賦值永遠是常數,你是不是要用 == 而不是 =?」對應的程式碼是 if (theTextBox.Visible = true)——條件永遠成立。
  • C# 的例子:子類別用 virtual 而不是 override 宣告同名方法,結果透過基底類別參考呼叫時執行的是基底的版本。編譯顯示「建置:1 個成功」,只在輸出視窗裡有一行警告。
  • 做法:讓編譯器把警告當錯誤(GCC 的 -Werror、IDE 的專案設定),CI 伺服器也要同樣設定。在專案開始時就設定好,專案中途才改,可能一次冒出幾百個錯誤。
  • 「编译器可以轻易处理警告信息,可是你不能。」

平衡的藝術:

  • 直譯語言通常也有執行期警告的旗標,要開啟並處理。
  • 少數警告因為編譯器或第三方程式碼的問題無法消除——真的沒辦法就不要再浪費時間,但這種情況很少。
  • 被棄用的方法有它被棄用的原因,不要再用;至少安排一個迭代安全地移除它們。
  • 把自己的方法標為棄用時,要記錄使用者該改用什麼、以及何時移除。

在今天的樣子:

  • Go 的編譯器把「未使用的變數」和「未使用的 import」直接當成錯誤——這個習慣被做進了語言本身。
  • 其他的檢查交給 go vet 和 golangci-lint,在 CI 中設為會讓建置失敗。舊專案可以用 --new-from-rev 只擋新增的問題(見 工具-破窗理論與軟體熵 的棘輪做法)。
  • 棄用:在 Go 的文件註解加上 // Deprecated: 改用 X。,工具會標示出來。

對問題各個擊破

Attack Problems in Isolation 什麼時候會冒出來:在龐大系統裡大海撈針找問題/完全不知道錯出在哪一層/想做出最小可重現的例子/想把可疑的元件抽出來單獨測

魔鬼的誘惑:「要调试一个明显的错误,只有去查看整个系统的代码,而且要全部过一遍。」

良策:「对问题各个击破。在解决问题时,要将问题域与其周边隔离开,特别是在大型应用中。」(旁註:用原型來隔離問題)

書中重點:

  • 單元測試迫使程式碼分層;依賴其他模組時用 mock 物件隔離,出問題時比較容易定位。「不要试图马上了解系统的所有细节。」
  • Fred 和 George 的故事:在時程緊迫的專案中遇到嚴重的資料損壞。資料庫程式碼沒有和應用程式分開,很難定位,也不能把整個程式碼庫寄給廠商。他們寫了一個小型原型重現症狀,寄給廠商請專家看。就算原型無法重現問題,也等於提供了一個可運作的範例,同樣有助於隔離問題。
  • 「你不可能在半空中修理飛機引擎」——要拆下來放到工作台上。
  • 程式碼彼此糾纏(「大泥球」)時,最好花時間把相關的程式碼抽出來,建一個可以執行的測試環境。

切身感受:「感觉就像在一个茶杯中寻找一根针,而不是大海捞针。」

平衡的藝術:

  • 把程式碼從執行環境中抽出來後,問題消失了——這也幫助你隔離了問題(問題在環境裡)。
  • 抽出來後問題還在——同樣幫助你隔離了問題(問題在程式碼裡)。
  • 二分搜尋定位問題很有用:把問題空間切一半,看哪一半有問題,再切一半。
  • 開始攻擊問題之前,先查問題解決日誌。

在今天的樣子:

  • 最小重現案例是回報開源專案 bug 的標準要求——也是 Fred 和 George 的做法。
  • 二分搜尋:git bisect run 在 commit 歷史上自動二分(見 工具-系統化除錯)。
  • 《程式設計實務》的除錯章節有同樣的「分而治之」招式,兩本書可以對照著看。

報告所有的異常

Report All Exceptions 什麼時候會冒出來:例外接起來卻什麼都沒做/錯誤被默默吞掉查不到原因/不知道該當場處理還是往上拋/系統壞了紀錄裡卻一片乾淨

魔鬼的誘惑:「不要让程序的调用者看到那些奇怪的异常……把你调用的一切都包起来,然后发送自己定义的异常——或者干脆自己解决掉。」

良策:「处理或是向上传播所有的异常。不要将它们压制不管,就算是临时这样做也不行。」

書中的案例:

  • Venkat 使用一個很流行的開源函式庫,某個應該建立物件的方法總是回傳 null。下載原始碼後才發現:底層的方法偵測到系統缺少必要的元件,拋出了一個帶有說明的例外——但上層的方法用空的 catch 區塊把例外吞掉,然後回傳 null。他讀了函式庫的程式碼才知道問題,安裝了缺少的元件。
  • 一個大型航空訂位系統當機,飛機停飛、上千旅客滯留、航空運輸大亂好幾天——原因是一台應用伺服器上發生了一個未檢查的例外。

切身感受:「当出现问题时,心里知道能够得到抛出的异常。而且没有空的异常处理方法。」

平衡的藝術:

  • 決定由誰負責處理例外,是設計工作的一部分。
  • 不是所有問題都應該拋出例外。
  • 回報的例外要在程式碼的脈絡中有意義——拋出 NullPointerException 就像拋出一個 null 物件一樣沒有幫助。
  • 有執行期的除錯日誌時,捕捉或拋出例外都要記錄。
  • 沒有人願意呼叫一個會拋出 31 種檢查例外的方法——這是設計問題,要解決而不是隨便打補丁。

Go 的對應:

  • 「空的 catch 區塊」在 Go 裡是 _ = f() 或 if err != nil { return nil }——同樣吞掉了原因。golangci-lint 的 errcheck 會抓出沒有檢查的錯誤。
  • 「處理或傳播」在 Go 就是處理或回傳,只做一件:不要又記 log 又回傳(見 工具-Go錯誤該包還是該轉)。
  • 傳播時用 %w 包裝並加上脈絡,讓上層能用 errors.Is 判斷(見 工具-Go現代錯誤處理)。

提供有用的錯誤信息

Provide Useful Error Messages 什麼時候會冒出來:錯誤訊息只寫著發生錯誤/使用者回報時說不清楚哪裡壞了/看到例外訊息完全無法定位/想讓錯誤訊息帶上情境跟輸入

魔鬼的誘惑:「要提供给他们干净整洁的错误信息。要使用如下这样让人舒服的词句:‘用户错误。替换,然后继续。’」

良策:「展示有用的错误信息。提供更易于查找错误细节的方式,发生问题时,要展示出尽量多的支持细节,不过别让用户陷入其中。」

書中重點:

  • 「操作無法執行」這種訊息比當機好,但對診斷沒有幫助。
  • 兩全的做法:顯示一個清楚的說明(「無法登入」)加上一個「詳細資料」的連結,點開可以看到完整的例外鏈(「驗證密碼時出錯 → 連接資料庫時出錯」)。正式環境則提供一個錯誤編號,支援人員請使用者唸出編號,就能在日誌中快速定位。
  • 日誌可以記錄當時的系統狀態,方便重建問題——但密碼、銀行帳號這類敏感資訊不應顯示,甚至不應記入日誌。
  • 側欄〈區分錯誤類型〉:
    • 程式缺陷(真正的 bug):使用者和管理員都無能為力。
    • 環境問題(資料庫連不上、磁碟滿了、權限不足):程式設計師沒辦法,但管理員應該能解決。
    • 使用者錯誤:告訴使用者哪裡操作有誤,他可以重來。
    • 依類型給不同的對象合適的建議。

平衡的藝術:

  • 「找不到檔案」沒有幫助;「無法開啟 /andy/project/main.yaml 以供讀取」有用得多。
  • 不必等例外才發現問題;在關鍵點用斷言,失敗時提供同樣詳細的資訊。
  • 提供更多資訊時,不要洩露安全資訊、個人隱私、商業機密。
  • 給使用者的訊息可以包含一個鍵值,方便在日誌中定位。

在今天的樣子:

  • 「錯誤編號」就是 request ID/trace ID:回應中帶給使用者,日誌中同樣記錄,支援人員一查就找到(見 工具-遙測與監控)。
  • 書中的三種錯誤類型對應到 HTTP 狀態碼:使用者錯誤是 4xx,環境問題與程式缺陷是 5xx——而且 5xx 的回應不應該把內部細節顯示給使用者。
  • Go 的錯誤訊息慣例(小寫、無句號、逐層加上脈絡)見 工具-Go錯誤該包還是該轉。

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 這一章的價值在「下一次」:日誌、零警告、有意義的錯誤訊息,都是為了讓下一次除錯更快。在趕時程時最容易被省略,也最容易在之後付出代價。
  • 錯誤訊息的詳細程度要分對象:開發環境越詳細越好;正式環境對外只給錯誤類型與編號,細節留在日誌。

🔗 相關工具