Go 錯誤處理完全指南

Go 錯誤處理完整指南 — error interface、errors.Is/As、wrap、自訂 error、panic/recover、defer 順序


目錄


什麼是 Go 的錯誤處理?

Go 採用錯誤即值(errors are values)的設計哲學 — 錯誤是普通的 return value,不是 exception。

核心特點

  • 🎯 顯式處理:每個可能出錯的函式都回傳 error,呼叫端必須處理
  • 無 try/catch:沒有 exception,控制流清晰
  • 🔧 可組合:error 是介面,可以 wrap、檢查、轉型
  • 📦 panic 保留給「程式設計錯誤」:array 越界、nil 指標 → panic;I/O 失敗 → return error

為什麼這樣設計?

傳統 exception 的問題

  • 控制流隱藏(任何函式可能丟 exception)
  • 錯誤類型編譯時不可見
  • 忘了 catch 會炸到最外層

Go 的做法

result, err := doSomething()
if err != nil {
    return fmt.Errorf("doSomething failed: %w", err)
}
// 用 result

每行都明確處理錯誤,沒有「驚喜」。


error 介面與基本用法

error 介面定義

整個 Go 錯誤系統只建立在這一個介面上:

type error interface {
    Error() string
}

任何Error() string 方法的型別都是 error。

產生 error 的三種方式

import (
    "errors"
    "fmt"
)

// 1. errors.New — 最簡單的純文字錯誤
err1 := errors.New("檔案不存在")

// 2. fmt.Errorf — 含格式化參數
filename := "config.json"
err2 := fmt.Errorf("檔案 %s 不存在", filename)

// 3. fmt.Errorf 含 %w 動詞 → 包裝 error
err3 := fmt.Errorf("讀取 config 失敗: %w", err1)

標準回傳模式

func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("除以零")
    }
    return a / b, nil
}

func main() {
    result, err := divide(10, 0)
    if err != nil {
        fmt.Println("錯誤:", err)
        return
    }
    fmt.Println(result)
}

慣例:error 是最後一個 return value

// ✅ Go 慣例:error 放最後
func ReadConfig(path string) (Config, error)
func ParseInt(s string) (int, error)

// ❌ 違反慣例
func ReadConfig(path string) (error, Config)

錯誤包裝(Error Wrapping)

從 Go 1.13 起,fmt.Errorf%w 動詞會包裝底層 error,保留原始錯誤鏈。

%w vs %v 的差別

baseErr := errors.New("connection refused")

// %v 只取出文字(會斷鏈)
wrapped1 := fmt.Errorf("無法連線資料庫: %v", baseErr)
// wrapped1 內部沒有保留 baseErr 的參考

// %w 包裝(保留鏈)
wrapped2 := fmt.Errorf("無法連線資料庫: %w", baseErr)
// wrapped2 可以用 errors.Unwrap / errors.Is / errors.As 找回 baseErr

兩者輸出的字串都是 "無法連線資料庫: connection refused",但鏈結結構不同

多層包裝

func loadConfig() error {
    err := readFile("config.json")
    if err != nil {
        return fmt.Errorf("loadConfig: %w", err)
    }
    return nil
}

func startServer() error {
    err := loadConfig()
    if err != nil {
        return fmt.Errorf("startServer: %w", err)
    }
    return nil
}

// main 拿到的 error 文字會是:
// startServer: loadConfig: open config.json: no such file or directory

每一層都加上自己的 context,最終 error 訊息含完整呼叫鏈。

Unwrap

err := fmt.Errorf("外層: %w", errors.New("內層"))
inner := errors.Unwrap(err)
// inner.Error() == "內層"

通常不直接用 Unwrap,而用 errors.Is / errors.As(見下節)。


errors.Is 與 errors.As

%w 把 error 鏈起來後,怎麼判斷鏈裡是否有特定 error?用 errors.Iserrors.As

errors.Is — 判斷是否為特定錯誤

import (
    "errors"
    "io"
)

func readFile(path string) error {
    // 假設這裡讀檔回傳 io.EOF
    return io.EOF
}

func main() {
    err := readFile("/tmp/foo")

    // ❌ 老派寫法(會錯)
    if err == io.EOF {
        // 如果 err 被 wrap 過,這個判斷失敗
    }

    // ✅ 正確寫法
    if errors.Is(err, io.EOF) {
        fmt.Println("檔案結束")
    }
}

errors.Is沿著 wrap 鏈檢查,任何一層 match 就回 true。

errors.As — 取出特定型別

type PathError struct {
    Path string
    Op   string
    Err  error
}

func (e *PathError) Error() string {
    return fmt.Sprintf("%s %s: %v", e.Op, e.Path, e.Err)
}

func main() {
    err := someFunc()

    var pathErr *PathError
    if errors.As(err, &pathErr) {
        // 鏈裡有 *PathError,pathErr 已指向它
        fmt.Println("路徑:", pathErr.Path)
    }
}

Is vs As 比較

需求
「這個錯誤是不是 io.EOF / sql.ErrNoRows?」 errors.Is
「我要取出 wrap 內的 *MyError 結構讀欄位」 errors.As
兩者都可以 偏好 errors.Is(更簡潔)

自訂 error 型別

當錯誤需要攜帶額外資料時(如 status code、欄位名),定義自訂 error 型別。

基本範例:欄位驗證錯誤

type ValidationError struct {
    Field   string
    Value   any
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("欄位 %q (值 %v): %s", e.Field, e.Value, e.Message)
}

// 使用
func validateAge(age int) error {
    if age < 0 || age > 150 {
        return &ValidationError{
            Field:   "age",
            Value:   age,
            Message: "必須在 0-150 之間",
        }
    }
    return nil
}

func main() {
    err := validateAge(-5)

    var vErr *ValidationError
    if errors.As(err, &vErr) {
        fmt.Println("欄位:", vErr.Field)   // age
        fmt.Println("錯誤值:", vErr.Value) // -5
    }
}

含底層錯誤的自訂型別

實作 Unwrap() 方法讓 errors.Is/As 能穿透:

type DBError struct {
    Query string
    Err   error // 包裝底層錯誤
}

func (e *DBError) Error() string {
    return fmt.Sprintf("db query failed: %s: %v", e.Query, e.Err)
}

// 實作 Unwrap,讓 errors.Is 可以穿透
func (e *DBError) Unwrap() error {
    return e.Err
}

// 使用
err := &DBError{
    Query: "SELECT * FROM users",
    Err:   sql.ErrNoRows,
}

errors.Is(err, sql.ErrNoRows) // true(穿透到 Unwrap)

用 pointer receiver 還是 value receiver?

慣例:自訂 error 用 pointer receiver

// ✅ Pointer receiver — 推薦
func (e *MyError) Error() string { ... }
return &MyError{...}

// ⚠️ Value receiver — 可以但少見
func (e MyError) Error() string { ... }
return MyError{...}

理由:

  • Pointer 比較不會誤觸發 nil interface 陷阱(見常見問題
  • 大多數標準庫的 error 型別都用 pointer

Sentinel Errors 設計模式

Sentinel error:在套件層級宣告的固定錯誤值,給呼叫端對照用。

範例:標準庫的 sentinel

// io 套件
var EOF = errors.New("EOF")

// sql 套件
var ErrNoRows = errors.New("sql: no rows in result set")

呼叫端可以對照:

rows, err := db.Query(...)
if errors.Is(err, sql.ErrNoRows) {
    // 沒結果,不算錯
    return nil, nil
}

何時用 sentinel vs 自訂型別?

情境
錯誤是「狀態」,無額外資料 Sentinel(io.EOF
錯誤有額外資料要傳 自訂型別(PathError
介面回傳值 Sentinel(呼叫端好對照)

別把 sentinel 寫成可變

// ❌ 用 var 但可能被誤改
var ErrNotFound = errors.New("not found")

// 更嚴謹:建立後不要曝露在 mutable 環境
// (Go 沒有 const error,所以 var 是慣例做法)

panic / recover 與 defer

panic 不是 exception

panic 在 Go 是「程式設計錯誤」的訊號,不是業務錯誤

場景
檔案開啟失敗 / 網路錯誤 / 使用者輸入錯 return error
nil 指標 dereference / 陣列越界 / 不變式被違反 Runtime 自動 panic
程式設計者明確標示「不該發生」 手動 panic("invariant violated")

panic 的傳播行為

func a() {
    panic("出事了")
}

func b() {
    a() // panic 從 a 往上傳
}

func main() {
    b() // 不處理,程式直接 crash + 印 stack trace
}

panic 會沿 call stack 往上傳,沿途的 defer 仍會執行,直到遇到 recover

recover:唯一能攔截 panic 的方法

recover 只能在 defer 內呼叫才有效。

func safeRun() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("攔截到 panic:", r)
        }
    }()

    panic("出事了")
}

func main() {
    safeRun()
    fmt.Println("safeRun 結束,但 main 繼續跑")
}

何時該用 recover?

很少。合理用途:

  1. 頂層 server 攔截每個 request 的 panic(避免整個 process 死)
  2. goroutine 隔離:一個 goroutine panic 不該拖死整個 process
  3. 第三方 callback 不可信時包裹起來
// 範例:HTTP middleware
func recoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("panic in handler: %v\n%s", rec, debug.Stack())
                http.Error(w, "Internal Server Error", 500)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

⚠️ 不要用 panic/recover 模擬 exception

// ❌ 反模式 — 不要這樣用 Go
func parseFile(path string) (result Result) {
    defer func() {
        if r := recover(); r != nil {
            result = Result{Error: r.(error)}
        }
    }()

    if !exists(path) {
        panic(errors.New("not found")) // 應該 return error
    }
    // ...
}

defer 執行順序與陷阱

LIFO 順序

defer 是 Last-In-First-Out(後進先出):

func main() {
    defer fmt.Println("1")
    defer fmt.Println("2")
    defer fmt.Println("3")
    fmt.Println("main")
}
// 輸出:
// main
// 3
// 2
// 1

參數在 defer 宣告時求值

func main() {
    x := 10
    defer fmt.Println("x =", x) // 求值:x = 10
    x = 20
    // 函式結束時印:x = 10(不是 20)
}

但如果用 closure 捕獲:

func main() {
    x := 10
    defer func() {
        fmt.Println("x =", x) // closure 在執行時求值
    }()
    x = 20
    // 印:x = 20
}

defer + 修改 named return value

func addOne() (n int) {
    defer func() {
        n++ // 修改 named return
    }()
    return 5
    // 實際 return 6
}

巧用在「retry / cleanup 後改 return」。

defer 的常見模式:清理資源

func process(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close() // 函式結束自動關

    // 處理 f ...
    return nil
}

defer 在迴圈中的陷阱

// ❌ 在迴圈內 defer Close,所有檔案開啟期間都不會關
for _, path := range paths {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close() // 累積!函式結束才一起 close
    // ...
}

// ✅ 包成 function
for _, path := range paths {
    if err := processOne(path); err != nil {
        return err
    }
}

func processOne(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close() // 本 function 結束就 close
    // ...
    return nil
}

實戰範例

範例 1:完整的錯誤處理鏈

package main

import (
    "errors"
    "fmt"
    "os"
)

// 自訂 error 型別
type ConfigError struct {
    Path string
    Err  error
}

func (e *ConfigError) Error() string {
    return fmt.Sprintf("config %s: %v", e.Path, e.Err)
}

func (e *ConfigError) Unwrap() error {
    return e.Err
}

// 套件層級 sentinel
var ErrConfigMissing = errors.New("config 檔案缺少必要欄位")

// 包裝的低階函式
func readConfigFile(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, &ConfigError{Path: path, Err: err}
    }
    return data, nil
}

// 高階函式
func loadConfig(path string) error {
    _, err := readConfigFile(path)
    if err != nil {
        return fmt.Errorf("loadConfig: %w", err)
    }
    return nil
}

func main() {
    err := loadConfig("/no/such/path.json")
    if err == nil {
        return
    }

    fmt.Println("錯誤:", err)
    // 輸出:loadConfig: config /no/such/path.json: open /no/such/path.json: no such file or directory

    // 用 errors.Is 檢查底層
    if errors.Is(err, os.ErrNotExist) {
        fmt.Println("確認是檔案不存在")
    }

    // 用 errors.As 取出自訂型別
    var cfgErr *ConfigError
    if errors.As(err, &cfgErr) {
        fmt.Println("Config 路徑:", cfgErr.Path)
    }
}

範例 2:HTTP handler 加 recover

package main

import (
    "fmt"
    "log"
    "net/http"
    "runtime/debug"
)

func recoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("[panic] %v\n%s", rec, debug.Stack())
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

func handleRisky(w http.ResponseWriter, r *http.Request) {
    var p *int
    fmt.Fprintln(w, *p) // nil pointer → panic
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/risky", handleRisky)
    log.Fatal(http.ListenAndServe(":8080", recoverMiddleware(mux)))
}

範例 3:goroutine 內 panic 隔離

func safeGo(fn func()) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                log.Printf("[goroutine panic] %v\n%s", r, debug.Stack())
            }
        }()
        fn()
    }()
}

func main() {
    safeGo(func() {
        panic("worker panicked")
    })

    time.Sleep(time.Second)
    fmt.Println("main 還活著")
}

最佳實踐

1. 永遠檢查 error,別用 _ 偷懶

// ❌ 拋棄錯誤
data, _ := os.ReadFile(path)

// ✅ 處理
data, err := os.ReadFile(path)
if err != nil {
    return fmt.Errorf("read %s: %w", path, err)
}

2. 包裝時加 context

// ❌ 訊息沒幫助
if err != nil {
    return err
}

// ✅ 告訴呼叫端「在做什麼時失敗」
if err != nil {
    return fmt.Errorf("loadUser id=%s: %w", id, err)
}

3. 不要在多處包同樣訊息

// ❌ 訊息累積冗餘
return fmt.Errorf("loadUser: %w", err)
// 在多層都這樣寫 → "loadUser: loadUser: loadUser: not found"

// ✅ 每層加自己的 context(不重複)
return fmt.Errorf("loadUser id=%s: %w", id, err)

4. 對外 API 用 sentinel 或定型錯誤,內部用 wrap

// 套件對外
var (
    ErrNotFound = errors.New("not found")
    ErrInvalid  = errors.New("invalid input")
)

// 內部實作
func getUser(id string) (*User, error) {
    u, err := db.Query(id)
    if err == sql.ErrNoRows {
        return nil, ErrNotFound // 轉成套件對外的 sentinel
    }
    if err != nil {
        return nil, fmt.Errorf("db query: %w", err)
    }
    return u, nil
}

5. panic 只用於不變式違反

// ✅ 合理:邏輯不該到這
func direction(d int) string {
    switch d {
    case 1: return "north"
    case 2: return "south"
    case 3: return "east"
    case 4: return "west"
    }
    panic(fmt.Sprintf("invalid direction: %d", d))
}

// ❌ 不該:使用者輸入錯誤該 return error
func parseAge(s string) int {
    n, err := strconv.Atoi(s)
    if err != nil {
        panic(err) // 不對!應該 return (int, error)
    }
    return n
}

6. defer 寫在資源取得之後

// ✅ 立刻 defer
f, err := os.Open(path)
if err != nil {
    return err
}
defer f.Close()

// ❌ 中間插入別的邏輯
f, err := os.Open(path)
if err != nil {
    return err
}
// 一堆別的邏輯 ...
defer f.Close() // 不該晚這麼多

常見問題

問題 1:nil error 比較失敗

症狀

var err error = (*MyError)(nil)
if err == nil {
    fmt.Println("err 是 nil")
} else {
    fmt.Println("err 不是 nil") // 印出這個!
}

原因err 介面值非 nil(雖然底層 pointer 是 nil,但介面有「型別」資訊)

解決

// ❌ 不要回傳具名 nil pointer 當 error
func foo() error {
    var e *MyError = nil
    return e // 介面值非 nil
}

// ✅ 明確回傳 nil
func foo() error {
    return nil
}

問題 2:errors.Is 對自訂型別不生效

症狀

type MyErr struct{ Code int }
func (e *MyErr) Error() string { return "..." }

err1 := &MyErr{Code: 1}
err2 := &MyErr{Code: 1}
errors.Is(err1, err2) // false!

原因errors.Is 預設用 == 比較。&MyErr{1} 兩次建立的 pointer 不同。

解決:實作 Is 方法

func (e *MyErr) Is(target error) bool {
    t, ok := target.(*MyErr)
    if !ok {
        return false
    }
    return e.Code == t.Code
}

問題 3:defer 沒執行

症狀:函式結束但 defer 沒跑

可能原因

  1. os.Exit() 直接結束(不會跑 defer)
  2. fatal panic(如 unrecovered 從別處傳上來且未被 recover)
// ❌ os.Exit 跳過 defer
func main() {
    defer fmt.Println("cleanup")
    os.Exit(1) // defer 不會跑
}

// ✅ 用 return / panic
func main() {
    defer fmt.Println("cleanup")
    return
}

問題 4:recover 沒攔截到 panic

檢查清單

  1. recover() 必須在 defer 內呼叫
  2. defer 必須在 panic 發生的同一個 goroutine
  3. defer 必須在 panic 之前已宣告
// ❌ goroutine 內的 panic 主 goroutine 攔不到
func main() {
    defer func() {
        recover() // 攔不到 goroutine 內的 panic
    }()

    go func() {
        panic("子 goroutine 出事")
    }()

    time.Sleep(time.Second)
}

// ✅ 在子 goroutine 內 recover
go func() {
    defer func() {
        if r := recover(); r != nil {
            log.Println(r)
        }
    }()
    panic("...")
}()

問題 5:%w 包了之後 error 訊息變很長

原因:每層 wrap 都加自己的訊息,多層後變很冗

緩解

  • 每層只加新資訊,不重複上一層已有的
  • 對外 API 不暴露 wrap 細節(轉成 sentinel 或自訂型別)

問題 6:return err vs return fmt.Errorf("...: %w", err)

判斷

  • 該層有新增 context(如「在 loadUser 時」)→ wrap
  • 純轉發、無新增資訊 → 直接 return err
// 直接轉發 OK
func wrapper() error {
    return inner()
}

// 加 context 才 wrap
func loadUser(id string) error {
    if err := inner(); err != nil {
        return fmt.Errorf("loadUser id=%s: %w", id, err)
    }
    return nil
}

總結

核心要點

Go 錯誤處理 = error 介面 + errors.Is/As + %w 包裝 + 自訂型別 + sentinel
panic 只給「不該發生」用,recover 只在頂層攔截
defer 是 LIFO,參數宣告時求值

設計原則

  • ✅ 錯誤是值,return 而非 throw
  • ✅ 每層 wrap 加 context(用 %w
  • ✅ 對外 API 用 sentinel / 定型 error
  • ✅ panic 只給程式設計錯誤
  • ✅ recover 用在頂層攔截(HTTP middleware、goroutine 隔離)

速查表

// 建立 error
errors.New("msg")
fmt.Errorf("msg %d", n)
fmt.Errorf("ctx: %w", err)   // 包裝

// 檢查
errors.Is(err, io.EOF)
errors.As(err, &myErr)
errors.Unwrap(err)

// 自訂型別
type MyErr struct { ... }
func (e *MyErr) Error() string { ... }
func (e *MyErr) Unwrap() error { return e.inner }
func (e *MyErr) Is(t error) bool { ... } // 選擇性

// panic / recover
defer func() {
    if r := recover(); r != nil {
        // 處理
    }
}()

何時用什麼速查

情境
業務錯誤 / I/O 錯誤 return error
程式設計錯誤 / 不變式違反 panic
套件對外定義常見錯誤 sentinel (var ErrXxx = errors.New(...))
錯誤要帶資料 自訂 struct 實作 error
包裝下層錯誤 fmt.Errorf("...: %w", err)
檢查特定錯誤 errors.Is
取出特定型別 errors.As
攔截 panic defer func(){ recover() }()

相關閱讀


參考資源


建立日期:2026-05-16 最後更新:2026-05-16

🔗相關文章