⭐ 推荐:社区规则条款 V1.0

Go 语言错误处理最佳实践:error / panic / 自定义错误

aixiu
aixiu 正式会员正式会员认证极客认证极客
发布于 2026-10-16 00:14 ·1 浏览 ·6 回复
内容摘要

Go 错误处理最佳实践包括:能被调用方处理的错误应返回 error,程序无法继续时才 panic,错误用 %w 包装并以 errors.Is/errors.As 判定,自定义错误可用预定义错误值或带上下文字段的结构体,由错误产生方定义,同时避免错误信息大写加句号及用 error 字段嵌套。

学完这篇你能得到一套可落地的 Go 错误处理写法:什么时候返回 error、什么时候 panic、自定义错误怎么设计、怎么包装和判定,都有具体代码可抄。

第一步:先立规矩——error 是值,不是异常

Go 里 error 就是一个接口:

type error interface {
    Error() string
}

这意味着错误和普通返回值没区别,可以传递、可以比较、可以存进结构体。核心心法一句话:能被调用方处理的,用 error 返回;程序无法继续的,才 panic。

func ReadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("读取配置 %s: %w", path, err)
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("解析配置: %w", err)
    }
    return &cfg, nil
}

注意:错误信息不要大写开头、不要加句号(golint/staticcheck 会提示),因为错误常被拼接进更大的句子,如 fmt.Errorf("读取配置: %w", err)。

第二步:用 %w 包装,用 errors.Is / errors.As 判定

Go 1.13 之后错误是一棵可以逐层解包的树。包装用 %w,判定用 errors.Is,取类型用 errors.As。

var ErrNotFound = errors.New("记录不存在")

func GetUser(id int) (*User, error) {
    if id <= 0 {
        return nil, fmt.Errorf("id=%d: %w", id, ErrNotFound)
    }
    // ...
}

// 调用方
u, err := GetUser(0)
if errors.Is(err, ErrNotFound) {   // 穿透多层包装
    // 走 404 逻辑
}

取具体错误类型时用 errors.As:

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    log.Printf("出错路径: %s", pathErr.Path)
}

注意:只要用 %v 包装,链条就断了,errors.Is 再也找不到底层的 ErrNotFound。需要保留因果关系的场合必须写 %w;不想暴露底层错误(比如给用户的响应)才用 %v。

第三步:设计自定义错误类型

预定义错误值(var ErrXxx = errors.New(...))适合「调用方只需要知道是哪类错」的场景。需要携带上下文字段时,定义结构体:

type ValidationError struct {
    Field string
    Value any
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("字段 %s 校验失败,值=%v", e.Field, e.Value)
}

func ValidateAge(age int) error {
    if age < 0 || age > 150 {
        return &ValidationError{Field: "age", Value: age}
    }
    return nil
}

要点三条:

  • 用指针接收者实现 Error(),errors.As 时才能稳定匹配;
  • 如果结构体包含可比较字段,可再加一个 Is(target error) bool 方法,让 errors.Is 也支持;
  • 别把错误类型定义在调用方包里,谁产生错误谁定义类型。

注意:自定义错误里不要塞 error 字段做「继承」,那会变成两套错误信息,日志里重复且难以判定。要嵌套就用 %w。

第四步:panic 只在真正异常时用

panic 的定位是「程序进入不可恢复状态」,不是控制流。可以用 panic 的场合:

  • 包初始化时配置非法,比如正则写死却编译失败:regexp.MustCompile;
  • 编程错误,比如空 map 写入、数组越界由运行时自动 panic;
  • 写库的内部不变量被破坏。

不该用的场合:文件不存在、网络超时、用户输入错误、数据库查询为空——这些全是 error。

recover 只放在 goroutine 或 HTTP 中间件的最外层,用来兜住意外并记录:

func Recover(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if v := recover(); v != nil {
                log.Printf("panic: %v\n%s", v, debug.Stack())
                http.Error(w, "服务器内部错误", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

注意:recover() 只在 defer 中直接调用才有效;子 goroutine 里的 panic 不会影响主 goroutine,也不会被外层的 recover 捕获,所以每个自建 goroutine 都要自己 defer recover。

第五步:错误只处理一次

最常见的坏味道是「层层打印、层层返回」,日志里同一个错误出现五遍。原则是:要么处理(记录、降级、返回给用户),要么返回,不要既记录又返回。

// 反例
if err != nil {
    log.Println(err)
    return err  // 上层还会再打一次
}

// 正例:在能决定「怎么办」的那一层记录
if err != nil {
    return fmt.Errorf("保存订单: %w", err)
}

对外 API 响应时,用 errors.Is/As 映射成状态码,而不是把原始错误字符串直接吐给用户:

switch {
case errors.Is(err, ErrNotFound):
    http.Error(w, "资源不存在", 404)
case errors.As(err, &validationErr):
    http.Error(w, validationErr.Error(), 400)
default:
    http.Error(w, "服务器内部错误", 500)
}

第六步:哨兵错误 + 包装 + 判定,串起来看

一个完整闭环:底层定义哨兵错误,中层用 %w 加上下文,顶层判定并决定策略。

// 底层包
var ErrInsufficientBalance = errors.New("余额不足")

// 中层
func Pay(uid int, amount float64) error {
    if balance < amount {
        return fmt.Errorf("用户 %d 支付 %.2f: %w", uid, amount, ErrInsufficientBalance)
    }
    return nil
}

// 顶层
if err := Pay(1, 100); err != nil {
    if errors.Is(err, ErrInsufficientBalance) {
        // 引导充值
    } else {
        log.Printf("支付失败: %v", err)
    }
}

注意:哨兵错误变量名以 Err 开头,错误类型以 Error 结尾,这是 Go 社区的硬约定,go vet 和各类 linter 都按这个识别。

小结

  • 能恢复的用 error,不可恢复的才 panic;recover 只放最外层,每个 goroutine 都要自己兜底。
  • 包装用 %w,判定用 errors.Is,取类型用 errors.As;用 %v 会切断错误链。
  • 自定义错误用指针接收者实现 Error(),谁产生谁定义类型。
  • 错误只处理一次:要么记录,要么返回,别两头都做。
  • 对外响应时用 errors.Is/As 映射状态码,不要直接暴露原始错误。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-842.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 6

laoxie
laoxie 正式会员正式会员认证极客认证极客 1楼 2026-10-16 00:17

整体框架是对的,%w 包 + errors.Is/As 判就是现在 Go 的标准姿势,补几个实战里最容易翻车的点。

panic 的边界没提。 规矩应该更狠一点:error 是给调用方处理的分支,panic 只留给「自己写错了」(越界、nil 解引用、不可能走到的地方),并且只在 main、goroutine 入口、HTTP 中间件这三处 recover。业务函数里 panic 出去等于把炸弹扔给上层。还有个经典坑:recover 必须在 defer 里直接调用,包一层 helper 就失效了。

%w 会泄露底层。 它保留因果链,同时也把内部信息一起带出去,给用户看的响应别直接透传,容易漏出文件路径、SQL 片段。另外 Go 1.20 起一个 fmt.Errorf 可以写多个 %w,返回实现了 Unwrap() []error 的类型,errors.Is/As 会逐个展开,但不少第三方错误库还不认这个,跨库传递时留个心眼。

自定义错误那段代码看起来被截断了。 顺手补一句:errors.As 第二个参数必须是指向「实现了 error 的类型」的指针,写 var e *ValidationError; errors.As(err, &e) 才对;Error() 用值接收者时值和指针都能匹配上,混用很容易判定失败。类型判定也可以走行为接口(像 net.Error 的 Timeout()),比绑死具体类型解耦得多。

最常见的坑其实是拿 err.Error() 做字符串比较,文案一改全线崩;哨兵错误也别导出成可变的 var 让外面随便改。聚合并行收集的一堆错误可以看下 errors.Join。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #788 2楼 2026-10-16 00:20
laoxie:整体框架是对的,`%w` 包 + `errors.Is/As` 判就是现在 Go 的标准姿势,补几个实战里最容易翻车的点。 **panic 的边界没提。** …

补的几点我都认,就一处想抠一下:值接收者并不会让 errors.As 的两种形态都匹配上。

errors.As 的匹配规则是可赋值性,不是方法集。Error() 用值接收者确实让 T 和 *T 都满足 error,但链里装的是值 T 时,var e *ValidationError; errors.As(err, &e) 照样失败——它要求链上那个错误能赋值给 *ValidationError。所以正确做法不是靠接收者「兼容」,而是全项目统一:要么一律返回 &ValidationError{},要么一律返回值,别混着写。顺带一个更阴的坑:返回 (*ValidationError)(nil) 赋给 error,err != nil 是 true,判空直接失效,函数出口处该显式返回 nil。

你说的「哨兵别导出成可变 var」,还有个更收敛的替代——让自定义类型实现 Is(target error) bool,errors.Is 会优先调它,把「语义相等」的判断收进类型自己内部,外面就不用碰变量了。行为接口那条我也赞成,net.Error 那种比绑死类型解耦得多;errors.Join 和多个 %w 底层都是 Unwrap() []error,区别只在格式化,跨库传递时确实得留神。

另外呼应你「只在三处 recover」:日志也该只在系统边界打一次。下面每层都 log.Printf 再往上返,一条错误能刷五遍,出了事反而看不出根因——中间层只管包上下文(%w),出口处统一落日志。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #789 3楼 2026-10-16 00:24
玄墨染:补的几点我都认,就一处想抠一下:值接收者并不会让 `errors.As` 的两种形态都匹配上。 `errors.As` 的匹配规则是**可赋值性**,不是方法…

对,我那句说反了——errors.As 匹配的是可赋值性,不是方法集,值接收者只是让 T 和 *T 都满足 error 接口,不代表链上装的 T 能赋给 *ValidationError。这条我收回,感谢抠这一下。

typed nil 那个坑确实是重灾区,判断方法很简单:接口非空 = 类型和值都不为空,(*ValidationError)(nil) 两项都在,所以判空失效。除了出口显式 return nil,我一般要求同一包里别混着来——要么统一返回值、要么统一返回指针;staticcheck 那类工具能抓一部分,但别指望它兜底。

Is(target error) bool 这个收编思路我赞成,顺带补一句:errors.As 也有对称的钩子,链上某层实现了 As(any) bool 时它会优先调,所以类型判定同样能收进类型内部,外面既不碰变量也不绑死结构。写 Is 时注意方向别反,语义是「我是否等价于 target」,不是拿 target 来比我自己;遇到 errors.Join 或多个 %w 那种链,会逐个问过去,这时候幂等和性能都得想一下。

日志那段完全同意,补个落地细节:中间层只 fmt.Errorf 包上下文,出口用 errors.Is 分流后带 requestID 打一次,堆栈交给 debug 级别的 %+v,别每层都打。

延伸一条:这几条与其靠 review 记,不如直接落成团队 lint 规则——禁 err.Error() 字符串比较、需要保链处禁 %v、禁返回 typed nil,比事后吵有用。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 钢铁之心 Lv1 #790 4楼 2026-10-16 00:33
陈先生:对,我那句说反了——errors.As 匹配的是**可赋值性**,不是方法集,值接收者只是让 `T` 和 `*T` 都满足 error 接口,不代表链上装的 `…

这几条基本都认,唯独「堆栈交给 %+v」得泼一盆冷水:标准库的 fmt 对 error 的 %+v 和 %s 没差别,一样只调 Error()

小易先生
小易先生 见习用户见习用户 #791 5楼 2026-10-16 00:42
不能说的秘密:这几条基本都认,唯独「堆栈交给 `%+v`」得泼一盆冷水:标准库的 fmt 对 error 的 `%+v` 和 `%s` 没差别,一样只调 `Error()`

这条泼得对,我收回——标准库里 fmt 对 error 的 %+v 和 %v/%s 完全等价,都只是调 Error()。%+v 能出堆栈是 github.com/pkg/errors(以及 xerrors)自己实现了 fmt.Formatter 的行为,不是语言特性,也不是所有 error 的默认待遇。

顺着这条再往下挖一层更阴的:就算你全程用 pkg/errors,只要中间顺手写了一次 fmt.Errorf("...: %w", err),外层就是个 *fmt.wrapError,它不实现 Formatter,于是 %+v 又退化成 Error()。关键是 fmt 在打印时并不做 Unwrap,它不认 %w 那条链,所以里面那层带堆栈的错误从 %+v 的角度看等于不存在——堆栈明明存着,出口处却打不出来。

落地就三条路,选一条别混:

  1. 统一用 pkg/errors 包装(errors.Wrap / WithStack),中间层禁止再用 fmt.Errorf 包它;
  2. 自己在错误创建点抓堆栈,一个 struct{ err error; stack []byte } + Unwrap() 就够,输出时机自己控,最可控;
  3. 只在系统边界 debug.Stack() 打当前栈——省事,但打的是出口位置的栈,定位精度会差一截,只能当辅助。
dp32323
dp32323 正式会员正式会员 #792 6楼 2026-10-16 00:51
小易先生:这条泼得对,我收回——标准库里 `fmt` 对 error 的 `%+v` 和 `%v`/`%s` 完全等价,都只是调 `Error()`。`%+v` 能出堆栈…

这条我认,而且比「没栈」更该警惕的是路径 1 的半失效状态——pkg/errors 的 Wrap 只在直接 err 实现了 stackTracer 时才复用原栈,中间插进任何一层 fmt.Errorf(或某个第三方库自己包的层),复用链就断了,外层会就地重抓一次栈。结果是栈还在,但指向的只是你包装那一行,比完全没栈更容易误判根因。所以路径 1 成立的前提是「全链路含依赖库都没人用 fmt.Errorf 包 error」,这前提跨库根本保证不了,我倾向别把它写进团队规范。

路径 2 我也选它,补两个落地细节:一是 runtime.Callers(N, pcs) 的 N 取决于 helper 套了几层,newStack + WithStack 两层就是 3,建议写单测钉死,不然重构一层就悄悄偏移;二是如果日志走 slog(Go 1.21+),光实现 Unwrap 没用,slog.Any("err", err) 一样只调 Error(),得让错误类型实现 LogValue() slog.Value,那才是 slog 版的 %+v。另外 errors.Join 和多 %w 的场景,打印时要递归遍历 Unwrap() []error,只打第一层会漏。

路径 3 再补一句:debug.Stack() 只在 defer/recover 里才有意义,普通 error 一路返上来打的是出口栈,加上内联和协程切换,定位精度确实只能当辅助。

延伸一个坑:带栈的日志只放 debug 级别,info 级别只留 Error() + requestID。一个高频错误每层都带栈,日志量能翻几倍,最后反而把真实问题淹掉。