編程基礎 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://walker-learn.xyz/archives/6719

(0)
Walker的頭像Walker
上一篇 12小時前
下一篇 11小時前

相關推薦

  • Go工程師體系課 017

    限流、熔斷與降級入門(含 Sentinel 實戰) 結合課件第 3 章(3-1 ~ 3-9)的視頻要點,整理一套面向初學者的服務保護指南,幫助理解“為甚麼需要限流、熔斷和降級”,以及如何用 Sentinel 快速上手。 學習路線速覽 3-1 理解服務雪崩與限流、熔斷、降級的背景 3-2 Sentinel 與 Hystrix 對比,明確技術選型 3-3 Sen…

  • Go工程師體系課 014

    rocketmq 快速入門 去我們的各種配置(podman)看是怎麼安裝的 概念介紹 RocketMQ 是阿里開源、Apache 頂級項目的分布式消息中間件,核心組件: NameServer:服務發現與路由 Broker:消息存儲、投遞、拉取 Producer:消息生產者(發送消息) Consumer:消息消費者(訂閱並消費消息) Topic/Tag:主題/…

  • Go工程師體系課 019

    Go 內存模型與 GC 1. 內存分配基礎 1.1 棧(Stack)與堆(Heap) ┌─────────────────────────────┐ │ 堆 (Heap) │ ← 動態分配,GC 管理 │ ┌─────┐ ┌─────┐ ┌─────┐ │ │ │ obj │ │ obj │ │ obj │ │ │ └─────┘ └─────┘ └────…

  • 編程基礎 0007_併發模式

    Go 併發模式 常見的 Go 併發設計模式,每個模式都有完整可運行示例和適用場景說明 1. Worker Pool 模式 固定數量的 worker goroutine 從共享的任務隊列中取任務執行,控制併發度。 package main import ( "fmt" "sync" "time" ) …

    後端開發 17小時前
    100
  • Go工程師體系課 009

    其它一些功能 個人中心 收藏 管理收貨地址(增刪改查) 留言 拷貝inventory_srv--> userop_srv 查詢替換所有的inventory Elasticsearch 深度解析文檔 1. 甚麼是Elasticsearch Elasticsearch是一個基於Apache Lucene構建的分布式、RESTful搜索和分析引擎,能夠快速地…

    後端開發 6小時前
    000
簡體中文 繁體中文 English