错误处理

错误处理(Error Handling)

Go 语言的错误处理采用了独特的显式错误返回模式,而非其他语言中常见的 try-catch 机制。error 是一个内置接口类型,函数通过返回 error 来通知调用者发生了异常情况。这种设计使错误处理路径在代码中清晰可见,强制开发者直面每个可能出错的地方。

error 接口

error 是 Go 语言中唯一内置的错误类型,它是一个接口:

// builtin 包中定义
type error interface {
    Error() string
}

任何实现了 Error() string 方法的类型都满足 error 接口。error 的零值是 nil,表示没有错误。

基本使用模式

Go 函数通常将 error 作为最后一个返回值:

package main

import (
    "fmt"
    "os"
)

func readFile(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    return data, nil
}

func main() {
    data, err := readFile("config.yaml")
    if err != nil {
        fmt.Fprintf(os.Stderr, "读取文件失败: %v\n", err)
        return
    }
    fmt.Println(string(data))
}

errors.New()

errors.New() 用于创建简单的错误值,它接受一个字符串参数,返回一个 error 接口值:

package main

import (
    "errors"
    "fmt"
)

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

func main() {
    result, err := divide(10, 0)
    if err != nil {
        fmt.Println("错误:", err)
        // 输出: 错误: division by zero
        return
    }
    fmt.Println("结果:", result)
}

errors.New() 创建的错误类型是内部结构 *errors.errorString,它实现了 error 接口:

// errors 包内部实现(简化)
type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

func New(text string) error {
    return &errorString{text}
}

哨兵错误(Sentinel Errors)

哨兵错误(Sentinel Errors)

哨兵错误是预先定义好的错误值,通常使用包级别的 var 声明。调用者可以通过 ==errors.Is() 来判断函数返回的是哪种特定的错误。

package mypkg

import "errors"

// 定义哨兵错误
var (
    ErrNotFound     = errors.New("not found")
    ErrInvalidInput = errors.New("invalid input")
    ErrPermission   = errors.New("permission denied")
)

func FindUser(id int) (*User, error) {
    if id <= 0 {
        return nil, ErrInvalidInput
    }
    // 模拟查找
    if id == 999 {
        return nil, ErrNotFound
    }
    return &User{ID: id, Name: "Alice"}, nil
}
// 调用方使用哨兵错误进行判断
user, err := mypkg.FindUser(999)
if err != nil {
    if errors.Is(err, mypkg.ErrNotFound) {
        fmt.Println("用户不存在")
    } else if errors.Is(err, mypkg.ErrInvalidInput) {
        fmt.Println("无效的输入参数")
    }
    return
}

fmt.Errorf 与错误包装(%w)

基本错误格式化

fmt.Errorf() 可以创建带有格式化信息的错误:

func openConfig(filename string) error {
    if filename == "" {
        return fmt.Errorf("配置文件名不能为空")
    }
    // ...
    return nil
}

错误包装(Error Wrapping)

Go 1.13 引入了 %w 动词,支持错误包装,即在错误信息中嵌入另一个错误,形成错误链:

package main

import (
    "fmt"
    "os"
)

func readConfig(filename string) ([]byte, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        // 使用 %w 包装底层错误
        return nil, fmt.Errorf("读取配置文件 %q 失败: %w", filename, err)
    }
    return data, nil
}

func loadConfig(filename string) (*Config, error) {
    data, err := readConfig(filename)
    if err != nil {
        // 继续包装,形成错误链
        return nil, fmt.Errorf("加载配置失败: %w", err)
    }
    return parseConfig(data), nil
}

func main() {
    _, err := loadConfig("missing.yaml")
    if err != nil {
        fmt.Println(err)
        // 输出: 加载配置失败: 读取配置文件 "missing.yaml" 失败: open missing.yaml: no such file or directory
    }
}

%w 与 %v 的区别

err := errors.New("原始错误")

// 使用 %w 包装
wrapped := fmt.Errorf("上下文信息: %w", err)
fmt.Println(errors.Is(wrapped, err))  // true ✅

// 使用 %v 包装(错误变成纯字符串)
notWrapped := fmt.Errorf("上下文信息: %v", err)
fmt.Println(errors.Is(notWrapped, err))  // false ❌

错误链(Error Chain)

通过多次 %w 包装,可以形成一条错误链。Go 1.13 新增的 errors.Unwrap() 函数可以逐层解包:

func main() {
    err1 := errors.New("磁盘 I/O 错误")
    err2 := fmt.Errorf("读取文件失败: %w", err1)
    err3 := fmt.Errorf("初始化配置失败: %w", err2)

    // 逐层解包
    fmt.Println(err3)             // 初始化配置失败: 读取文件失败: 磁盘 I/O 错误
    fmt.Println(errors.Unwrap(err3))  // 读取文件失败: 磁盘 I/O 错误
    fmt.Println(errors.Unwrap(errors.Unwrap(err3)))  // 磁盘 I/O 错误
}

errors.Is() 与 errors.As()

Go 1.13 引入了 errors.Is()errors.As(),专门用于在错误链中查找特定错误。

errors.Is() — 判断错误链中是否包含某个错误

// 函数签名
func Is(err, target error) bool

errors.Is() 会沿着错误链逐层检查,直到找到匹配的错误或到达链尾:

var ErrNotFound = errors.New("not found")

func getUser(id int) (*User, error) {
    if id == 0 {
        return nil, fmt.Errorf("查询用户失败: %w", ErrNotFound)
    }
    return &User{ID: id}, nil
}

func main() {
    _, err := getUser(0)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("用户未找到")
    }
    // 即使 ErrNotFound 被多层包装,errors.Is() 也能找到它
}

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

// 函数签名
func As(err error, target any) bool

errors.As() 在错误链中查找第一个匹配目标指针类型的错误,并将其赋值给目标:

// 自定义错误类型
type NotFoundError struct {
    Resource string
    ID       int
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s (id=%d) 未找到", e.Resource, e.ID)
}

func getUser(id int) (*User, error) {
    if id == 0 {
        return nil, &NotFoundError{Resource: "User", ID: id}
    }
    return &User{ID: id}, nil
}

func main() {
    _, err := getUser(0)

    // 使用 errors.As() 提取特定类型的错误
    var notFound *NotFoundError
    if errors.As(err, &notFound) {
        fmt.Printf("资源: %s, ID: %d\n", notFound.Resource, notFound.ID)
        // 输出: 资源: User, ID: 0
    }
}

自定义错误类型

当简单的错误字符串不够用时,可以定义自定义错误类型来携带更多上下文信息:

基本自定义错误

// 解析错误:携带字段名和期望类型
type ParseError struct {
    Field   string
    Value   string
    Expect  string
}

func (e *ParseError) Error() string {
    return fmt.Sprintf("字段 %q 的值 %q 无法解析为 %s", e.Field, e.Value, e.Expect)
}

func parseInt(value, field string) (int, error) {
    n, err := strconv.Atoi(value)
    if err != nil {
        return 0, &ParseError{
            Field:  field,
            Value:  value,
            Expect: "int",
        }
    }
    return n, nil
}

func main() {
    age, err := parseInt("abc", "age")
    if err != nil {
        // 类型断言获取详细信息
        if pe, ok := err.(*ParseError); ok {
            fmt.Printf("字段: %s, 值: %s, 期望: %s\n", pe.Field, pe.Value, pe.Expect)
        }
    }
}

带方法的自定义错误

自定义错误可以添加辅助方法,提供更丰富的错误处理能力:

// HTTP 错误
type HTTPError struct {
    StatusCode int
    Message    string
    Err        error
}

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

// 实现 Unwrap 方法,使错误支持 errors.Is/As
func (e *HTTPError) Unwrap() error {
    return e.Err
}

// 辅助方法
func (e *HTTPError) IsClientError() bool {
    return e.StatusCode >= 400 && e.StatusCode < 500
}

func (e *HTTPError) IsServerError() bool {
    return e.StatusCode >= 500
}

func fetchData(url string) ([]byte, error) {
    resp, err := http.Get(url)
    if err != nil {
        return nil, &HTTPError{StatusCode: 0, Message: "请求失败", Err: err}
    }
    defer resp.Body.Close()

    if resp.StatusCode >= 400 {
        return nil, &HTTPError{
            StatusCode: resp.StatusCode,
            Message:    "服务器返回错误状态码",
        }
    }
    return io.ReadAll(resp.Body)
}

func main() {
    _, err := fetchData("https://example.com/notfound")
    if err != nil {
        var httpErr *HTTPError
        if errors.As(err, &httpErr) {
            if httpErr.IsClientError() {
                fmt.Printf("客户端错误: HTTP %d\n", httpErr.StatusCode)
            }
        }
    }
}

错误处理的最佳实践

1. 尽早返回(Fail Fast)

遇到错误时立即返回,避免深层嵌套:

// ❌ 不好的做法:深层嵌套
func process() error {
    result, err := step1()
    if err == nil {
        result2, err := step2(result)
        if err == nil {
            result3, err := step3(result2)
            if err == nil {
                return doSomething(result3)
            }
            return err
        }
        return err
    }
    return err
}

// ✅ 好的做法:尽早返回
func process() error {
    result, err := step1()
    if err != nil {
        return fmt.Errorf("step1 失败: %w", err)
    }

    result2, err := step2(result)
    if err != nil {
        return fmt.Errorf("step2 失败: %w", err)
    }

    result3, err := step3(result2)
    if err != nil {
        return fmt.Errorf("step3 失败: %w", err)
    }

    return doSomething(result3)
}

2. 错误包装添加上下文

每次处理错误时,用 fmt.Errorf 添加上下文信息,形成清晰的错误链:

// ✅ 在每一层添加有意义的上下文
func LoadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("读取配置文件 %s: %w", path, err)
    }
    config, err := parseConfig(data)
    if err != nil {
        return nil, fmt.Errorf("解析配置文件 %s: %w", path, err)
    }
    return config, nil
}

3. 只处理一次错误

// ❌ 不好的做法:既记录日志又返回错误(导致错误被多次处理)
func processFile(filename string) error {
    f, err := os.Open(filename)
    if err != nil {
        log.Printf("打开文件失败: %v", err)  // 在这里记录了
        return fmt.Errorf("打开文件失败: %w", err)  // 调用者还会再记录
    }
    defer f.Close()
    // ...
    return nil
}

// ✅ 好的做法:只返回错误,让调用者决定如何处理
func processFile(filename string) error {
    f, err := os.Open(filename)
    if err != nil {
        return fmt.Errorf("打开文件 %s: %w", filename, err)
    }
    defer f.Close()
    // ...
    return nil
}

// ✅ 好的做法:在顶层统一记录日志
func main() {
    if err := processFile("data.txt"); err != nil {
        log.Fatal(err)  // 错误链中的所有上下文信息都会被打印
    }
}

4. 不要忽略错误

// ❌ 绝对不要忽略错误
f, _ := os.Open("config.txt")  // 如果打开失败,后续代码会 panic

// ✅ 始终检查错误
f, err := os.Open("config.txt")
if err != nil {
    return fmt.Errorf("打开配置文件: %w", err)
}
defer f.Close()

5. 合理设计错误类型

// ✅ 包级别暴露错误值和类型
package mydb

// 哨兵错误,用于简单的判断
var ErrNotFound = errors.New("record not found")

// 自定义错误类型,用于携带详细信息
type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }

try 提案为什么被拒绝了?

Go 社区曾多次尝试简化错误处理的语法,其中最著名的是 Go 2 草案中的 try 提案。该提案希望引入一个内置的 try 函数,自动处理错误返回:

提案内容

// 提案设想的 try 语法(未通过)
func process() (Result, error) {
    // try 会自动检查 error,如果非 nil 则立即返回
    content := try(os.ReadFile("input.txt"))
    parsed := try(parseContent(content))
    result := try(transform(parsed))
    return result, nil
}

被拒的原因

  1. 降低了可见性try 隐藏了错误处理,违背了 Go “显式处理错误” 的设计哲学
  2. 错误上下文丢失:使用 try 时很难方便地用 fmt.Errorf 包装错误上下文
  3. 不够通用try 只适用于返回 error 的情况,但实际中还有 bool + error 等模式
  4. 可读性未必提升try 让代码看起来像没有错误处理的”快乐路径”,但实际错误可能发生在任何一行

当前社区的趋势

Go 2 的错误处理改进转向了不改变语法、而是增强工具链的方向:

  • errors 包增强Is()As()Unwrap()
  • fmt.Errorf%w:错误包装
  • errors.Join()(Go 1.20):合并多个错误
  • 多错误处理errors.Join(err1, err2, err3)
// Go 1.20+ 支持合并多个错误
err := errors.Join(
    errors.New("文件不存在"),
    errors.New("权限不足"),
    errors.New("磁盘已满"),
)
// err.Error() = "文件不存在\n权限不足\n磁盘已满"

// errors.Is 对合并的错误同样有效
fmt.Println(errors.Is(err, os.ErrNotExist))  // 根据具体错误值判断

练习题

练习 1:错误链与 errors.Is

以下代码的输出是什么?请解释错误链的工作方式。

package main

import (
    "errors"
    "fmt"
)

var ErrDBConn = errors.New("数据库连接失败")

func queryDB() error {
    return fmt.Errorf("执行查询失败: %w", ErrDBConn)
}

func handleRequest() error {
    return fmt.Errorf("处理请求失败: %w", queryDB())
}

func main() {
    err := handleRequest()
    fmt.Println(err)

    fmt.Println(errors.Is(err, ErrDBConn))
    fmt.Println(errors.Is(err, errors.New("数据库连接失败")))
}
参考答案

输出

处理请求失败: 执行查询失败: 数据库连接失败
true
false

分析

  1. handleRequest() 返回的错误链为三层:"处理请求失败""执行查询失败"ErrDBConn
  2. errors.Is(err, ErrDBConn) 返回 true,因为 errors.Is() 会沿错误链逐层解包,找到 ErrDBConn 这个哨兵错误
  3. errors.Is(err, errors.New("数据库连接失败")) 返回 false,因为 errors.Is() 使用值比较(指针比较),每次调用 errors.New() 都会创建一个新的错误值,即使字符串内容相同也不是同一个错误

关键知识点errors.Is() 比较的是错误值(指针),不是错误消息字符串。因此判断哨兵错误时,必须使用包中定义的那个变量。

练习 2:自定义错误类型与 errors.As

编写一个 ValidationError 类型,包含 FieldMessage 字段。实现 Error()Unwrap() 方法,并在 validateUser 函数中使用它来验证用户输入。

参考答案

代码

package main

import (
    "errors"
    "fmt"
    "regexp"
)

// ValidationError 自定义验证错误类型
type ValidationError struct {
    Field   string
    Message string
    Err     error // 包装底层错误
}

func (e *ValidationError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("验证失败 [%s]: %s: %v", e.Field, e.Message, e.Err)
    }
    return fmt.Sprintf("验证失败 [%s]: %s", e.Field, e.Message)
}

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

// validateUser 验证用户输入
func validateUser(name, email, ageStr string) error {
    if name == "" {
        return &ValidationError{
            Field:   "name",
            Message: "用户名不能为空",
        }
    }

    if len(name) > 20 {
        return &ValidationError{
            Field:   "name",
            Message: fmt.Sprintf("用户名长度不能超过 20 个字符(当前 %d)", len(name)),
        }
    }

    emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
    if !emailRegex.MatchString(email) {
        return &ValidationError{
            Field:   "email",
            Message: fmt.Sprintf("邮箱格式无效: %q", email),
        }
    }

    // 验证年龄(包装底层错误)
    age, err := strconv.Atoi(ageStr)
    if err != nil {
        return &ValidationError{
            Field:   "age",
            Message: "年龄必须是数字",
            Err:     err, // 包装底层转换错误
        }
    }
    if age < 0 || age > 150 {
        return &ValidationError{
            Field:   "age",
            Message: fmt.Sprintf("年龄必须在 0-150 之间(当前 %d)", age),
        }
    }

    return nil
}

func main() {
    tests := []struct {
        name, email, age string
    }{
        {"", "test@example.com", "25"},
        {"Alice", "invalid-email", "25"},
        {"Bob", "bob@example.com", "abc"},
        {"Charlie", "charlie@example.com", "200"},
        {"Dave", "dave@example.com", "30"},
    }

    for _, tt := range tests {
        err := validateUser(tt.name, tt.email, tt.age)
        if err != nil {
            var ve *ValidationError
            if errors.As(err, &ve) {
                fmt.Printf("字段: %s, 信息: %s\n", ve.Field, ve.Message)
                // 检查是否有底层错误
                if unwrapped := errors.Unwrap(err); unwrapped != nil {
                    fmt.Printf("  底层错误: %v\n", unwrapped)
                }
            }
        } else {
            fmt.Printf("✅ 验证通过: name=%s, email=%s, age=%s\n", tt.name, tt.email, tt.age)
        }
    }
}

输出

字段: name, 信息: 用户名不能为空
字段: email, 信息: 邮箱格式无效: "invalid-email"
字段: age, 信息: 年龄必须是数字
  底层错误: strconv.Atoi: parsing "abc": invalid syntax
字段: age, 信息: 年龄必须在 0-150 之间(当前 200)
✅ 验证通过: name=Dave, email=dave@example.com, age=30

练习 3:错误包装的最佳实践

指出以下代码中的错误处理问题,并给出改进版本。

func ProcessData(filename string) ([]byte, error) {
    f, err := os.Open(filename)
    if err != nil {
        log.Printf("打开文件失败: %v\n", err)
        return nil, err
    }
    defer f.Close()

    data, err := io.ReadAll(f)
    if err != nil {
        return nil, err
    }

    result := bytes.TrimSpace(data)
    if len(result) == 0 {
        return nil, fmt.Errorf("数据为空")
    }

    return result, nil
}
参考答案

问题分析

  1. 第 6-8 行:既记录了日志(log.Printf),又返回了错误。这导致错误会被处理两次——当前函数记录一次,调用者可能还会再记录一次
  2. 第 6-8 行:直接返回 err 而没有包装,丢失了上下文信息(哪个文件打开失败)
  3. 第 14-15 行:直接返回底层 err,没有添加上下文信息(在读取哪个文件时失败)
  4. 第 18-19 行fmt.Errorf("数据为空") 没有携带额外信息,不如使用哨兵错误

改进版本

var ErrEmptyData = errors.New("数据为空")

func ProcessData(filename string) ([]byte, error) {
    f, err := os.Open(filename)
    if err != nil {
        // ✅ 添加上下文,不重复记录日志
        return nil, fmt.Errorf("打开文件 %q: %w", filename, err)
    }
    defer f.Close()

    data, err := io.ReadAll(f)
    if err != nil {
        // ✅ 添加上下文
        return nil, fmt.Errorf("读取文件 %q: %w", filename, err)
    }

    result := bytes.TrimSpace(data)
    if len(result) == 0 {
        // ✅ 使用哨兵错误,方便调用者使用 errors.Is() 判断
        return nil, fmt.Errorf("文件 %q: %w", filename, ErrEmptyData)
    }

    return result, nil
}

改进要点

  • 去掉了 log.Printf,遵循”错误只处理一次”原则
  • 所有错误都用 fmt.Errorf 添加了文件名上下文
  • 空数据错误使用哨兵错误 + 包装,调用者可以用 errors.Is(err, ErrEmptyData) 判断