0.6 Happy path 理論:錯誤路徑放哪裡
Happy path 指程式在沒有任何失敗時走的那條執行路徑:讀檔成功、解析成功、寫入成功、回傳結果。圍繞 happy path 的可讀性,主流語言分成兩套理論,目標一致 — 讓讀者一眼看到正常流程 — 分歧在錯誤路徑的去向:例外(exception)模型把錯誤移出正文、交給呼叫鏈上方的集中處理點;Go 把錯誤留在正文、用早期返回把它排到視線邊緣。這一章攤開兩種理論的機制與成本,說明 Go 為什麼選第二種,以及第一種在什麼情境下仍然合理。
0.3 錯誤處理已經建立 if err != nil 的操作面;這一章回答更上游的問題:當習慣例外模型的工程師質疑「這些檢查只是冒泡(bubbling、把錯誤原樣沿呼叫鏈往上傳)、憑什麼多付三行」,Go 的設計取捨怎麼回應。
錯誤移出正文:集中處理的機制
集中處理理論的核心主張是:錯誤處理程式碼跟業務邏輯分離,正文只保留成功流程,失敗由呼叫鏈上方的集中點統一接住。例外模型是這個理論的主要載體 — 錯誤發生時堆疊自動展開,途中每一層函式都能維持零錯誤處理程式碼:
1public static void Main(string[] args)
2{
3 try {
4 new Program().Run(); // 整個程式的失敗都在這裡接住
5 }
6 catch (Exception ex) {
7 logger.Log(ex);
8 }
9}
10
11private List<User> GetUsers()
12{
13 // 正文只有成功流程:連線、查詢、回傳
14 using var conn = new SqlConnection(Config.ConnStr);
15 return conn.Query<User>("select id, name from Users").ToList();
16}這個寫法的收益真實存在:正文密度高、往上傳遞失敗零成本、資源釋放交給 using 這類語言機制。對「任何失敗的處置都相同」的程式 — 短生命腳本、原型、一次性批次工作 — 集中處理的資訊損失很小,因為所有錯誤的終點相同:記錄、放棄、退出。
集中處理的成本在程式長大後浮現,而且集中在三個機制上:
失敗點不可枚舉。正文看不出哪一行可能失敗 — conn.Query 會拋出連線逾時、SQL 語法錯誤、權限不足,這些資訊在呼叫處不可見,讀者要翻文件或實作才能列出失敗模式。要測試失敗路徑時,第一步「找出有哪些失敗路徑」就需要正文以外的知識。這個成本描述的是 unchecked 例外;Java 曾用 checked exception 把失敗型態寫進函式簽名、讓呼叫點可枚舉,但後續語言(C#、Kotlin)評估維護成本後刻意不跟進、Java 生態自己也大幅退回 unchecked — 這場自然實驗顯示「枚舉失敗點」的需求真實存在、在例外模型內滿足它的成本過高。
控制流程隱藏。例外是一種非局部跳轉:任何一行都可能把執行權直接移轉到數層之外的 catch 區塊。同一個社群通常同時反對 goto 又接受例外,而兩者最關鍵的差異在跳轉方向 — 例外的跳轉固定往呼叫鏈上方,規則比 goto 收斂,但「讀這一行時要同時記住外層有哪些 catch」的認知負擔仍然存在。
錯誤上下文止於堆疊追蹤。未捕捉例外帶著 stack trace 抵達頂層 — 它回答「在哪裡爆」,但回答「當時在做什麼、對哪筆資料失敗」需要每一層主動附加脈絡。例外模型做得到這件事(逐層攔截、包上脈絡、重新拋出),但那是跟預設方向相反的自律動作 — 集中處理的收益本來就建立在每一層沉默通過上。on-call 工程師收到的警報只有 NullReferenceException 加一串呼叫堆疊時,除錯的起點是重建「這個請求當時想做什麼」,而這個資訊在錯誤冒泡的路上每一層都曾經在手上、每一層都沒有留下來。
錯誤靠邊:line of sight 的機制
Happy path 沿著程式的左緣往下流,錯誤與邊界情況縮排處理、就地退出 — 這是 Mat Ryer 在 Golang UK Conference 提出的 line of sight(視線)閱讀模型:讀者掃一個直欄就能追完正常流程,縮排區塊則是失敗行為的所在。Go 的慣例圍繞這個模型成形:條件反轉(提早檢查失敗條件)、早期返回、成功的 return 放在函式最後一行、大的條件區塊抽成獨立函式。
1func LoadUsers(db *sql.DB) ([]User, error) {
2 rows, err := db.Query("select id, name from users")
3 if err != nil {
4 return nil, fmt.Errorf("query users: %w", err)
5 }
6 defer rows.Close()
7
8 var users []User
9 for rows.Next() {
10 var u User
11 if err := rows.Scan(&u.ID, &u.Name); err != nil {
12 return nil, fmt.Errorf("scan user row: %w", err)
13 }
14 users = append(users, u)
15 }
16 if err := rows.Err(); err != nil {
17 return nil, fmt.Errorf("iterate user rows: %w", err)
18 }
19
20 return users, nil
21}視線理論跟集中處理理論共享同一個目標 — happy path 一眼可讀 — 達成手段是結構分離而非視野移除:錯誤路徑留在正文,用縮排跟左緣區隔開。左緣是「一切順利時會發生什麼」,縮排是「這一步失敗時程式怎麼結束」,兩條路徑同時可見、各自可掃。
這個結構把三個集中處理的成本反轉回來:失敗點可枚舉(每個 if err != nil 就是一個)、控制流程線性(失敗的出口就在失敗的下一行)、脈絡累積有自然的掛載點(每個檢查點都是補「做什麼失敗」的機會)。付出的代價同樣真實:正文變長、每個呼叫點三行的儀式成本、以及一種新的退化風險 — 檢查點淪為複製貼上的儀式,這是下一節的主題。
冒泡批評成立的條件
「if err != nil { return err } 只是把錯誤往上傳,跟例外的自動冒泡做同一件事,卻多付三行」— 這是對 Go 錯誤處理最直接的批評,需要誠實對待,因為它在特定條件下完全成立:裸的 return err 沒有補脈絡、沒有做決策、沒有轉換錯誤型態,它的資訊產出跟例外冒泡相同,成本卻更高。一個 codebase 裡絕大多數檢查點都是裸冒泡時,團隊付了顯式錯誤的成本、沒拿到它的收益。
Go 慣例的完整形靠兩個機制讓這三行產生資訊:
包裝脈絡。fmt.Errorf("charge order %q: %w", order.ID, err) 讓錯誤鏈在每一層累積操作脈絡 — 抵達記錄點的錯誤讀起來是 handle checkout: charge order "ord-4471": connection refused,一行內回答「做什麼事、對哪筆資料、哪一步失敗」。堆疊追蹤與錯誤鏈承載不同的資訊:前者是位置座標、後者是操作敘事,生產環境警報能不能直接行動,取決於錯誤抵達時有沒有帶著操作敘事。
顯式忽略是意圖宣告。val, _ := parse(s) 告訴下一個讀者:作者看過這個錯誤、決定不處理。例外模型裡「沒處理」跟「沒想到會失敗」在程式碼上長得一樣,review 時無從區分;錯誤是回傳值時,忽略這個動作本身留下痕跡。
由此得到一條可操作判準:每個錯誤檢查點應該是決策點。決策的選項包括補脈絡後回傳、記錄後降級、重試、轉成協定語意(HTTP status、gRPC code)、顯式忽略並註明理由。檢視程式時的訊號很直接 — 裸 return err 是少數、只出現在沒有脈絡可補的薄層(單行轉呼叫)時,顯式錯誤在產出資訊;裸 return err 是預設姿勢時,冒泡批評對這個 codebase 成立。
這條批評還有一個更深的版本:三行儀式是 Go 的語言選擇、而非顯式錯誤值模型的必然 — Rust 用 Result 型別加 ? 運算子拿到同一組性質(失敗點可枚舉、忽略要顯式、錯誤可作為值傳遞),冒泡只花一個字元。Go 官方評估過同方向的語法提案(try、check/handle),以「隱藏控制流程、給函式增加第二種退出形狀」為主要理由擱置 — 這個決定延續 0.1 簡單哲學的取捨:讓所有控制流程長同一個樣子、代價是儀式成本留在正文。反過來,紀律良好的例外實踐也會在每個處置不同的邊界攔截並補上脈絡 —「檢查點是決策點」這條紀律在兩個模型裡各有語法形式,差別在哪個模型把它做成預設路徑。
並發模型讓錯誤必須是值
例外的「往上拋」依賴一個結構前提:呼叫堆疊上方永遠有人在。單執行緒的請求處理裡這個前提成立 — 從 handler 往上走總會遇到框架的邊界層。Go 的並發模型打破這個前提:goroutine 啟動之後跟呼叫者的堆疊脫鉤,go f() 執行的函式 panic 時,只有同一個 goroutine 內的 defer recover() 攔得住,呼叫者這一側沒有任何攔截點;而未被 recover 的 panic 會終止整個 process,不只結束那一顆 goroutine。「往上拋」在並發結構裡沒有穩定的「上」,失敗要離開 goroutine 而不擊落整個程式,只能先轉成值送出去。
錯誤是普通值的直接後果,是錯誤獲得了跨並發邊界移動的能力:放進 channel 送回主流程、由 errgroup 匯集多個 goroutine 中的第一個失敗、存進共享結構等會合點檢查。錯誤處理模型跟並發模型是同一組設計決策 — 語言選了輕量 goroutine 當並發單位、鼓勵「一個請求開好幾個 goroutine」的工作型態,錯誤就需要一種能在 goroutine 之間自由傳遞、聚合、比較的形式,而「值」正是這種形式。這也是「Go 為什麼不乾脆加上例外」的機制層答案:例外模型跟堆疊綁定,堆疊在 Go 的並發設計裡是短命、大量、彼此獨立的資源。
panic/recover 在這個設計裡有明確定位:process 層的最後防線,保留給不變量破壞 — 陣列越界、nil dereference、程式員錯誤。標準庫內部存在用 panic 簡化深層遞迴錯誤傳遞的實作(encoding/json 的解碼器),但在公開 API 邊界一律轉回 error — panic 作為套件內部的實作細節可以存在,error 才是跨越 API 邊界的介面契約。
服務的邊界防線也屬同一個定位。net/http 內建的 per-connection recover 只記錄 panic 後丟棄該連線,保住 server 不整個崩;要把 panic 轉成乾淨的 500 回應,通常靠團隊自己加一層 recovery middleware。這層防線的角色是「最後一道」,而集中處理理論把它當「唯一一道」— 兩種理論的差異濃縮在這一個定位差。
判讀條件:兩種理論各自適合的服務型態
理論選擇的判準是錯誤處置的分歧度與程式的生命型態,同一個團隊在不同專案可以合法地選不同邊。
| 情境訊號 | 較划算的理論 | 機制原因 |
|---|---|---|
| 所有失敗的處置相同(放棄、記錄、退出) | 集中處理 | 錯誤終點唯一、就地決策沒有資訊增量 |
| 錯誤處置分歧(重試、降級、部分成功、回滾) | 顯式錯誤值 | 處置決策需要發生在錯誤現場、脈絡還在手上時 |
| 短生命腳本、原型、一次性批次 | 集中處理 | 維護期短、失敗點枚舉的投資回收不了 |
| 長時間運行、高併發、goroutine 之間傳遞失敗 | 顯式錯誤值 | 錯誤要跨並發邊界移動、堆疊展開沒有穩定的「上」 |
| 警報品質有要求、on-call 要能直接行動 | 顯式錯誤值 | 操作敘事要靠每層主動累積、集中點補不回來 |
| 多人長期維護、review 要能看出失敗行為 | 顯式錯誤值 | 失敗點可枚舉、忽略有痕跡、意圖可 review |
| 寫 library、被呼叫端的套件 | 顯式錯誤值 | 不擁有進入點與頂層集中點、處置權在呼叫者手上 |
表格裡每一行的判讀都有情境細節。「所有失敗處置相同」最常見的形態是 request-scoped(以單一請求為生命週期)的服務:一個請求失敗、回 500、下一個請求照常 — 這正是例外語言的 web 框架用 per-request catch 做的事,而 Go 服務同樣有這一層(recovery middleware),差別在 Go 把它定位成 panic 的最後防線、預期正常失敗都在抵達之前被 error 路徑處理完。「錯誤處置分歧」的典型是呼叫外部依賴的服務:資料庫逾時要重試、快取失敗要降級穿透、下游 4xx 要原樣轉譯、5xx 要熔斷 — 這些決策各自需要錯誤現場的資訊,集中到頂層時「哪個呼叫、什麼條件」已經要靠解析錯誤內容重建。
表格的判讀還有兩個前提。選擇權屬於擁有進入點的人:library 作者不擁有呼叫者的 main,無論偏好哪套理論,錯誤都只能做成回傳值交給呼叫者決策 — 前一節 encoding/json 在 API 邊界把 panic 轉回 error,就是這個約束的實例。「一次性批次」指失敗即中止的批次;要跑完全部再回報哪幾筆失敗的批次(一萬筆裡壞三筆、要列出是哪三筆),實際上是「錯誤處置分歧」的形態 — 每筆各自留下錯誤值、最後聚合回報,落在顯式錯誤值那一側。
反向的邊界同樣需要判讀:Go 程式內部也有滑向「happy path only」的姿勢,訊號包括 _ 吞錯而不註明理由、Must* 系列出現在請求路徑(它的合法位置是啟動期初始化 — 設定解析失敗時快速崩潰是合理處置)、以及前一節說的裸 return err 成為預設。這些訊號密集出現時,程式的字面形式是顯式錯誤、實際行為已經退回集中處理,而且比真正的例外模型更糟 — 例外至少保證錯誤抵達頂層,被 _ 吞掉的錯誤直接消失。
下一步路由
錯誤處理的操作面 — 包裝脈絡的寫法、HTTP handler 的協定轉換、記錄責任的位置 — 在 0.3 錯誤處理:把失敗路徑寫出來。早期返回與巢狀消除的實作練習在 5.1 錯誤回傳與早期返回。並發情境下的錯誤路徑與可靠性設計在 5.7 錯誤處理與測試在高併發服務中的角色;goroutine 與 channel 的基礎在模組四:並發模型。
Happy path 偏差同樣發生在 UI 設計層 — 開發者只設計「一切順利」的畫面、使用者在失敗狀態下沒有退出路徑 — 那是同一個詞在不同抽象層的失敗模式,見 反模式:假設使用者只走 happy path。