3. 哨兵错误:定义、使用场景与优缺点

哨兵错误(Sentinel Error)是 Go 语言中最基础、最直观的错误处理模式。它通过预定义包级变量来标识特定的、可预期的错误状态,调用方通过 == 直接比较错误值,从而做出分支决策。这种模式在标准库和业务代码中广泛存在,是理解 Go 错误处理哲学的起点。

3.1 哨兵错误的定义与核心模式

哨兵错误的定义遵循一个固定模式:在包级别使用 var 声明一个 error 类型的变量,并通过 errors.New 或 fmt.Errorf 赋予其固定的错误消息。这个变量就是“哨兵”——它像一个守卫,代表一种特定的错误语义。

// 标准库中的经典哨兵
var io.EOF = errors.New("EOF")
var sql.ErrNoRows = errors.New("sql: no rows in result set")

// 业务代码中的哨兵
package user

var (
    ErrUserNotFound      = errors.New("user not found")
    ErrEmailAlreadyExist = errors.New("email already exists")
    ErrInvalidPassword   = errors.New("invalid password format")
)

调用方通过 == 运算符直接与哨兵变量比较,判断错误类型:

err := userRepo.FindByID(ctx, 42)
if err == user.ErrUserNotFound {
    // 执行用户未找到的特定逻辑
    return nil, http.StatusNotFound
}
关键特征:哨兵错误是包级公开变量,调用方依赖其变量标识而非错误消息字符串进行判断。错误消息仅用于日志和展示,不参与逻辑分支。

3.2 使用场景分析

哨兵错误并非万能,它有明确的适用边界。下表总结了典型的使用场景与反模式:

场景 是否推荐 说明
标准库预定义错误(io.EOF, sql.ErrNoRows) ✅ 推荐 语义稳定,调用方需要精确判断终止条件
业务层“预期内”的查找/校验失败 ✅ 推荐 如用户不存在、邮箱已注册、状态不合法
需要调用方做不同分支处理的错误 ✅ 推荐 调用方根据哨兵决定返回 404、409 或 400
携带动态上下文(如 ID、详情)的错误 ❌ 不推荐 哨兵是静态值,无法携带动态数据
需要多层包装传递的错误 ❌ 不推荐 包装后 == 失效,需改用 errors.Is
第三方库返回的未知错误 ❌ 不推荐 无法预定义所有可能值,应使用类型或行为判断
业务实践建议:在业务层,哨兵错误最适合定义“业务规则违反”类错误——这些错误语义固定、不依赖上下文、且调用方需要精确区分。例如:ErrInsufficientBalance、ErrOrderAlreadyPaid。

3.3 哨兵错误的优缺点

哨兵错误模式简洁直接,但也存在明显的局限性。理解其两面性,才能在实际项目中做出合理选择。

优点

  • 简单直观:使用 == 直接比较,无需学习额外 API,代码可读性高。
  • 零依赖:仅依赖标准库 errors 包,不引入第三方库。
  • 编译期安全:变量名拼写错误在编译阶段即可发现,不会出现运行时字符串匹配的隐蔽 bug。
  • IDE 友好:编辑器可自动补全、跳转到定义,重构时变量重命名自动生效。
  • 性能优异:指针比较,时间复杂度 O(1),无字符串解析开销。

缺点

  • 无法携带上下文:哨兵是静态单例,无法附加“哪个用户未找到”“哪个订单已支付”等动态信息。
  • 与错误包装不兼容:一旦使用 fmt.Errorf("wrap: %w", err) 包装,== 直接失效,必须改用 errors.Is。
  • 包间耦合:调用方必须 import 定义哨兵的包,形成编译期依赖,不利于分层解耦。
  • 扩展性差:新增一个哨兵意味着修改包公开 API,可能破坏兼容性。
  • 滥用导致膨胀:为每个细粒度场景都定义哨兵,会导致包级变量爆炸,维护成本上升。
常见陷阱:不要将哨兵错误与 errors.New 返回的匿名错误混淆。每次调用 errors.New 都会分配新的唯一地址,即使消息相同,== 比较也会返回 false。哨兵必须通过 var 声明为包级变量,确保全局唯一地址。

3.4 哨兵错误在调用链中的行为

下面的 SVG 图展示了哨兵错误在直接返回与包装返回两种场景下的断言差异:

哨兵错误断言路径对比 直接返回(== 有效) 业务函数 return ErrUserNotFound 调用方 if err == ErrUserNotFound 断言结果 ✅ 匹配成功 包装返回(== 失效) 业务函数 return fmt.Errorf("wrap: %w", ErrUserNotFound) 调用方 if err == ErrUserNotFound 断言结果 ❌ 匹配失败(包装后地址不同) 结论:直接返回用 ==;包装后必须用 errors.Is() 遍历错误链

3.5 哨兵错误与 errors.Is 的配合

从 Go 1.13 开始,标准库提供了 errors.Is 函数,用于在错误链中查找匹配的哨兵。这使得哨兵错误在包装场景下依然可用,但调用方必须改变断言方式:

// 不推荐:包装后 == 失效
if err == user.ErrUserNotFound { // 永远为 false
    // ...
}

// 推荐:使用 errors.Is 遍历错误链
if errors.Is(err, user.ErrUserNotFound) {
    // 即使 err 被多层包装,也能正确匹配
    return nil, http.StatusNotFound
}
最佳实践:在业务代码中,始终使用 errors.Is 代替 == 进行哨兵错误断言。这样做有两个好处:一是兼容包装场景,二是当未来重构从直接返回改为包装返回时,调用方代码无需修改。

3.6 哨兵错误的定义规范

为了在团队中保持一致性,建议遵循以下命名与组织规范:

规范项 推荐做法 示例
命名前缀 统一使用 Err 前缀 ErrUserNotFound
声明位置 集中放在包级 var 块中 文件顶部或独立的 errors.go
错误消息 小写开头,不加标点,描述语义 "user not found"
公开性 首字母大写,允许外部包引用 var ErrUserNotFound
文档注释 每个哨兵附带使用场景说明 // ErrUserNotFound 在根据 ID 查询用户时返回
// errors.go - 集中定义业务哨兵错误
package user

import "errors"

// ErrUserNotFound 在根据 ID 或邮箱查询用户时返回。
var ErrUserNotFound = errors.New("user not found")

// ErrEmailAlreadyExist 在注册或更新邮箱时,检测到重复时返回。
var ErrEmailAlreadyExist = errors.New("email already exists")

// ErrInvalidPassword 在密码不满足复杂度要求时返回。
var ErrInvalidPassword = errors.New("invalid password format")