Go error 处理:errors.Is/As 与错误包装
Go 的错误处理一直被吐槽啰嗦,但真正让人头疼的不是 if err != nil 写得多,而是错误传到上层就"失真"了 。你在最底层返回了 sql.ErrNoRows,一路 return err 传上来,到 handler 里想判断"是不是记录不存在",结果发现要么信息丢了,要么只能用 err.Error() == "..." 这种字符串比较------脆得一改文案就崩。
Go 1.13 引入的错误包装(wrapping)和 errors.Is / errors.As 就是来解决这个的。这篇讲清楚:怎么包装错误既保留上下文又不丢原始类型,以及怎么在上层正确地判断和提取错误。
朴素写法的两个坑
先看没有包装时的常见做法。坑一:直接 return err,上层拿到错误但不知道它是在哪一步出的。
go
func loadUser(id int) (*User, error) {
row := db.QueryRow("SELECT ... WHERE id = ?", id)
var u User
if err := row.Scan(&u.Name); err != nil {
return nil, err // 只返回底层错误,丢了"loadUser 这一层"的上下文
}
return &u, nil
}
坑二:为了加上下文,用 fmt.Errorf 拼字符串,结果原始错误类型被抹掉了:
go
if err := row.Scan(&u.Name); err != nil {
// %v 只是把错误转成字符串塞进去,sql.ErrNoRows 这个"身份"没了
return nil, fmt.Errorf("loadUser id=%d: %v", id, err)
}
上层想判断"是不是没查到记录",只能被迫写:
go
if strings.Contains(err.Error(), "no rows") { // 脆弱:依赖错误文案,库一改就废
// ...
}
字符串匹配是错误处理里最该避免的反模式。
用 %w 包装:既加上下文又保留原始错误
fmt.Errorf 的 %w 动词(w = wrap)是关键。它在字符串里嵌入上下文的同时,把原始错误"链接"进新错误里,原始类型不丢:
go
func loadUser(id int) (*User, error) {
row := db.QueryRow("SELECT name FROM users WHERE id = ?", id)
var u User
if err := row.Scan(&u.Name); err != nil {
// 用 %w 而不是 %v:错误链里仍能找到 sql.ErrNoRows
return nil, fmt.Errorf("loadUser id=%d: %w", id, err)
}
return &u, nil
}
%v 和 %w 打印出来的文字一模一样,区别全在能不能被 errors.Is/As 识别 。记住一条规则:给上层留了判断需要的错误,就用 %w;纯粹只想记个日志、不需要上层再识别,用 %v。
errors.Is:判断"是不是某个特定错误"
包装之后,上层用 errors.Is 沿着错误链查找,不管中间包了多少层:
go
u, err := loadUser(42)
if err != nil {
// errors.Is 会顺着 %w 链一路找,底层的 sql.ErrNoRows 照样能命中
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "用户不存在", http.StatusNotFound)
return
}
// 其他错误当 500
http.Error(w, "服务器错误", http.StatusInternalServerError)
return
}
errors.Is(err, target) 的语义是"err 的错误链里,是否存在等于 target 的错误"。这就是它取代 err == sql.ErrNoRows 的原因------直接 == 在错误被包装后会失败,errors.Is 不会。
自定义哨兵错误(sentinel error)也用同一套:
go
// 用 errors.New 定义包级别的哨兵错误,导出给调用方判断
var ErrUserBanned = errors.New("user is banned")
func checkUser(u *User) error {
if u.Banned {
return fmt.Errorf("checkUser %s: %w", u.Name, ErrUserBanned)
}
return nil
}
// 调用方
if errors.Is(err, ErrUserBanned) {
// 精确处理"被封禁"这一种情况
}
errors.As:提取"某种类型的错误"拿它的字段
有时候光知道"是哪种错误"不够,你还想拿到错误里携带的数据 (比如出错的字段名、HTTP 状态码)。这时用 errors.As,它把错误链里匹配类型的那个错误"取出来"赋值给你的变量:
go
// 一个携带额外信息的自定义错误类型
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("字段 %s 校验失败: %s", e.Field, e.Msg)
}
func validate(u *User) error {
if u.Age < 0 {
return &ValidationError{Field: "age", Msg: "不能为负数"}
}
return nil
}
上层用 errors.As 把它捞出来,读它的字段:
go
err := validate(u)
var vErr *ValidationError
// errors.As 找到链里第一个 *ValidationError,赋值给 vErr,返回 true
if errors.As(err, &vErr) {
// 现在能拿到具体是哪个字段错了
fmt.Printf("请检查字段: %s (%s)\n", vErr.Field, vErr.Msg)
}
注意 errors.As 的第二个参数是指向目标变量的指针 (这里 vErr 本身是 *ValidationError,所以传 &vErr)。这是新手最容易搞错的地方,传错编译期就会 panic 提示你。
Is 还是 As?一句话区分
errors.Is:判断"是不是这一个/这一种已知错误",拿来做分支。目标是一个具体的错误值(哨兵错误)。errors.As:判断"链里有没有某种类型 的错误",并且要取出它携带的字段。目标是一个类型。
简记:只要判断身份用 Is,还想读数据用 As。
一个容易忽略的细节:别过度包装
包装很好用,但不是每层都要包。原则是:每一层加的上下文要有信息增量。下面这种层层复读是噪声:
go
// 反面例子:每层都包一遍,错误信息变成 "a: b: c: d: 真正的错误"
return fmt.Errorf("service call: %w", err) // 上层
return fmt.Errorf("repo query: %w", err) // 中层
return fmt.Errorf("db exec: %w", err) // 底层
实践建议:在跨越模块边界 或**能补充关键上下文(如 id、操作名)**时包装一次,中间纯透传的地方直接 return err 即可。
小结
fmt.Errorf用%w包装错误:既加上下文,又让原始错误留在链里可被识别;%v会抹掉类型,只适合记日志。errors.Is(err, target)沿错误链判断"是不是某个特定错误",取代脆弱的==和字符串匹配。errors.As(err, &target)从链里提取某种类型的错误,拿来读它携带的字段;第二个参数要传指针。- 判断身份用
Is,要读数据用As;包装要有信息增量,别层层复读。 - 一句话记忆:用
%w包上下文,用Is/As拆链判断------错误传到天边,身份也不会丢。