%w 和 %v 的区别,是一个 API 设计决策,而不是格式化选择

Go 错误包装实战指南:什么时候该用 %w,什么时候该用 %v
🔗 %w 背后隐藏的契约
大多数 Go 开发者很早就学会了错误包装:fmt.Errorf("获取用户失败: %w", err)。看起来很无害——我们只是在添加上下文。但 %w 做了一件 %v 不会做的事:它让被包装的错误可以通过 errors.Is 和 errors.As 被检查。
这意味着调用方现在可以这样写:
1 | if errors.Is(err, sql.ErrNoRows) { |
这就是一份契约。你的函数现在隐式承诺它使用 SQL 数据库。如果你换成 Redis、加了缓存层、或换了 ORM,依赖 sql.ErrNoRows 的调用方会静默失效——他们的 errors.Is 检查不再匹配,代码路径悄然改变,编译器不会给出任何警告。
💥 过度包装的代价
消息膨胀。 深层错误链产生冗长、脆弱的字符串:
1 | "处理订单: 验证支付: 扣款: 调用stripe: Post https://api.stripe.com/v1/charges: dial tcp: connect: connection refused" |
当你把 验证支付 重命名为 校验支付 时,所有匹配该字符串的告警规则都会失效。同一个根因通过不同代码路径产生不同字符串,让日志聚合变得痛苦。
泄露实现细节。 每个 %w 都是通向你内部实现的窗口。对于被他人使用的库和服务来说,这些细节不应该成为公开接口的一部分。
🔌 %v 的妙用
%v 产生与 %w 完全相同的人类可读输出,但切断了错误链。调用方在日志中能看到信息,但无法用程序检查内部错误:
1 | // %w — 调用方可以解包检查 |
这不是对开发者隐藏信息,而是不让实现细节成为 API 表面积的一部分。
🧭 决策框架
调研了 CockroachDB、Terraform 和 Go 标准库的处理方式后,一个清晰的模式浮现:
包内: 直接返回错误。调用方是你自己的代码——你控制两端。在包内添加上下文往往制造冗余。
1 | func (s *Store) getUser(id string) (*User, error) { |
跨包边界: 默认用 %v 包装。添加你的包正在尝试做什么的上下文,但不暴露你内部在用什么。
1 | func (s *UserService) GetUser(id string) (*User, error) { |
系统边界: 将错误翻译为领域特定的哨兵错误。不要让数据库错误、HTTP 错误或 RPC 错误泄漏到服务边界之外。
1 | var ErrUserNotFound = errors.New("用户不存在") |
📋 按应用类型的建议
库: 默认用 %v。只在有意为之、有文档记录、作为 API 契约一部分时才用 %w 暴露内部错误。定义自己的哨兵错误。
CLI 工具: 放心用 %w。调用栈浅,输出面向用户,最大化上下文有助于调试。没人会程序化地检查你 CLI 的错误。
服务: 在包边界用 %w 做内部错误检查,在系统边界用 %v,在 handler 层结合结构化日志记录请求级别的上下文。
📝 结构化日志:另一种思路
Dave Cheney——用 pkg/errors 最早推广错误包装的人——后来进化了他的想法。与其把调试上下文塞进错误字符串,不如用结构化日志字段来承载:
1 | func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) { |
错误保持干净和领域化。调试上下文进入结构化日志,可查询、可过滤,不会污染错误链。
💡 总结
%w 和 %v 的选择是一个 API 设计决策。%w 意味着:”我承诺这个错误类型是我接口的一部分。” %v 意味着:”我会告诉你发生了什么,但我怎么实现的是我的事。”
默认用 %v。只在你有意识地决定内部错误类型是公开契约一部分时才用 %w。在系统边界,翻译——而不是包装。
你的团队是怎么处理错误包装的?有 lint 规则强制执行特定策略,还是靠个人判断?
评论