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.Open、json.Unmarshal 等,都會回傳 (T, error)。
重點:
error只是一個描述錯誤訊息的介面,不負責堆疊追蹤、錯誤類別等資訊,這些需求需要自行擴充。
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/errors 或 golang.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。 |
額外建議:
- 使用
defer搭配錯誤回傳:在需要清理資源的函式中,defer可以保證資源釋放,即使發生錯誤也不會遺漏。 - 在 API 層統一錯誤格式:例如返回 JSON
{ "code": 1001, "message": "參數錯誤" },有助於前端統一處理。 - 避免在錯誤中泄漏敏感資訊:錯誤訊息不應直接暴露資料庫密碼、金鑰等資訊。
實際應用場景
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 專案中寫出 更可靠、更易除錯 的程式碼,為日後的功能擴充與維護奠定堅實基礎。祝開發順利!