編程基礎 0005_錯誤處理進階

Go 錯誤處理進階

目錄

  1. Go 錯誤處理哲學
  2. error 接口本質
  3. 自定義錯誤類型
  4. fmt.Errorf 與 %w 包裝錯誤
  5. errors.Is 和 errors.As
  6. 哨兵錯誤模式
  7. 錯誤處理最佳實踐
  8. 實際項目中的錯誤處理模式

1. Go 錯誤處理哲學

1.1 與 try-catch 的根本區別

在 Java、Python、C++ 等語言中,異常處理依賴 try-catch 機制——異常被"拋出"後沿調用棧向上傳播,直到被某個 catch 塊捕獲。這種模式有幾個問題:

  • 異常的傳播路徑是隱式的,調用者不知道某個函數可能拋出什麼異常
  • 異常控制流類似 goto,會打斷正常的代碼邏輯
  • 容易被濫用(用異常做流程控制)

Go 採用了完全不同的哲學:錯誤是值(errors are values)。錯誤通過函數返回值顯式傳遞,調用者必須主動檢查並處理錯誤。

// Java 風格(隱式異常傳播)
try {
    File f = openFile("config.json");
    String data = readAll(f);
    Config config = parse(data);
} catch (FileNotFoundException e) {
    // 處理文件不存在
} catch (IOException e) {
    // 處理IO錯誤
} catch (ParseException e) {
    // 處理解析錯誤
}

// Go 風格(顯式錯誤處理)
f, err := os.Open("config.json")
if err != nil {
    return fmt.Errorf("打開配置文件失敗: %w", err)
}
defer f.Close()

data, err := io.ReadAll(f)
if err != nil {
    return fmt.Errorf("讀取配置文件失敗: %w", err)
}

var config Config
if err := json.Unmarshal(data, &config); err != nil {
    return fmt.Errorf("解析配置文件失敗: %w", err)
}

1.2 顯式錯誤處理的優勢

package main

import (
    "fmt"
    "strconv"
)

// 每個可能失敗的操作都顯式返回錯誤
// 調用者可以清楚看到哪些步驟可能出錯
func parseAndDouble(s string) (int, error) {
    // 第一步:解析字符串爲整數
    n, err := strconv.Atoi(s)
    if err != nil {
        return 0, fmt.Errorf("解析整數失敗: %w", err)
    }

    // 第二步:檢查範圍
    if n < 0 || n > 1000 {
        return 0, fmt.Errorf("數值 %d 超出有效範圍 [0, 1000]", n)
    }

    return n * 2, nil
}

func main() {
    // 正常情況
    result, err := parseAndDouble("42")
    if err != nil {
        fmt.Println("錯誤:", err)
        return
    }
    fmt.Println("結果:", result) // 結果: 84

    // 非數字
    _, err = parseAndDouble("abc")
    if err != nil {
        fmt.Println("錯誤:", err) // 錯誤: 解析整數失敗: strconv.Atoi: parsing "abc": invalid syntax
    }

    // 超出範圍
    _, err = parseAndDouble("9999")
    if err != nil {
        fmt.Println("錯誤:", err) // 錯誤: 數值 9999 超出有效範圍 [0, 1000]
    }
}

1.3 Go 的錯誤處理格言

Rob Pike 在 "Errors are values" 博文中指出,既然錯誤是普通的值,就可以用編程技巧來簡化錯誤處理,而不是機械地 if err != nil

package main

import (
    "bufio"
    "fmt"
    "os"
)

// errWriter 封裝了 writer 和錯誤狀態
// 一旦發生錯誤,後續寫入操作自動跳過
type errWriter struct {
    w   *bufio.Writer
    err error
}

func (ew *errWriter) write(buf []byte) {
    if ew.err != nil {
        return // 已經有錯誤了,跳過
    }
    _, ew.err = ew.w.Write(buf)
}

func (ew *errWriter) flush() error {
    if ew.err != nil {
        return ew.err
    }
    return ew.w.Flush()
}

func writeReport(f *os.File) error {
    bw := bufio.NewWriter(f)
    ew := &errWriter{w: bw}

    // 不需要每次都檢查錯誤,最後統一檢查
    ew.write([]byte("=== 報告標題 ===\n"))
    ew.write([]byte("第一部分內容...\n"))
    ew.write([]byte("第二部分內容...\n"))
    ew.write([]byte("=== 報告結束 ===\n"))

    return ew.flush()
}

func main() {
    f, err := os.Create("/tmp/report.txt")
    if err != nil {
        fmt.Println("創建文件失敗:", err)
        return
    }
    defer f.Close()

    if err := writeReport(f); err != nil {
        fmt.Println("寫入報告失敗:", err)
        return
    }
    fmt.Println("報告寫入成功")
}

2. error 接口本質

2.1 error 是一個接口

Go 內置的 error 類型實際上是一個非常簡單的接口:

// Go 標準庫中的定義
type error interface {
    Error() string
}

任何實現了 Error() string 方法的類型都滿足 error 接口。這種設計極其簡潔而強大。

2.2 標準庫中的 errors.New 和 fmt.Errorf

package main

import (
    "errors"
    "fmt"
)

func main() {
    // errors.New 創建一個簡單的錯誤
    err1 := errors.New("something went wrong")
    fmt.Println(err1)        // something went wrong
    fmt.Printf("%T\n", err1) // *errors.errorString

    // fmt.Errorf 創建格式化的錯誤
    name := "config.json"
    err2 := fmt.Errorf("無法打開文件 %s", name)
    fmt.Println(err2) // 無法打開文件 config.json

    // error 的零值是 nil,表示沒有錯誤
    var err3 error
    fmt.Println(err3 == nil) // true
}

2.3 errors.New 的內部實現

// 標準庫 errors 包的實現非常簡單
// 這裏展示其核心邏輯

package main

import "fmt"

// errorString 是一個實現了 error 接口的簡單結構體
type errorString struct {
    s string
}

// Error 方法使 errorString 滿足 error 接口
func (e *errorString) Error() string {
    return e.s
}

// New 創建一個新的錯誤,返回指針
// 返回指針而不是值,是爲了讓每次 New 調用產生不同的錯誤實例
func New(text string) error {
    return &errorString{text}
}

func main() {
    err1 := New("出錯了")
    err2 := New("出錯了")

    // 雖然文本相同,但它們是不同的錯誤實例(指針不同)
    fmt.Println(err1 == err2) // false

    fmt.Println(err1) // 出錯了
}

2.4 nil error 的陷阱

package main

import "fmt"

// MyError 自定義錯誤類型
type MyError struct {
    Code    int
    Message string
}

func (e *MyError) Error() string {
    return fmt.Sprintf("錯誤碼 %d: %s", e.Code, e.Message)
}

func doSomething(fail bool) error {
    // 注意:這裏聲明瞭一個具體類型的指針
    var err *MyError

    if fail {
        err = &MyError{Code: 404, Message: "未找到"}
    }

    // 危險!即使 err 是 nil 指針,返回後 error 接口值不爲 nil
    return err
}

func doSomethingCorrect(fail bool) error {
    if fail {
        return &MyError{Code: 404, Message: "未找到"}
    }
    // 直接返回 nil,不經過具體類型變量
    return nil
}

func main() {
    // 錯誤示範:即使沒有失敗,err 也不等於 nil!
    err := doSomething(false)
    if err != nil {
        // 會進入這裏!因爲 error 接口內部持有 (*MyError, nil)
        // 接口值只有在類型和值都爲 nil 時纔等於 nil
        fmt.Println("錯誤的判斷 - 進入了錯誤分支")
        fmt.Printf("err 類型: %T, 值: %v\n", err, err)
    }

    // 正確示範
    err2 := doSomethingCorrect(false)
    if err2 == nil {
        fmt.Println("正確:沒有錯誤") // 正確:沒有錯誤
    }
}

關鍵教訓:永遠不要返回一個具體類型的 nil 指針作爲 error 接口值。如果沒有錯誤,直接 return nil


3. 自定義錯誤類型

3.1 簡單的自定義錯誤

package main

import "fmt"

// ValidationError 驗證錯誤,包含字段名和錯誤原因
type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("驗證失敗 [%s]: %s", e.Field, e.Message)
}

// NotFoundError 資源未找到錯誤
type NotFoundError struct {
    Resource string
    ID       string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s (ID: %s) 不存在", e.Resource, e.ID)
}

func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{
            Field:   "age",
            Message: "年齡不能爲負數",
        }
    }
    if age > 150 {
        return &ValidationError{
            Field:   "age",
            Message: "年齡不能超過150",
        }
    }
    return nil
}

func findUser(id string) (string, error) {
    // 模擬查找用戶
    users := map[string]string{
        "1": "Alice",
        "2": "Bob",
    }
    name, ok := users[id]
    if !ok {
        return "", &NotFoundError{
            Resource: "用戶",
            ID:       id,
        }
    }
    return name, nil
}

func main() {
    // 測試驗證錯誤
    if err := validateAge(-5); err != nil {
        fmt.Println(err) // 驗證失敗 [age]: 年齡不能爲負數

        // 類型斷言獲取詳細信息
        if ve, ok := err.(*ValidationError); ok {
            fmt.Printf("字段: %s, 原因: %s\n", ve.Field, ve.Message)
        }
    }

    // 測試未找到錯誤
    _, err := findUser("99")
    if err != nil {
        fmt.Println(err) // 用戶 (ID: 99) 不存在

        if nfe, ok := err.(*NotFoundError); ok {
            fmt.Printf("資源類型: %s, ID: %s\n", nfe.Resource, nfe.ID)
        }
    }
}

3.2 攜帶上下文的錯誤類型

package main

import (
    "fmt"
    "time"
)

// OpError 記錄操作上下文的錯誤
type OpError struct {
    Op      string    // 操作名稱
    Path    string    // 資源路徑
    Err     error     // 底層錯誤(支持錯誤鏈)
    Time    time.Time // 發生時間
}

func (e *OpError) Error() string {
    return fmt.Sprintf("操作 %s 在 %s 上失敗 [%s]: %v",
        e.Op, e.Path, e.Time.Format("15:04:05"), e.Err)
}

// Unwrap 支持錯誤鏈解包
func (e *OpError) Unwrap() error {
    return e.Err
}

// PermissionError 權限錯誤
type PermissionError struct {
    User   string
    Action string
}

func (e *PermissionError) Error() string {
    return fmt.Sprintf("用戶 %s 沒有 %s 權限", e.User, e.Action)
}

func readConfig(user string) error {
    // 模擬權限檢查失敗
    permErr := &PermissionError{
        User:   user,
        Action: "read",
    }

    // 包裝爲操作錯誤
    return &OpError{
        Op:   "ReadConfig",
        Path: "/etc/app/config.yaml",
        Err:  permErr,
        Time: time.Now(),
    }
}

func main() {
    err := readConfig("guest")
    if err != nil {
        fmt.Println(err)
        // 操作 ReadConfig 在 /etc/app/config.yaml 上失敗 [14:30:05]: 用戶 guest 沒有 read 權限
    }
}

3.3 多錯誤聚合

package main

import (
    "fmt"
    "strings"
)

// MultiError 聚合多個錯誤
type MultiError struct {
    Errors []error
}

func (me *MultiError) Error() string {
    if len(me.Errors) == 0 {
        return "沒有錯誤"
    }
    msgs := make([]string, len(me.Errors))
    for i, err := range me.Errors {
        msgs[i] = fmt.Sprintf("  [%d] %s", i+1, err.Error())
    }
    return fmt.Sprintf("發生 %d 個錯誤:\n%s", len(me.Errors), strings.Join(msgs, "\n"))
}

// Add 添加錯誤(忽略 nil)
func (me *MultiError) Add(err error) {
    if err != nil {
        me.Errors = append(me.Errors, err)
    }
}

// ErrorOrNil 如果沒有錯誤則返回 nil
func (me *MultiError) ErrorOrNil() error {
    if len(me.Errors) == 0 {
        return nil
    }
    return me
}

// validateUser 同時驗證多個字段
func validateUser(name string, age int, email string) error {
    me := &MultiError{}

    if name == "" {
        me.Add(fmt.Errorf("姓名不能爲空"))
    }
    if age < 0 || age > 150 {
        me.Add(fmt.Errorf("年齡 %d 不在有效範圍內", age))
    }
    if !strings.Contains(email, "@") {
        me.Add(fmt.Errorf("郵箱 %q 格式不正確", email))
    }

    return me.ErrorOrNil()
}

func main() {
    // 多個字段驗證失敗
    err := validateUser("", -1, "not-an-email")
    if err != nil {
        fmt.Println(err)
        // 發生 3 個錯誤:
        //   [1] 姓名不能爲空
        //   [2] 年齡 -1 不在有效範圍內
        //   [3] 郵箱 "not-an-email" 格式不正確
    }

    // 全部驗證通過
    err = validateUser("Alice", 30, "alice@example.com")
    fmt.Println("全部通過:", err == nil) // 全部通過: true
}

4. fmt.Errorf 與 %w 包裝錯誤

4.1 錯誤包裝基礎(Go 1.13+)

Go 1.13 引入了錯誤包裝機制,通過 fmt.Errorf%w 動詞可以將一個錯誤包裝在另一個錯誤中,形成錯誤鏈。

package main

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

func readFile(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        // %w 包裝原始錯誤,保留錯誤鏈
        return nil, fmt.Errorf("讀取文件 %s: %w", path, err)
    }
    return data, nil
}

func loadConfig() ([]byte, error) {
    data, err := readFile("/etc/app/config.json")
    if err != nil {
        // 繼續包裝,形成多層錯誤鏈
        return nil, fmt.Errorf("加載配置: %w", err)
    }
    return data, nil
}

func main() {
    _, err := loadConfig()
    if err != nil {
        fmt.Println("錯誤信息:", err)
        // 加載配置: 讀取文件 /etc/app/config.json: open /etc/app/config.json: no such file or directory

        // 可以通過 errors.Is 檢查底層錯誤
        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("根因:文件不存在")
        }
    }
}

4.2 %w 與 %v 的區別

package main

import (
    "errors"
    "fmt"
    "io"
)

func example_w() error {
    // %w 包裝錯誤 -- 保留錯誤鏈,可以用 errors.Is/errors.As 查找
    return fmt.Errorf("操作失敗: %w", io.EOF)
}

func example_v() error {
    // %v 僅格式化錯誤文本 -- 丟失錯誤鏈,無法用 errors.Is/errors.As 查找
    return fmt.Errorf("操作失敗: %v", io.EOF)
}

func main() {
    err1 := example_w()
    fmt.Println("使用 %%w:", errors.Is(err1, io.EOF)) // true -- 錯誤鏈完整

    err2 := example_v()
    fmt.Println("使用 %%v:", errors.Is(err2, io.EOF)) // false -- 錯誤鏈斷裂
}

4.3 多重包裝(Go 1.20+)

Go 1.20 起,fmt.Errorf 支持多個 %w,可以同時包裝多個錯誤:

package main

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

func complexOperation() error {
    err1 := os.ErrPermission
    err2 := io.ErrUnexpectedEOF

    // 同時包裝兩個錯誤
    return fmt.Errorf("操作失敗: %w 且 %w", err1, err2)
}

func main() {
    err := complexOperation()
    fmt.Println(err) // 操作失敗: permission denied 且 unexpected EOF

    // 兩個底層錯誤都可以被 errors.Is 找到
    fmt.Println("是權限錯誤?", errors.Is(err, os.ErrPermission))   // true
    fmt.Println("是意外EOF?", errors.Is(err, io.ErrUnexpectedEOF)) // true
}

4.4 手動實現 Unwrap

package main

import (
    "errors"
    "fmt"
    "io"
)

// QueryError 包裝底層錯誤的自定義類型
type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string {
    return fmt.Sprintf("查詢 %q 失敗: %v", e.Query, e.Err)
}

// Unwrap 讓 errors.Is/errors.As 能沿鏈查找
func (e *QueryError) Unwrap() error {
    return e.Err
}

// JoinedError 包裝多個底層錯誤(Go 1.20 風格)
type JoinedError struct {
    Message string
    Errs    []error
}

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

// Unwrap 返回切片(Go 1.20+ 支持的多錯誤解包協議)
func (e *JoinedError) Unwrap() []error {
    return e.Errs
}

func main() {
    // 單錯誤鏈
    qErr := &QueryError{
        Query: "SELECT * FROM users",
        Err:   io.EOF,
    }
    fmt.Println(qErr)                        // 查詢 "SELECT * FROM users" 失敗: EOF
    fmt.Println(errors.Is(qErr, io.EOF))     // true

    // 多錯誤鏈
    jErr := &JoinedError{
        Message: "多個操作失敗",
        Errs:    []error{io.EOF, io.ErrClosedPipe},
    }
    fmt.Println(errors.Is(jErr, io.EOF))           // true
    fmt.Println(errors.Is(jErr, io.ErrClosedPipe)) // true
}

5. errors.Is 和 errors.As

5.1 errors.Is -- 檢查錯誤鏈中是否包含某個特定錯誤值

package main

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

var ErrDatabase = errors.New("數據庫錯誤")

func queryDB() error {
    return fmt.Errorf("執行查詢: %w", ErrDatabase)
}

func handleRequest() error {
    return fmt.Errorf("處理請求: %w", queryDB())
}

func main() {
    err := handleRequest()

    // errors.Is 會沿著錯誤鏈逐層查找
    // err -> "處理請求" -> "執行查詢" -> ErrDatabase
    fmt.Println(errors.Is(err, ErrDatabase)) // true

    // 常見的標準庫哨兵錯誤判斷
    err2 := fmt.Errorf("讀取結束: %w", io.EOF)
    fmt.Println(errors.Is(err2, io.EOF)) // true

    // 不在錯誤鏈中的錯誤
    fmt.Println(errors.Is(err, os.ErrNotExist)) // false

    // == 只能匹配同一個錯誤實例,不能沿鏈查找
    fmt.Println(err == ErrDatabase) // false(err 是包裝後的,不等於原始值)
}

5.2 errors.As -- 從錯誤鏈中提取特定類型的錯誤

package main

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

// APIError 自定義API錯誤
type APIError struct {
    StatusCode int
    Message    string
}

func (e *APIError) Error() string {
    return fmt.Sprintf("API錯誤 %d: %s", e.StatusCode, e.Message)
}

func callAPI() error {
    apiErr := &APIError{StatusCode: 403, Message: "禁止訪問"}
    return fmt.Errorf("調用用戶服務: %w", apiErr)
}

func main() {
    err := callAPI()

    // errors.As 從錯誤鏈中找到匹配類型的錯誤並賦值給 target
    var apiErr *APIError
    if errors.As(err, &apiErr) {
        fmt.Printf("狀態碼: %d, 消息: %s\n", apiErr.StatusCode, apiErr.Message)
        // 狀態碼: 403, 消息: 禁止訪問
    }

    // 標準庫中的實際應用:提取網絡錯誤的詳細信息
    _, netErr := net.Dial("tcp", "invalid-host:99999")
    if netErr != nil {
        var opErr *net.OpError
        if errors.As(netErr, &opErr) {
            fmt.Printf("網絡操作: %s, 地址: %v\n", opErr.Op, opErr.Addr)
        }
    }
}

5.3 errors.Is 與 errors.As 的對比

package main

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

func main() {
    // 製造一個文件操作錯誤
    _, err := os.Open("/這個文件不存在")
    if err == nil {
        return
    }

    wrappedErr := fmt.Errorf("初始化失敗: %w", err)

    // errors.Is: 用於匹配特定的錯誤【值】
    // 問題:這個錯誤是不是 os.ErrNotExist?
    if errors.Is(wrappedErr, os.ErrNotExist) {
        fmt.Println("Is: 文件不存在") // 匹配成功
    }

    // errors.As: 用於匹配特定的錯誤【類型】,並提取該類型的值
    // 問題:這個錯誤鏈中有沒有 *os.PathError 類型的錯誤?
    var pathErr *os.PathError
    if errors.As(wrappedErr, &pathErr) {
        fmt.Printf("As: 路徑=%s, 操作=%s\n", pathErr.Path, pathErr.Op)
        // As: 路徑=/這個文件不存在, 操作=open
    }

    // 總結:
    // errors.Is(err, target)    -> 值比較,類似 err == target 但支持錯誤鏈
    // errors.As(err, &target)   -> 類型匹配,類似類型斷言但支持錯誤鏈
}

5.4 自定義 Is 和 As 方法

package main

import (
    "errors"
    "fmt"
)

// ErrCode 基於錯誤碼的錯誤類型
type ErrCode struct {
    Code    int
    Message string
}

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

// Is 自定義比較邏輯:只要錯誤碼相同就認爲匹配
func (e *ErrCode) Is(target error) bool {
    t, ok := target.(*ErrCode)
    if !ok {
        return false
    }
    return e.Code == t.Code
}

func main() {
    err1 := &ErrCode{Code: 404, Message: "用戶未找到"}
    err2 := &ErrCode{Code: 404, Message: "訂單未找到"}
    err3 := &ErrCode{Code: 500, Message: "內部錯誤"}

    // 雖然消息不同,但錯誤碼相同,Is 返回 true
    fmt.Println(errors.Is(err1, err2)) // true
    fmt.Println(errors.Is(err1, err3)) // false

    // 包裝後仍然有效
    wrapped := fmt.Errorf("服務層: %w", err1)
    target := &ErrCode{Code: 404} // 只關心錯誤碼
    fmt.Println(errors.Is(wrapped, target)) // true
}

6. 哨兵錯誤模式

6.1 什麼是哨兵錯誤

哨兵錯誤(Sentinel Errors)是預定義的、包級別的錯誤變量,用作特定條件的標識符。調用者通過 errors.Is 來檢查是否遇到了某個特定的已知錯誤。

package main

import (
    "errors"
    "fmt"
    "io"
    "bufio"
    "strings"
)

func main() {
    // 標準庫中最常見的哨兵錯誤:io.EOF
    reader := strings.NewReader("Hello")
    scanner := bufio.NewScanner(reader)

    // io.EOF 表示數據讀取完畢,不是真正的"錯誤"
    buf := make([]byte, 10)
    for {
        n, err := strings.NewReader("Hello").Read(buf)
        if errors.Is(err, io.EOF) {
            fmt.Printf("讀取完畢,最後讀取 %d 字節\n", n)
            break
        }
        if err != nil {
            fmt.Println("讀取出錯:", err)
            break
        }
        fmt.Printf("讀取 %d 字節: %s\n", n, buf[:n])
    }

    _ = scanner
}

6.2 標準庫中的哨兵錯誤示例

package main

import (
    "context"
    "database/sql"
    "errors"
    "fmt"
    "io"
    "os"
    "time"
)

func main() {
    // 1. io.EOF - 數據流結束
    fmt.Println("io.EOF:", io.EOF)

    // 2. io.ErrUnexpectedEOF - 讀取到一半意外終止
    fmt.Println("io.ErrUnexpectedEOF:", io.ErrUnexpectedEOF)

    // 3. os.ErrNotExist - 文件不存在
    _, err := os.Stat("/不存在的文件")
    if errors.Is(err, os.ErrNotExist) {
        fmt.Println("文件不存在")
    }

    // 4. os.ErrPermission - 權限不足
    fmt.Println("os.ErrPermission:", os.ErrPermission)

    // 5. sql.ErrNoRows - 查詢沒有返回結果
    // 注意:這不是錯誤,只是表示沒有匹配的行
    fmt.Println("sql.ErrNoRows:", sql.ErrNoRows)

    // 6. context.Canceled - 上下文被取消
    ctx, cancel := context.WithCancel(context.Background())
    cancel()
    fmt.Println("ctx.Err():", ctx.Err())
    fmt.Println("是 Canceled?", errors.Is(ctx.Err(), context.Canceled))

    // 7. context.DeadlineExceeded - 上下文超時
    ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Nanosecond)
    defer cancel2()
    time.Sleep(1 * time.Millisecond)
    fmt.Println("是 DeadlineExceeded?", errors.Is(ctx2.Err(), context.DeadlineExceeded))
}

6.3 自定義哨兵錯誤

package main

import (
    "errors"
    "fmt"
)

// 包級別的哨兵錯誤,使用 errors.New 創建
var (
    ErrUserNotFound    = errors.New("用戶未找到")
    ErrUserExists      = errors.New("用戶已存在")
    ErrInvalidPassword = errors.New("密碼無效")
    ErrAccountLocked   = errors.New("賬戶已鎖定")
)

// UserStore 模擬用戶存儲
type UserStore struct {
    users map[string]string // username -> password
}

func NewUserStore() *UserStore {
    return &UserStore{
        users: map[string]string{
            "alice": "secret123",
        },
    }
}

func (s *UserStore) Register(username, password string) error {
    if _, exists := s.users[username]; exists {
        return fmt.Errorf("註冊用戶 %s: %w", username, ErrUserExists)
    }
    s.users[username] = password
    return nil
}

func (s *UserStore) Login(username, password string) error {
    stored, exists := s.users[username]
    if !exists {
        return fmt.Errorf("登錄 %s: %w", username, ErrUserNotFound)
    }
    if stored != password {
        return fmt.Errorf("登錄 %s: %w", username, ErrInvalidPassword)
    }
    return nil
}

func main() {
    store := NewUserStore()

    // 註冊已存在的用戶
    err := store.Register("alice", "newpass")
    if errors.Is(err, ErrUserExists) {
        fmt.Println("註冊失敗:用戶已存在")
    }

    // 登錄不存在的用戶
    err = store.Login("bob", "pass")
    if errors.Is(err, ErrUserNotFound) {
        fmt.Println("登錄失敗:用戶未找到")
    }

    // 密碼錯誤
    err = store.Login("alice", "wrong")
    if errors.Is(err, ErrInvalidPassword) {
        fmt.Println("登錄失敗:密碼錯誤")
    }

    // 使用 switch 分發錯誤處理
    err = store.Login("bob", "test")
    switch {
    case errors.Is(err, ErrUserNotFound):
        fmt.Println("-> 建議註冊新賬號")
    case errors.Is(err, ErrInvalidPassword):
        fmt.Println("-> 建議重置密碼")
    case errors.Is(err, ErrAccountLocked):
        fmt.Println("-> 請聯繫管理員")
    case err != nil:
        fmt.Println("-> 未知錯誤:", err)
    default:
        fmt.Println("-> 登錄成功")
    }
}

7. 錯誤處理最佳實踐

7.1 何時使用 panic

panic 用於不可恢復的程序錯誤,而不是業務邏輯錯誤。

package main

import "fmt"

// 合理使用 panic 的場景

// 1. 程序初始化時的致命錯誤
func mustConnect(dsn string) {
    // 如果數據庫連接失敗,程序無法運行
    if dsn == "" {
        panic("數據庫連接字符串不能爲空")
    }
    // ... 連接數據庫
}

// 2. Must 函數模式:將 error 轉化爲 panic
// 標準庫中有 template.Must, regexp.MustCompile 等
func MustParseConfig(path string) map[string]string {
    config := map[string]string{"key": "value"} // 模擬解析
    if path == "" {
        panic("配置文件路徑爲空")
    }
    return config
}

// 3. 真正不可能發生的錯誤(程序邏輯bug)
func divide(a, b int) int {
    if b == 0 {
        // 如果調用者保證 b != 0,這裏 panic 表示程序有 bug
        panic("除數爲零:這是一個編程錯誤")
    }
    return a / b
}

func main() {
    // Must 模式通常在 init 或 main 的開頭使用
    config := MustParseConfig("app.yaml")
    fmt.Println(config)
}

7.2 recover 捕獲 panic

package main

import "fmt"

// safeExecute 安全執行一個函數,捕獲可能的 panic
func safeExecute(fn func()) (err error) {
    defer func() {
        if r := recover(); r != nil {
            // 將 panic 轉化爲 error
            err = fmt.Errorf("捕獲到 panic: %v", r)
        }
    }()

    fn()
    return nil
}

func riskyFunction() {
    panic("出大事了!")
}

func main() {
    // 場景:HTTP 中間件防止單個請求的 panic 導致服務崩潰
    err := safeExecute(riskyFunction)
    if err != nil {
        fmt.Println("安全捕獲:", err)
        // 安全捕獲: 捕獲到 panic: 出大事了!
    }

    fmt.Println("程序繼續運行...")
}

7.3 錯誤處理的黃金法則

package main

import (
    "fmt"
    "log"
)

// 規則1: 錯誤只應該被處理一次
// 要麼處理它(記錄日誌、降級、返回默認值),要麼向上傳遞
// 不要既記日誌又返回錯誤

// 錯誤示範
func badExample() error {
    err := fmt.Errorf("something failed")
    if err != nil {
        log.Println("錯誤:", err) // 處理了一次(記日誌)
        return err                 // 又返回了(讓調用者再處理一次)
    }
    return nil
}

// 正確示範 A: 只向上傳遞(加上下文)
func goodExampleA() error {
    err := fmt.Errorf("something failed")
    if err != nil {
        return fmt.Errorf("執行操作X: %w", err) // 只傳遞,不記日誌
    }
    return nil
}

// 正確示範 B: 只在這裏處理
func goodExampleB() int {
    err := fmt.Errorf("something failed")
    if err != nil {
        log.Println("操作X失敗,使用默認值:", err) // 處理:記日誌+降級
        return 42                                    // 返回默認值
    }
    return 0
}

// 規則2: 總是爲錯誤添加上下文
func fetchUserOrders(userID string) error {
    // 不好: return err
    // 好:   return fmt.Errorf("獲取用戶 %s 的訂單: %w", userID, err)
    return fmt.Errorf("獲取用戶 %s 的訂單: %w", userID,
        fmt.Errorf("數據庫查詢超時"))
}

// 規則3: 在頂層處理錯誤,底層只傳遞
func main() {
    // 這裏是"頂層",負責最終處理
    if err := fetchUserOrders("u123"); err != nil {
        log.Println("請求處理失敗:", err)
        // 可能還要返回 HTTP 500、發送告警等
    }

    _ = badExample()
    _ = goodExampleA()
    _ = goodExampleB()
}

7.4 不要忽略錯誤

package main

import (
    "fmt"
    "os"
)

func main() {
    // 糟糕:忽略了 Close 的錯誤(寫入可能沒有刷到磁盤)
    f, _ := os.Create("/tmp/test.txt")
    f.Write([]byte("data"))
    f.Close() // Close 可能返回錯誤!

    // 正確方式
    f2, err := os.Create("/tmp/test2.txt")
    if err != nil {
        fmt.Println("創建文件失敗:", err)
        return
    }

    _, err = f2.Write([]byte("data"))
    if err != nil {
        f2.Close()
        fmt.Println("寫入失敗:", err)
        return
    }

    if err := f2.Close(); err != nil {
        fmt.Println("關閉文件失敗(數據可能未完全寫入):", err)
        return
    }

    // 如果確實要忽略某個錯誤,用 _ 顯式忽略並加註釋
    _ = os.Remove("/tmp/maybe-not-exists.txt") // 忽略: 文件可能不存在,不影響邏輯
}

8. 實際項目中的錯誤處理模式

8.1 分層錯誤設計

在一個分層架構中(Repository -> Service -> Handler),每層添加自己的上下文:

package main

import (
    "errors"
    "fmt"
)

// ========== 基礎錯誤定義 ==========

var (
    ErrNotFound     = errors.New("資源未找到")
    ErrUnauthorized = errors.New("未授權")
    ErrInternal     = errors.New("內部錯誤")
)

// ========== Repository 層 ==========

type UserRepo struct{}

func (r *UserRepo) FindByID(id int) (string, error) {
    if id == 0 {
        return "", fmt.Errorf("UserRepo.FindByID(id=%d): %w", id, ErrNotFound)
    }
    if id < 0 {
        return "", fmt.Errorf("UserRepo.FindByID(id=%d): 數據庫連接失敗: %w", id, ErrInternal)
    }
    return "Alice", nil
}

// ========== Service 層 ==========

type UserService struct {
    repo *UserRepo
}

func (s *UserService) GetUserProfile(id int) (string, error) {
    name, err := s.repo.FindByID(id)
    if err != nil {
        return "", fmt.Errorf("UserService.GetUserProfile: %w", err)
    }
    return fmt.Sprintf("用戶資料: %s", name), nil
}

// ========== Handler 層 ==========

type UserHandler struct {
    service *UserService
}

func (h *UserHandler) HandleGetUser(id int) {
    profile, err := h.service.GetUserProfile(id)
    if err != nil {
        // 在最頂層根據錯誤類型決定如何響應
        switch {
        case errors.Is(err, ErrNotFound):
            fmt.Printf("HTTP 404: %v\n", err)
        case errors.Is(err, ErrUnauthorized):
            fmt.Printf("HTTP 401: %v\n", err)
        case errors.Is(err, ErrInternal):
            fmt.Printf("HTTP 500: %v\n", err)
        default:
            fmt.Printf("HTTP 500: 未知錯誤: %v\n", err)
        }
        return
    }
    fmt.Printf("HTTP 200: %s\n", profile)
}

func main() {
    handler := &UserHandler{
        service: &UserService{
            repo: &UserRepo{},
        },
    }

    handler.HandleGetUser(1)  // HTTP 200: 用戶資料: Alice
    handler.HandleGetUser(0)  // HTTP 404: UserService.GetUserProfile: UserRepo.FindByID(id=0): 資源未找到
    handler.HandleGetUser(-1) // HTTP 500: UserService.GetUserProfile: UserRepo.FindByID(id=-1): 數據庫連接失敗: 內部錯誤
}

8.2 錯誤碼設計模式

package main

import (
    "errors"
    "fmt"
)

// AppError 統一的應用錯誤類型
type AppError struct {
    Code    string // 業務錯誤碼,如 "USER_001"
    Message string // 面向用戶的消息
    Err     error  // 底層錯誤(可選)
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}

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

// Is 允許通過錯誤碼進行匹配
func (e *AppError) Is(target error) bool {
    t, ok := target.(*AppError)
    if !ok {
        return false
    }
    return e.Code == t.Code
}

// HTTPStatus 根據錯誤碼前綴返回 HTTP 狀態碼
func (e *AppError) HTTPStatus() int {
    switch e.Code[:3] {
    case "VAL": // 驗證錯誤
        return 400
    case "AUT": // 認證錯誤
        return 401
    case "FBD": // 權限錯誤
        return 403
    case "NTF": // 未找到
        return 404
    default:
        return 500
    }
}

// 預定義的錯誤模板
var (
    ErrCodeValidation  = &AppError{Code: "VAL_001"}
    ErrCodeNotFound    = &AppError{Code: "NTF_001"}
    ErrCodeAuth        = &AppError{Code: "AUT_001"}
    ErrCodeInternal    = &AppError{Code: "INT_001"}
)

// NewNotFoundError 創建一個未找到錯誤
func NewNotFoundError(resource string, id interface{}) *AppError {
    return &AppError{
        Code:    "NTF_001",
        Message: fmt.Sprintf("%s (ID=%v) 不存在", resource, id),
    }
}

// NewValidationError 創建一個驗證錯誤
func NewValidationError(field, reason string) *AppError {
    return &AppError{
        Code:    "VAL_001",
        Message: fmt.Sprintf("字段 %s 驗證失敗: %s", field, reason),
    }
}

// NewInternalError 創建一個內部錯誤(包裝底層錯誤)
func NewInternalError(msg string, cause error) *AppError {
    return &AppError{
        Code:    "INT_001",
        Message: msg,
        Err:     cause,
    }
}

func processOrder(userID, productID int) error {
    if userID <= 0 {
        return NewValidationError("userID", "必須大於0")
    }
    if productID == 99 {
        return NewNotFoundError("商品", productID)
    }
    if productID < 0 {
        return NewInternalError("查詢商品失敗",
            fmt.Errorf("數據庫超時"))
    }
    return nil
}

func handleRequest(userID, productID int) {
    err := processOrder(userID, productID)
    if err == nil {
        fmt.Println("200 OK: 訂單創建成功")
        return
    }

    // 從錯誤中提取 AppError
    var appErr *AppError
    if errors.As(err, &appErr) {
        fmt.Printf("HTTP %d | 錯誤碼: %s | %s\n",
            appErr.HTTPStatus(), appErr.Code, appErr.Message)
    } else {
        fmt.Println("HTTP 500 | 未知錯誤:", err)
    }
}

func main() {
    handleRequest(1, 1)    // 200 OK: 訂單創建成功
    handleRequest(0, 1)    // HTTP 400 | 錯誤碼: VAL_001 | 字段 userID 驗證失敗: 必須大於0
    handleRequest(1, 99)   // HTTP 404 | 錯誤碼: NTF_001 | 商品 (ID=99) 不存在
    handleRequest(1, -1)   // HTTP 500 | 錯誤碼: INT_001 | 查詢商品失敗

    // 使用 errors.Is 通過錯誤碼模板匹配
    err := processOrder(0, 1)
    fmt.Println("\n是驗證錯誤?", errors.Is(err, ErrCodeValidation)) // true
    fmt.Println("是未找到?", errors.Is(err, ErrCodeNotFound))       // false
}

8.3 defer 與錯誤處理的結合

package main

import (
    "fmt"
    "os"
)

// writeToFile 演示使用 defer 和命名返回值處理關閉錯誤
func writeToFile(path, content string) (err error) {
    f, err := os.Create(path)
    if err != nil {
        return fmt.Errorf("創建文件: %w", err)
    }

    // 使用 defer + 命名返回值來處理 Close 錯誤
    defer func() {
        closeErr := f.Close()
        if err == nil {
            // 如果寫入沒有出錯,但 Close 出錯了,返回 Close 的錯誤
            err = closeErr
        }
        // 如果寫入已經出錯了,Close 的錯誤就忽略(保留原始錯誤)
    }()

    _, err = f.WriteString(content)
    if err != nil {
        return fmt.Errorf("寫入內容: %w", err)
    }

    return nil
}

// transaction 模擬數據庫事務的錯誤處理模式
func transaction(shouldFail bool) (err error) {
    fmt.Println("開始事務")

    defer func() {
        if err != nil {
            fmt.Println("回滾事務")
            // rollback()
        } else {
            fmt.Println("提交事務")
            // commit()
        }
    }()

    fmt.Println("執行操作1")
    if shouldFail {
        return fmt.Errorf("操作1失敗")
    }
    fmt.Println("執行操作2")

    return nil
}

func main() {
    // 寫文件
    if err := writeToFile("/tmp/test_defer.txt", "Hello, Go!"); err != nil {
        fmt.Println("寫文件失敗:", err)
    } else {
        fmt.Println("寫文件成功")
    }

    // 事務成功
    fmt.Println("\n--- 事務成功 ---")
    transaction(false)

    // 事務失敗
    fmt.Println("\n--- 事務失敗 ---")
    transaction(true)
}

8.4 使用 errors.Join(Go 1.20+)

package main

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

// cleanup 清理多個資源,收集所有錯誤
func cleanup(files []*os.File) error {
    var errs []error
    for _, f := range files {
        if err := f.Close(); err != nil {
            errs = append(errs, fmt.Errorf("關閉 %s: %w", f.Name(), err))
        }
    }
    // errors.Join 將多個錯誤合併爲一個
    // 如果 errs 爲空,返回 nil
    return errors.Join(errs...)
}

func main() {
    // errors.Join 基本用法
    err := errors.Join(
        fmt.Errorf("錯誤1: 連接超時"),
        fmt.Errorf("錯誤2: 寫入失敗"),
        nil, // nil 會被自動忽略
        fmt.Errorf("錯誤3: 緩存未命中"),
    )

    fmt.Println("合併後的錯誤:")
    fmt.Println(err)
    // 錯誤1: 連接超時
    // 錯誤2: 寫入失敗
    // 錯誤3: 緩存未命中

    // errors.Join 返回的錯誤也支持 errors.Is
    sentinel := errors.New("特殊錯誤")
    joined := errors.Join(
        fmt.Errorf("包裝: %w", sentinel),
        fmt.Errorf("另一個錯誤"),
    )
    fmt.Println("\n包含特殊錯誤?", errors.Is(joined, sentinel)) // true
}

總結

場景 推薦方式
函數可能失敗 返回 (result, error)
添加上下文 fmt.Errorf("上下文: %w", err)
檢查特定錯誤值 errors.Is(err, target)
提取特定錯誤類型 errors.As(err, &target)
定義已知錯誤條件 包級別哨兵變量 var ErrXxx = errors.New(...)
定義攜帶數據的錯誤 自定義結構體實現 error 接口
不可恢復的錯誤 panic(僅限程序 bug 或初始化失敗)
合併多個錯誤 errors.Join(Go 1.20+)
一次包裝多個錯誤 fmt.Errorf("... %w ... %w", err1, err2)(Go 1.20+)

主題測試文章,只做測試使用。發佈者:Walker,轉轉請注明出處:https://www.walker-learn.xyz/archives/6719

(0)
Walker的頭像Walker
上一篇 2026年3月6日 22:00
下一篇 2026年3月6日 21:00

相關推薦

  • Go工程師體系課 004

    需求分析 後臺管理系統 商品管理 商品列表 商品分類 品牌管理 品牌分類 訂單管理 訂單列表 用戶信息管理 用戶列表 用戶地址 用戶留言 輪播圖管理 電商系統 登錄頁面 首頁 商品搜索 商品分類導航 輪播圖展示 推薦商品展示 商品詳情頁 商品圖片展示 商品描述 商品規格選擇 加入購物車 購物車 商品列表 數量調整 刪除商品 結算功能 用戶中心 訂單中心 我的…

    2026年3月7日
    7300
  • 編程基礎 0006_併發進階_sync包與Context

    併發進階:sync 包與 Context 一、sync 包詳解 1. sync.Mutex 與 sync.RWMutex // Mutex: 互斥鎖,同一時間只有一個 goroutine 能持有 var mu sync.Mutex var count int func increment() { mu.Lock() defer mu.Unlock() cou…

    後端開發 2026年3月6日
    7100
  • Go資深工程師講解(慕課) 007_godoc與代碼生成

    Go 文檔生成與示例代碼 對應視頻 8-6 生成文檔和示例代碼 1. godoc 文檔生成 Go 的文檔直接從源碼註釋中提取,不需要特殊標記語法。 1.1 註釋規範 // Package queue 實現了一個簡單的 FIFO 隊列。 // // 該隊列基於切片實現,支持 Push、Pop 和 IsEmpty 操作。 package queue // Que…

    後端開發 2026年3月6日
    7700
  • Go工程師體系課 001

    轉型 想在短時間系統轉到Go工程理由 提高CRUD,無自研框架經驗 拔高技術深度,做專、做精需求的同學 進階工程化,擁有良好開發規範和管理能力的 工程化的重要性 高級開的期望 良好的代碼規範 深入底層原理 熟悉架構 熟悉k8s的基礎架構 擴展知識廣度,知識的深度,規範的開發體系 四個大的階段 go語言基礎 微服務開發的(電商項目實戰) 自研微服務 自研然後重…

    後端開發 2026年3月6日
    7000
  • Go資深工程師講解(慕課) 000_課程目錄索引

    Google資深工程師深度講解Go語言 - 課程目錄索引 課程來源:慕課網(百度網盤備份)講師風格:從 Google 工程實踐出發,注重底層原理和工程規範 完整視頻章節與筆記對照表 章節 視頻文件 筆記位置 狀態 Ch1 課程介紹 1-1 課程導讀 — 跳過 1-2 安裝與環境 001.md > GOPATH、環境變量 已覆蓋 Ch2 基礎語法 2-1…

    後端開發 2026年3月6日
    7000
簡體中文 繁體中文 English