Go 错误处理
Go 把错误当作普通返回值:error 接口、哨兵错误、fmt.Errorf %w 包装,以及 errors.Is/As 的判断方式;强调显式检查、早返回,而不是用 panic 表达常规失败。
[!info] 关联笔记
Go 错误处理
这个概念为什么会出现
程序会失败:文件不存在、网络超时、校验不通过、磁盘满。语言必须回答:
- 失败信息如何回到调用方?
- 调用方如何被迫(或被提醒)处理?
- 如何在多层调用中保留上下文,又不丢掉“根因身份”?
部分语言用异常栈做默认通道。Go 的选择更克制:错误是值,通常作为函数的最后一个返回值显式传递;控制流保持可见,失败路径与成功路径写在同一套 if/return 里。
代价是样板代码更多;收益是审查时能看见每条失败分支,也避免“远程 catch 吞掉关键错误”。
[!abstract] 一句话理解
error是单方法接口;失败用返回值表达,调用方显式检查。用%w包装上下文,用errors.Is/errors.As判断错误身份,不要用 panic 处理常规业务失败。
最小可运行示例
先把示例放进一个真实场景,再看代码:
场景:HTTP 服务查用户资料
接口 GET /users/{id} 背后要查用户。
可能失败的原因很多,但对调用方最重要的是两类决策:
- 找不到 → 返回 404,不算系统故障
- 别的错误 → 返回 500,要打日志/告警
因此仓库层用哨兵错误 ErrNotFound 表达“不存在”,
上层用 errors.Is 判断身份,而不是解析错误字符串。
package main
import (
"errors"
"fmt"
)
// ErrNotFound 是包级哨兵:表示“资源不存在”。
// 调用方用 errors.Is 判断身份,不要用字符串包含匹配。
var ErrNotFound = errors.New("not found")
// findUser 模拟“按 ID 查用户名”。
//
// 业务意图:
// 1. id 非法或库中没有 → 返回 ErrNotFound(可被上层识别为 404);
// 2. 查到 → 返回用户名 + nil;
// 3. 无论成败,错误文案带上本层操作与 id,方便日志定位。
//
// 教学点:
// - (value, error) 双返回值:成功 (name, nil),失败 ("", err);
// - %w 包装:保留 ErrNotFound 身份,同时附加 findUser 上下文;
// - 调用方用 errors.Is 分支,而不是 if err.Error() == "..."。
func findUser(id int) (string, error) {
// 简化:只认 id==1 为存在;其余都当“找不到”。
// 真实项目里这里会是 DB/缓存查询。
if id != 1 {
// 包装而不是直接 return ErrNotFound:
// 上层日志能看到“是 findUser 在查哪个 id 时失败的”。
return "", fmt.Errorf("findUser: id=%d: %w", id, ErrNotFound)
}
return "alice", nil
}
// handleGetUser 模拟 HTTP handler 的错误分支。
// 业务意图:把“领域错误”映射成对外语义(404 vs 其他)。
func handleGetUser(id int) {
name, err := findUser(id)
if err != nil {
// 先判断“是不是找不到”,再决定响应。
if errors.Is(err, ErrNotFound) {
// 即使中间包了多层 %w,Is 仍能沿着链认出 ErrNotFound。
fmt.Println("HTTP 404 missing:", err)
return
}
// 非 NotFound:系统/未知错误路径。
fmt.Println("HTTP 500 other:", err)
return
}
// 成功路径:返回业务数据。
fmt.Println("HTTP 200 ok:", name)
}
func main() {
// 场景 1:非法/不存在 id → 期望走 404 分支
handleGetUser(0)
// 场景 2:存在的用户 → 期望 200
handleGetUser(1)
}
建议运行:
go run .
期望输出:
HTTP 404 missing: findUser: id=0: not found
HTTP 200 ok: alice
结合场景再看三个关注点
-
成功
(value, nil),失败(零值, err)
findUser查不到时返回""+ error,查到时返回"alice"+ nil。
调用方永远先看err,再碰name。 -
%w既加上下文,又保留身份
日志里是findUser: id=0: not found;程序里仍可用errors.Is(..., ErrNotFound)。 -
分支靠
errors.Is,不靠文案
对应“404 vs 500”的产品决策:身份稳定,文案可改。
核心概念与准确模型
error 是接口
type error interface {
Error() string
}
- 任何实现
Error() string的类型都是错误 - 字符串给人读;程序逻辑应依赖类型/哨兵/链,而不是解析文案
参考:Package builtin — error、Package errors。
惯用检查与早返回
v, err := do()
if err != nil {
return fmt.Errorf("do: %w", err)
}
// 成功路径继续,少一层嵌套
这与 流程控制 的“扁平化、提前返回”一致。不要把主逻辑塞进巨大的 else。
构造错误的常见方式
| 方式 | 用途 |
|---|---|
errors.New("...") | 简单静态错误 / 哨兵 |
fmt.Errorf("... %v", x) | 带格式化文案(默认不形成包装链) |
fmt.Errorf("... %w", err) | 包装底层错误,保留链 |
自定义类型实现 Error() | 携带字段(码、路径、重试提示等) |
哨兵错误(sentinel)
var ErrNotFound = errors.New("not found")
- 适合稳定、可比较身份的少量公开错误
- 调用方用
errors.Is(err, ErrNotFound)(不要只写err == ErrNotFound,除非能保证未包装) - 不宜为每种临时失败都导出哨兵,否则 API 表面膨胀
包装与 %w(Go 1.13+)
return fmt.Errorf("load config %s: %w", path, err)
%w使errors.Unwrap/errors.Is/errors.As能穿透- 文案加上下文(操作、路径、id);底层保留根因
- 同一错误不要既改写身份又丢掉链
更细的包装策略见 错误包装;本篇只要求建立正确默认习惯。
errors.Is 与 errors.As(高阶心智)
if errors.Is(err, ErrNotFound) { ... }
var pe *os.PathError
if errors.As(err, &pe) {
// pe 指向链中匹配的具体类型
}
| API | 用途 |
|---|---|
errors.Is(err, target) | 链上是否匹配某哨兵/实现了 Is 的相等语义 |
errors.As(err, &target) | 链上是否存在可赋给 target 所指类型的错误,并取出 |
规则直觉:
- 判断“是不是这类失败” →
Is - 取出结构化字段 →
As - 不要用
strings.Contains(err.Error(), "not found")做控制流
参考:Working with Errors in Go 1.13。
成功时必须返回真正的 nil 接口
// 错误:把 typed nil 放进 error
func f() error {
var e *MyError = nil
return e // 返回值 != nil
}
原因见 接口值与 nil。正确:return nil。
何时用 panic
| 场景 | 倾向 |
|---|---|
| 可预期的业务/IO/校验失败 | 返回 error |
| 程序员错误(无法继续的不变量破坏) | 可 panic(如数组越界本身也是 runtime panic) |
| 库的公开 API 常规路径 | 避免 panic;让调用方有机会处理 |
| 进程边界的最后防线 | recover 记日志后退出或隔离(见 defer/panic 笔记) |
不要把 panic/recover 当成 Java 式 try/catch 业务框架。
设计动机
- 失败路径可见
审查 diff 时能看到每个if err != nil。 - 错误是值,可包装、可比较、可测试
不必依赖隐式栈展开规则。 - 与多返回值协同
(T, error)成为标准库与社区的默认方言。 - 分层加上下文
底层报事实,上层报意图(“打开配置失败”)。
边界情况与反直觉行为
1. err != nil 仍可能“看起来像空”
typed nil 装进 error 后,!= nil 为 true。
2. %v 与 %w 不同
%v/%s 只影响打印;不建立 Unwrap 链。要链必须用 %w(或实现 Unwrap)。
3. 哨兵被包装后 == 失效
err := fmt.Errorf("x: %w", ErrNotFound)
err == ErrNotFound // false
errors.Is(err, ErrNotFound) // true
4. 忽略错误
_ = do() 或 do() 不接 err 会丢掉失败;go vet 与代码审查应盯住。
5. 错误文案稳定性
Error() 字符串面向人;不要把完整句子当 API 契约。公开契约用哨兵、类型或 errors.Is 语义。
常见误区
[!warning] 常见误区:用 panic 表达“找不到/校验失败” 错误:
if id==0 { panic("bad id") }作为 API 常态。
正确:返回error;panic 留给真正不可恢复的程序错误。
[!warning] 常见误区:字符串匹配错误 错误:
if err.Error() == "not found"。
正确:errors.Is/errors.As或稳定类型。
[!warning] 常见误区:丢失上下文或丢失根因 错误:
return errors.New("failed")丢掉底层 err;或只return err在边界毫无上下文。
正确:边界fmt.Errorf("open %s: %w", path, err)。
[!warning] 常见误区:深层 else 金字塔 错误:成功逻辑缩进五层。
正确:if err != nil { return ... }早返回。
[!warning] 常见误区:返回 typed nil 错误:
var e *T; return e。
正确:return nil。
与相邻概念对比
| 概念 | 差异 |
|---|---|
| panic/recover | 非常规控制流;默认不用于业务错误 |
| 接口 | error 本身是接口;满足规则与方法集一致 |
| 类型断言 | 可手动拆错误类型;优先 errors.As 以支持链 |
| 日志 | 错误返回给调用方决策;日志是可观测性,不能替代返回 |
工程实践
- 签名稳定
可能失败的导出函数以error为最后返回值。 - 错误只处理一次
要么返回,要么在边界记录并降级;避免“打日志又原样返回”导致重复日志风暴(团队可约定唯一边界)。 - 上下文从外到内读起来像句子
"save user 42: update db: connection reset"。 - 包级哨兵谨慎导出
只导出调用方必须分支的错误。 - 自定义错误类型
需要字段或Is/Unwrap方法时再上类型;简单场景errors.New/fmt.Errorf足够。 - 测试
用errors.Is/As断言,而不是全文相等(除非测文案本身)。 - 并发与错误
多 goroutine 汇聚错误用errgroup等模式(进阶);仍保持“错误是值”。 - 用户可见 vs 内部
API 对外映射安全消息;内部链留给日志与排查。
func Load(path string) (Config, error) {
b, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("load config %s: %w", path, err)
}
cfg, err := parse(b)
if err != nil {
return Config{}, fmt.Errorf("load config %s: parse: %w", path, err)
}
return cfg, nil
}
可验证实验
实验 1:基本返回
写 (int, error) 除法,除 0 返回错误,主函数早返回。
实验 2:%w + Is
包装 ErrNotFound,对比 == 与 errors.Is。
实验 3:As
自定义 type MyError struct{ Code int },包装后用 errors.As 取 Code。
实验 4:typed nil
type MyError struct{}
func (e *MyError) Error() string { return "x" }
func f() error { var e *MyError; return e }
fmt.Println(f() == nil) // false
实验 5:panic 对比
用 panic 抛业务错误再 recover,观察为何堆栈与调用约定比返回值更难组合。
本节总结
- 本质:错误是实现了
Error() string的值,经返回值显式流动。 - 关键规则:检查
err、早返回、%w保链、Is/As判断身份、常规失败不 panic。 - 最易错:吞错误、字符串匹配、typed nil、边界无上下文。
- 下一步:错误包装;defer/panic/recover。
自测题
概念题
- 为什么说 Go 的错误处理是“显式”的?
%w相对%v多提供了什么?- 什么情况下用
errors.As而不是errors.Is?
代码推理题
var ErrX = errors.New("x")
func f() error {
return fmt.Errorf("f: %w", ErrX)
}
func main() {
err := f()
fmt.Println(err == ErrX)
fmt.Println(errors.Is(err, ErrX))
}
两行打印分别是什么?
工程思考题
公共库函数读文件失败时,应返回原始 *os.PathError、包装后的错误,还是新哨兵?如何让调用方仍能判断 fs.ErrNotExist?
参考答案
展开
- 失败通过返回值出现在签名与调用处,必须写检查,而不是默认抛到远处 catch。
%w建立可 Unwrap 的包装关系,供Is/As使用;%v通常只格式化文本。- 需要取出具体错误类型的字段或方法时用
As;只需判断是否某哨兵/同类失败用Is。
代码题:false然后true。
工程题:在边界fmt.Errorf("read %s: %w", path, err)包装以加上下文,并确保仍%w底层错误,使errors.Is(err, fs.ErrNotExist)成立;不必只返回裸 PathError 而丢失业务上下文,也不要换成无法 Unwrap 的新错误。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Package errors | 标准库 | New、Is、As、Unwrap、Join |
| Working with Errors in Go 1.13 | 官方博客 | 包装与 Is/As |
| Go Wiki: Errors | 官方 wiki | 错误处理惯例 |
| Spec — Errors(见 interface / 返回值相关章节) | 规范 | 语言层面无异常语法 |
| fmt.Errorf | 标准库 | %w 动词 |
| Package io/fs — ErrNotExist | 标准库 | 哨兵与 Is 的实际用例 |
笔记元信息
- 建议文件名:
go-error-handling.md - 所属阶段:语言基础 / 工程习惯
- 学习顺序:函数与控制流之后;包装与 panic 之前或并行
- 建议下一篇:错误包装 或 defer/panic/recover
- 本篇状态:已深化(结构完整;Is/As 保持高阶概述,细节链向包装笔记)