Go 错误包装实战指南:%w 和 %v 的选择不是格式问题,而是 API 设计

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

go-error-wrapping-guide

Go 错误包装实战指南:什么时候该用 %w,什么时候该用 %v

🔗 %w 背后隐藏的契约

大多数 Go 开发者很早就学会了错误包装:fmt.Errorf("获取用户失败: %w", err)。看起来很无害——我们只是在添加上下文。但 %w 做了一件 %v 不会做的事:它让被包装的错误可以通过 errors.Iserrors.As 被检查。

这意味着调用方现在可以这样写:

1
2
3
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
2
3
4
5
// %w — 调用方可以解包检查
return fmt.Errorf("加载配置: %w", err)

// %v — 相同的日志输出,但链条被切断
return fmt.Errorf("加载配置: %v", err)

这不是对开发者隐藏信息,而是不让实现细节成为 API 表面积的一部分。

🧭 决策框架

调研了 CockroachDB、Terraform 和 Go 标准库的处理方式后,一个清晰的模式浮现:

包内: 直接返回错误。调用方是你自己的代码——你控制两端。在包内添加上下文往往制造冗余。

1
2
3
func (s *Store) getUser(id string) (*User, error) {
return s.db.QueryRow(/* ... */) // 直接返回就好
}

跨包边界: 默认用 %v 包装。添加你的包正在尝试做什么的上下文,但不暴露你内部在用什么。

1
2
3
4
5
6
7
func (s *UserService) GetUser(id string) (*User, error) {
u, err := s.store.getUser(id)
if err != nil {
return nil, fmt.Errorf("获取用户 %s: %v", id, err)
}
return u, nil
}

系统边界: 将错误翻译为领域特定的哨兵错误。不要让数据库错误、HTTP 错误或 RPC 错误泄漏到服务边界之外。

1
2
3
4
5
6
7
8
9
10
11
12
var ErrUserNotFound = errors.New("用户不存在")

func (s *UserService) GetUser(id string) (*User, error) {
u, err := s.store.getUser(id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound // 翻译,不是包装
}
if err != nil {
return nil, fmt.Errorf("获取用户 %s: %v", id, err)
}
return u, nil
}

📋 按应用类型的建议

库: 默认用 %v。只在有意为之、有文档记录、作为 API 契约一部分时才用 %w 暴露内部错误。定义自己的哨兵错误。

CLI 工具: 放心用 %w。调用栈浅,输出面向用户,最大化上下文有助于调试。没人会程序化地检查你 CLI 的错误。

服务: 在包边界用 %w 做内部错误检查,在系统边界用 %v,在 handler 层结合结构化日志记录请求级别的上下文。

📝 结构化日志:另一种思路

Dave Cheney——用 pkg/errors 最早推广错误包装的人——后来进化了他的想法。与其把调试上下文塞进错误字符串,不如用结构化日志字段来承载:

1
2
3
4
5
6
7
8
9
10
11
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
u, err := s.store.getUser(id)
if err != nil {
slog.ErrorContext(ctx, "存储层查询失败",
"user_id", id,
"error", err,
)
return nil, ErrUserNotFound
}
return u, nil
}

错误保持干净和领域化。调试上下文进入结构化日志,可查询、可过滤,不会污染错误链。

💡 总结

%w%v 的选择是一个 API 设计决策。%w 意味着:”我承诺这个错误类型是我接口的一部分。” %v 意味着:”我会告诉你发生了什么,但我怎么实现的是我的事。”

默认用 %v。只在你有意识地决定内部错误类型是公开契约一部分时才用 %w。在系统边界,翻译——而不是包装。

你的团队是怎么处理错误包装的?有 lint 规则强制执行特定策略,还是靠个人判断?

为什么 Go 是大模型代码生成的最佳语言 分布式系统心跳机制:你应该了解的 Go 实现

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×