为什么 Go 的错误处理值得专门写一篇
Go 没有 Java 那样的 checked exception,也没有 Python 的 try/except 统一捕获。它把 error 当作普通的值返回,让错误处理变成显式的、散布在每一行调用之后的判断。这种设计常被新手诟病”满屏 if err != nil”,但用好了,它带来的可读性与可控性是异常机制难以比拟的。
一、error 是值,不是异常
在 Go 里,error 是一个内建接口:
type error interface {
Error() string
}
任何实现了 Error() string 的类型都能当作错误。这意味着错误可以被比较、包装、携带上下文,而不只是抛出一个调用栈。最根本的工程纪律是:error 必须被处理或被显式忽略,绝不能随手丢弃。
二、哨兵错误与 errors.Is
标准库定义了一批可导出的”哨兵错误”(sentinel error),例如 io.EOF、sql.ErrNoRows。早期代码习惯用 == 比较:
if err == sql.ErrNoRows {
// 没有记录
}
问题在于,一旦错误被 wrap(用 %w 包装),== 比较就会失效。正确做法是用 errors.Is,它会沿错误链逐层比对底层错误:
if errors.Is(err, sql.ErrNoRows) {
// 正确处理包装后的错误
}
三、自定义错误类型与 errors.As
当错误需要携带结构化字段(如错误码、重试次数)时,应定义自定义类型并用 errors.As 提取:
type TimeoutError struct {
Timeout time.Duration
}
func (e *TimeoutError) Error() string {
return fmt.Sprintf("timeout after %s", e.Timeout)
}
// 调用方
var te *TimeoutError
if errors.As(err, &te) {
log.Printf("超时时长: %v", te.Timeout)
}
errors.As 同样会穿透包装层,比直接对 err 做类型断言稳健得多。
四、用 %w 包装错误链
Go 1.13 引入 %w,让你在传播错误时保留原始错误:
if err != nil {
return fmt.Errorf("读取配置失败: %w", err)
}
这样上层既能用 errors.Is/As 还原底层错误,又能在日志里看到完整的上下文链。注意:%w 每个格式化串只能用一次,且不要用 %v 代替——%v 会丢失可解包性。
五、panic 的正确边界
panic 不是异常的替代品。它只应用于真正不可恢复的程序错误(如初始化阶段配置缺失)。对外的边界(HTTP handler、goroutine 入口)应当 recover 并返回错误,而不是让进程崩溃。
经验法则:库函数返回 error,程序主流程在边界处兜底 panic。把 error 留给调用方决策,把 panic 留给真正不可恢复的场景。
六、可直接落地的实践清单
- 不要用 _ = err 静默丢弃,至少打日志或加注释说明为何忽略。
- 对外 API 返回的错误不要暴露内部实现细节(数据库字段名、路径)。
- 错误文案用小写开头、不加句末标点,保持 wrap 后拼接自然。
- 在顶层统一用 errors.Is/As 做分支判断,而非 ==。
错误处理没有银弹,但遵循”显式处理、链式包装、边界兜底”三原则,足以让 Go 项目在规模增长后仍保持错误可控。下一篇可以聊聊如何用结构化日志与错误链配合,形成可观测性闭环。
