在 Go 的世界里,每个错误都值得被包装吗?还是说我们只是在制造"俄罗斯套娃"?
开场白:一个让人头秃的场景
想象一下这个画面:凌晨三点,警报响起,你的服务挂了。日志上赫然写着:
connection refused
就这?没了?
哪个连接?数据库?Redis?还是第三方支付 API?你在代码库里 grep 了半天,加了临时日志,像个侦探一样抽丝剥茧……最后发现是库存服务挂了。
这时候你才明白:裸错误就像裸奔,虽然坦诚,但真的不适合在公共场合出现。
第一章:裸错误的尴尬
让我们看一个典型的下单函数:
func placeOrder(ctx context.Context, req OrderReq) error {
user, err := users.Get(ctx, req.UserID)
if err != nil {
return err // 😱 裸错误,瑟瑟发抖
}
err = inventory.Reserve(ctx, req.ItemID, req.Qty)
if err != nil {
return err // 😱 又是裸错误
}
err = payments.Charge(ctx, user.PaymentID, req.Total)
if err != nil {
return err // 😱 裸错误三连击
}
return saveOrder(ctx, user.ID, req.ItemID)
}
这四个调用任何一个都可能返回 connection refused。当它们真的挂了,你的日志就像在跟你玩猜谜游戏:"猜猜是哪个服务挂了?猜对了也没奖!"
包装错误:给错误穿上衣服
解决方案显而易见——给错误穿上"上下文"的外套:
user, err := users.Get(ctx, req.UserID)
if err != nil {
return fmt.Errorf("获取用户 %s: %w", req.UserID, err)
}
err = inventory.Reserve(ctx, req.ItemID, req.Qty)
if err != nil {
return fmt.Errorf("为商品 %s 预留库存: %w", req.ItemID, err)
}
现在日志变成了:
为商品 item-123 预留库存: connection refused
完美!至少你知道是哪个服务在哪个商品上摔了跟头。
第二章:包装派的信仰
Dave Cheney 的福音
错误包装界的"教父" Dave Cheney 在 2016 年就说过:"别只是检查错误,要包装它们!" 他的 pkg/errors 库引入了 errors.Wrap,不仅添加上下文,还附带堆栈跟踪。
理念很简单:每个函数都知道自己当时在尝试做什么,如果你不立刻记录下来,这个上下文就永远丢失了。
CockroachDB 的极端实践
CockroachDB 团队把这个理念发挥到了极致。他们用自己的 cockroachdb/errors 包,在每个包装点都捕获堆栈跟踪:
// CockroachDB 风格:每个包装都有堆栈跟踪
if err := r.validateCmd(ctx, cmd); err != nil {
return errors.Wrap(err, "验证命令")
}
if err := r.stage(ctx, cmd); err != nil {
return errors.Wrap(err, "暂存命令")
}
Terraform AWS Provider 的规范
Terraform AWS Provider 的贡献指南强制要求所有资源操作使用统一的包装格式:
// Terraform AWS Provider 风格
output, err := conn.CreateVpc(ctx, input)
if err != nil {
return fmt.Errorf("创建 EC2 VPC: %w", err)
}
d.SetId(aws.ToString(output.Vpc.VpcId))
if _, err := WaitVPCAvailable(ctx, conn, d.Id()); err != nil {
return fmt.Errorf(
"等待 EC2 VPC (%s) 可用: %w",
d.Id(), err,
)
}
wrapcheck linter:代码警察
还有个叫 wrapcheck 的 linter 把这个变成了规则:它不会标记所有的裸 return err,只会标记那些来自其他包的错误:
func placeOrder(ctx context.Context, req OrderReq) error {
// users.Get 在其他包:wrapcheck 会报警
user, err := users.Get(ctx, req.UserID)
if err != nil {
return err // 没包装:linter 警告 ⚠️
}
// validate 在同一包:wrapcheck 允许
err = validate(req)
if err != nil {
return err // 没问题,同一包 ✅
}
}
金句时间
"过度包装的风险,尤其是在我的私有代码中,远低于包装不足的风险——当服务崩溃时,你只得到一个
io.EOF。"
听起来包装就是一切的答案?别急,故事还没完……
第三章:过度包装的代价
代价一:错误消息的"俄罗斯套娃"
当每一层都包装时,你的错误消息会变成这样:
下订单: 为商品 item-123 预留库存:
检查仓库: 查询数据库:
connection refused
四层上下文,就为了一个 connection refused! 中间那两层(检查仓库 和 查询数据库)既没提供仓库 ID,也没提供 SQL 查询,只是在复述调用链。
更糟糕的是,这让错误字符串变得极其脆弱:
- 有人把
checkWarehouse重命名为checkStock?错误字符串变了 - 你设置的告警规则匹配
checking warehouse: querying database: connection refused?Boom!告警失效 - 同样的根本原因(
connection refused)通过不同的代码路径产生不同的错误字符串,在日志仪表盘上根本无法聚合
Jay Conrod 的忠告
Go 团队的 Jay Conrod 提出了错误处理准则:
每个函数负责在错误消息中包含自己的值,但不包括已经传递给返回包装错误函数的参数。
说人话就是:如果 os.Open 已经在错误中包含了文件路径,你的包装器就不应该再次添加路径:
// 冗余:路径出现了两次
return fmt.Errorf("打开 %s: %w", path, err)
// open /etc/app.yaml: opening /etc/app.yaml: permission denied
// 更好:添加你在做什么,而不是 Open 已经说了什么
return fmt.Errorf("读取配置: %w", err)
// reading config: open /etc/app.yaml: permission denied
Google Go 风格指南也说了深谙此道的话:
在为错误添加信息时,避免添加底层错误已经提供的冗余信息。
代价二:%w 创建了你不想要的"契约"
这是最容易被忽视的陷阱!
%w 在 fmt.Errorf 中创建了一个错误链,调用者可以用 errors.Is 和 errors.As 来遍历。这意味着被包装的错误成为了你函数 API 的一部分。
看这个例子:
func LookupUser(ctx context.Context, id string) (*User, error) {
row := db.QueryRowContext(ctx, "SELECT ...", id)
var u User
if err := row.Scan(&u.Name, &u.Email); err != nil {
return nil, fmt.Errorf(
"查找用户 %s: %w", id, err,
)
}
return &u, nil
}
因为用了 %w,调用者现在可以这样做:
err := LookupUser(ctx, "user-123")
if errors.Is(err, sql.ErrNoRows) {
// 用户不存在
}
听起来不错?等等,灾难来了:
三个月后,你决定从 database/sql 迁移到 ORM,或者在前面加了个缓存层。那些依赖 sql.ErrNoRows 的调用者悄无声息地挂了。
⚠️ 重要警告
%w让被包装的错误成为你函数 API 的一部分。调用者可以通过errors.Is和errors.As依赖内部错误类型。如果你后来改变了内部错误(换数据库、加缓存层),这些调用者就会崩溃。只有当你确实想暴露内部错误时,才使用
%w。
第四章:%v —— 保守派的护身符
%v vs %w:一场关于"断链"的哲学
%v 添加了同样的上下文文本(人类阅读日志看到的消息完全一样),但切断了错误链。调用者无法通过它使用 errors.Is 或 errors.As:
// %w:调用者可以 errors.Is(err, sql.ErrNoRows)
return fmt.Errorf("获取用户 %s: %w", id, err)
// %v:同样的消息文本,但链被切断了
return fmt.Errorf("获取用户 %s: %v", id, err)
两者产生相同的日志输出。但用 %v,你可以随时更换数据库,而不会破坏那些依赖内部错误类型的调用者。
系统边界:翻译优于包装
Google Go 风格指南建议在系统边界进行翻译而不是包装:
在你的系统与外部系统(如 RPC、IPC 或存储)交互的地方,通常最好将特定领域的错误转换为标准化的错误空间(例如 gRPC 状态码),而不是简单地用
%w包装原始底层错误。
举个例子,你的仓库层通过 pgx 与 PostgreSQL 对话:
// ❌ 糟糕:用 %w 暴露了 pgx 错误
func (r *UserRepo) Get(ctx context.Context, id string) (*User, error) {
row := r.db.QueryRow(ctx, "SELECT ...", id)
if err := row.Scan(&u.Name, &u.Email); err != nil {
return nil, fmt.Errorf("获取用户 %s: %w", id, err)
}
return &u, nil
}
现在任何调用者都可以 errors.Is(err, pgx.ErrNoRows),把他们和你的数据库驱动绑死了。
// ✅ 更好:翻译成领域错误
var ErrNotFound = errors.New("未找到")
func (r *UserRepo) Get(ctx context.Context, id string) (*User, error) {
row := r.db.QueryRow(ctx, "SELECT ...", id)
if err := row.Scan(&u.Name, &u.Email); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound // 返回自己的错误
}
return nil, fmt.Errorf("获取用户 %s: %v", id, err) // %v 切断链
}
return &u, nil
}
调用者检查 errors.Is(err, ErrNotFound) —— 这是你的错误,而不是 errors.Is(err, pgx.ErrNoRows)。当你从 Postgres 换到 MySQL 时,调用者不会崩溃。
第五章:标准库的"双面人生"
标准库其实早就给出了答案:哨兵错误 + 自定义错误类型 + %w 和 %v 混用。
哨兵错误:io.EOF 的传说
像 io 这样的包定义了哨兵错误 —— 包级变量,调用者用 errors.Is 检查:
// 定义
var EOF = errors.New("EOF")
// Reader 实现
func (r *myReader) Read(p []byte) (int, error) {
if r.pos >= len(r.data) {
return 0, io.EOF // 哨兵错误
}
// ...
}
哨兵错误适用于调用者只需要知道"哪种失败发生了"的场景。
自定义错误类型:os.PathError 的智慧
当调用者需要结构化元数据(不仅仅是身份)时,标准库使用自定义错误类型。os.Open 定义了 *fs.PathError 结构体,包含操作名、文件路径和底层 syscall 错误:
// fs 包中的定义
type PathError struct {
Op string // "open", "read", "write"
Path string // 文件路径
Err error // 底层 syscall 错误
}
func (e *PathError) Unwrap() error { return e.Err }
// os.Open 内部
func Open(name string) (*File, error) {
// ...
return nil, &PathError{Op: "open", Path: name, Err: err}
}
因为 PathError 实现了 Unwrap(),errors.Is(err, fs.ErrNotExist) 可以通过链条工作。但与 fmt.Errorf 包装不同,上下文在类型化的结构体字段中。 调用者可以提取这些字段来决定怎么做。
第六章:结构化日志 —— 错误包装的"备胎"?
Dave Cheney 的"自我背叛"
还记得那个创造了 pkg/errors 并推广错误包装的 Dave Cheney 吗?他在 2021 年为 pkg/errors 寻找新维护者时写道:
"我不再使用这个包了,事实上,我不再包装错误了。"
— Dave Cheney 在 pkg/errors #245
他的理由是:结构化日志可以携带错误包装本应提供的调试上下文。
两种approach的对比
包装方式(把上下文烤进错误字符串):
err = inventory.Reserve(ctx, req.ItemID, req.Qty)
if err != nil {
return fmt.Errorf(
"为商品 %s 预留库存: %w", req.ItemID, err,
)
}
结构化日志(保持错误值干净,把上下文作为独立的键值对字段):
err = inventory.Reserve(ctx, req.ItemID, req.Qty)
if err != nil {
slog.Error("预留库存失败",
"item_id", req.ItemID,
"err", err,
)
return err // 裸错误返回
}
📝 注意
结构化日志和包装不是互斥的。你可以在包边界用
%w包装错误字符串,在 handler 层用slog记录请求范围的上下文(用户 ID、请求 ID、追踪 ID)。这样错误值就不需要承载所有这些信息。
第七章:应用类型决定策略
场景一:库(Library)—— 保守派
原则:保守,保守,再保守!
Google 风格指南在这里最直接适用,因为你是在交付 API 契约。
- 默认使用
%v,以免意外暴露实现细节 - 只在确实想让调用者检查内部错误时使用
%w,并文档化你在这样做 - 定义自己的哨兵错误供调用者检查
var ErrNotFound = errors.New("项目未找到")
func (c *Client) Fetch(ctx context.Context, id string) (*Item, error) {
resp, err := c.http.Get(ctx, c.url+"/items/"+id)
if err != nil {
if isNotFound(err) {
return nil, ErrNotFound // 自己的哨兵错误
}
return nil, fmt.Errorf("获取项目 %s: %v", id, err) // %v 默认
}
// ...
}
为什么? 一个用 %w 包装的库会把调用者和它的依赖绑定。如果 v2 从 pgx 切换到 database/sql,每个做 errors.Is(err, pgconn.something) 的调用者都会崩溃。
场景二:CLI 工具 —— 放纵派
原则:随便包装,想怎么包就怎么包!
- 调用栈浅
- 错误消息就是用户看到的输出
- 没人会对你的 CLI 错误调用
errors.Is - 最大化的上下文帮助人类阅读终端输出
func run() error {
cfg, err := loadConfig(cfgPath)
if err != nil {
return fmt.Errorf("加载配置 %s: %w", cfgPath, err)
}
conn, err := connect(cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("连接数据库: %w", err)
}
return migrate(conn)
}
场景三:服务(Service)—— 中庸之道
原则:看情况,混合策略!
这是最难给出公式化答案的地方。你有结构化日志和分布式追踪,但也有深层调用栈和许多依赖。
作者的落地策略:
- 在包边界包装,带上你尝试做什么的上下文
- 在自己的代码库内使用
%w,调用者应该能够检查内部错误 - 当错误跨越系统边界时(RPC、数据库调用、第三方 API)使用
%v - 同包调用跳过包装
重写开头的 placeOrder 函数:
func placeOrder(ctx context.Context, req OrderReq) error {
user, err := users.Get(ctx, req.UserID) // (1) 其他包
if err != nil {
return fmt.Errorf("获取用户 %s: %w", req.UserID, err)
}
err = inventory.Reserve(ctx, req.ItemID, req.Qty) // (2) 其他包
if err != nil {
return fmt.Errorf("为商品 %s 预留库存: %w", req.ItemID, err)
}
err = payments.Charge(ctx, user.PaymentID, req.Total) // (3) 其他包
if err != nil {
return fmt.Errorf("收费支付: %w", err)
}
return saveOrder(ctx, user.ID, req.ItemID) // (4) 同包,裸返回
}
注释说明:
- (1)
users.Get在其他包 —— 用用户 ID 包装 - (2)
inventory.Reserve在其他包 —— 用商品 ID 包装 - (3)
payments.Charge在其他包 —— 用操作名包装 - (4) 同包的内部辅助函数 —— 裸返回就够了
在 handler 层,用 %v 翻译成外部领域,不暴露内部实现:
func handlePlaceOrder(
ctx context.Context, req *pb.OrderReq,
) (*pb.OrderResp, error) {
err := placeOrder(ctx, fromProto(req))
if err != nil {
slog.Error("下订单",
"user_id", req.UserId,
"item_id", req.ItemId,
"err", err,
)
// %v:给人类的上下文,不给调用者链条
return nil, status.Errorf(codes.Internal, "下订单: %v", err)
}
return &pb.OrderResp{}, nil
}
终章:终极指南
没有共识,也不需要共识。 处理错误的目的都是为了后面更好的解决问题:
✅ 检查清单
| 场景 | 策略 | 理由 |
|---|---|---|
| 包内 | 裸 return err | 调用者已经有上下文 |
| 包边界 | fmt.Errorf("做 X: %w", err) + 识别信息 | 添加调用者不知道的信息 |
| 系统边界 | 翻译而非包装,用 %v | 不暴露实现细节 |
| 库 | 默认 %v + 自己的哨兵错误 | 保护 API 契约 |
| CLI | 到处 %w | 错误消息就是用户输出 |
| 服务 | 以上全部 + handler 层 slog | 混合策略 |
🎯 核心原则
- 只在添加内部错误尚未携带的信息时包装
- 使用
%w时,意识到你在创建 API 契约 - 拿不定主意时,从
%v开始(从%v到%w是向后兼容的) - wrapcheck linter 可以自动执行包边界包装
- 在 handler 层使用结构化日志,错误值就不需要承载所有上下文
结语:没有银弹,只有权衡
Go 的错误包装就像穿衣服:
- 裸错误:在家里可以,但在公共场合不合适
- 过度包装:像穿了十层外套,热死还看不出里面是什么
- 恰到好处的包装:既保暖又美观,还不会束缚行动
正如 Go 团队成员 Marcel van Lohuizen 所说:
"我包装又不包装……如果我想要上下文,我就包装它。如果我创建了一个新错误,我就包装它。但有时你并没有真正添加太多信息,那我就不包装。所以这取决于情况。"
看,连 Go 团队的人都没有标准答案。所以,别纠结了,根据你的实际情况做权衡吧!
💬 互动时间:你在项目中是怎么处理错误包装的?是"包装派"还是"裸奔派"?欢迎在评论区分享你的故事(和血泪教训)!
Happy Coding, and May Your Errors Be Informative! 🎉