主题
Go 错误处理:保留原因,让调用者知道下一步怎么做
读配置失败时,一句“出错了”不够用。排查的人需要知道在读哪份配置,调用者需要判断是参数不合法、没有权限,还是操作已经被取消。同一个错误要承担两种工作:给人看上下文,给程序保留可判断的信息。
Go 用普通返回值传递这类失败。本文围绕一条“解析配置 → 添加上下文 → 调用方判断”的调用链,讲清 error、包装、Is、As、Join,再处理清理失败、取消和 panic。所有展示输出来自 Go 1.27.2、Linux/amd64 的新实验;文末放完整程序与测试。
先认入口:创建、补充、判断和合并
| 要做的事 | 常用入口 | 得到什么,或要注意什么 |
|---|---|---|
| 创建一个只有文字的错误 | errors.New("...") | 每次调用都创建不同的错误值 |
| 创建带格式化信息的错误 | fmt.Errorf("... %v", value) | 文字可以带变量 |
| 添加上下文并保留下层错误 | fmt.Errorf("read config: %w", err) | 调用方可以继续检查被包装的原因 |
| 判断是否匹配某种错误 | errors.Is(err, target) | 沿包装关系查找,不只看最外层 |
| 提取特定错误类型及其字段 | errors.As(err, &target) | 找到后把值写入目标变量 |
| 直接返回匹配类型的值 | errors.AsType[*MyError](err) | Go 1.26 起可用,返回值和 bool |
| 保留多个错误 | errors.Join(err1, err2) | 构成多分支错误树,忽略 nil 错误 |
| 取单层包装原因 | errors.Unwrap(err) | 只认识 Unwrap() error,不展开 Join |
这张表来自当前版本的 errors 与 fmt 文档。Is、As 是 Go 1.13 的入口,Join 在 Go 1.20 加入,AsType 在 Go 1.26 加入。项目最低版本较早时,不要直接复制新 API。[1][2]
error 是一个接口,nil 表示成功需要认真兑现
内置的 error 很小:
go
type error interface {
Error() string
}一个类型只要有 Error() string 方法,就能作为错误返回。错误值可以只有一段文字,也可以带字段、保留另一个错误。接口本身不规定严重程度、不自动记录日志,也不会自动触发重试。[3]
下面的函数约定:成功时返回正整数和 nil,失败时返回 0 与非 nil 错误:
go
n, err := loadLimit("8")
fmt.Println(n, err)
_, err = loadLimit("0")
fmt.Println(err)text
8 <nil>
load config: field limit: must be positive调用方应先按这个函数的约定处理 err。不要推广成“任何函数返回错误时,其他返回值都没有意义”:例如 io.Reader.Read 可以在同一次调用里返回读到的字节数和错误,文档要求先处理那部分数据。能否使用部分结果,要查具体 API。[4]
nil 指针装进 error 后,可能已经不是 nil
假设自定义错误的方法接收者是 *FieldError:
go
var p *FieldError
var err error = p
fmt.Println(p == nil, err == nil)text
true falseerr 现在保存着动态类型 *FieldError,以及这个类型的 nil 指针值;接口本身并非 nil。若函数声明返回 error,却把一个没有赋值的 *FieldError 变量直接返回,调用者仍会走错误分支。成功路径应明确 return nil,或让用于累计错误的变量本身就是 error。不要用“打印出来像 nil”判断成功,也不要假设每个错误类型的 Error 方法都能处理 nil 接收者。[5]
先确定调用者需要什么,再选错误形式
| 形式 | 适合表达 | 优点 | 代价与边界 |
|---|---|---|---|
| 普通文字错误 | 调用方只需知道失败,详情供人排查 | 定义简单 | 不适合靠解析文字驱动业务分支 |
哨兵错误,如 ErrPositive | 一个稳定、可识别的错误类别 | 调用方可用 errors.Is 判断 | 导出后就成为 API 的一部分 |
自定义类型,如 *FieldError | 调用方需要字段名、操作名等结构化细节 | 可用 As 提取字段 | 类型和字段也形成兼容约定 |
| 包装后的错误 | 在原有原因上补充当前层上下文 | 不丢下层可判断信息 | 可能把不想暴露的底层细节变成契约 |
这些形式可以一起用。下面用哨兵表示“不满足正数要求”,再用类型指出“哪个字段不满足”:[6]
go
var ErrPositive = errors.New("must be positive")
type FieldError struct {
Field string
Err error
}
func (e *FieldError) Error() string {
return fmt.Sprintf("field %s: %v", e.Field, e.Err)
}
func (e *FieldError) Unwrap() error { return e.Err }FieldError.Error 决定打印文字,FieldError.Unwrap 则让错误检查函数看到里面的 Err。两个方法承担不同职责。只写前者,文字虽然出现了“must be positive”,却不会因此自动建立包装关系。[1:1]
哨兵通常在包级创建一次,然后重复返回或包装。同一段文字再次传给 errors.New,会产生另一个错误值:
go
err := fmt.Errorf("load config: %w", ErrPositive)
fmt.Println(err == ErrPositive, errors.Is(err, ErrPositive))
fmt.Println(errors.Is(err, errors.New("must be positive")))text
false true
false第一行的直接相等检查失败,是因为 err 是外面的包装错误。Is 能沿包装关系找到同一个哨兵。第二行重新创建了一个错误,只有文字相同,并不是同一个哨兵。错误文字将来可能增加路径、字段或其他上下文,用 err.Error() == "..." 判断外部 API 的错误种类会很脆弱。[7]
这不意味着 == 在错误上永远不合法。调用方明确依赖“直接返回这个哨兵”的约定时可以用它;例如 io.EOF 的文档对 Reader 有直接返回的专门要求。对允许包装的错误类别,优先用 Is。[4:1][7:1]
%w 保留原因,%v 只把文字放进去
配置读取这一层可以这样补充上下文:
go
func loadLimit(s string) (int, error) {
n, err := parseLimit(s)
if err != nil {
return 0, fmt.Errorf("load config: %w", err)
}
return n, nil
}parseLimit 负责解析及验证,外层只说“在加载配置时失败”。不要每一层都重复“error failed”,上下文应当增加新的定位信息。是否记录日志也应有明确负责的层,否则同一个失败逐层打印,容易得到一串内容重复的日志。后两点是工程建议;%w 保留错误的机制由 fmt.Errorf 文档规定。[8]
改成 %v,打印出来可能同样清楚,但程序已经查不到原来的错误:
go
textOnly := fmt.Errorf("load config: %v", ErrPositive)
fmt.Println(errors.Is(textOnly, ErrPositive))text
false该不该包装,要看 API 准备承诺什么。比如仓储层直接 %w 包装数据库驱动的某个错误,外部代码就可能开始依赖那个驱动。以后换驱动,错误类型消失,调用者行为也会变。若希望对外只承诺“记录不存在”,可以映射成自己稳定的错误类别;若调用者本来就需要识别原始文件或网络错误,保留它才有用。不要把“加上下文”机械地理解成“所有底层错误都一律 %w”。[6:1]
包装前先判断 err != nil。fmt.Errorf 不是“参数为 nil 就自动返回 nil”的透传函数;成功路径应直接返回 nil。另一方面,错误文字中的文件路径、请求内容或身份信息最终可能进入日志或响应,是否暴露这些内容应在边界处决定。这里的取舍是 API 与日志设计问题,而非 %w 自动替你处理。[8:1]
Is 判断类别,As 提取细节
我们已经知道错误属于哪一类,下一步可能还需要知道哪个字段错了。直接做类型断言只检查最外层,而 loadLimit 已经包装过一次:
go
_, err := loadLimit("0")
var fieldErr *FieldError
ok := errors.As(err, &fieldErr)
fmt.Println(ok, fieldErr.Field)
_, direct := err.(*FieldError)
fmt.Println(direct)text
true limit
falseerrors.As 找到了内层的 *FieldError。传入 &fieldErr 是因为它需要给变量赋值:目标变量类型是 *FieldError,它的地址自然是 **FieldError。这个双指针经常让人犹豫,但不是多写了一个 &。只有 ok 为真,才应读取匹配值的字段;上面的演示输入固定会匹配,业务代码应把字段访问放进成功分支。[9]
Go 1.26 起,可以用泛型入口省去目标指针:
go
if fieldErr, ok := errors.AsType[*FieldError](err); ok {
fmt.Println(fieldErr.Field)
}text
limit当前文档建议多数场景优先用 AsType。它的类型参数需要实现 error;As 还支持把目标写成其他接口类型,用来匹配错误提供的某种行为。因此,知道新入口更方便,也仍然需要能读懂大量现有的 As(err, &target)。[9:1][2:1]
As 的目标必须是合法的非 nil 指针,指向实现 error 的类型或一个接口类型。文末反例把错误值本身传进去,go vet 能指出问题;强行 go run 则会 panic。别把错误处理代码写成新的崩溃入口。[9:2]
Is 与 As 也允许错误类型通过 Is(error) bool、As(any) bool 定制匹配。普通包装类型只实现 Unwrap 通常已经足够;需要定制时,Is 方法只做当前层的匹配,不应自己递归解包。框架会继续遍历,否则匹配职责容易重复。[7:2][9:3]
Join 把错误链变成树
一次启动可能有两件独立的事失败:配置值不合法,同时读取另一份配置没有权限。只返回其中一个会丢信息;把文字拼在一起又丢掉机器可判断的原因。errors.Join 可以同时保留它们:[10]
go
left := &FieldError{Field: "limit", Err: ErrPositive}
right := fmt.Errorf("read config: %w", fs.ErrPermission)
joined := errors.Join(left, nil, right)
tree := fmt.Errorf("startup: %w", joined)
fmt.Printf("%q\n", joined.Error())
fmt.Println(errors.Is(tree, ErrPositive), errors.Is(tree, fs.ErrPermission))text
"field limit: must be positive\nread config: permission denied"
true trueJoin 忽略 nil 参数,剩下的错误文字用换行连接。图中的箭头表示 Unwrap 暴露出的包装关系:[10:1]
正在绘制图示…
查看图源代码
flowchart TD
A["startup 包装错误"] --> B["Join 返回的错误"]
B --> C["FieldError:Field = limit"]
B --> D["read config 包装错误"]
C --> E["ErrPositive"]
D --> F["fs.ErrPermission"]
Is、As 和 AsType 先检查当前节点,再按子节点顺序深度优先遍历。因此 Is 可以找到两条分支中的原因;As 遇到第一个匹配就返回,不会给你“一次取出所有 FieldError”的结果。两个分支都含同类型错误时,顺序就有意义,文末测试专门检查了这一点。[1:2][2:2]
有一个 API 名字很容易误导:
go
fmt.Println(errors.Unwrap(joined) == nil)
fmt.Println(errors.Join(nil, nil) == nil)text
true
true第一行不表示 joined 没有子错误。它实现的是 Unwrap() []error,而辅助函数 errors.Unwrap 只调用 Unwrap() error。自己用 for err = errors.Unwrap(err) 写遍历,会漏掉多分支;判断类别或提取一个类型时直接用 Is、As。自定义多错误类型若实现 Unwrap() []error,返回的切片中不能包含 nil 元素。[11][1:3]
fmt.Errorf 也允许多个 %w,例如 fmt.Errorf("both: %w; %w", a, b),返回值保留两项原因。本次对两个哨兵的 Is 判断均为 true。选择 Join 还是多个 %w,主要看你需要默认换行文字,还是一段明确的组合描述。[8:2]
Join 只是组织已经发生的错误。它不会替你启动并发任务,不会自动补偿已完成的操作,也不能说明其中哪一个可以重试。重试还要考虑是否可恢复、请求是否可重复执行以及调用方是否已经取消;这些决策不能只靠“返回了一个非 nil error”。
主操作和清理同时失败时,别把其中一个盖掉
下面把主操作与清理都写成函数参数,以便测试所有组合;它不是一段真实文件读写:
go
func finish(work, close func() error) (err error) {
defer func() { err = errors.Join(err, close()) }()
return work()
}
err := finish(
func() error { return ErrPositive },
func() error { return fs.ErrPermission },
)
fmt.Println(errors.Is(err, ErrPositive), errors.Is(err, fs.ErrPermission))text
true truereturn work() 先把主操作的结果赋给命名结果 err,延迟函数再合并清理错误。两个都成功时,Join(nil, nil) 仍然返回 nil。这种模式的价值是保留两份信息;前提是你的操作确实需要把清理失败交给调用者。若业务要求“主错误优先,清理错误另行记录”,那就明确写出该契约,别让后一次赋值偶然决定。[10:2][12]
取消和超时也有稳定的判断入口
context.Canceled 表示操作被取消,context.DeadlineExceeded 表示期限已到。下层把它们包装后,上层仍可用 errors.Is 判断。取消通常意味着这次请求不应继续消耗资源;不能看到一次网络调用失败就无条件启动新重试。[13]
如果需要保留更具体的取消原因,可以使用 WithCancelCause:
go
ctx, cancel := context.WithCancelCause(context.Background())
cancel(ErrStopped)
fmt.Println(ctx.Err(), context.Cause(ctx))
fmt.Println(errors.Is(ctx.Err(), ErrStopped), errors.Is(context.Cause(ctx), ErrStopped))text
context canceled operator stopped task
false truectx.Err() 仍然返回通用的 context.Canceled,具体原因从 context.Cause(ctx) 读取。不要把两者混成一个字段。这个实验使用显式取消,不依赖睡眠与调度时序;context 的传播和生命周期管理有单独文章。[14]
panic 与 recover:处理非正常退出,要有明确边界
error 是函数正常返回协议的一部分;panic 则停止正常执行,沿当前 goroutine 的调用栈执行延迟函数。若没有合适的恢复,程序最终终止。通常,用户输入不合法、文件不存在、服务超时等预期内的失败用 error 返回;违反内部不变量、某些运行时错误,以及明确约定会 panic 的 API,才进入另一种控制流。[15]
| 需求 | 一般选择 | 理由 |
|---|---|---|
| 调用方应根据失败调整输入或分支 | 返回 error | 失败属于函数可预期的结果 |
| 一个包内部深层解析遇到无法继续的状态 | 可以内部 panic,再在受控边界转换 | 对外仍应兑现清楚的错误契约 |
| 隔离一个独立任务的异常退出 | 在该任务自己的 goroutine 边界恢复 | 能把这次任务标记失败,而不是伪装成功 |
| 捕获后什么都不做,继续当成功 | 不建议 | 控制流被恢复不代表数据状态已恢复 |
下面的 boundary 展示怎么把 panic 变成明确错误。若 panic 值本身是 error,就保留它的包装关系;其他值只转换成文字:
go
func boundary(task func()) (err error) {
defer func() {
if p := recover(); p != nil {
if cause, ok := p.(error); ok {
err = fmt.Errorf("task panicked: %w", cause)
} else {
err = fmt.Errorf("task panicked: %v", p)
}
}
}()
task()
return nil
}执行 boundary(func() { panic(ErrPositive) }) 后打印错误和 errors.Is(err, ErrPositive):
text
task panicked: must be positive true这个例子只保证“异常没有被当成成功,error 类型的原因仍可检查”。它没有回滚任务已经做出的修改,没有记录堆栈,也不保证共享状态能继续使用。真实服务若采用类似边界,需要自行处理这些后果。是否恢复某类内部 panic,应由该边界的职责决定,不要给所有函数加一个吞掉 panic 的模板。[16]
恢复还有两条硬条件:recover 必须由延迟执行的函数直接调用,并且只作用于同一个 goroutine。把它藏进普通 helper,会得到 nil;父 goroutine 的 defer 也抓不到子 goroutine 的 panic。恢复后返回的是包含该 defer 的函数,不会跳回 panic 后面的那一行继续执行。详细的直接/间接对照见《Go 函数》。[15:1]
本篇另外运行了一个独立进程:父 goroutine 注册了 recover,子 goroutine 直接 panic。结果出现 panic: child panic,进程退出码为 2,没有打印 parent recover。它验证的是 goroutine 边界,不是在主测试进程里留下一个会终止所有测试的任务。
把判断方式当作 API 的一部分来测试
错误文本用于定位,错误类别和结构字段用于程序分支。测试可以检查:成功路径真的是 nil;失败仍能 Is 到约定的哨兵;As 能提取约定字段;添加一层上下文后这些行为不变;同时出错时需要保留的两项原因都还在。对于自己明确定义的固定文本,当然也可以测文字,但不要以此替代错误契约测试。[6:2]
本文的测试就是按这些问题组织的。这样将来给 loadLimit 增加一句上下文,测试不会因为所有报错字符串都变长而全部失效,同时真正丢失包装关系的修改会被发现。
依据与验证范围
2026-10-13,Go 1.27.2、Linux/amd64 的本次验证:
go run .:验证成功与失败返回、哨兵身份、包装及纯文字对照、As/AsType、多错误树、nil、取消原因和受控恢复。go test -count=1 -v .:4 项测试通过,覆盖配置错误契约、错误树的首个匹配、主操作与清理的 4 种组合、panic 边界。go test -race -count=1 .和go vet .:通过。这里只验证给出的有限样例,没有测试真实文件系统故障、网络超时或业务重试。- 错误
As目标的反例被 vet 拒绝,运行时也如期 panic。子 goroutine 的未恢复 panic 在独立二进制中验证,退出码为 2。
完整可复现代码
创建下面的文件,先运行正常样例。两个 testdata 反例要单独执行;它们本来就是为了观察失败,不应当放进生产调用路径。
sh
go version
go run .
go test -count=1 -v .
go test -race -count=1 .
go vet .
# 预期失败:错误的 As 目标
go vet ./testdata/asbad
go run ./testdata/asbad
# 独立运行,预期退出码 2;不要用这次运行的失败判断主测试失败
go build -o panic-child ./testdata/goroutine
./panic-child错误 As 目标的本次运行诊断为 panic: errors: *target must be interface or implement error。堆栈地址、goroutine 编号和文件路径会随环境变化,不作为稳定输出契约。
go.mod
go.mod
module example.com/go-errors
go 1.27.0main.go
代码块 · 91 行 · 点击收起 / 展开
go
package main
import (
"context"
"errors"
"fmt"
"io/fs"
"strconv"
)
var ErrPositive = errors.New("must be positive")
var ErrStopped = errors.New("operator stopped task")
type FieldError struct {
Field string
Err error
}
func (e *FieldError) Error() string { return fmt.Sprintf("field %s: %v", e.Field, e.Err) }
func (e *FieldError) Unwrap() error { return e.Err }
func parseLimit(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, &FieldError{Field: "limit", Err: err}
}
if n <= 0 {
return 0, &FieldError{Field: "limit", Err: ErrPositive}
}
return n, nil
}
func loadLimit(s string) (int, error) {
n, err := parseLimit(s)
if err != nil {
return 0, fmt.Errorf("load config: %w", err)
}
return n, nil
}
func finish(work, close func() error) (err error) {
defer func() { err = errors.Join(err, close()) }()
return work()
}
func boundary(task func()) (err error) {
defer func() {
if p := recover(); p != nil {
if cause, ok := p.(error); ok {
err = fmt.Errorf("task panicked: %w", cause)
} else {
err = fmt.Errorf("task panicked: %v", p)
}
}
}()
task()
return nil
}
func main() {
n, err := loadLimit("8")
fmt.Println("success:", n, err)
_, err = loadLimit("0")
fmt.Println("error:", err)
fmt.Println("sentinel:", err == ErrPositive, errors.Is(err, ErrPositive))
fmt.Println("same text:", errors.Is(err, errors.New("must be positive")))
var fe *FieldError
fmt.Println("As:", errors.As(err, &fe), fe.Field)
fe2, ok := errors.AsType[*FieldError](err)
fmt.Println("AsType:", ok, fe2.Field)
_, direct := err.(*FieldError)
fmt.Println("direct assertion:", direct)
textOnly := fmt.Errorf("load config: %v", ErrPositive)
fmt.Println("text-only Is:", errors.Is(textOnly, ErrPositive))
left := &FieldError{Field: "limit", Err: ErrPositive}
right := fmt.Errorf("read config: %w", fs.ErrPermission)
joined := errors.Join(left, nil, right)
tree := fmt.Errorf("startup: %w", joined)
fmt.Printf("joined text: %q\n", joined.Error())
fmt.Println("tree Is:", errors.Is(tree, ErrPositive), errors.Is(tree, fs.ErrPermission))
fmt.Println("Unwrap join:", errors.Unwrap(joined) == nil)
fmt.Println("all nil:", errors.Join(nil, nil) == nil)
multi := fmt.Errorf("both: %w; %w", ErrPositive, fs.ErrPermission)
fmt.Println("multiple %%w:", errors.Is(multi, ErrPositive), errors.Is(multi, fs.ErrPermission))
combined := finish(func() error { return ErrPositive }, func() error { return fs.ErrPermission })
fmt.Println("work and close:", errors.Is(combined, ErrPositive), errors.Is(combined, fs.ErrPermission))
var p *FieldError
var typedNil error = p
fmt.Println("typed nil:", p == nil, typedNil == nil)
ctx, cancel := context.WithCancelCause(context.Background())
cancel(ErrStopped)
fmt.Println("context:", ctx.Err(), context.Cause(ctx))
fmt.Println("cause Is:", errors.Is(ctx.Err(), ErrStopped), errors.Is(context.Cause(ctx), ErrStopped))
panicErr := boundary(func() { panic(ErrPositive) })
fmt.Println("recovered:", panicErr, errors.Is(panicErr, ErrPositive))
}main_test.go
代码块 · 69 行 · 点击收起 / 展开
go
package main
import (
"errors"
"fmt"
"io/fs"
"strconv"
"testing"
)
func TestLimitContract(t *testing.T) {
if n, err := loadLimit("8"); n != 8 || err != nil {
t.Fatal(n, err)
}
_, err := loadLimit("0")
if !errors.Is(err, ErrPositive) {
t.Fatal(err)
}
if fe, ok := errors.AsType[*FieldError](err); !ok || fe.Field != "limit" {
t.Fatal(err)
}
_, err = loadLimit("many")
if !errors.Is(err, strconv.ErrSyntax) {
t.Fatal(err)
}
}
func TestTreeAndFirstMatch(t *testing.T) {
a := &FieldError{Field: "a", Err: ErrPositive}
b := &FieldError{Field: "b", Err: fs.ErrPermission}
err := fmt.Errorf("top: %w", errors.Join(a, b))
var got *FieldError
if !errors.As(err, &got) || got != a {
t.Fatal(got)
}
if !errors.Is(err, ErrPositive) || !errors.Is(err, fs.ErrPermission) {
t.Fatal(err)
}
if errors.Unwrap(errors.Join(a, b)) != nil {
t.Fatal("Unwrap should not traverse []error")
}
if errors.Join(nil, nil) != nil {
t.Fatal("nil join")
}
}
func TestFinish(t *testing.T) {
cases := []struct{ work, close error }{{nil, nil}, {ErrPositive, nil}, {nil, fs.ErrPermission}, {ErrPositive, fs.ErrPermission}}
for _, tc := range cases {
err := finish(func() error { return tc.work }, func() error { return tc.close })
if (err == nil) != (tc.work == nil && tc.close == nil) {
t.Fatal(err)
}
for _, want := range []error{tc.work, tc.close} {
if want != nil && !errors.Is(err, want) {
t.Fatal(err)
}
}
}
}
func TestBoundary(t *testing.T) {
if err := boundary(func() {}); err != nil {
t.Fatal(err)
}
if err := boundary(func() { panic(ErrPositive) }); !errors.Is(err, ErrPositive) {
t.Fatal(err)
}
if err := boundary(func() { panic("broken") }); err == nil || err.Error() != "task panicked: broken" {
t.Fatal(err)
}
}testdata/asbad/main.go
go
package main
import "errors"
func main() { err := errors.New("x"); errors.As(err, err) }testdata/goroutine/main.go
go
package main
import "fmt"
func main() {
defer func() { fmt.Println("parent recover:", recover()) }()
go func() { panic("child panic") }()
select {}
}errors包文档,Go 1.27.2,包装方法、错误树和各入口;同版本源码errors.go。 ↩︎ ↩︎ ↩︎ ↩︎Go 官方:Working with Errors in Go 1.13,其中的“Whether to Wrap”和“Errors and package APIs”。 ↩︎ ↩︎ ↩︎
fmt.Errorf。 ↩︎ ↩︎ ↩︎