本文 AI 產出,尚未審核

Golang 錯誤處理與測試

錯誤型態(error)與自訂錯誤


簡介

在 Go 語言中,錯誤(error)是一等公民。不同於例外機制(Exception)需要額外的 try/catch 結構,Go 直接把錯誤作為普通的返回值,讓開發者在每一次呼叫之後都必須顯式檢查。這種設計雖然看起來繁瑣,卻極大提升了程式的可預測性與可維護性,尤其在大型服務或底層函式庫中,錯誤的傳遞與分類往往是系統穩定性的關鍵。

本單元將深入探討 Go 內建的 error 介面、如何建立自訂錯誤類型、以及在測試中如何驗證錯誤行為。文章以 淺顯易懂 的方式說明概念,並提供多個實務範例,適合剛踏入 Go 的新手以及希望提升錯誤處理技巧的中階開發者。


核心概念

1. error 介面的本質

在 Go 中,error 被定義為一個只包含 Error() string 方法的介面:

type error interface {
    Error() string
}

任何實作了 Error() string 方法的型別,都可以被視為 error。標準函式庫大量使用此介面,例如 os.Openjson.Unmarshal 等,都會回傳 (T, error)

重點error 只是一個描述錯誤訊息的介面,不負責堆疊追蹤、錯誤類別等資訊,這些需求需要自行擴充。


2. 建立自訂錯誤類型

自訂錯誤通常有兩個目的:

  1. 提供更豐富的錯誤資訊(如錯誤碼、上下文、堆疊)。
  2. 讓呼叫端能透過類型斷言或 errors.Is / errors.As 判斷錯誤類別

以下示範三種常見的自訂錯誤寫法。

2.1. 最簡單的結構體實作

package main

import (
    "fmt"
)

// MyError 為最簡單的自訂錯誤,只保存訊息
type MyError struct {
    Msg string
}

// Error 實作 error 介面
func (e *MyError) Error() string {
    return e.Msg
}

func doSomething(flag bool) error {
    if !flag {
        return &MyError{Msg: "flag 必須為 true"}
    }
    return nil
}

func main() {
    if err := doSomething(false); err != nil {
        fmt.Println("發生錯誤:", err)
    }
}

說明MyError 只保留一個字串訊息,使用 &MyError{} 產生指標,以符合 error 介面的需求。這種寫法適合 只需要傳遞簡單文字 的情境。

2.2. 含錯誤碼的結構體

package main

import (
    "fmt"
)

// ErrCode 定義錯誤碼
type ErrCode int

const (
    ErrInvalidInput ErrCode = iota + 1
    ErrNotFound
    ErrPermissionDenied
)

// CodeError 包含錯誤碼與訊息
type CodeError struct {
    Code    ErrCode
    Message string
}

// Error 實作
func (e *CodeError) Error() string {
    return fmt.Sprintf("[Code %d] %s", e.Code, e.Message)
}

// Helper 讓外部可直接比較錯誤碼
func (e *CodeError) Is(target error) bool {
    t, ok := target.(*CodeError)
    return ok && e.Code == t.Code
}

func findUser(id int) error {
    if id <= 0 {
        return &CodeError{Code: ErrInvalidInput, Message: "使用者 ID 必須大於 0"}
    }
    // 假設查無此人
    return &CodeError{Code: ErrNotFound, Message: fmt.Sprintf("找不到 ID 為 %d 的使用者", id)}
}

func main() {
    err := findUser(-1)
    if err != nil {
        fmt.Println(err)

        // 使用 errors.Is 進行錯誤碼比對
        if errors.Is(err, &CodeError{Code: ErrInvalidInput}) {
            fmt.Println("=> 參數錯誤,需要檢查輸入")
        }
    }
}

說明CodeError 透過 Is 方法讓 errors.Is 能正確比對錯誤碼。這在 API 回傳統一錯誤碼業務邏輯需要分辨不同失敗類型 時非常有用。

2.3. 包含堆疊資訊的錯誤(使用第三方套件)

Go 標準庫不提供堆疊追蹤功能,常見的做法是結合 github.com/pkg/errorsgolang.org/x/xerrors

package main

import (
    "fmt"

    "github.com/pkg/errors"
)

func readFile(path string) error {
    // 假設這裡發生了底層錯誤
    err := fmt.Errorf("open %s: no such file or directory", path)
    // 使用 Wrap 加上堆疊資訊
    return errors.Wrap(err, "readFile 失敗")
}

func main() {
    if err := readFile("/tmp/missing.txt"); err != nil {
        // 直接印出完整堆疊
        fmt.Printf("%+v\n", err)
    }
}

說明errors.Wrap 會把原始錯誤包裝起來,同時保留呼叫堆疊。透過 %+v 格式化即可看到每一層的呼叫位置,對於 除錯與日誌 非常有幫助。


3. 錯誤的比較與判斷

自 Go 1.13 起,標準庫提供了兩個重要函式:

函式 用途 範例
errors.Is(err, target) 判斷 err 是否等於或包裹了 target(支援 Is 方法) if errors.Is(err, os.ErrNotExist) { … }
errors.As(err, &target) 嘗試把 err 轉型為特定錯誤類型 var perr *PathError; if errors.As(err, &perr) { … }
package main

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

func openFile(name string) error {
    _, err := os.Open(name)
    return err // 直接回傳底層的 *os.PathError
}

func main() {
    err := openFile("not_exist.txt")
    if err != nil {
        // 使用 errors.Is 判斷檔案不存在
        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("檔案不存在")
        }

        // 使用 errors.As 把錯誤轉成 *os.PathError 取得更多資訊
        var pathErr *os.PathError
        if errors.As(err, &pathErr) {
            fmt.Printf("錯誤路徑: %s, 錯誤原因: %v\n", pathErr.Path, pathErr.Err)
        }
    }
}

4. 在測試中驗證錯誤

單元測試(unit test)常需要確認函式在特定條件下會回傳正確的錯誤。以下示範使用 testing 套件與 errors.Is 進行斷言。

package mypkg

import (
    "errors"
    "testing"
)

// 被測試的函式
func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("除數不可為 0")
    }
    return a / b, nil
}

// 測試案例
func TestDivide_Errors(t *testing.T) {
    _, err := Divide(10, 0)

    if err == nil {
        t.Fatalf("預期會回傳錯誤,但 got nil")
    }

    // 直接比對錯誤訊息(不建議在正式專案中硬編碼訊息)
    if err.Error() != "除數不可為 0" {
        t.Errorf("錯誤訊息不符合預期: %v", err)
    }

    // 若使用自訂錯誤類型,可改用 errors.Is
    // if !errors.Is(err, ErrDivByZero) { … }
}

技巧

  • 避免直接比對字串:在大型專案中,錯誤訊息可能會因國際化或調整而改變,建議使用錯誤類型或錯誤碼。
  • 使用 table‑driven 測試:可以一次測試多組輸入與期望錯誤,提升可讀性與維護性。
func TestDivide_TableDriven(t *testing.T) {
    tests := []struct {
        a, b   int
        want   int
        errMsg string
    }{
        {10, 2, 5, ""},
        {10, 0, 0, "除數不可為 0"},
    }

    for _, tt := range tests {
        got, err := Divide(tt.a, tt.b)
        if tt.errMsg == "" && err != nil {
            t.Fatalf("不應有錯誤, got %v", err)
        }
        if tt.errMsg != "" {
            if err == nil || err.Error() != tt.errMsg {
                t.Fatalf("預期錯誤訊息 %q, got %v", tt.errMsg, err)
            }
        }
        if got != tt.want {
            t.Fatalf("Divide(%d,%d) = %d, want %d", tt.a, tt.b, got, tt.want)
        }
    }
}

常見陷阱與最佳實踐

陷阱 說明 最佳實踐
忽略錯誤 直接寫 _, _ = fn(),錯過關鍵失敗資訊。 每一次返回 error 都必須檢查,即使是暫時不需要處理,也應記錄或傳遞。
僅比較錯誤字串 if err.Error() == "xxx" 在訊息變更時會破壞程式。 使用 錯誤類型、錯誤碼或 errors.Is 進行比較。
自訂錯誤未實作 Unwrap 無法使用 errors.Is/As 追蹤包裝層。 若錯誤會被 fmt.Errorf("%w", err) 包裝,請實作 Unwrap() error 或直接使用 fmt.Errorf 包裝。
過度使用全域錯誤變數 例如 var ErrInvalid = errors.New("invalid"),在多個套件間共享會導致衝突。 為每個套件定義 獨立的錯誤變數,或使用 自訂類型 包含錯誤碼。
在測試中硬編碼錯誤訊息 文字變更會導致測試失敗。 透過 自訂錯誤類型錯誤碼 斷言,或使用 errors.Is

額外建議

  1. 使用 defer 搭配錯誤回傳:在需要清理資源的函式中,defer 可以保證資源釋放,即使發生錯誤也不會遺漏。
  2. 在 API 層統一錯誤格式:例如返回 JSON { "code": 1001, "message": "參數錯誤" },有助於前端統一處理。
  3. 避免在錯誤中泄漏敏感資訊:錯誤訊息不應直接暴露資料庫密碼、金鑰等資訊。

實際應用場景

1. Web API 的錯誤回傳

type APIError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

func (e *APIError) Error() string { return e.Message }

func NewAPIError(code int, msg string) *APIError {
    return &APIError{Code: code, Message: msg}
}

// handler 範例
func getUserHandler(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.URL.Query().Get("id"))
    if err != nil {
        writeJSON(w, http.StatusBadRequest, NewAPIError(4001, "ID 必須是數字"))
        return
    }

    user, err := dbFindUser(id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            writeJSON(w, http.StatusNotFound, NewAPIError(4040, "找不到使用者"))
            return
        }
        // 其他未知錯誤
        writeJSON(w, http.StatusInternalServerError, NewAPIError(5000, "系統錯誤"))
        return
    }

    writeJSON(w, http.StatusOK, user)
}

說明:API 端統一使用 APIError,讓前端只需要根據 code 判斷錯誤類型。

2. 資料庫交易(Transaction)中的錯誤傳遞

func Transfer(tx *sql.Tx, from, to int, amount float64) error {
    // 1. 扣款
    if _, err := tx.Exec("UPDATE account SET balance = balance - ? WHERE id = ?", amount, from); err != nil {
        return fmt.Errorf("扣款失敗: %w", err)
    }

    // 2. 入帳
    if _, err := tx.Exec("UPDATE account SET balance = balance + ? WHERE id = ?", amount, to); err != nil {
        return fmt.Errorf("入帳失敗: %w", err)
    }

    // 3. 提交交易
    if err := tx.Commit(); err != nil {
        return fmt.Errorf("提交交易失敗: %w", err)
    }
    return nil
}

在呼叫端:

tx, _ := db.Begin()
if err := Transfer(tx, 1, 2, 100); err != nil {
    tx.Rollback() // 確保失敗時回滾
    log.Printf("交易失敗: %v", err)
}

重點:使用 %w 包裝錯誤,使得外層可以透過 errors.Is 判斷根本原因(例如 sql.ErrNoRows),同時保留完整堆疊。

3. CLI 工具的錯誤回報

func main() {
    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, "錯誤:", err)
        // 若是自訂的退出碼錯誤,可根據錯誤類型決定退出碼
        var exitErr *ExitError
        if errors.As(err, &exitErr) {
            os.Exit(exitErr.Code)
        }
        os.Exit(1)
    }
}

type ExitError struct {
    Code int
    Msg  string
}

func (e *ExitError) Error() string { return e.Msg }

func run() error {
    // 假設缺少必需參數
    return &ExitError{Code: 2, Msg: "缺少 --config 參數"}
}

此模式讓 CLI 在不同失敗情境下回傳不同的退出碼,方便腳本判斷。


總結

  • Go 的 error 介面只有一個 Error() string 方法,簡潔卻足以支撐彈性的錯誤處理
  • 透過 自訂錯誤結構(加入錯誤碼、上下文或堆疊)可以讓錯誤資訊更完整,且方便在呼叫端 使用 errors.Is / errors.As 進行類型判斷。
  • 測試 時應避免硬編碼錯誤訊息,建議使用錯誤類型或錯誤碼斷言,並採用 table‑driven 測試提升可讀性。
  • 常見陷阱包括忽略錯誤、僅比較字串、未實作 Unwrap、過度使用全域錯誤變數等;遵循最佳實踐可大幅提升程式的健壯性與可維護性。
  • Web API、資料庫交易、CLI 工具 等實務場景中,適當的錯誤分類與回傳策略是系統穩定運作的關鍵。

掌握了錯誤型態與自訂錯誤的設計,你就能在 Go 專案中寫出 更可靠、更易除錯 的程式碼,為日後的功能擴充與維護奠定堅實基礎。祝開發順利!