本文 AI 產出,尚未審核

Golang – 錯誤處理與測試

主題:錯誤包裝(fmt.Errorf, %w


簡介

在 Go 語言中,錯誤(error)是一等公民。隨著程式規模的擴大,單純回傳 errors.New("...") 已無法滿足 追蹤錯誤根源層層傳遞測試斷言 的需求。Go 1.13 引入的錯誤包裝機制,讓開發者可以在保留原始錯誤資訊的同時,加入更具語意的上下文。

使用 fmt.Errorf 搭配 %w 動詞,我們可以:

  • 保留 原始錯誤,讓 errors.Iserrors.As 正確辨識。
  • 添加 呼叫堆疊或業務說明,使日誌更具可讀性。
  • 在測試 中精確斷言特定錯誤類型,而不必依賴字串比對。

本篇文章將從概念說明、實作範例、常見陷阱到最佳實踐,完整呈現錯誤包裝的全貌,協助初學者快速上手,也為中階開發者提供更深層的技巧。


核心概念

1. 為什麼要包裝錯誤?

  • 上下文遺失:直接回傳底層錯誤(例如資料庫連線失敗)時,呼叫端無法得知是哪個操作失敗。
  • 錯誤類型辨識:只靠錯誤訊息字串,測試與錯誤處理會變得脆弱。
  • 可讀性:日誌若只顯示「permission denied」難以定位問題根源。

重點:錯誤包裝提供 可組合 的錯誤資訊,同時保持 可比對性errors.Is)與 可類型化errors.As)。

2. fmt.Errorf%w 的語法

fmt.Errorf(format string, a ...interface{}) error
  • %w:只能出現在 一次,且必須對應一個 error 參數。它會把該錯誤「包裝」進新的錯誤物件。
  • 其他動詞(如 %v%s)僅是把錯誤訊息文字化,不會 產生包裝關係。

範例fmt.Errorf("open %s: %w", file, err)

3. errors.Iserrors.As

  • errors.Is(err, target):檢查 err(或其鏈結中的任何錯誤)是否等於 target
  • errors.As(err, &target):若鏈結中有符合類型的錯誤,會把它指派給 target

這兩個函式是 錯誤包裝 的核心,讓我們能在上層安全地判斷錯誤類型。


程式碼範例

以下示範 4 個實用情境,從最基礎到較進階的錯誤包裝技巧。

範例 1:最簡單的錯誤包裝

package main

import (
	"errors"
	"fmt"
)

func readFile(name string) error {
	// 假設這裡發生了底層錯誤
	origErr := errors.New("file not found")
	// 使用 %w 包裝,加入呼叫上下文
	return fmt.Errorf("readFile(%s): %w", name, origErr)
}

func main() {
	if err := readFile("config.yaml"); err != nil {
		// 判斷是否為「file not found」錯誤
		if errors.Is(err, errors.New("file not found")) {
			fmt.Println("⚠️ 找不到檔案,請確認路徑")
		}
		fmt.Println(err) // 輸出:readFile(config.yaml): file not found
	}
}

說明%werrors.Is 能正確辨識底層錯誤,即使外層已加入額外訊息。


範例 2:多層錯誤包裝與 errors.As

package main

import (
	"errors"
	"fmt"
	"net"
)

type NetError struct {
	Op  string
	Err error
}

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

func connect(addr string) error {
	// 假設底層是 net.Dial 的錯誤
	_, err := net.Dial("tcp", addr)
	if err != nil {
		// 包裝成自訂的 NetError
		return &NetError{Op: "connect", Err: err}
	}
	return nil
}

func main() {
	if err := connect("256.256.256.256:80"); err != nil {
		// 使用 errors.As 取得底層 *net.OpError
		var opErr *net.OpError
		if errors.As(err, &opErr) {
			fmt.Printf("⚡️ 網路層錯誤:%s\n", opErr)
		}
		fmt.Println(err) // 輸出:connect: dial tcp: address 256.256.256.256:80: invalid IP address
	}
}

說明:自訂錯誤類型實作 Unwrap(),讓 errors.As 能穿透多層包裝,直接取得底層 *net.OpError


範例 3:在測試中斷言錯誤

package service

import (
	"errors"
	"fmt"
	"testing"
)

var ErrInvalidInput = errors.New("invalid input")

func Process(v int) error {
	if v < 0 {
		return fmt.Errorf("Process: %w", ErrInvalidInput)
	}
	return nil
}

func TestProcess_InvalidInput(t *testing.T) {
	err := Process(-5)
	if !errors.Is(err, ErrInvalidInput) {
		t.Fatalf("期望錯誤 %v,但得到 %v", ErrInvalidInput, err)
	}
}

說明:測試只要檢查 errors.Is,即使錯誤訊息改變(例如加入更多上下文),測試仍會通過,避免因字串變動造成的脆弱測試。


範例 4:多個 %w 的錯誤(不允許)

%w 只能使用一次,若不小心寫成以下程式碼,編譯會失敗:

// 錯誤範例:編譯錯誤,%w 只能出現一次
func badWrap(err1, err2 error) error {
    // compile error: multiple %w verbs in fmt.Errorf format string
    return fmt.Errorf("first: %w, second: %w", err1, err2)
}

正確寫法是只包裝一次,或自行建立自訂錯誤類型來保存多個底層錯誤。


常見陷阱與最佳實踐

陷阱 說明 建議的做法
使用 %v 取代 %w 只會把錯誤文字化,失去包裝關係,errors.Is/As 失效。 必須保留原始錯誤時,務必使用 %w
多次使用 %w fmt.Errorf 只允許一個 %w,會編譯錯誤。 若需多個底層錯誤,考慮自訂錯誤結構(實作 Unwrap)或使用 errors.Join(Go 1.20+)。
忘記 Unwrap 實作 自訂錯誤若不實作 Unwrap()errors.Is/As 無法穿透。 為自訂錯誤類型實作 Unwrap() error,回傳被包裝的錯誤。
在測試中比對錯誤字串 字串容易變動,測試易斷裂。 使用 errors.Iserrors.As 進行斷言。
過度包裝 包裝層級過深會讓日誌難以閱讀。 僅在需要提供新上下文時才包裝,避免「包裝過度」的噪音。

最佳實踐總結

  1. 只在需要上下文時包裝,使用 fmt.Errorf("...: %w", err)
  2. 自訂錯誤類型 時,實作 Error()Unwrap(),讓 errors.Is/As 正常運作。
  3. 測試斷言 盡量使用 errors.Is / errors.As,避免硬編碼錯誤訊息。
  4. 日誌輸出 時,可使用 fmt.Printf("%+v\n", err) 搭配 errors.Unwrap,取得完整鏈結。
  5. Go 1.20+ 若需要合併多個錯誤,可使用 errors.Join,而不是重複 %w

實際應用場景

1. 微服務間的 RPC 呼叫

func CallRemote(ctx context.Context, svc string, payload []byte) error {
    resp, err := http.Post(svc, "application/json", bytes.NewReader(payload))
    if err != nil {
        return fmt.Errorf("CallRemote %s: %w", svc, err) // 包裝底層網路錯誤
    }
    defer resp.Body.Close()
    if resp.StatusCode >= 500 {
        return fmt.Errorf("CallRemote %s: server error %d", svc, resp.StatusCode)
    }
    return nil
}

上層服務只要 errors.Is(err, context.DeadlineExceeded) 即可判斷是否因超時失敗,而不必解析字串。

2. 資料庫交易(Transaction)失敗回滾

func Transfer(db *sql.DB, from, to int, amount float64) error {
    tx, err := db.Begin()
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback() // 若後續出錯自動回滾

    if err = debit(tx, from, amount); err != nil {
        return fmt.Errorf("debit: %w", err)
    }
    if err = credit(tx, to, amount); err != nil {
        return fmt.Errorf("credit: %w", err)
    }
    if err = tx.Commit(); err != nil {
        return fmt.Errorf("commit tx: %w", err)
    }
    return nil
}

呼叫端只要檢查 errors.Is(err, sql.ErrTxDone) 或自訂的 ErrInsufficientFunds,即可針對不同失敗原因做出相應處理。

3. CLI 工具的錯誤回傳

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

func main() {
    if err := run(); err != nil {
        fmt.Fprintf(os.Stderr, "error: %v\n", err)
        os.Exit(1)
    }
}

使用 %wmain 能夠根據錯誤類型決定不同的退出碼(例如 os.Exit(2) 表示參數錯誤),而不必依賴字串比對。


總結

  • 錯誤包裝 是 Go 1.13 之後的標準做法,fmt.Errorf 搭配 %w 能在保留原始錯誤的同時,加入有意義的上下文。
  • 透過 errors.Iserrors.As,我們可以在任何層級安全地判斷錯誤類型或取得底層錯誤,這對 測試日誌錯誤恢復 都至關重要。
  • 自訂錯誤類型 必須實作 Unwrap(),才能讓包裝鏈完整。
  • 常見的錯誤包括使用錯誤的格式動詞、過度包裝或忘記 Unwrap,只要遵守「只在需要時包裝」的原則,就能保持程式碼的可讀性與可維護性。

掌握了錯誤包裝的技巧後,你的 Go 程式將更具 可觀測性可測試性彈性,在實務開發中也能更快速定位與處理問題。祝你在 Golang 的錯誤處理之路上越走越順! 🚀