Go error 处理:errors.Is/As 与错误包装
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,上层拿到错误但不知道它是在哪一步出的。
funcloadUser(idint)(*User,error){row:=db.QueryRow("SELECT ... WHERE id = ?",id)varu Useriferr:=row.Scan(&u.Name);err!=nil{returnnil,err// 只返回底层错误,丢了"loadUser 这一层"的上下文}return&u,nil}坑二:为了加上下文,用fmt.Errorf拼字符串,结果原始错误类型被抹掉了:
iferr:=row.Scan(&u.Name);err!=nil{// %v 只是把错误转成字符串塞进去,sql.ErrNoRows 这个"身份"没了returnnil,fmt.Errorf("loadUser id=%d: %v",id,err)}上层想判断"是不是没查到记录",只能被迫写:
ifstrings.Contains(err.Error(),"no rows"){// 脆弱:依赖错误文案,库一改就废// ...}字符串匹配是错误处理里最该避免的反模式。
用 %w 包装:既加上下文又保留原始错误
fmt.Errorf的%w动词(w = wrap)是关键。它在字符串里嵌入上下文的同时,把原始错误"链接"进新错误里,原始类型不丢:
funcloadUser(idint)(*User,error){row:=db.QueryRow("SELECT name FROM users WHERE id = ?",id)varu Useriferr:=row.Scan(&u.Name);err!=nil{// 用 %w 而不是 %v:错误链里仍能找到 sql.ErrNoRowsreturnnil,fmt.Errorf("loadUser id=%d: %w",id,err)}return&u,nil}%v和%w打印出来的文字一模一样,区别全在能不能被 errors.Is/As 识别。记住一条规则:给上层留了判断需要的错误,就用%w;纯粹只想记个日志、不需要上层再识别,用%v。
errors.Is:判断"是不是某个特定错误"
包装之后,上层用errors.Is沿着错误链查找,不管中间包了多少层:
u,err:=loadUser(42)iferr!=nil{// errors.Is 会顺着 %w 链一路找,底层的 sql.ErrNoRows 照样能命中iferrors.Is(err,sql.ErrNoRows){http.Error(w,"用户不存在",http.StatusNotFound)return}// 其他错误当 500http.Error(w,"服务器错误",http.StatusInternalServerError)return}errors.Is(err, target)的语义是"err 的错误链里,是否存在等于 target 的错误"。这就是它取代err == sql.ErrNoRows的原因——直接==在错误被包装后会失败,errors.Is不会。
自定义哨兵错误(sentinel error)也用同一套:
// 用 errors.New 定义包级别的哨兵错误,导出给调用方判断varErrUserBanned=errors.New("user is banned")funccheckUser(u*User)error{ifu.Banned{returnfmt.Errorf("checkUser %s: %w",u.Name,ErrUserBanned)}returnnil}// 调用方iferrors.Is(err,ErrUserBanned){// 精确处理"被封禁"这一种情况}errors.As:提取"某种类型的错误"拿它的字段
有时候光知道"是哪种错误"不够,你还想拿到错误里携带的数据(比如出错的字段名、HTTP 状态码)。这时用errors.As,它把错误链里匹配类型的那个错误"取出来"赋值给你的变量:
// 一个携带额外信息的自定义错误类型typeValidationErrorstruct{FieldstringMsgstring}func(e*ValidationError)Error()string{returnfmt.Sprintf("字段 %s 校验失败: %s",e.Field,e.Msg)}funcvalidate(u*User)error{ifu.Age<0{return&ValidationError{Field:"age",Msg:"不能为负数"}}returnnil}上层用errors.As把它捞出来,读它的字段:
err:=validate(u)varvErr*ValidationError// errors.As 找到链里第一个 *ValidationError,赋值给 vErr,返回 trueiferrors.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。
一个容易忽略的细节:别过度包装
包装很好用,但不是每层都要包。原则是:每一层加的上下文要有信息增量。下面这种层层复读是噪声:
// 反面例子:每层都包一遍,错误信息变成 "a: b: c: d: 真正的错误"returnfmt.Errorf("service call: %w",err)// 上层returnfmt.Errorf("repo query: %w",err)// 中层returnfmt.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拆链判断——错误传到天边,身份也不会丢。
