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 这一层的上下文}returnu,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)}returnu,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 定义包级别的哨兵错误,导出给调用方判断varErrUserBannederrors.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.Age0{returnValidationError{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拆链判断——错误传到天边,身份也不会丢。