错误处理
这一课你将能做到
- 用
errors.New / fmt.Errorf 创建错误,自定义错误类型,并按 (T, error) 规范层层返回
- 用
%w 包装错误形成错误链,并用 errors.Is / errors.As 精准判断
- 说清 panic/recover 的适用边界,写出能 recover 的防御代码
为什么要学
Go 没有 try/catch。错误是普通的返回值:函数显式返回 error,调用方显式处理。好处是控制流全部写在明面上——哪一行可能失败、失败后走哪条路,一目了然;代价是满屏 if err != nil,你需要一套规范让它不变成灾难。
这套规范就是本课内容:创建、包装、判断、兜底。写服务时它是日常;面试时 errors.Is/As、wrap 链、panic 边界是固定考题。
先建立图像
错误像快递单在调用链上逐层「贴单」:
readConfig() "读取配置失败: %w"
└── readFile() "打开文件失败: %w"
└── os.Open open config.yaml: no such file or directory ← 根因
错误链(wrap 链):
"读取配置失败: 打开文件失败: open config.yaml: no such file or directory"
↑ 最外层是当下语境 ↑ 中间层 ↑ 最内层是原始根因
errors.Is(err, fs.ErrNotExist) → 顺着链找根因,true
errors.As(err, &pathErr) → 顺着链找某类型的错误对象
每一层只负责两件事:决定自己处理还是上抛;上抛时用 %w 附加上下文。
跟着做
步骤 1:创建错误——两种基本姿势
package main
import (
"errors"
"fmt"
)
func sqrtGuess(n float64) (float64, error) {
if n < 0 {
// errors.New:静态固定文案
return 0, errors.New("n 不能为负数")
}
// 这里的近似算法只作演示
guess := n
for i := 0; i < 10; i++ {
guess = (guess + n/guess) / 2
}
return guess, nil
}
func main() {
v, err := sqrtGuess(-4)
if err != nil {
fmt.Println("出错了:", err)
return
}
fmt.Println(v)
}
需要动态信息时用 fmt.Errorf(本质是先格式化再 errors.New):
// fmt.Errorf("用户 %d 不存在", id) → "用户 42 不存在"
步骤 2:自定义错误类型——携带结构化信息
error 只是接口,任何类型实现 Error() string 都能当错误用:
package main
import (
"errors"
"fmt"
)
// 自定义错误:错误码、字段名都结构化保留,调用方可以按需取用
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("字段 %s 校验失败: %s", e.Field, e.Msg)
}
func checkAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{Field: "age", Msg: "超出合理范围"}
}
return nil
}
func main() {
err := checkAge(-1)
fmt.Println(err) // 字段 age 校验失败: 超出合理范围
var ve *ValidationError
if errors.As(err, &ve) { // 还原成具体类型,拿到结构化字段
fmt.Println("出错字段:", ve.Field)
}
var e2 error = errors.New("普通错误")
fmt.Println(errors.As(e2, &ve)) // false:类型不匹配,安全返回
}
步骤 3:哨兵错误(sentinel error)与 errors.Is
预先定义包级错误变量,作为「可编程的错误值」供调用方比较:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("记录不存在") // 惯例命名:Err 前缀
func findUser(id int) (string, error) {
users := map[int]string{1: "张三"}
name, ok := users[id]
if !ok {
return "", ErrNotFound
}
return name, nil
}
func main() {
_, err := findUser(99)
if err != nil {
if errors.Is(err, ErrNotFound) { // 判断错误链上是否有这个哨兵
fmt.Println("查无此人,返回 404")
return
}
fmt.Println("其他错误:", err)
}
}
步骤 4:%w 包装——错误链的正确打开方式
package main
import (
"errors"
"fmt"
"os"
)
var ErrConfig = errors.New("配置错误")
func loadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
// %w 包装:保留原错误,附加当下语境
return fmt.Errorf("读取配置 %s 失败: %w", path, err)
}
if len(data) == 0 {
// 可以同时包装哨兵,实现「即有语境又可判断」
return fmt.Errorf("配置为空: %w", ErrConfig)
}
return nil
}
func main() {
err := loadConfig("not-exist.yaml")
fmt.Println(err)
// 输出:读取配置 not-exist.yaml 失败: open not-exist.yaml: no such file or directory
fmt.Println(errors.Is(err, os.ErrNotExist)) // true,链上有根因
fmt.Println(errors.Is(err, ErrConfig)) // false
err2 := loadConfig("/dev/null") // 空文件场景
fmt.Println(errors.Is(err2, ErrConfig)) // true
// 注意:%v 也会拼接文案,但会「剪断」错误链,Is/As 全部失效
broken := fmt.Errorf("读取配置失败: %v", err)
fmt.Println(errors.Is(broken, os.ErrNotExist)) // false!
}
一句话记住:转发错误一律 %w,除非有明确理由断链。
步骤 5:错误处理三原则(示例贯穿)
package main
import (
"errors"
"fmt"
"log"
)
var ErrAuth = errors.New("鉴权失败")
func parseToken(token string) error {
if token == "" {
return fmt.Errorf("token 为空: %w", ErrAuth)
}
return nil
}
// 原则一:要么处理,要么上抛,不允许吞掉
func handleRequest(token string) error {
if err := parseToken(token); err != nil {
return fmt.Errorf("解析 token: %w", err) // 原则二:上抛时加上下文
}
return nil
}
func main() {
err := handleRequest("")
if err != nil {
if errors.Is(err, ErrAuth) {
fmt.Println("返回 401")
}
log.Printf("请求失败: %v", err) // 原则三:日志只在最外层记一次
}
}
步骤 6:panic 与 recover——最后的保险丝
package main
import "fmt"
func safeDiv(a, b int) (result int) {
defer func() {
if r := recover(); r != nil {
fmt.Println("捕获 panic:", r)
result = 0 // 兜底值
}
}()
return a / b // b=0 触发 runtime panic
}
func main() {
fmt.Println(safeDiv(10, 0)) // 捕获后返回 0,程序继续
fmt.Println("程序还活着")
}
- panic 用于程序 bug(数组越界、nil 解引用、初始化失败),不是业务错误——用户输错密码返回 error,不该 panic。
- recover 只在 defer 中有效,且只应在最外层兜底(如 HTTP server 每请求一个 goroutine,标准库已内置 recover),业务代码滥用 recover 会把 bug 藏起来。
讲清楚要点
- 错误是值:它就是函数的一个普通返回值,没有隐藏的控制流,所见即所得。
(T, error) 签名约定:err 非 nil 时 T 必须是零值,调用方才能放心只看 err;判断顺序永远是 if err != nil 在前。
%w 才包装,%v 会断链:Is/As 只认 %w 建立的链。
- Is 判值,As 判型:
errors.Is(err, ErrXxx) 对哨兵值;errors.As(err, &target) 提取具体错误类型的字段。
- 哨兵错误是公共 API:命名
ErrXxx、放包级、写进文档;一旦发布就成为契约,轻易不能改语义。
- 日志只记一次:中间层只包装不打印,最外层统一落日志,避免同一错误刷三遍。
你可能会懵的地方
-
满屏 if err != nil,为什么 Go 不引入 try/catch?
官方观点:异常把错误路径藏进「快乐路径」之外,控制流不透明;Go 把每个可能失败的调用摆到台面上。社区已有 ? 类提案多次被拒,短期内不会变。
-
err 非 nil 时第一个返回值返回什么?
零值(0、""、nil)。不要返回 -1、-999 这类魔数,调用方不该被迫去猜「这个值能不能信」。
-
errors.Is 和 == 判断错误有什么区别?
== 只比较单个对象;Is 会顺着 wrap 链逐层解包比较,还支持类型自定义 Is 方法。包装过的错误用 == 会漏判,永远用 Is。
-
recover 写在普通函数体里有效吗?
无效。recover 必须被 defer 的函数直接调用才生效。而且它只能接 panic,接不住 fatal error(如并发写 map、栈耗尽)。
-
一个 fmt.Errorf 能包多个 %w 吗?
Go 1.20 起支持多个 %w,Is/As 会检查所有包装项;但为了可读性,惯例仍是一条链一层 %w。
错法对照
| 错法 | 问题 | 正解 |
|---|
_ = doSomething() 吞错误 | 失败无感知,bug 延后爆发 | 显式处理,或注释说明为什么可忽略 |
fmt.Errorf("xx: %v", err) | 链被剪断,Is/As 失效 | 用 %w 包装 |
if err == ErrNotFound | 包装后不相等,漏判 | errors.Is(err, ErrNotFound) |
返回 nil, errors.New(...) 之外还返回 -1 | 调用方困惑该信哪个值 | 错误时返回类型零值 |
| 中间层每层都 log.Printf | 同一错误日志刷屏 | 只在最外层记一次 |
| 用 panic 表达「用户输入不合法」 | 业务错误炸掉整个进程 | 返回 error,panic 留给程序 bug |
卡点急救
- err 明明有值却没进 if:检查是不是在多返回值时把 err 接给了
_;或 if 初始化变量作用域出了块。
- errors.Is 一直是 false:链上有
%v 断层,逐层排查 fmt.Errorf 的动词。
panic: runtime error: invalid memory address:nil 指针解引用。追根因:哪个函数返回了没检查的 nil(map 取值、类型断言、出错的构造函数)。
- recover 后程序行为诡异:recover 吞掉了不该吞的 panic,把兜底范围缩小到明确边界,或干脆去掉。
面试常问
-
谈谈 Go 的错误处理哲学,和 try/catch 的对比?
错误是显式返回值,控制流可见、强制逐层决策;异常机制捕获点与抛出点分离,容易漏接与滥用。代价是样板代码多,收益是可靠性与可读性,且可用 wrap 链 + Is/As 补足表达力。
-
errors.Is / errors.As / errors.Unwrap 的区别?
Is 判断链上是否存在某个「值」(哨兵);As 判断链上是否存在某「类型」并提取到变量;Unwrap 是底层机制,返回上一层错误,Is/As 就是沿着它遍历的。
-
什么时候用 panic?
程序级不可恢复错误:初始化阶段配置缺失、不变量被破坏、数组越界等 bug。业务错误一律 error;最外层(如 HTTP server、定时任务)做统一 recover 兜底即可。
-
如何设计一个库的错误 API?
导出哨兵错误(ErrXxx)供 Is 判断;复杂场景定义错误结构体(携带字段)供 As 提取;内部错误一律 %w 包装保链;文档写明会返回哪些哨兵。
自测三问
%v 和 %w 包装错误的本质区别是什么?各写一行代码演示。
- 想拿到错误链里
*PathError 的 Path 字段,用 Is 还是 As?写出代码骨架。
- recover 写在函数中间某一行会生效吗?为什么?
小结一句话
Go 的错误处理 = 错误是值 + 一律 %w 包装保链 + Is 判值 / As 判型 + 最外层记一次日志;panic 只留给 bug,recover 只放在边界。
延伸