跳至内容
错误不是字符串:errors.Is、As、Join 与业务错误建模

错误不是字符串:errors.Is、As、Join 与业务错误建模

2026年7月1日·
yanlong

错误文本是给人看的,错误关系才是给程序判断的。只要调用方开始写 strings.Contains(err.Error(), "not found"),错误处理就已经退化成一个没有版本约束的文本协议。

Go 的错误链解决的正是这个问题:保留上下文,同时让调用方判断稳定语义。

三种需要区分的东西

一条业务错误通常包含三个层次:

  • 身份:它是不是某类错误,例如资源不存在;
  • 数据:哪个资源、哪个字段、哪个参数出了问题;
  • 上下文:错误发生在调用链的哪一步。

哨兵错误适合表达稳定身份:

var ErrOrderNotFound = errors.New("order not found")

自定义类型适合携带结构化数据:

type ValidationError struct {
    Field  string
    Reason string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("invalid %s: %s", e.Field, e.Reason)
}

包装负责补充调用上下文:

return fmt.Errorf("load order %q: %w", id, ErrOrderNotFound)

这三层可以组合,不需要选边站。

用 errors.Is 判断身份

errors.Is 会沿着错误链展开,而 == 只比较当前值:

if errors.Is(err, ErrOrderNotFound) {
    http.Error(w, "order not found", http.StatusNotFound)
    return
}

要让链成立,包装时必须使用 %w%v 只把文本拼进去:

// 保留错误链
fmt.Errorf("query database: %w", err)

// 只有文本,errors.Is / As 无法继续向下查找
fmt.Errorf("query database: %v", err)

一个 fmt.Errorf 可以包装多个 %w 参数,但调用方看到的是一棵错误树,不要依赖遍历顺序来实现业务优先级。

用 errors.As 提取数据

var validationErr *ValidationError
if errors.As(err, &validationErr) {
    writeJSON(w, http.StatusBadRequest, map[string]string{
        "field":  validationErr.Field,
        "reason": validationErr.Reason,
    })
    return
}

注意目标参数是“指向目标类型变量的指针”。如果错误类型本身是指针实现 error,这里通常就会出现 **ValidationError 的形态:变量是 *ValidationError,再把它的地址传给 errors.As

不要为了省掉 As 直接做 err.(*ValidationError)。包装一层后,直接断言就会失效。

自定义 Is:匹配语义,不必暴露同一个值

有些错误实例携带不同数据,但属于同一类别:

var ErrConflict = errors.New("conflict")

type VersionConflictError struct {
    Expected int64
    Actual   int64
}

func (e *VersionConflictError) Error() string {
    return fmt.Sprintf(
        "version conflict: expected %d, got %d",
        e.Expected,
        e.Actual,
    )
}

func (e *VersionConflictError) Is(target error) bool {
    return target == ErrConflict
}

现在 errors.Is(err, ErrConflict) 可以判断类别,errors.As 又能取出版本数据。Is 方法应该做浅比较,不能再次调用 errors.Is 导致递归语义难以预测。

errors.Join 适合“都执行了,多个都失败了”

关闭多个独立资源时,不应因为第一个失败就丢弃后面的错误:

func closeAll(closers ...io.Closer) error {
    var errs []error
    for _, closer := range closers {
        if err := closer.Close(); err != nil {
            errs = append(errs, err)
        }
    }
    return errors.Join(errs...)
}

Join 返回的错误会匹配其中任意子错误。它适合并行任务收尾、批量操作和多资源清理,不适合代替主次分明的调用链。比如“保存订单失败,随后回滚也失败”通常应该明确保留主错误与回滚错误的角色,而不是让上层猜哪个更重要。

业务层不要泄露基础设施错误

sql.ErrNoRows 一路返回到 HTTP 层,会让接口层依赖数据库实现。仓储层应在边界处翻译:

func (r *OrderRepository) Find(
    ctx context.Context,
    id string,
) (Order, error) {
    order, err := r.query(ctx, id)
    if errors.Is(err, sql.ErrNoRows) {
        return Order{}, ErrOrderNotFound
    }
    if err != nil {
        return Order{}, fmt.Errorf("query order %q: %w", id, err)
    }
    return order, nil
}

是否包装底层错误取决于抽象承诺。包装意味着调用方理论上可以通过 errors.Is 观察它,这会成为 API 的一部分。跨越领域边界时,常常应该翻译;在同一基础设施层内,包装保留根因更有利于排障。

日志不要每层都打一遍

错误应该沿调用链补充上下文,在真正处理它的边界记录一次。每层都 log.Error 再返回,会让同一次失败产生五条相似日志,还可能缺少统一请求信息。

比较稳定的分工是:

  • 底层返回可判断的错误并补充操作上下文;
  • 业务层决定重试、降级或翻译领域错误;
  • HTTP、任务消费者等边界统一记录并转换为外部响应。

敏感参数不要写进 Error()。错误最终可能进入日志、指标标签和外部响应,账号、令牌、完整 SQL 等信息应单独处理。

错误信息的写法

错误文本通常小写开头、不带句号,因为上层可能继续包装:

handle request: create order: reserve inventory: deadline exceeded

每一层写“正在做什么”,而不是重复 failedfailed to create order: failed to reserve... 信息密度很低。

一套可落地的判断顺序

在系统边界处理错误时,一般先判断确定性强的业务语义,再处理取消和超时,最后落到未知内部错误:

switch {
case errors.Is(err, ErrOrderNotFound):
    // 404
case errors.Is(err, context.DeadlineExceeded):
    // 504 或业务定义的超时码
case errors.Is(err, context.Canceled):
    // 客户端离开或上游取消
default:
    // 500,并记录完整错误链
}

错误设计做得好,调用方不需要知道你的错误文本,也不需要知道数据库、RPC 客户端或文件系统实现。它只需要知道自己被承诺可以判断哪些语义。

延伸阅读