跳转至

📖 错误处理

学习时间: 约 3-4 小时 | 难度: ⭐⭐⭐ 中级 | 前置知识: Go基础语法、接口概念

📚 章节概述

Go 语言采用了独特的显式错误处理机制——没有 try/catch/finally,而是通过返回值传递错误。这种设计强迫开发者在每个调用点思考错误情况,虽然代码看起来"啰嗦",但代码路径更清晰。本章将全面讲解 Go 的 error 接口、错误处理模式、errors 包、panic/recover 以及工程级的错误处理策略。

Go错误处理流程图

上图概括了 Go 中从错误返回、包装到分类处理的典型链路,可作为编写业务代码的通用模板。

🎯 学习目标

  • 深入理解 error 接口和哨兵错误
  • 掌握错误包装(Go 1.13+ %w)与 errors.Is / errors.As
  • 学会自定义错误类型的设计
  • 理解 panic/recover 的使用场景与限制
  • 掌握工程级错误处理的最佳实践

📌 概念:error 接口

1.1 error 是一个接口

Go
// error 是 Go 内置的接口,只有一个方法
type error interface {
    Error() string
}

任何实现了 Error() string 方法的类型都是 error。这使得错误可以携带丰富的上下文信息。

1.2 创建错误

Go
package main

import (
    "errors"
    "fmt"
)

func main() {
    // 方式1:errors.New — 创建简单错误
    err1 := errors.New("something went wrong")
    fmt.Println(err1) // something went wrong

    // 方式2:fmt.Errorf — 格式化错误消息
    name := "config.yaml"
    err2 := fmt.Errorf("无法读取文件 %s: 权限不足", name)
    fmt.Println(err2) // 无法读取文件 config.yaml: 权限不足
}

1.3 错误处理的基本模式

Go
import (
    "fmt"
    "os"
    "strconv"
)

func readConfig(filename string) ([]byte, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        return nil, err // 原样返回
    }
    return data, nil
}

func parsePort(s string) (int, error) {
    port, err := strconv.Atoi(s)
    if err != nil {
        return 0, fmt.Errorf("解析端口号失败: %w", err) // 包装错误
    }
    if port < 1 || port > 65535 {
        return 0, fmt.Errorf("端口号 %d 超出有效范围 [1, 65535]", port)
    }
    return port, nil
}

func main() {
    data, err := readConfig("config.yaml")
    if err != nil {
        fmt.Println("配置读取失败:", err)
        return
    }
    fmt.Println("配置内容:", string(data))
}

📌 概念:自定义错误类型

2.1 哨兵错误(Sentinel Errors)

哨兵错误是预定义的全局错误值,用于表示特定的错误条件:

Go
import (
    "errors"
    "io"
)

// 标准库中的哨兵错误示例
// var EOF = errors.New("EOF")
// var ErrNotExist = errors.New("file does not exist")

// 自定义哨兵错误
var (
    ErrNotFound     = errors.New("resource not found")
    ErrUnauthorized = errors.New("unauthorized access")
    ErrConflict     = errors.New("resource conflict")
)

func findUser(id int) (*User, error) {
    // ... 查询数据库
    if user == nil {
        return nil, ErrNotFound
    }
    return user, nil
}

func main() {
    user, err := findUser(42)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("用户不存在")
    } else if err != nil {
        fmt.Println("查询失败:", err)
    }
    _ = user
}

2.2 自定义错误结构体

Go
// 携带丰富上下文的错误类型
type AppError struct {
    Code    int    // 错误码
    Message string // 用户可见的错误消息
    Op      string // 操作名称
    Err     error  // 底层错误
}

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

// 实现 Unwrap 方法以支持 errors.Is/As 链式查找
func (e *AppError) Unwrap() error {
    return e.Err
}

// HTTP 友好的错误类型
type HTTPError struct {
    StatusCode int
    Message    string
    Detail     string
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message)
}

func NewNotFoundError(resource string) *HTTPError {
    return &HTTPError{
        StatusCode: 404,
        Message:    fmt.Sprintf("%s not found", resource),
    }
}

func NewInternalError(err error) *HTTPError {
    return &HTTPError{
        StatusCode: 500,
        Message:    "internal server error",
        Detail:     err.Error(),
    }
}

2.3 多错误聚合(Go 1.20+)

Go
// Go 1.20 支持 errors.Join 合并多个错误
func validateUser(u User) error {
    var errs []error

    if u.Name == "" {
        errs = append(errs, errors.New("name is required"))
    }
    if u.Email == "" {
        errs = append(errs, errors.New("email is required"))
    }
    if u.Age < 0 || u.Age > 150 {
        errs = append(errs, fmt.Errorf("invalid age: %d", u.Age))
    }

    return errors.Join(errs...) // 返回 nil 如果 errs 为空
}

func main() {
    err := validateUser(User{})
    if err != nil {
        fmt.Println(err)
        // name is required
        // email is required
    }
}

📌 概念:错误包装与检查(Go 1.13+)

3.1 fmt.Errorf 的 %w 动词

Go
func readFile(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("readFile(%s): %w", path, err)
    }
    return data, nil
}

func loadConfig() (*Config, error) {
    data, err := readFile("/etc/app/config.yaml")
    if err != nil {
        return nil, fmt.Errorf("loadConfig: %w", err)
    }
    // 解析...
    return nil, nil
}

// 错误链:loadConfig: readFile(/etc/app/config.yaml): open ...: no such file

3.2 errors.Is — 判断错误链中是否包含特定错误

Go
func main() {
    err := loadConfig()

    // errors.Is 会递归检查错误链
    if errors.Is(err, os.ErrNotExist) {
        fmt.Println("配置文件不存在,使用默认配置")
    } else if errors.Is(err, os.ErrPermission) {
        fmt.Println("权限不足,请检查文件权限")
    } else if err != nil {
        fmt.Println("未知错误:", err)
    }
}

3.3 errors.As — 从错误链中提取特定类型的错误

Go
func handleRequest() error {
    return &AppError{
        Code:    404,
        Message: "user not found",
        Op:      "handleRequest",
    }
}

func main() {
    err := handleRequest()

    // errors.As 从错误链中提取匹配类型
    var appErr *AppError
    if errors.As(err, &appErr) {
        fmt.Printf("应用错误 Code=%d, Op=%s\n", appErr.Code, appErr.Op)
    }

    var httpErr *HTTPError
    if errors.As(err, &httpErr) {
        fmt.Printf("HTTP 状态码: %d\n", httpErr.StatusCode)
    }
}

📌 概念:panic 和 recover

4.1 panic 的使用场景

Panic 应该只用于真正不可恢复的错误

Go
// ✅ 适合 panic 的场景
func MustCompileRegex(pattern string) *regexp.Regexp {
    re, err := regexp.Compile(pattern)
    if err != nil {
        panic(fmt.Sprintf("invalid regex pattern: %s: %v", pattern, err))  // panic 抛出不可恢复的运行时错误
    }
    return re
}

// 程序初始化时使用 Must 模式
var emailRegex = MustCompileRegex(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)

// ❌ 不应该用 panic 的场景
func findUser(id int) *User {
    user := db.Find(id)
    if user == nil {
        panic("user not found") // 应该返回 error
    }
    return user
}

4.2 recover 捕获 panic

Go
// safeExecute 使用命名返回值 err,使 defer 中可修改返回的错误
func safeExecute(fn func()) (err error) {
    defer func() {
        if r := recover(); r != nil {  // recover 捕获panic,恢复正常执行
            // 将 panic 转为 error
            // 通过类型断言判断 panic 值的类型并分别处理
            switch v := r.(type) {
            case error:
                err = fmt.Errorf("panic recovered: %w", v)
            case string:
                err = fmt.Errorf("panic recovered: %s", v)
            default:
                err = fmt.Errorf("panic recovered: %v", v)
            }
            // 打印堆栈信息(调试用)
            debug.PrintStack()
        }
    }()
    fn()
    return nil
}

func main() {
    err := safeExecute(func() {
        panic("boom!")
    })
    if err != nil {
        fmt.Println("捕获到错误:", err)
    }
    fmt.Println("程序继续运行")
}

4.3 HTTP 中间件中的 recover

Go
func RecoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                // 记录错误和堆栈
                log.Printf("panic: %v\n%s", err, debug.Stack())
                // 返回 500
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

💻 代码示例:实战应用

示例1:分层错误处理架构

Go
// 定义不同层的错误
package apperr

type ErrorType int

const (
    NotFound ErrorType = iota
    Validation
    Authorization
    Internal
)

type Error struct {
    Type    ErrorType
    Message string
    Err     error
}

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

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

// 工厂函数
func NewNotFound(msg string) *Error {
    return &Error{Type: NotFound, Message: msg}
}

func NewValidation(msg string) *Error {
    return &Error{Type: Validation, Message: msg}
}

func Wrap(err error, msg string) *Error {
    return &Error{Type: Internal, Message: msg, Err: err}
}

// --- 仓储层:将底层数据库错误转换为业务错误类型 ---
func (r *UserRepo) FindByID(id int) (*User, error) {
    user, err := r.db.Query("SELECT ...")
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, apperr.NewNotFound("user not found") // 底层“没查到行”转为业务“未找到”
        }
        return nil, apperr.Wrap(err, "query user failed")
    }
    return user, nil
}

// --- 服务层:添加当前操作的上下文后继续向上传递 ---
func (s *UserService) GetUser(id int) (*User, error) {
    user, err := s.repo.FindByID(id)
    if err != nil {
        return nil, fmt.Errorf("UserService.GetUser: %w", err)
    }
    return user, nil
}

// --- 处理层:根据业务错误类型映射为对应的 HTTP 状态码 ---
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.service.GetUser(id)
    if err != nil {
        var appErr *apperr.Error
        if errors.As(err, &appErr) { // 从错误链中提取业务错误类型
            switch appErr.Type {
            case apperr.NotFound:
                http.Error(w, appErr.Message, 404)
            case apperr.Validation:
                http.Error(w, appErr.Message, 400)
            default:
                http.Error(w, "internal error", 500)
            }
            return
        }
        http.Error(w, "internal error", 500)
        return
    }
    json.NewEncoder(w).Encode(user)
}

示例2:重试机制

Go
type RetryableError struct {
    Err       error
    Retryable bool
}

func (e *RetryableError) Error() string { return e.Err.Error() }
func (e *RetryableError) Unwrap() error { return e.Err }

// WithRetry 实现带递增延迟的重试机制,支持通过 RetryableError 控制是否可重试
func WithRetry(fn func() error, maxRetries int, delay time.Duration) error {
    var lastErr error
    for i := 0; i <= maxRetries; i++ {
        err := fn()
        if err == nil {
            return nil // 执行成功,直接返回
        }
        lastErr = err

        // 检查是否可重试
        var retryErr *RetryableError
        if errors.As(err, &retryErr) && !retryErr.Retryable {
            return err // 不可重试,直接返回
        }

        if i < maxRetries {
            log.Printf("重试 %d/%d: %v", i+1, maxRetries, err)
            time.Sleep(delay * time.Duration(i+1)) // 线性递增延迟,避免短时间内大量重试
        }
    }
    return fmt.Errorf("达到最大重试次数 (%d): %w", maxRetries, lastErr)
}

示例3:错误收集器

Go
// ErrorCollector 线程安全的错误收集器,适用于并发场景下汇总多个 goroutine 的错误
type ErrorCollector struct {
    errors []error
    mu     sync.Mutex // 保护并发读写 errors 切片
}

func (ec *ErrorCollector) Add(err error) {
    if err == nil {
        return
    }
    ec.mu.Lock()
    defer ec.mu.Unlock()
    ec.errors = append(ec.errors, err)
}

func (ec *ErrorCollector) Error() error {
    ec.mu.Lock()
    defer ec.mu.Unlock()
    return errors.Join(ec.errors...)
}

func (ec *ErrorCollector) HasErrors() bool {
    ec.mu.Lock()
    defer ec.mu.Unlock()
    return len(ec.errors) > 0
}

// 使用示例:并发处理文件,WaitGroup 等待所有 goroutine 完成后统一返回错误
func processFiles(files []string) error {
    ec := &ErrorCollector{}
    var wg sync.WaitGroup

    for _, f := range files {
        wg.Add(1)
        go func(filename string) { // 参数传入避免闭包捕获循环变量
            defer wg.Done()
            if err := processFile(filename); err != nil {
                ec.Add(fmt.Errorf("处理 %s: %w", filename, err))
            }
        }(f)
    }

    wg.Wait()
    return ec.Error()
}

✅ 最佳实践

1. 始终检查错误,永远不要忽略

Go
// ✅ 正确
data, err := os.ReadFile("file.txt")
if err != nil {
    return fmt.Errorf("读取文件失败: %w", err)
}

// ❌ 用 _ 忽略错误
data, _ := os.ReadFile("file.txt")

2. 错误消息应该提供上下文但不重复

Go
// ✅ 好:添加当前操作的上下文
return fmt.Errorf("解析用户记录 (id=%d): %w", id, err)

// ❌ 坏:重复底层错误的信息
return fmt.Errorf("failed to read file: read file error: %w", err)

3. 优先使用 %w 而非 %v 包装错误

Go
// ✅ 使用 %w 保留错误链
return fmt.Errorf("operation failed: %w", err)

// ❌ 使用 %v 会断开错误链
return fmt.Errorf("operation failed: %v", err)

4. panic 仅用于不可恢复的程序错误

Go
// ✅ 程序初始化、不变量被破坏
func init() {
    if os.Getenv("SECRET_KEY") == "" {
        panic("SECRET_KEY environment variable is required")
    }
}

// ❌ 业务逻辑错误不应该 panic
func createUser(name string) {
    if name == "" {
        panic("name is empty") // 应该返回 error
    }
}

5. 在包边界转换错误类型

Go
// 包内部使用特定错误类型
// 暴露给外部时包装为包的公共错误
func (s *Storage) Get(key string) ([]byte, error) {
    data, err := s.redis.Get(ctx, key).Bytes()
    if err == redis.Nil {
        return nil, ErrKeyNotFound // 转换为包定义的哨兵错误
    }
    if err != nil {
        return nil, fmt.Errorf("storage.Get: %w", err)
    }
    return data, nil
}

🎯 面试题

Q1: Go 为什么选择显式错误返回而不是 try/catch?

A: Go 的设计哲学强调显式和简洁。显式错误返回的优势:1) 代码执行路径清晰可见,每个错误都必须处理;2) 没有隐式的控制流跳转;3) 错误是值,可以用普通的编程技巧处理。缺点是代码中有大量 if err != nil 检查,但 Go 团队认为这种"啰嗦"换来的是可读性和可靠性。

Q2: errors.Is 和 errors.As 的区别?

A: - errors.Is(err, target) 检查错误链中是否包含特定的错误值(通过 == 或 Is 方法比较),常用于哨兵错误判断 - errors.As(err, &target) 从错误链中提取特定错误类型并赋值给 target,常用于获取自定义错误的详细信息 - 二者都会递归调用 Unwrap() 遍历整个错误链

Q3: panic 什么时候会导致程序崩溃?

A: 当 panic 沿调用栈向上传播到达 goroutine 栈顶时,且没有被 recover 捕获,程序会崩溃并打印堆栈信息。注意:一个 goroutine 的 panic 无法被另一个 goroutine 的 recover 捕获。HTTP 服务器框架通常在每个请求的 goroutine 中添加 recover 中间件来防止单个请求的 panic 导致整个服务崩溃。

Q4: 如何实现带堆栈信息的错误?

A: 标准 errors 包不提供堆栈信息。可以用 runtime.Callers() 获取调用栈,或使用第三方库如 pkg/errors。Go 1.13+ 推荐用 %w 包装错误链来提供上下文,而非依赖堆栈追踪。在需要堆栈时,可以在 recover 中使用 debug.Stack() 获取。

Q5: defer 和错误处理有什么关系?

A: defer 常用于确保资源清理不受错误影响。经典模式:

Go
f, err := os.Open(name)
if err != nil { return err }
defer f.Close()
defer 也用于 recover 捕获 panic,以及在函数返回前修改 named return value 来统一错误处理。

Q6: 如何优雅地处理多个并发操作的错误?

A: 使用 errgroup.Groupgolang.org/x/sync/errgroup),它会在任何一个 goroutine 返回错误时取消其他任务:

Go
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error { return task1(ctx) })
g.Go(func() error { return task2(ctx) })
if err := g.Wait(); err != nil { ... }


📋 学习检查清单

  • 理解 error 接口及其实现方式
  • 能使用 errors.New、fmt.Errorf 创建错误
  • 掌握 %w 错误包装和 errors.Is/As 的使用
  • 能设计自定义错误类型(含 Unwrap 方法)
  • 了解哨兵错误的使用场景
  • 理解 panic/recover 的正确使用场景
  • 能在 HTTP 服务中实现 recover 中间件
  • 掌握分层错误处理架构设计

上一章: 指针与内存 | 下一章: 并发编程