文章目录

Go 错误处理:自定义错误类型与错误链

发布于 2026-04-04 16:06:49 · 浏览 15 次 · 评论 0 条

Go 错误处理:自定义错误类型与错误链

在 Go 项目中,错误处理是绕不开的话题。新手程序员常常把 error 当作简单的字符串处理,导致调试时无法定位问题根源、错误信息丢失、错误类型难以区分。这篇文章将带你掌握 Go 错误处理的核心技巧:自定义错误类型错误链


一、为什么需要自定义错误类型

Go 标准库中的 error 是一个简单的接口,只定义了一个方法:

type error interface {
    Error() string
}

这个设计足够简洁,但在复杂项目中会遇到三个痛点:

第一,错误类型无法区分。所有错误都返回字符串,判断"是哪种错误"只能靠字符串比对,一旦错误描述改动,代码就崩溃。

第二,上下文信息丢失。底层函数返回的错误经过多层调用后,调用者往往不知道错误发生在哪一层。

第三,无法携带额外数据。有些错误需要附带更多信息,比如重试次数、超时时间、请求 ID,单纯的字符串无法满足。

自定义错误类型和错误链正是为了解决这些问题。


二、定义第一个自定义错误类型

创建一个自定义错误类型,只需要让结构体实现 error 接口即可。最简单的方式是定义一个包含错误信息的结构体。

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on field '%s': %s", e.Field, e.Message)
}

ValidationError 实现了 Error() 方法,因此它就是一个合法的 error 类型。现在可以在业务代码中使用它:

func ValidateAge(age int) error {
    if age < 0 {
        return &ValidationError{Field: "age", Message: "must be non-negative"}
    }
    if age > 150 {
        return &ValidationError{Field: "age", Message: "must be realistic"}
    }
    return nil
}

调用方收到错误后,可以通过类型断言或 errors.As 判断错误类型并提取详细信息:

age, _ := strconv.Atoi("abc")
err := ValidateAge(age)

if ve, ok := err.(*ValidationError); ok {
    fmt.Printf("字段: %s, 问题: %s\n", ve.Field, ve.Message)
}
// 输出: 字段: age, 问题: must be non-negative

三、使用 errors.As 安全的类型判断

上面的类型断言写法有个问题:如果 errnil,或者不是 *ValidationError 类型,程序不会报错,但 okfalse 时我们无法区分"确实是这个类型"还是"根本不是错误"。更推荐的做法是使用 errors.As

var ve *ValidationError
if errors.As(err, &ve) {
    fmt.Printf("字段: %s, 问题: %s\n", ve.Field, ve.Message)
}

errors.As 会沿着错误链向上查找,只要链上有任意一个错误是 *ValidationError 类型,就能匹配成功。


四、构建错误链:errors.Wrap 与 %w

真实项目中,一个错误往往有多层来源。网络请求可能因为 DNS 解析失败、超时、服务器返回 500 错误等原因失败。直接返回底层错误会丢失调用链信息。

Go 1.13 引入了错误包装机制,使用 %w 格式化动词可以包装一个错误:

func fetchFromDB(query string) error {
    return fmt.Errorf("database connection failed: %w", errors.New("connection timeout"))
}

func processData(query string) error {
    err := fetchFromDB(query)
    if err != nil {
        return fmt.Errorf("failed to process data: %w", err)
    }
    return nil
}

调用 processData("SELECT *") 时,返回的错误包含三层信息:

failed to process data: database connection failed: connection timeout

这就是错误链。最外层是 processData 的错误,中间层是 fetchFromDB 的错误,最底层是原始错误。


五、错误链的解包与查询

错误链的意义不仅在于保留信息,还在于可以在任意层级判断错误类型或值。

errors.Is:判断错误链中是否包含指定错误

var errNotFound = errors.New("record not found")

func findUser(id int) error {
    return fmt.Errorf("user not found: %w", errNotFound)
}

func getUserProfile(id int) error {
    err := findUser(id)
    if errors.Is(err, errNotFound) {
        fmt.Println("用户不存在,执行降级逻辑")
        return nil
    }
    return err
}

errors.Is 会自动 unwrap 错误链,检查链上是否存在完全匹配的 errNotFound

errors.Join:合并多个错误

当一个操作可能同时返回多个独立错误时,可以使用 errors.Join

var errs []error
errs = append(errs, saveUser(u))
errs = append(errs, sendWelcomeEmail(u))
errs = append(errs, logAuditTrail(u))

if err := errors.Join(errs...); err != nil {
    return fmt.Errorf("failed to complete user onboarding: %w", err)
}

errors.Join 会将所有错误合并为一个错误,错误信息中包含所有错误描述。


六、完整的错误处理实践

下面是一个综合示例,展示如何组织一个清晰的错误处理体系。

package main

import (
    "errors"
    "fmt"
)

// ===== 自定义错误类型 =====

type NotFoundError struct {
    Resource string
    ID       interface{}
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s not found: %v", e.Resource, e.ID)
}

// ===== 业务错误定义 =====

var (
    ErrUserNotFound = &NotFoundError{Resource: "user"}
    ErrInvalidInput = errors.New("invalid input")
)

// ===== 业务函数 =====

func FindUser(id int) error {
    if id <= 0 {
        return fmt.Errorf("invalid id %d: %w", id, ErrInvalidInput)
    }
    if id != 100 {
        return fmt.Errorf("user lookup failed: %w", &NotFoundError{Resource: "user", ID: id})
    }
    return nil
}

func GetUserName(id int) (string, error) {
    err := FindUser(id)
    if err != nil {
        return "", fmt.Errorf("get user name failed: %w", err)
    }
    return "Alice", nil
}

// ===== 主函数中的调用方式 =====

func main() {
    _, err := GetUserName(999)
    if err != nil {
        // 判断是否是输入错误
        if errors.Is(err, ErrInvalidInput) {
            fmt.Println("请检查输入参数")
            return
        }

        // 判断是否是找不到资源错误
        var nf *NotFoundError
        if errors.As(err, &nf) {
            fmt.Printf("资源: %s, ID: %v\n", nf.Resource, nf.ID)
            return
        }

        fmt.Println("未知错误:", err)
    }
}

运行结果:

资源: user, ID: 999

这个示例展示了几个关键实践:预定义公共错误变量便于测试和比较;使用 errors.Is 判断包装后的错误使用 errors.As 提取嵌套的自定义错误类型


七、最佳实践总结

在实际项目中遵循以下原则,错误处理会变得清晰可控:

预定义 Sentinel 错误。将需要判断的公共错误定义为包级变量,如 var ErrNotFound = errors.New("not found"),这样调用方可以通过 errors.Is 进行稳定比较,避免字符串匹配的脆弱性。

在错误类型中包含结构化数据。不要只在错误字符串里写信息,把关键数据(ID、时间、字段名)放到结构体字段中,方便程序读取而非人工解析。

包装时保留原始错误。使用 %w 而非 %s%v,只有保留原始错误,调用方才可能进行深度判断。

避免过度包装。每层都包装错误会导致错误信息冗长,只在需要向上传递上下文时才包装,在可以明确处理并返回新错误的层直接返回新错误。

注意错误类型比较。自定义错误类型如果是指针类型,比较时要确认 errors.Is 的行为是否符合预期,结构体类型在 errors.As 匹配时要求类型完全一致。


通过自定义错误类型,你可以为不同业务场景定义清晰的错误模型;通过错误链,你可以在层层传递中保留完整的上下文信息。这两者的结合是构建可维护 Go 应用的基础。

评论 (0)

暂无评论,快来抢沙发吧!

扫一扫,手机查看

扫描上方二维码,在手机上查看本文