Go 错误处理

Go 把错误当作普通返回值:error 接口、哨兵错误、fmt.Errorf %w 包装,以及 errors.Is/As 的判断方式;强调显式检查、早返回,而不是用 panic 表达常规失败。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 错误处理

这个概念为什么会出现

程序会失败:文件不存在、网络超时、校验不通过、磁盘满。语言必须回答:

  • 失败信息如何回到调用方?
  • 调用方如何被迫(或被提醒)处理?
  • 如何在多层调用中保留上下文,又不丢掉“根因身份”?

部分语言用异常栈做默认通道。Go 的选择更克制:错误是值,通常作为函数的最后一个返回值显式传递;控制流保持可见,失败路径与成功路径写在同一套 if/return 里。

代价是样板代码更多;收益是审查时能看见每条失败分支,也避免“远程 catch 吞掉关键错误”。

[!abstract] 一句话理解 error 是单方法接口;失败用返回值表达,调用方显式检查。用 %w 包装上下文,用 errors.Is/errors.As 判断错误身份,不要用 panic 处理常规业务失败。

最小可运行示例

先把示例放进一个真实场景,再看代码:

场景:HTTP 服务查用户资料

接口 GET /users/{id} 背后要查用户。
可能失败的原因很多,但对调用方最重要的是两类决策:

  1. 找不到 → 返回 404,不算系统故障
  2. 别的错误 → 返回 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

结合场景再看三个关注点

  1. 成功 (value, nil),失败 (零值, err)
    findUser 查不到时返回 "" + error,查到时返回 "alice" + nil。
    调用方永远先看 err,再碰 name

  2. %w 既加上下文,又保留身份
    日志里是 findUser: id=0: not found;程序里仍可用 errors.Is(..., ErrNotFound)

  3. 分支靠 errors.Is,不靠文案
    对应“404 vs 500”的产品决策:身份稳定,文案可改。

核心概念与准确模型

error 是接口

type error interface {
	Error() string
}
  • 任何实现 Error() string 的类型都是错误
  • 字符串给人读;程序逻辑应依赖类型/哨兵/链,而不是解析文案

参考:Package builtin — errorPackage 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.Iserrors.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 业务框架。

设计动机

  1. 失败路径可见
    审查 diff 时能看到每个 if err != nil
  2. 错误是值,可包装、可比较、可测试
    不必依赖隐式栈展开规则。
  3. 与多返回值协同
    (T, error) 成为标准库与社区的默认方言。
  4. 分层加上下文
    底层报事实,上层报意图(“打开配置失败”)。

边界情况与反直觉行为

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 以支持链
日志错误返回给调用方决策;日志是可观测性,不能替代返回

工程实践

  1. 签名稳定
    可能失败的导出函数以 error 为最后返回值。
  2. 错误只处理一次
    要么返回,要么在边界记录并降级;避免“打日志又原样返回”导致重复日志风暴(团队可约定唯一边界)。
  3. 上下文从外到内读起来像句子
    "save user 42: update db: connection reset"
  4. 包级哨兵谨慎导出
    只导出调用方必须分支的错误。
  5. 自定义错误类型
    需要字段或 Is/Unwrap 方法时再上类型;简单场景 errors.New/fmt.Errorf 足够。
  6. 测试
    errors.Is/As 断言,而不是全文相等(除非测文案本身)。
  7. 并发与错误
    多 goroutine 汇聚错误用 errgroup 等模式(进阶);仍保持“错误是值”。
  8. 用户可见 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.AsCode

实验 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

自测题

概念题

  1. 为什么说 Go 的错误处理是“显式”的?
  2. %w 相对 %v 多提供了什么?
  3. 什么情况下用 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

参考答案

展开
  1. 失败通过返回值出现在签名与调用处,必须写检查,而不是默认抛到远处 catch。
  2. %w 建立可 Unwrap 的包装关系,供 Is/As 使用;%v 通常只格式化文本。
  3. 需要取出具体错误类型的字段或方法时用 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 保持高阶概述,细节链向包装笔记)
创建于 2026/6/20 更新于 2026/7/15