Go 错误处理最佳实践:error 包装、errors.Is/As 与错误码体系

Go 错误处理工程实践深度:error 接口与哨兵错误、fmt.Errorf 与 %w 包装链、errors.Is/As/Unwrap 的判定与提取、自定义错误类型、错误码体系与领域错误设计,以及生产环境日志告警中的错误纪律。

导语:Go 的错误哲学是"显式、可组合、可判定"

相比 try-catch 的隐式异常流,Go 选择了显式的多返回值错误:每个可能失败的调用都把一个 error 摆在眼前。初看啰嗦,实则是把错误当作"值"来编程——你可以存储它、包装它、组合它、判定它的类型。这正是生产系统最需要的:错误不再只是一条日志,而是可编程的数据。

但要写出健壮的 Go 错误处理,光会 if err != nil 远远不够。errors.Is 与 errors.As 给了错误链"类型判定"的能力,%w 让包装层层叠加而不丢失根因。本文把这套体系从原理到工程实践完整拆解。

一句话总结:Go 错误处理的核心是"错误即值"——用 %w 保持包装链、用 errors.Is 判哨兵、用 errors.As 提取类型、用错误码对齐业务与协议。

1. error 接口与基础模式

1.1 error 只是一个接口

// error 接口:只有一个方法,任何实现 Error() string 的类型都是 error
type error interface {
    Error() string
}

// 两个最基础的构造方式
err := errors.New("database connection refused")

err2 := fmt.Errorf("user %s not found", "alice")

1.2 多返回值:错误即值

// 错误与结果并列返回,调用方必须显式处理
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

// 正确处理 vs 忽略处理
func main() {
    result, err := divide(10, 0)
    if err != nil {
        log.Printf("除法失败: %v", err)
        return
    }
    _ = result
}

1.3 两条基本原则

□ 规则1:错误值必须是"值"而不是"流程"——可以被比较、包装、存储
□ 规则2:调用失败就必须返回 err,绝不吞掉错误只记日志再继续

吞错误的常见姿势是 resp, _ := http.Get(url)——丢失的错误信息会让线上排障变成大海捞针。

一句话总结:Go 用多返回值把错误显式摆在调用方手里,正确姿势是"失败必须返回 err、err 必须被处理",错误本身是可编程的值。

2. 错误包装:fmt.Errorf 与 %w

2.1 为什么需要包装

// 不包装:底层错误信息丢失,只留下"看起来像什么都干了"的模糊描述
if err := openFile(path); err != nil {
    return fmt.Errorf("failed to open: %v", err) // %v 丢失错误链
}

// 包装:%w 保留完整错误链,errors.Is/As 可以穿透到根因
if err := openFile(path); err != nil {
    return fmt.Errorf("open config file: %w", err) // %w 建立可判定链
}

%v 只是把错误转成字符串拼进新错误,%w 则把原错误作为可判定的一环存进包装错误——这是现代 Go 错误链的基石。

2.2 多层包装的解剖

func LoadConfig(path string) (*Config, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("load config %s: %w", path, err) // 第3层
    }
    defer f.Close()
    // ...
}

func main() {
    _, err := LoadConfig("/etc/app.yaml")
    // err.Error() 输出:
    //   load config /etc/app.yaml: open /etc/app.yaml: no such file or directory
    // 从外层到根因,信息逐层保留
}

2.3 包装的纪律

□ 每层包装都加上"这一层做了什么"的上下文(哪个文件、哪个参数)
□ 上下文加"动词+宾语":open config / query user / call rpc
□ 不要重复包装同一层:能直接返回就用 %w 或直接返回
□ 包内用 %w,跨包传递保持 %w 直到顶层再决定如何展示

一句话总结:fmt.Errorf("...: %w", err) 是错误链的接缝——每包一层就多一层上下文,而根因始终可被 errors.Is/As 触达。

3. errors.Is / errors.As / errors.Unwrap

3.1 errors.Is:判断"链上是否存在某个哨兵错误"

// errors.Is(err, target):沿着错误链逐层比较,相等或命中 target 即 true
if errors.Is(err, os.ErrNotExist) {
    // 文件不存在:创建目录重试
    return createDirAndRetry()
}

// 典型哨兵目标
var ErrUserNotFound = errors.New("user not found")
// ...
if errors.Is(err, ErrUserNotFound) {
    http.Error(w, "user missing", http.StatusNotFound)
}

errors.Is 遍历整条 %w 包装链,只要有一层等于 ErrUserNotFound 就返回 true。这解决了"深层函数返回的错误,上层怎么知道根因"的问题。

3.2 errors.As:提取链上的特定错误类型

// errors.As(err, target):在链上找到第一个可赋给 target 的错误类型
var pathErr *os.PathError
if errors.As(err, &pathErr) {
    // 拿到了具体的 *os.PathError,可访问其 Op、Path、Err 字段
    log.Printf("path operation %s on %s failed: %v",
        pathErr.Op, pathErr.Path, pathErr.Err)
}

// 自定义类型提取
var bizErr *BusinessError
if errors.As(err, &bizErr) {
    // 业务错误:提取业务码做降级或重试
    return bizErr.Code, bizErr
}

As 的 target 必须是指向"实现了 error 的类型"的指针。它用于"我需要那个具体错误类型的字段"的场景,而 Is 用于"我只关心是不是某个哨兵"的场景。

3.3 errors.Unwrap 与自定义链

// Unwrap() 方法定义"我的下一层是谁"——凡是实现它的类型都可参与错误链
type wrappedError struct {
    msg string
    err error
}

func (w *wrappedError) Error() string { return w.msg }
func (w *wrappedError) Unwrap() error { return w.err } // 暴露链的下一环

// fmt.Errorf("%w") 内部就依赖 Unwrap 构建链
// errors.Is 与 errors.As 都是沿着 Unwrap() 逐层下钻

一句话总结:Is 沿链找"是否是某个哨兵",As 沿链提取"某个具体错误类型",Unwrap 定义链的走向——三者合起来让错误链可以精确判定与提取。

4. 哨兵错误与自定义错误类型

4.1 哨兵错误的两种风格

// 风格1:errors.New 哨兵 —— 只用于"相等判定"
var ErrNotFound = errors.New("not found")
var ErrConflict = errors.New("conflict")

// 风格2:带数据的状态错误 —— 用 errors.As 提取状态码
type StatusError struct {
    Code    int
    Message string
    Cause   error
}

func (e *StatusError) Error() string { return fmt.Sprintf("%d: %s", e.Code, e.Message) }
func (e *StatusError) Unwrap() error  { return e.Cause }

风格1 适合"只需要知道是或不是"的边界信号;风格2 适合"错误本身携带结构化信息(状态码、字段名)“的场景。

4.2 自定义错误实现 error 接口

// 完整示例:带重试语义的业务错误
type RetryableError struct {
    RetryAfter time.Duration
    Cause      error
}

func (e *RetryableError) Error() string {
    return fmt.Sprintf("retry after %v: %v", e.RetryAfter, e.Cause)
}
func (e *RetryableError) Unwrap() error { return e.Cause }

// 使用:上游返回限流错误时,调用方决定是否重试
var errRate = &RetryableError{RetryAfter: 2 * time.Second, Cause: ErrRateLimited}
// ...
var rerr *RetryableError
if errors.As(err, &rerr) {
    time.Sleep(rerr.RetryAfter) // 根据建议时间退避重试
    retry()
}

4.3 哨兵错误的定义位置

□ 与定义它们的包同文件或 errors 子包,避免循环依赖
□ 导出哨兵:ErrNotFound、ErrConflict —— 包外部可通过 errors.Is 判定
□ 不导出的哨兵仅限包内使用,防止公共 API 耦合内部细节
□ 哨兵错误文案应稳定:它可能被当作程序接口的一部分对外比较

一句话总结:哨兵错误用于"边界判定”,自定义类型用于"携带结构化数据",定义位置决定可复用边界——导出哨兵、私有类型、保持文案稳定。

5. 错误码体系与领域错误设计

5.1 三层错误码

// 错误码 = 模块码 + 业务码,统一封装
const (
    // 通用码
    CodeOK            = 0
    CodeBadRequest    = 40000
    CodeUnauthorized  = 40100
    CodeNotFound      = 40400
    CodeConflict      = 40900
    CodeInternal      = 50000

    // 用户模块
    CodeUserNotFound  = 40401
    CodeUserLocked    = 40301
)

// 统一响应结构
type APIError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
    Detail  string `json:"detail,omitempty"`
}

5.2 领域错误 + HTTP 状态码的映射

func MapToHTTP(err error) int {
    switch {
    case errors.Is(err, ErrUserNotFound):
        return http.StatusNotFound
    case errors.Is(err, ErrUnauthorized):
        return http.StatusUnauthorized
    case errors.Is(err, ErrRateLimited):
        return http.StatusTooManyRequests
    default:
        return http.StatusInternalServerError
    }
}

// 在 handler 层统一收口:
func handleError(w http.ResponseWriter, err error) {
    code := MapToHTTP(err)
    apiErr := &APIError{Code: classify(err), Message: err.Error()}
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    json.NewEncoder(w).Encode(apiErr)
}

5.3 错误码设计五原则

1. 可读:code 与 message 成对出现,code 稳定、message 可换文案
2. 分层:业务码(前端用)与底层 error(日志用)分离
3. 枚举化:所有错误码集中定义,避免魔法数字散落
4. 版本化:错误码不随意变更,变更需走协议升级
5. 可追溯:错误码 + traceID 关联,日志与响应一一对应

一句话总结:错误码体系让"机器可读"与"人可读"分离,领域错误通过 errors.Is 分类,在 handler 层统一映射为 HTTP 状态与 JSON 结构。

6. 生产环境的错误处理纪律

纪律反例正例
绝不吞错resp, _ := client.Get(url)resp, err := client.Get(url); if err != nil { return err }
保留根因fmt.Errorf("%v", err)fmt.Errorf("call rpc: %w", err)
判定用 Is/Aserr == ErrNotFound(丢失链上匹配)errors.Is(err, ErrNotFound)
日志记录一次每层都 log只在顶层统一记录,携带完整链与 traceID
错误文案稳定哨兵文案随意改哨兵文案冻结,展示文案另存 message
错误码对齐handler 里散落 http.StatusNotFound集中 MapToHTTP 映射表
panic 传递深层 panic 上层不 recover只在边界(goroutine 入口)recover 并转 err
区分确定性未知错误一律 500Is/As 分类后 4xx/5xx 分流

7. 总结

工具作用一句要义
errors.New构造哨兵错误边界信号,文案稳定
fmt.Errorf + %w包装错误链每层加上下文,保留根因
errors.Is链上哨兵判定深层错误也能判断是不是它
errors.As提取具体错误类型拿到结构化错误做业务决策
errors.Unwrap定义链的走向自定义类型参与错误链
自定义错误类型携带结构化数据状态码、重试建议、字段名
错误码体系机器可读协议code+message 成对,统一映射

落地记住六件事:每层包装用 %w 并加"动词+宾语"上下文、判定一律 errors.Is/As 不直接比较、哨兵错误集中定义且文案冻结、错误码与 HTTP 状态在 handler 统一映射、错误只在顶层记录一次并带 traceID、panic 只在 goroutine 边界 recover。把错误当成可组合、可判定、可追踪的值,你的 Go 服务在排障时才会"一层层剥到根因"。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. Go 日志与可观测性:slog、结构化日志与 OpenTelemetry 集成
  2. Go 测试与基准实战:表驱动、Mock、Fuzz 与 pprof 基准分析
  3. Go GC 与内存调优:逃逸分析、内存池、GOGC、GOMEMLIMIT 实战