导语:Go 的错误哲学是"显式、可组合、可判定"
相比 try-catch 的隐式异常流,Go 选择了显式的多返回值错误:每个可能失败的调用都把一个 error 摆在眼前。初看啰嗦,实则是把错误当作"值"来编程——你可以存储它、包装它、组合它、判定它的类型。这正是生产系统最需要的:错误不再只是一条日志,而是可编程的数据。
但要写出健壮的 Go 错误处理,光会 if err != nil 远远不够。errors.Is 与 errors.As 给了错误链"类型判定"的能力,%w 让包装层层叠加而不丢失根因。本文把这套体系从原理到工程实践完整拆解。
一句话总结:Go 错误处理的核心是"错误即值"——用
%w保持包装链、用errors.Is判哨兵、用errors.As提取类型、用错误码对齐业务与协议。
1. error 接口与基础模式
1.1 error 只是一个接口
// error 接口:只有一个方法,任何实现 Error() string 的类型都是 error
type error interface {
Error() string
}
// 两个最基础的构造方式
err := errors.New("database connection refused")
err2 := fmt.Errorf("user %s not found", "alice")
1.2 多返回值:错误即值
// 错误与结果并列返回,调用方必须显式处理
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
// 正确处理 vs 忽略处理
func main() {
result, err := divide(10, 0)
if err != nil {
log.Printf("除法失败: %v", err)
return
}
_ = result
}
1.3 两条基本原则
□ 规则1:错误值必须是"值"而不是"流程"——可以被比较、包装、存储
□ 规则2:调用失败就必须返回 err,绝不吞掉错误只记日志再继续
吞错误的常见姿势是 resp, _ := http.Get(url)——丢失的错误信息会让线上排障变成大海捞针。
一句话总结:Go 用多返回值把错误显式摆在调用方手里,正确姿势是"失败必须返回 err、err 必须被处理",错误本身是可编程的值。
2. 错误包装:fmt.Errorf 与 %w
2.1 为什么需要包装
// 不包装:底层错误信息丢失,只留下"看起来像什么都干了"的模糊描述
if err := openFile(path); err != nil {
return fmt.Errorf("failed to open: %v", err) // %v 丢失错误链
}
// 包装:%w 保留完整错误链,errors.Is/As 可以穿透到根因
if err := openFile(path); err != nil {
return fmt.Errorf("open config file: %w", err) // %w 建立可判定链
}
%v 只是把错误转成字符串拼进新错误,%w 则把原错误作为可判定的一环存进包装错误——这是现代 Go 错误链的基石。
2.2 多层包装的解剖
func LoadConfig(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("load config %s: %w", path, err) // 第3层
}
defer f.Close()
// ...
}
func main() {
_, err := LoadConfig("/etc/app.yaml")
// err.Error() 输出:
// load config /etc/app.yaml: open /etc/app.yaml: no such file or directory
// 从外层到根因,信息逐层保留
}
2.3 包装的纪律
□ 每层包装都加上"这一层做了什么"的上下文(哪个文件、哪个参数)
□ 上下文加"动词+宾语":open config / query user / call rpc
□ 不要重复包装同一层:能直接返回就用 %w 或直接返回
□ 包内用 %w,跨包传递保持 %w 直到顶层再决定如何展示
一句话总结:
fmt.Errorf("...: %w", err)是错误链的接缝——每包一层就多一层上下文,而根因始终可被errors.Is/As触达。
3. errors.Is / errors.As / errors.Unwrap
3.1 errors.Is:判断"链上是否存在某个哨兵错误"
// errors.Is(err, target):沿着错误链逐层比较,相等或命中 target 即 true
if errors.Is(err, os.ErrNotExist) {
// 文件不存在:创建目录重试
return createDirAndRetry()
}
// 典型哨兵目标
var ErrUserNotFound = errors.New("user not found")
// ...
if errors.Is(err, ErrUserNotFound) {
http.Error(w, "user missing", http.StatusNotFound)
}
errors.Is 遍历整条 %w 包装链,只要有一层等于 ErrUserNotFound 就返回 true。这解决了"深层函数返回的错误,上层怎么知道根因"的问题。
3.2 errors.As:提取链上的特定错误类型
// errors.As(err, target):在链上找到第一个可赋给 target 的错误类型
var pathErr *os.PathError
if errors.As(err, &pathErr) {
// 拿到了具体的 *os.PathError,可访问其 Op、Path、Err 字段
log.Printf("path operation %s on %s failed: %v",
pathErr.Op, pathErr.Path, pathErr.Err)
}
// 自定义类型提取
var bizErr *BusinessError
if errors.As(err, &bizErr) {
// 业务错误:提取业务码做降级或重试
return bizErr.Code, bizErr
}
As 的 target 必须是指向"实现了 error 的类型"的指针。它用于"我需要那个具体错误类型的字段"的场景,而 Is 用于"我只关心是不是某个哨兵"的场景。
3.3 errors.Unwrap 与自定义链
// Unwrap() 方法定义"我的下一层是谁"——凡是实现它的类型都可参与错误链
type wrappedError struct {
msg string
err error
}
func (w *wrappedError) Error() string { return w.msg }
func (w *wrappedError) Unwrap() error { return w.err } // 暴露链的下一环
// fmt.Errorf("%w") 内部就依赖 Unwrap 构建链
// errors.Is 与 errors.As 都是沿着 Unwrap() 逐层下钻
一句话总结:
Is沿链找"是否是某个哨兵",As沿链提取"某个具体错误类型",Unwrap定义链的走向——三者合起来让错误链可以精确判定与提取。
4. 哨兵错误与自定义错误类型
4.1 哨兵错误的两种风格
// 风格1:errors.New 哨兵 —— 只用于"相等判定"
var ErrNotFound = errors.New("not found")
var ErrConflict = errors.New("conflict")
// 风格2:带数据的状态错误 —— 用 errors.As 提取状态码
type StatusError struct {
Code int
Message string
Cause error
}
func (e *StatusError) Error() string { return fmt.Sprintf("%d: %s", e.Code, e.Message) }
func (e *StatusError) Unwrap() error { return e.Cause }
风格1 适合"只需要知道是或不是"的边界信号;风格2 适合"错误本身携带结构化信息(状态码、字段名)“的场景。
4.2 自定义错误实现 error 接口
// 完整示例:带重试语义的业务错误
type RetryableError struct {
RetryAfter time.Duration
Cause error
}
func (e *RetryableError) Error() string {
return fmt.Sprintf("retry after %v: %v", e.RetryAfter, e.Cause)
}
func (e *RetryableError) Unwrap() error { return e.Cause }
// 使用:上游返回限流错误时,调用方决定是否重试
var errRate = &RetryableError{RetryAfter: 2 * time.Second, Cause: ErrRateLimited}
// ...
var rerr *RetryableError
if errors.As(err, &rerr) {
time.Sleep(rerr.RetryAfter) // 根据建议时间退避重试
retry()
}
4.3 哨兵错误的定义位置
□ 与定义它们的包同文件或 errors 子包,避免循环依赖
□ 导出哨兵:ErrNotFound、ErrConflict —— 包外部可通过 errors.Is 判定
□ 不导出的哨兵仅限包内使用,防止公共 API 耦合内部细节
□ 哨兵错误文案应稳定:它可能被当作程序接口的一部分对外比较
一句话总结:哨兵错误用于"边界判定”,自定义类型用于"携带结构化数据",定义位置决定可复用边界——导出哨兵、私有类型、保持文案稳定。
5. 错误码体系与领域错误设计
5.1 三层错误码
// 错误码 = 模块码 + 业务码,统一封装
const (
// 通用码
CodeOK = 0
CodeBadRequest = 40000
CodeUnauthorized = 40100
CodeNotFound = 40400
CodeConflict = 40900
CodeInternal = 50000
// 用户模块
CodeUserNotFound = 40401
CodeUserLocked = 40301
)
// 统一响应结构
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
Detail string `json:"detail,omitempty"`
}
5.2 领域错误 + HTTP 状态码的映射
func MapToHTTP(err error) int {
switch {
case errors.Is(err, ErrUserNotFound):
return http.StatusNotFound
case errors.Is(err, ErrUnauthorized):
return http.StatusUnauthorized
case errors.Is(err, ErrRateLimited):
return http.StatusTooManyRequests
default:
return http.StatusInternalServerError
}
}
// 在 handler 层统一收口:
func handleError(w http.ResponseWriter, err error) {
code := MapToHTTP(err)
apiErr := &APIError{Code: classify(err), Message: err.Error()}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(apiErr)
}
5.3 错误码设计五原则
1. 可读:code 与 message 成对出现,code 稳定、message 可换文案
2. 分层:业务码(前端用)与底层 error(日志用)分离
3. 枚举化:所有错误码集中定义,避免魔法数字散落
4. 版本化:错误码不随意变更,变更需走协议升级
5. 可追溯:错误码 + traceID 关联,日志与响应一一对应
一句话总结:错误码体系让"机器可读"与"人可读"分离,领域错误通过 errors.Is 分类,在 handler 层统一映射为 HTTP 状态与 JSON 结构。
6. 生产环境的错误处理纪律
| 纪律 | 反例 | 正例 |
|---|---|---|
| 绝不吞错 | resp, _ := client.Get(url) | resp, err := client.Get(url); if err != nil { return err } |
| 保留根因 | fmt.Errorf("%v", err) | fmt.Errorf("call rpc: %w", err) |
| 判定用 Is/As | err == ErrNotFound(丢失链上匹配) | errors.Is(err, ErrNotFound) |
| 日志记录一次 | 每层都 log | 只在顶层统一记录,携带完整链与 traceID |
| 错误文案稳定 | 哨兵文案随意改 | 哨兵文案冻结,展示文案另存 message |
| 错误码对齐 | handler 里散落 http.StatusNotFound | 集中 MapToHTTP 映射表 |
| panic 传递 | 深层 panic 上层不 recover | 只在边界(goroutine 入口)recover 并转 err |
| 区分确定性 | 未知错误一律 500 | Is/As 分类后 4xx/5xx 分流 |
7. 总结
| 工具 | 作用 | 一句要义 |
|---|---|---|
errors.New | 构造哨兵错误 | 边界信号,文案稳定 |
fmt.Errorf + %w | 包装错误链 | 每层加上下文,保留根因 |
errors.Is | 链上哨兵判定 | 深层错误也能判断是不是它 |
errors.As | 提取具体错误类型 | 拿到结构化错误做业务决策 |
errors.Unwrap | 定义链的走向 | 自定义类型参与错误链 |
| 自定义错误类型 | 携带结构化数据 | 状态码、重试建议、字段名 |
| 错误码体系 | 机器可读协议 | code+message 成对,统一映射 |
落地记住六件事:每层包装用 %w 并加"动词+宾语"上下文、判定一律 errors.Is/As 不直接比较、哨兵错误集中定义且文案冻结、错误码与 HTTP 状态在 handler 统一映射、错误只在顶层记录一次并带 traceID、panic 只在 goroutine 边界 recover。把错误当成可组合、可判定、可追踪的值,你的 Go 服务在排障时才会"一层层剥到根因"。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。