Go 的错误处理:包一层、判一下、记一次
Go 的错误处理常被吐槽啰嗦,但真正让人踩坑的不是 if err != nil,而是三个更细的问题:什么时候该包、怎么判断错误类型、日志该在哪一层打。这三件事做错,代码能跑,排查时会非常难受。
一、包错误用 %w,不要用 %v
给错误加上下文是好事,但方式有讲究:
// 丢了原始错误,调用方再也判断不了
return fmt.Errorf("读取配置失败: %v", err)
// 保留了错误链
return fmt.Errorf("读取配置失败: %w", err)
%v 只是把错误转成字符串拼进去,原始错误对象就没了。调用方拿到的是一个全新的 error,errors.Is 一律返回 false。
%w 会把原错误包在新错误里,形成一条链。调用方可以一路 errors.Is(err, fs.ErrNotExist) 判断到底是不是文件不存在,而不需要匹配字符串。
只在一种情况下用 %v:你确定这个错误不需要被上层识别,只是想留个现场。
二、判断错误用 errors.Is,不要比字符串
// 脆弱:文案一改就失效
if err.Error() == "file not found" { ... }
// 正确
if errors.Is(err, fs.ErrNotExist) { ... }
字符串判断的问题在跨层时特别明显:底层把 file not found 改成 no such file,上层所有判断同时失效,而且不会报错,只是行为悄悄变了。
需要从错误里取信息时用 errors.As:
var pe *fs.PathError
if errors.As(err, &pe) {
fmt.Println("出问题的路径:", pe.Path)
}
errors.As 会沿着错误链找第一个匹配的类型,再把值赋给目标变量。比自己逐层做类型断言省事得多。
三、定义哨兵错误,不要到处 new
需要让调用方区分「哪一类错误」时,在包里定义好变量:
var (
ErrNotLoggedIn = errors.New("未登录")
ErrRateLimited = errors.New("被限流")
)
返回时直接 return ErrRateLimited 或者包装一下 fmt.Errorf("第 %d 次请求: %w", i, ErrRateLimited),调用方统一用 errors.Is 判断。
这样错误从字符串变成了类型系统的一部分:改名、改文案都不影响判断。
四、日志只记一次,在哪一层记要想清楚
一个常见的坏味道是同一个错误在日志里出现三次:
func a() error {
if err := b(); err != nil {
log.Printf("a 失败: %v", err)
return err
}
return nil
}
底层记、中间层记、最外层再记,排查时看到的像是三个不同的故障。
规矩是:谁处理这个错误,谁记录它。中间层只负责加上下文往回传:
if err := b(); err != nil {
return fmt.Errorf("同步任务 %s: %w", id, err)
}
只有最终决定「这次操作失败了,要让人知道」的那一层才打日志。这样一条日志就是一次失败的完整结论,前面的上下文都在错误链里。
五、不要在两处同时处理和返回
最后一条边界情况:有人习惯「记完日志再返回错误」,结果上层又记一次。判断标准很简单——如果这一层不打算让程序停下来,就别记 Error,传上去就好。
反过来,如果这一层要吞掉错误(比如批量任务里某一条失败不影响其余),那就必须记,而且要带够信息:哪一条、为什么、后续怎么处理。
for _, item := range items {
if err := process(item); err != nil {
// 吞掉但记录,带上 item 的标识
slog.Warn("处理失败,跳过", "item", item.ID, "err", err)
continue
}
}
这种情况下用 Warn 比 Error 更准确:整体任务是成功的,只是有一条没做。
六、小结
四句话:
1. 包错误用 %w,保住错误链; 2. 判断用 errors.Is / errors.As,别比字符串; 3. 可识别的错误定义成哨兵变量; 4. 错误只在处理它的那一层记录。
这些都不影响程序跑起来,影响的是三个月后你在日志里找问题的时间。
- 点赞
- 收藏
- 关注作者
评论(0)