错误不是字符串:errors.Is、As、Join 与业务错误建模
错误文本是给人看的,错误关系才是给程序判断的。只要调用方开始写 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每一层写“正在做什么”,而不是重复 failed。failed 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 客户端或文件系统实现。它只需要知道自己被承诺可以判断哪些语义。