fmt.errorf("%w") 是唯一能保留错误链的包装方式,它使 errors.is 和 errors.as 有效、支持 %+v 展开全链及位置信息,并要求每层均用 %w、参数为 error 类型且不可用于非错误值。

fmt.Errorf("%w") 是唯一能保留错误链的包装方式
用 fmt.Errorf("xxx: %v", err) 或字符串拼接,原始错误就彻底断了——errors.Is(err, os.ErrNotExist) 一定返回 false,日志里只剩“加载配置失败”,看不出是文件不存在还是权限不足。只有 %w 才让新错误持有对原错误的引用,并暴露 Unwrap() 方法。
必须注意三点:
-
%w只能出现在格式字符串末尾,且参数必须是error类型,传string会编译报错 - 多层包装时,每一层都得用
%w,漏一层就断链;比如fmt.Errorf("db: %w", fmt.Errorf("redis: %v", err))中第二层用了%v,整条链就失效 - 不要在非错误值上硬套
%w,例如fmt.Errorf("id empty: %w", "")编译通过但运行时 panic
errors.Is 和 errors.As 必须配合 %w 才有效
直接用 == 或 strings.Contains(err.Error(), "no such file") 判断错误类型,等于放弃整个错误链机制。这类写法脆弱、不可维护、跨平台不一致。
正确做法是全程依赖标准库接口:
-
errors.Is(err, os.ErrNotExist)会逐层调用Unwrap(),直到匹配或返回nil -
errors.As(err, &pathErr)要求目标变量是指针(&pathErr),且pathErr必须是具体类型(如*os.PathError),不能是error接口本身 - 如果自定义错误类型(如
type AppError struct { Err error }),必须实现Unwrap() error方法返回e.Err,否则errors.Is到这一层就停住
日志中用 %+v 才能看到完整错误链和调用位置
默认 log.Printf("%v", err) 只打印最外层消息,fmt.Errorf("get user: %w", repoErr) 包装后,日志里仍只显示“get user”,看不到底层 sql.ErrNoRows 或具体 SQL 错误。
要真正定位根源,必须用 %+v:
-
log.Printf("failed: %+v", err)会递归展开所有%w包装层,并显示每层的文件与行号(需编译时未禁用内联,即不加-gcflags="all=-l") - 若用
log.SetFlags(log.Lshortfile | log.LstdFlags),日志开头自带位置信息,但那只是日志语句的位置,不是错误发生位置;%+v才是关键 - 第三方库如
github.com/pkg/errors的%+v输出更详细(含栈帧),但它和标准库不兼容;新项目优先用原生%w+%+v
defer 关闭资源时错误覆盖是静默丢失错误的高发场景
常见写法:defer f.Close() 看似简洁,但如果 f.Close() 失败,它会覆盖前面业务逻辑返回的真实错误(比如 json.Unmarshal 失败),最终调用方只收到 “close failed”,完全掩盖问题本质。
必须显式处理:
- 不要把
Close()放在无条件defer里;改用defer func() { if cerr := f.Close(); cerr != nil && err == nil { err = fmt.Errorf("close: %w", cerr) } }() - 若已存在
err,再出现关闭错误,应使用errors.Join(err, cerr)(Go 1.20+)合并,而不是丢弃或覆盖 - HTTP handler 或 CLI 入口这类顶层函数,必须确保最终返回的错误是完整、未被覆盖的;中间层包装时只加语义,不干预错误流向
最容易被忽略的是:错误链本身不记录调用栈,%w 只保留错误值和包装文本。%+v 能显示位置,靠的是编译器在构造 fmt.Errorf 时自动注入的 runtime.Frame,一旦函数被内联(-gcflags="all=-l")或中间件做了错误转换(如某些 Gin 插件把 error 转成 map),这些位置信息就没了。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











