我在给服务层错误补充“查询用户失败”这类上下文后,最先踩到的坑是:原来可以直接比较的哨兵错误,经过包装就不再等于原变量了。解决办法不是解析 err.Error(),而是用 fmt.Errorf 的 %w 保留原因,再让 errors.Is 判断包装链中是否仍有目标错误。
只要包装时使用%w,errors.Is(err, target)就可以沿着错误的Unwrap关系寻找目标;直接比较只适合你确实需要判断两个错误值完全相同的场景。
先用 %w 保留底层哨兵错误
哨兵错误通常是包级变量,调用方只关心“是不是这一类错误”。例如用户不存在时,底层可以返回 ErrUserNotFound,服务层增加上下文,但不要把它改成普通字符串:
package user
import (
"errors"
"fmt"
)
// ErrUserNotFound 是调用方可以稳定识别的错误类别。
var ErrUserNotFound = errors.New("user not found")
func loadUser(id string) error {
if id == "" {
// 这里用 %w 保留底层哨兵,外层才能继续匹配它。
return fmt.Errorf("load user %q: %w", id, ErrUserNotFound)
}
return nil
}
这里的关键不是错误文本,而是 %w 建立了可展开的包装关系。若写成 %v,字符串看起来仍然包含“user not found”,但底层错误不会按可匹配原因保留下来。
errors.Is 会沿包装链匹配目标
调用方不需要知道服务层加了多少层上下文,只需把返回错误和目标哨兵交给 errors.Is:
package main
import (
"errors"
"fmt"
)
var ErrUserNotFound = errors.New("user not found")
func findUser(id string) error {
// 示例只模拟底层返回,不依赖网络或数据库。
return fmt.Errorf("repository lookup %q: %w", id, ErrUserNotFound)
}
func main() {
err := fmt.Errorf("request failed: %w", findUser("u-42"))
// Is 关注包装链中的目标,不依赖每层的文字描述。
if errors.Is(err, ErrUserNotFound) {
fmt.Println("按不存在用户处理")
return
}
fmt.Println("按未知错误处理")
}
errors.Is 会先看当前错误,再按 Unwrap() error 展开包装关系。直接比较 err == ErrUserNotFound 在这个例子中会失败,因为最外层已经是新的包装错误值。
实际工作流里,我会把匹配放在错误处理分支的入口:先识别稳定类别,再决定返回码、用户提示或是否记录更详细的上下文。日志仍可保留原始错误,但不要让日志文本承担程序判断职责。
自定义 Is 和错误树会改变匹配范围
标准库还支持两种扩展关系:错误可以实现 Unwrap() error 形成单链,也可以实现 Unwrap() []error 表示多个子错误;错误类型还可以提供 Is(error) bool 做浅层语义匹配。
package main
import "errors"
var ErrPermission = errors.New("permission denied")
type accessError struct {
operation string
cause error
}
func (e *accessError) Error() string { return e.operation + ": " + e.cause.Error() }
// Unwrap 保留底层原因,让 errors.Is 可以继续向下查找。
func (e *accessError) Unwrap() error { return e.cause }
type categoryError struct{ code int }
func (e categoryError) Error() string { return "category error" }
// Is 只做当前层的类别比较,不在这里递归调用 Unwrap。
func (e categoryError) Is(target error) bool {
other, ok := target.(categoryError)
return ok && e.code == other.code
}
func main() {
wrapped := &accessError{operation: "open", cause: ErrPermission}
_ = errors.Is(wrapped, ErrPermission)
_ = errors.Is(categoryError{code: 7}, categoryError{code: 7})
}
自定义 Is 适合表达“同一错误类别”的匹配,但它应该是浅层判断,不要在里面再次遍历包装链。对于多个独立原因,可以用 errors.Join 形成错误树;errors.Is 会按树遍历去找目标,而不是只看单一父错误。
Is、As 和直接比较分别解决什么问题
这三个写法不要混成一套规则:
| 需求 | 选择 | 判断对象 |
|---|---|---|
| 是否属于某个稳定错误类别 | errors.Is | 哨兵错误或自定义 Is 语义 |
| 是否为某种具体错误类型,并取出字段 | errors.As | 包装链或错误树中的类型 |
| 是否就是同一个错误值 | == | 当前值是否与目标完全相同 |
因此,业务分支通常优先使用 errors.Is;需要读取自定义错误的状态码、路径或重试信息时使用 errors.As;只有 API 明确保证返回同一个值时,直接比较才是清晰的选择。
常见问题
为什么用了 %v,errors.Is 还是匹配不到?
%v 只格式化错误文本,不建立可展开的包装关系。需要保留原因时改用 %w,并确认目标错误本身就是你要匹配的哨兵。
errors.Is 能不能替代错误字符串判断?
能替代绝大多数程序分支中的字符串判断。错误文本适合日志和展示,不适合当稳定协议;如果没有哨兵或类型,先补上明确的错误契约。