Go-13 错误处理最佳实践
1. error 接口
Go 没有 try/catch/finally 这类异常机制,而是把错误当作普通的返回值来处理。函数把错误作为最后一个返回值交给调用方,调用方显式判断并处理。这是 Go 错误处理哲学的基石。
1.1 error 的本质是一个接口
标准库对 error 的定义极其简单,它就是一个只有一个方法的接口:
// 内置类型,定义在 builtin 包中
type error interface {
Error() string // 返回错误的文字描述
}
任何实现了 Error() string 方法的类型,都可以作为一个 error。这带来了巨大的灵活性:错误不仅是一段文字,还可以是携带任意上下文字段的结构体。
package main
import (
"errors"
"fmt"
)
func divide(a, b int) (int, error) {
if b == 0 {
// 返回一个 error 值,调用方必须自己判断
return 0, errors.New("除数不能为 0")
}
return a / b, nil
}
func main() {
result, err := divide(10, 0)
if err != nil { // 显式判断,这是 Go 最常见的一行
fmt.Println("出错了:", err) // 出错了: 除数不能为 0
return
}
fmt.Println("结果:", result)
}
1.2 显式错误处理 vs 异常
| 维度 | Go 的 error 返回值 | 传统异常(Java / Python) |
|---|---|---|
| 控制流 | 线性、显式,错误随返回值传递 | 隐式跳转,抛出后沿调用栈上溯 |
| 可见性 | 函数签名即声明可能失败 | 需查文档/注解才知道会抛什么 |
| 强制性 | 编译器不强制,但惯例强制检查 | 检查型异常需捕获,非检查型可漏 |
| 性能 | 无栈展开开销 | 抛异常需构造栈,代价高 |
| 代码风格 | if err != nil 较啰嗦 |
主流程干净,错误集中处理 |
Go 的取舍:用少量样板代码换取错误路径的清晰可见。看一个函数的签名,你就知道它会不会失败。
调用方视角:
result, err := doSomething()
│ │
正常值 错误值(nil 表示成功)
│ │
└───────┴──► 两者通常互斥,err != nil 时 result 应视为无效
2. 创建错误
错误是值(value),这是理解 Go 错误处理的关键。既然是值,就可以被存储、比较、传递、包装。
2.1 errors.New
最基础的方式,返回一个只包含字符串的错误:
package main
import (
"errors"
"fmt"
)
func main() {
err := errors.New("文件读取失败")
fmt.Println(err) // 文件读取失败
fmt.Printf("%T\n", err) // *errors.errorString
}
errors.New 底层返回的是 *errorString 指针,源码非常朴素:
// src/errors/errors.go
func New(text string) error {
return &errorString{text}
}
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
注意返回的是指针。这意味着两次 errors.New("同样的文字") 得到的是两个不同的错误值,== 比较为 false。这正是哨兵错误要用包级变量的原因(见第 3 节)。
2.2 fmt.Errorf 格式化错误
需要动态拼接信息时用 fmt.Errorf,它支持格式化占位符:
package main
import "fmt"
func openFile(name string) error {
return fmt.Errorf("打开文件 %q 失败,错误码 %d", name, 2)
}
func main() {
err := openFile("config.yaml")
fmt.Println(err) // 打开文件 "config.yaml" 失败,错误码 2
}
2.3 错误是值
因为错误是普通值,我们可以写出很地道的代码,例如把错误累计起来,或用变量表示某种固定错误:
// 错误可以作为包级变量存在(哨兵错误)
var ErrNotFound = errors.New("记录不存在")
// 错误可以被赋值、传递、放进切片
errs := []error{
errors.New("字段 name 不能为空"),
errors.New("字段 age 必须大于 0"),
}
3. 哨兵错误 Sentinel Error
哨兵错误指预先定义好的、可导出的错误变量,调用方用它作为"哨兵"来判断具体是哪种错误。命名惯例是 Err 前缀。
3.1 标准库中的哨兵错误
最经典的例子是 io.EOF:
package main
import (
"bufio"
"fmt"
"io"
"strings"
)
func main() {
r := bufio.NewReader(strings.NewReader("line1\nline2"))
for {
line, err := r.ReadString('\n')
fmt.Print(line)
if err == io.EOF { // 用哨兵错误判断读到了文件末尾
fmt.Println("\n[读取完毕]")
break
}
if err != nil {
fmt.Println("读取出错:", err)
break
}
}
}
3.2 自定义哨兵错误
package repo
import "errors"
// 预定义的哨兵错误,供调用方判断
var (
ErrNotFound = errors.New("记录不存在")
ErrConflict = errors.New("记录冲突")
ErrPermission = errors.New("无权限操作")
)
func FindUser(id int) (string, error) {
if id <= 0 {
return "", ErrNotFound
}
return "Alice", nil
}
调用方:
name, err := repo.FindUser(-1)
if err == repo.ErrNotFound { // 直接用 == 比较
fmt.Println("用户不存在,返回默认值")
}
3.3 哨兵错误的缺点:耦合
哨兵错误虽然简单,但存在明显问题:
- API 耦合:调用方必须
import你的包才能引用ErrNotFound,形成源码级依赖。 - 一旦导出就是契约:
io.EOF已成为公共约定,永远不能改,否则破坏所有下游。 - 无法携带上下文:它只是一个固定文字,无法告诉你"哪条记录"不存在。
- 直接
==比较在包装后会失效:如果错误被fmt.Errorf("%w")包装,==就判断不出来了(要用errors.Is,见第 6 节)。
经验:哨兵错误适合表达少量、稳定、语义明确的"结果状态"(如 EOF、NotFound)。需要携带上下文时,改用自定义错误类型。
4. 自定义错误类型
当错误需要携带结构化上下文(字段、错误码、原始错误)时,定义一个实现了 error 接口的结构体。
4.1 实现 Error() 方法
package main
import "fmt"
// 自定义错误类型,携带上下文字段
type ValidationError struct {
Field string // 出错的字段名
Value any // 非法的值
Message string // 说明
}
// 实现 error 接口
func (e *ValidationError) Error() string {
return fmt.Sprintf("字段 %q 校验失败(值=%v): %s", e.Field, e.Value, e.Message)
}
func validateAge(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Value: age, Message: "年龄不能为负"}
}
return nil
}
func main() {
err := validateAge(-5)
fmt.Println(err) // 字段 "age" 校验失败(值=-5): 年龄不能为负
}
4.2 类型断言获取详情
因为返回的是 error 接口,要读取结构体字段就需要类型断言(或 errors.As,见第 6 节):
err := validateAge(-5)
// 通过类型断言拿回具体类型,读取上下文字段
if ve, ok := err.(*ValidationError); ok {
fmt.Println("出错字段:", ve.Field) // 出错字段: age
fmt.Println("非法值:", ve.Value) // 非法值: -5
}
4.3 标准库范例:os.PathError
标准库大量使用自定义错误类型。os.PathError(现名 fs.PathError)就是典型:
// src/io/fs/fs.go
type PathError struct {
Op string // 操作,如 "open"
Path string // 路径
Err error // 底层错误
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}
// 它还实现了 Unwrap,能被 errors.Is/As 穿透(见第 5、6 节)
func (e *PathError) Unwrap() error { return e.Err }
实战中读取它的字段:
package main
import (
"errors"
"fmt"
"io/fs"
"os"
)
func main() {
_, err := os.Open("/不存在的文件")
var pe *fs.PathError
if errors.As(err, &pe) { // 提取 *fs.PathError
fmt.Println("操作:", pe.Op) // 操作: open
fmt.Println("路径:", pe.Path) // 路径: /不存在的文件
fmt.Println("底层:", pe.Err) // 底层: no such file or directory
}
}
陷阱:自定义错误类型的方法通常定义在指针接收者上。返回时若把一个"值类型的 nil 指针"赋给
error接口,会导致err != nil恒为真——即著名的"nil interface 陷阱"。务必让返回值声明为error类型,并在成功时直接return nil。
// 反例:函数返回具体指针类型,会踩 nil 陷阱
func bad() *ValidationError {
return nil // 这个 nil 装进 error 接口后 != nil!
}
func caller() {
var err error = bad() // err 的动态类型是 *ValidationError,非 nil
fmt.Println(err == nil) // false —— 陷阱!
}
5. 错误包装 Wrapping
真实调用链很深,底层错误往上抛时需要不断附加上下文,形成错误链。Go 1.13 引入的 %w 让包装成为一等公民。
5.1 用 %w 包装错误
fmt.Errorf 的 %w(wrap)会把原错误"包"进新错误,同时保留可追溯的引用:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("记录不存在")
func queryDB(id int) error {
return ErrNotFound // 假设底层返回哨兵错误
}
func getUser(id int) error {
if err := queryDB(id); err != nil {
// %w 包装,附加当前层的上下文
return fmt.Errorf("getUser(id=%d): %w", id, err)
}
return nil
}
func main() {
err := getUser(42)
fmt.Println(err) // getUser(id=42): 记录不存在
// 尽管被包装,仍能识别出底层的哨兵错误
fmt.Println(errors.Is(err, ErrNotFound)) // true
}
5.2 %w 与 %v 的区别
| 占位符 | 行为 | errors.Is/As 能否穿透 |
|---|---|---|
%w |
包装,保留原错误引用,形成错误链 | 能 |
%v |
仅格式化其文字,丢失原错误引用 | 不能 |
%s |
同 %v,仅取字符串 |
不能 |
wrapped := fmt.Errorf("上下文: %w", ErrNotFound) // 保留链
flat := fmt.Errorf("上下文: %v", ErrNotFound) // 只剩文字
fmt.Println(errors.Is(wrapped, ErrNotFound)) // true
fmt.Println(errors.Is(flat, ErrNotFound)) // false
5.3 Unwrap 方法与错误链
%w 生成的错误内部实现了 Unwrap() error,调用它就能剥掉一层,拿到被包装的错误。errors.Unwrap 是对外的入口:
err := fmt.Errorf("外层: %w", fmt.Errorf("中层: %w", ErrNotFound))
fmt.Println(err) // 外层: 中层: 记录不存在
fmt.Println(errors.Unwrap(err)) // 中层: 记录不存在
fmt.Println(errors.Unwrap(errors.Unwrap(err)))// 记录不存在
错误链结构可视化:
外层 err
│ Unwrap()
▼
中层 err
│ Unwrap()
▼
ErrNotFound(链尾,Unwrap 返回 nil)
5.4 Go 1.20 的多重 %w
Go 1.20 起,一个 fmt.Errorf 可以包含多个 %w,形成"树状"错误链(配合 errors.Join,见第 8 节):
err1 := errors.New("磁盘满")
err2 := errors.New("网络超时")
// Go 1.20+:一次包装两个错误
err := fmt.Errorf("批处理失败: %w; %w", err1, err2)
fmt.Println(errors.Is(err, err1)) // true
fmt.Println(errors.Is(err, err2)) // true
此时该错误的 Unwrap 方法签名是 Unwrap() []error(返回切片而非单个),errors.Is/As 会自动遍历所有分支。
6. errors.Is / errors.As
包装普及后,直接用 == 或类型断言都会因为"错误被包了几层"而失效。标准库因此提供了会遍历整条错误链的 errors.Is 和 errors.As。
6.1 errors.Is:链上是否存在目标错误
errors.Is(err, target) 沿错误链逐层 Unwrap,判断链上是否有某个错误等于 target(或实现了自定义 Is 方法并返回 true)。
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("记录不存在")
func main() {
err := fmt.Errorf("service层: %w",
fmt.Errorf("dao层: %w", ErrNotFound))
// == 只能比对最外层,包装后必然 false
fmt.Println(err == ErrNotFound) // false
// errors.Is 会遍历整条链
fmt.Println(errors.Is(err, ErrNotFound)) // true
}
用它替代对哨兵错误的 == 比较:
// 推荐写法
if errors.Is(err, io.EOF) { /* ... */ }
6.2 errors.As:提取特定类型的错误
errors.As(err, &target) 沿链查找第一个能赋值给 target 类型的错误,找到则赋值并返回 true。用于取回自定义错误类型的字段。
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
}
func (e *ValidationError) Error() string {
return "字段错误: " + e.Field
}
func main() {
// 把自定义错误包装两层
err := fmt.Errorf("请求处理失败: %w",
&ValidationError{Field: "email"})
var ve *ValidationError
if errors.As(err, &ve) { // 从链中提取出 *ValidationError
fmt.Println("提取到字段:", ve.Field) // 提取到字段: email
}
}
6.3 Is vs As 对比
| 函数 | 用途 | 第二参数 | 返回 |
|---|---|---|---|
errors.Is(err, target) |
判断链上是否有"某个具体错误值" | 一个 error 值 | bool |
errors.As(err, &target) |
提取链上"某个类型的错误" | 指向变量的指针 | bool,成功时赋值 |
一句话记忆:Is 比对"值",As 提取"类型"。
6.4 原理:遍历错误链
两者的核心逻辑都是循环 Unwrap,简化版实现如下:
// errors.Is 简化逻辑
func Is(err, target error) bool {
for {
if err == target {
return true
}
// 若错误自定义了 Is 方法,优先用它
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
return true
}
// 剥一层,继续
if err = errors.Unwrap(err); err == nil {
return false
}
}
}
errors.As 逻辑类似,只是把"值相等判断"换成"类型可赋值判断"(通过反射 reflect.TypeOf(target).AssignableTo)。Go 1.20 后两者都能处理 Unwrap() []error 的多分支链,用 DFS 遍历整棵树。
自定义错误可以实现
Is(target error) bool或As(target any) bool方法,来定制匹配逻辑。例如允许"同一错误码"的两个错误被Is视为相等。
7. panic vs error 的边界
Go 有 panic/recover,但它不是用来做常规错误处理的。分清边界是工程素养。
7.1 何时用 error(绝大多数情况)
可预期的失败——调用方能合理应对的失败,一律用 error 返回:
- 文件不存在、网络超时、参数校验不通过
- 数据库查不到记录、JSON 解析失败
- 任何"业务上可能发生"的情况
7.2 何时用 panic(极少数情况)
不可恢复的、程序员的错误,即程序进入了"本不该发生"的状态:
- 数组越界、空指针解引用(运行时自动 panic)
- 违反了不变量(invariant),如初始化必需的配置缺失导致无法启动
map并发写、类型断言失败(无,ok形式时)
package main
import "fmt"
// 程序启动时必须成功,失败无意义继续运行 —— 可以 panic
func mustLoadConfig(path string) map[string]string {
cfg := loadConfig(path)
if cfg == nil {
panic(fmt.Sprintf("配置文件 %s 加载失败,无法启动", path))
}
return cfg
}
func loadConfig(path string) map[string]string { return nil }
标准库惯例:以 Must 前缀命名的函数(regexp.MustCompile、template.Must)在失败时 panic,专用于包初始化等失败即等于程序 bug 的场景。
7.3 库不应 panic 越过边界
一条重要原则:库(package)不应让 panic 逃逸到调用方。库的公共 API 应返回 error。若库内部使用了 panic(如递归下降解析器),应在 API 边界用 recover 转换回 error:
package parser
import "fmt"
func Parse(input string) (result string, err error) {
// 在边界捕获内部 panic,转成 error 返回
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("解析失败: %v", r)
}
}()
return doParse(input), nil // doParse 内部可能 panic
}
func doParse(s string) string { /* ... */ return s }
| 场景 | 用 error | 用 panic |
|---|---|---|
| 用户输入非法 | ✅ | ❌ |
| 网络/IO 失败 | ✅ | ❌ |
| 初始化必需资源缺失 | 视情况 | ✅(Must 系列) |
| 不可能到达的分支(default) | — | ✅(防御性) |
| 库对外 API | ✅ | ❌(内部用完须 recover) |
panic 传播路径:
f() panic ──► 逐层向上,执行沿途 defer ──► recover 拦截?
├─ 是:恢复,程序继续
└─ 否:到达 goroutine 顶部 ──► 程序崩溃
注意:
recover只在defer函数中直接调用才有效。且 panic 只能恢复当前 goroutine 的,跨 goroutine 的 panic 无法被别处 recover,会直接使整个程序崩溃。
8. 常见错误处理模式
8.1 尽早返回(Guard Clause)
出错立刻返回,避免深层嵌套,让正常流程留在最左侧缩进:
// 推荐:尽早返回,主流程平铺
func process(id int) error {
user, err := fetchUser(id)
if err != nil {
return err
}
order, err := fetchOrder(user)
if err != nil {
return err
}
return saveResult(order)
}
8.2 包装以添加上下文
每一层附加自己知道的上下文,让最终错误信息形成一条清晰的"路径":
func fetchUser(id int) (string, error) {
row, err := db.Query(id)
if err != nil {
// 附加本层上下文,用 %w 保留可追溯性
return "", fmt.Errorf("fetchUser(id=%d): %w", id, err)
}
return row, nil
}
// 最终输出可能是:
// process: fetchUser(id=42): db query: connection refused
惯例:包装信息不要以大写开头、不要以标点结尾,因为它们会被拼接。写
"fetchUser: %w"而非"FetchUser failed. %w"。
8.3 反模式:既记日志又返回错误
同一个错误在每一层都 log.Println 再 return,会导致日志里同一错误重复出现十几遍,难以定位真正源头。
// 反例:重复日志
func bad() error {
if err := doWork(); err != nil {
log.Println("doWork 失败:", err) // ❌ 每层都打
return err
}
return nil
}
原则:要么处理(含记日志),要么返回(交给上层处理),不要两者都做。 通常在最顶层(如 HTTP handler、main)统一记录一次。
// 正确:底层只包装返回,顶层统一记日志
func handler(w http.ResponseWriter, r *http.Request) {
if err := process(getID(r)); err != nil {
log.Printf("请求失败: %+v", err) // ✅ 只在边界记一次
http.Error(w, "内部错误", 500)
}
}
8.4 errors.Join 聚合多个错误(Go 1.20)
需要收集多个独立错误(如批量校验、并发任务)时,用 errors.Join:
package main
import (
"errors"
"fmt"
)
func validate(name string, age int) error {
var errs []error
if name == "" {
errs = append(errs, errors.New("name 不能为空"))
}
if age < 0 {
errs = append(errs, errors.New("age 不能为负"))
}
// Join 会忽略 nil,全 nil 时返回 nil
return errors.Join(errs...)
}
func main() {
err := validate("", -1)
fmt.Println(err)
// name 不能为空
// age 不能为负
// Join 的结果仍支持 errors.Is 遍历每个子错误
target := errors.New("x")
_ = target
}
errors.Join 返回的错误其 Error() 会把各子错误用换行拼接,且实现了 Unwrap() []error,因此 errors.Is/As 能匹配到任意一个子错误。
8.5 在 defer 中处理错误
defer 结合命名返回值可以统一收尾(如关闭资源并把关闭错误合并进返回值):
package main
import (
"errors"
"fmt"
)
func writeFile() (err error) { // 命名返回值 err
f, e := openWriter()
if e != nil {
return e
}
// defer 中处理 Close 的错误,避免遗漏
defer func() {
if cerr := f.Close(); cerr != nil {
// 用 Join 把主错误和关闭错误合并
err = errors.Join(err, fmt.Errorf("关闭失败: %w", cerr))
}
}()
return f.Write("data")
}
type writer struct{}
func openWriter() (*writer, error) { return &writer{}, nil }
func (w *writer) Write(s string) error { return nil }
func (w *writer) Close() error { return nil }
关键点:
defer想修改返回的 error,必须使用命名返回值(err error),否则闭包里改的只是局部副本。
9. 优化与工程实践
9.1 三种错误处理策略
Dave Cheney 总结的经典分类,工程中按需选择:
| 策略 | 定义 | 优点 | 缺点 |
|---|---|---|---|
| Sentinel(哨兵) | 预定义 var ErrXxx |
简单直观 | API 耦合、无上下文 |
| Typed(类型) | 自定义 error 结构体 | 可携带字段、可断言 | 暴露类型、增加 API 面 |
| Opaque(不透明) | 只返回 error,调用方仅判断"有没有错" | 解耦最彻底 | 无法区分错误种类 |
优先级建议:能不透明就不透明(大多数情况调用方只关心成功与否);需要分类时用
errors.Is配哨兵,或用errors.As配类型;避免让调用方直接类型断言你的内部类型。
9.2 pkg/errors 的历史地位
在 Go 1.13 之前,标准库没有包装和堆栈能力,社区广泛使用 github.com/pkg/errors:
// 历史写法(Go 1.13 之前)
import "github.com/pkg/errors"
err := errors.Wrap(originalErr, "上下文") // 包装并记录堆栈
errors.Cause(err) // 取最底层原因
fmt.Printf("%+v", err) // 打印含堆栈
Go 1.13 把 %w/Is/As/Unwrap 吸收进标准库后,pkg/errors 基本退役。但它的一个能力标准库至今没有:自动记录堆栈信息。
9.3 给错误加堆栈信息
标准库 %w 只保留错误链,不含调用堆栈。生产环境常需堆栈来定位。方案:
- 使用第三方库如
github.com/pkg/errors(errors.WithStack)或较新的cockroachdb/errors。 - 或自己在自定义错误类型里用
runtime.Callers记录:
package main
import (
"fmt"
"runtime"
)
type stackError struct {
msg string
stack []uintptr
}
func newStackError(msg string) *stackError {
pcs := make([]uintptr, 32)
// 跳过 runtime.Callers 和 newStackError 自身两帧
n := runtime.Callers(2, pcs)
return &stackError{msg: msg, stack: pcs[:n]}
}
func (e *stackError) Error() string { return e.msg }
func (e *stackError) StackTrace() string {
frames := runtime.CallersFrames(e.stack)
var sb []byte
for {
f, more := frames.Next()
sb = append(sb, fmt.Sprintf("%s\n\t%s:%d\n", f.Function, f.File, f.Line)...)
if !more {
break
}
}
return string(sb)
}
9.4 错误码设计(面向前端/微服务)
对外 API 常需稳定的错误码(而非文字)供客户端判断。推荐用一个统一的错误类型承载:
package apperr
import "fmt"
type Code int
const (
OK Code = 0
NotFound Code = 40400
Unauthorized Code = 40100
Internal Code = 50000
)
type APIError struct {
Code Code // 稳定的机器可读码
Message string // 面向用户的提示
Err error // 内部原始错误(不返回给前端)
}
func (e *APIError) Error() string {
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}
func (e *APIError) Unwrap() error { return e.Err } // 支持 Is/As 穿透
// 构造器
func NotFoundErr(msg string, cause error) *APIError {
return &APIError{Code: NotFound, Message: msg, Err: cause}
}
9.5 错误分层
清晰的分层能让错误在正确的地方被"翻译":
┌────────────────────────────────────────────────┐
│ Handler: catch err, map to APIError/HTTP │ ← Handler 层:捕获错误,翻译成 APIError/HTTP 状态码,统一记日志
├────────────────────────────────────────────────┤
│ Service: wrap biz context (%w), decide │ ← Service 层:包装业务上下文(%w),做业务判断
├────────────────────────────────────────────────┤
│ DAO: map sql.ErrNoRows -> repo.ErrNotFound │ ← DAO 层:把 sql.ErrNoRows 翻译为 repo.ErrNotFound,屏蔽底层细节
└────────────────────────────────────────────────┘
关键实践:底层错误不应直接泄漏到上层甚至前端。DAO 层应把
sql.ErrNoRows转换为领域错误ErrNotFound,避免上层依赖数据库实现细节。
10. 高频面试题
Q1:Go 的 error 是什么?
error 是一个内置接口,只有一个方法 Error() string。任何实现该方法的类型都是一个错误。错误在 Go 中是普通的值,作为函数返回值显式传递和处理,而不是像 Java/Python 那样用异常抛出。
Q2:%w 和 %v 在 fmt.Errorf 里有什么区别?
%w(wrap):包装原错误,保留其引用形成错误链,之后可被errors.Is/As/Unwrap穿透识别;一个Errorf中 Go 1.20 起可有多个%w。%v:仅取原错误的文字表示,丢失引用,无法再被errors.Is/As追溯。- 结论:需要保留可追溯性用
%w,只想拼文字用%v。
Q3:errors.Is 和 errors.As 的区别?
errors.Is(err, target):沿错误链遍历,判断链上是否存在等于 target 的错误值,常用于比对哨兵错误(替代==)。errors.As(err, &target):沿链查找第一个能赋给 target 类型的错误并赋值,用于提取自定义错误类型以读取其字段。- 记忆:Is 比"值",As 取"类型"。两者都会遍历整条链(含 Go 1.20 的多分支
[]error)。
Q4:哨兵错误有什么缺点?
- 造成 API 耦合:调用方必须导入你的包才能引用错误变量;
- 一旦导出即成永久契约,难以修改;
- 无法携带上下文(只是固定文字);
- 被
%w包装后,直接用==判断会失效,必须改用errors.Is。 替代方案:用不透明错误(opaque)或自定义类型 +errors.As。
Q5:什么时候用 panic,什么时候用 error?
- 用 error:可预期的、调用方能应对的失败(IO、网络、参数校验、查无数据)。这是 99% 的情况。
- 用 panic:不可恢复的程序员错误或违反不变量的状态(越界、空指针、初始化必需资源缺失)。
- 原则:库不应让 panic 逃逸,内部若用 panic 必须在公共 API 边界用
recover转成 error。Must前缀函数是允许 panic 的约定(用于初始化)。
Q6:如何给错误加上调用堆栈?
标准库 %w 只保留错误链,不含堆栈。方案:
- 用第三方库
github.com/pkg/errors(errors.WithStack/errors.Wrap)或cockroachdb/errors; - 自定义错误类型中用
runtime.Callers抓取 PC,再用runtime.CallersFrames还原文件/行号。 注意堆栈应在错误产生的最底层记录一次,包装时不必重复抓取。
Q7:如何判断一个错误链里是否包含某个错误?为什么不能用 ==?
用 errors.Is(err, target)。因为错误在向上传递时常被 fmt.Errorf("%w") 包装了多层,最外层的错误值已不再等于 target,直接 == 只比对最外层必然失败;errors.Is 会循环调用 Unwrap() 遍历整条链逐个比对(并尊重自定义的 Is 方法),因此能穿透包装。
Q8:nil interface 陷阱是什么?
当函数返回具体的错误指针类型(如 *MyError)而非 error 接口,且返回了 nil 指针时,把它赋给 error 接口变量后,接口的动态类型非 nil、动态值为 nil,导致 err != nil 恒成立。规避方法:函数返回值直接声明为 error 类型,成功时 return nil,不要返回具体类型的 nil。
Q9:errors.Join 的作用?
Go 1.20 引入,用于把多个错误聚合成一个(自动忽略 nil,全 nil 返回 nil)。其 Error() 用换行拼接各子错误,并实现 Unwrap() []error,使 errors.Is/As 能匹配到任意子错误。适合批量校验、并发任务收集错误、defer 中合并关闭错误等场景。
小结
- error 是接口:只含
Error() string,错误是值,通过返回值显式传递,这是 Go 区别于异常机制的核心哲学。 - 创建:
errors.New造固定错误,fmt.Errorf造格式化错误。 - 哨兵错误
var ErrXxx:简单但耦合、无上下文;自定义错误类型可携带字段,用类型断言或errors.As读取。 - 包装 用
fmt.Errorf("%w")形成错误链,%v会丢链;Go 1.20 支持多重%w与errors.Join。 - 判断链:
errors.Is比对值(替代==),errors.As提取类型;两者都遍历整条链,靠Unwrap驱动。 - panic 边界:仅用于不可恢复的程序员错误,库对外 API 必须用 error,内部 panic 需在边界
recover。 - 工程实践:尽早返回、逐层包装上下文、不要重复记日志(只在边界记一次)、错误分层翻译、按需选择 sentinel/typed/opaque 策略、生产环境考虑堆栈与错误码设计。
掌握"错误即值 + 显式处理 + 错误链"这三点,就抓住了 Go 错误处理的精髓。
xingliuhua