3. 哨兵错误:定义、模式与报表实战
哨兵错误(Sentinel Error)是 Go 语言中最基础、最直观的错误处理模式。它通过预定义的全局错误变量,为调用方提供明确的“错误标识”,使得调用方可以通过 == 直接断言错误类型。在报表模块中,哨兵错误常用于表示“数据不存在”、“权限不足”、“报表已过期”等确定性的业务异常。
3.1 哨兵错误的定义与 var ErrXxx = errors.New 模式
哨兵错误的定义遵循一个固定模式:使用 var 声明包级别的错误变量,变量名以 Err 开头,通过 errors.New 创建不可变的错误实例。这种模式的核心价值在于:
- 可断言性:调用方可以直接使用
==比较,无需解析错误字符串。 - 单例性:每个哨兵错误在包内只存在一个实例,保证比较的可靠性。
- 自描述性:错误消息本身就是对异常场景的清晰说明。
核心原则:哨兵错误只适用于“完全确定、不会携带额外上下文”的纯标识性错误。一旦错误需要附加动态信息(如用户 ID、报表名称),就必须使用自定义错误类型或包装错误。
// 报表模块中的哨兵错误定义示例
package report
import "errors"
// 预定义的哨兵错误
var (
// ErrReportNotFound 表示请求的报表不存在
ErrReportNotFound = errors.New("report not found: the specified report ID does not exist")
// ErrReportExpired 表示报表已超过有效期
ErrReportExpired = errors.New("report expired: the report has passed its retention period")
// ErrReportAccessDenied 表示当前用户无权访问该报表
ErrReportAccessDenied = errors.New("report access denied: insufficient permissions")
// ErrReportGenerationFailed 表示报表生成过程中发生不可恢复的错误
ErrReportGenerationFailed = errors.New("report generation failed: internal processing error")
)
3.2 哨兵错误在报表模块中的典型应用
在报表系统中,哨兵错误通常出现在以下三个关键环节:
| 应用环节 | 哨兵错误 | 触发条件 | 调用方处理方式 |
|---|---|---|---|
| 报表查询 | ErrReportNotFound |
数据库查询返回空结果 | 返回 404 或提示用户重新输入 |
| 报表访问 | ErrReportAccessDenied |
用户角色/权限不匹配 | 返回 403 或触发权限升级流程 |
| 报表生成 | ErrReportGenerationFailed |
数据源异常或计算超时 | 记录日志并返回 500,触发重试 |
实战建议:在报表模块中,哨兵错误应作为“最后一道防线”使用。对于需要区分“哪个报表不存在”的场景,应使用自定义错误类型携带报表 ID,而非为每个报表 ID 定义哨兵错误。
3.3 哨兵错误断言流程
下图展示了调用方使用哨兵错误进行断言的标准流程:
3.4 哨兵错误断言代码实战
以下代码展示了在报表查询接口中,如何定义哨兵错误并在调用方进行断言:
// report/report.go - 报表模块核心代码
package report
import (
"errors"
"fmt"
)
// 哨兵错误定义
var (
ErrReportNotFound = errors.New("report not found")
ErrReportExpired = errors.New("report expired")
)
// ReportService 报表服务
type ReportService struct {
// 模拟数据存储
store map[string]*Report
}
// Report 报表结构
type Report struct {
ID string
Title string
Expired bool
}
// GetReport 根据 ID 获取报表
func (s *ReportService) GetReport(id string) (*Report, error) {
r, ok := s.store[id]
if !ok {
// 返回哨兵错误,不携带具体 ID
return nil, ErrReportNotFound
}
if r.Expired {
return nil, ErrReportExpired
}
return r, nil
}
// report_handler.go - HTTP 处理层
package main
import (
"net/http"
"yourproject/report"
)
func handleGetReport(w http.ResponseWriter, r *http.Request) {
reportID := r.URL.Query().Get("id")
svc := &report.ReportService{}
data, err := svc.GetReport(reportID)
if err != nil {
// 使用 == 进行哨兵错误断言
switch {
case errors.Is(err, report.ErrReportNotFound):
http.Error(w, "报表不存在", http.StatusNotFound)
case errors.Is(err, report.ErrReportExpired):
http.Error(w, "报表已过期", http.StatusGone)
default:
http.Error(w, "内部错误", http.StatusInternalServerError)
}
return
}
// 正常返回报表数据
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "报表标题: %s", data.Title)
}
避坑指南:虽然哨兵错误可以直接使用
== 比较,但在实际项目中强烈建议使用 errors.Is 进行断言。errors.Is 会沿着错误链递归比较,即使哨兵错误被包装过也能正确匹配,而 == 只能匹配最外层的错误实例。
3.5 哨兵错误 vs 自定义错误类型
在报表模块中,需要根据场景选择哨兵错误或自定义错误类型:
| 对比维度 | 哨兵错误 | 自定义错误类型 |
|---|---|---|
| 携带动态信息 | 不支持 | 支持(如报表 ID、用户 ID) |
| 断言方式 | == 或 errors.Is |
类型断言或 errors.As |
| 适用场景 | 纯标识性错误(不存在、过期) | 需要区分具体实例的错误 |
| 包外可见性 | 导出变量,包外可直接引用 | 导出类型,包外可断言 |
| 错误链支持 | 通过 fmt.Errorf("%w", ErrXxx) 包装 |
实现 Unwrap() 方法 |
选择建议:当错误信息完全固定、不需要区分“哪个报表不存在”时,使用哨兵错误;当需要携带动态上下文(如“报表 ID 为 xxx 的报表不存在”)时,使用自定义错误类型。在报表模块中,
ErrReportNotFound 适合作为哨兵错误,而 ReportNotFoundError{ReportID: "xxx"} 则适合作为自定义错误类型。