To Wrap or Not to Wrap:Go 错误处理的灵魂拷问

1 阅读12分钟

在 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 refusedBoom!告警失效
  • 同样的根本原因(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 创建了你不想要的"契约"

这是最容易被忽视的陷阱!

%wfmt.Errorf 中创建了一个错误链,调用者可以用 errors.Iserrors.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.Iserrors.As 依赖内部错误类型。如果你后来改变了内部错误(换数据库、加缓存层),这些调用者就会崩溃。

只有当你确实想暴露内部错误时,才使用 %w


第四章:%v —— 保守派的护身符

%v vs %w:一场关于"断链"的哲学

%v 添加了同样的上下文文本(人类阅读日志看到的消息完全一样),但切断了错误链。调用者无法通过它使用 errors.Iserrors.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 包装的库会把调用者和它的依赖绑定。如果 v2pgx 切换到 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)—— 中庸之道

原则:看情况,混合策略!

这是最难给出公式化答案的地方。你有结构化日志和分布式追踪,但也有深层调用栈和许多依赖。

作者的落地策略:

  1. 在包边界包装,带上你尝试做什么的上下文
  2. 在自己的代码库内使用 %w,调用者应该能够检查内部错误
  3. 当错误跨越系统边界时(RPC、数据库调用、第三方 API)使用 %v
  4. 同包调用跳过包装

重写开头的 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混合策略

🎯 核心原则

  1. 只在添加内部错误尚未携带的信息时包装
  2. 使用 %w 时,意识到你在创建 API 契约
  3. 拿不定主意时,从 %v 开始(从 %v%w 是向后兼容的)
  4. wrapcheck linter 可以自动执行包边界包装
  5. 在 handler 层使用结构化日志,错误值就不需要承载所有上下文

结语:没有银弹,只有权衡

Go 的错误包装就像穿衣服:

  • 裸错误:在家里可以,但在公共场合不合适
  • 过度包装:像穿了十层外套,热死还看不出里面是什么
  • 恰到好处的包装:既保暖又美观,还不会束缚行动

正如 Go 团队成员 Marcel van Lohuizen 所说:

"我包装又不包装……如果我想要上下文,我就包装它。如果我创建了一个新错误,我就包装它。但有时你并没有真正添加太多信息,那我就不包装。所以这取决于情况。"

看,连 Go 团队的人都没有标准答案。所以,别纠结了,根据你的实际情况做权衡吧!


💬 互动时间:你在项目中是怎么处理错误包装的?是"包装派"还是"裸奔派"?欢迎在评论区分享你的故事(和血泪教训)!


Happy Coding, and May Your Errors Be Informative! 🎉