Go 的错误是普通的值,返回之后由你检查。学会给错误包装上下文,用 errors.Is 和 errors.As 找到根因,一次合并多个错误,并把 panic 和 recover 留给真正的 bug。
Go 没有用来处理普通失败的异常。可能失败的函数把错误作为最后一个返回值,由调用方决定怎么处理。这让错误处理在每一行都看得见,也意味着添加上下文、找出根因的那些工具非常重要。
本文讲的就是这些工具:从 errors.New 讲到包装、errors.Is、errors.As 和 errors.Join,然后是 panic 和 recover,它们干的是另一种活。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果粘贴而来。
错误就是任何带 Error 方法的值
Go 的 error 类型是一个内置接口,只有一个方法 Error() string。任何有这个方法的类型都是错误。标准库给了你两种快速创建错误的方式:消息固定时用 errors.New,消息里需要带值时用 fmt.Errorf。
package main
import (
"errors"
"fmt"
"strconv"
)
func parseAge(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("age %q is not a number", s)
}
if n < 0 {
return 0, errors.New("age can't be negative")
}
return n, nil
}
func main() {
for _, in := range []string{"42", "-3", "old"} {
age, err := parseAge(in)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println("age:", age)
}
var err error = errors.New("disk full")
fmt.Printf("%T\n", err)
}
输出:
age: 42
error: age can't be negative
error: age "old" is not a number
*errors.errorString
parseAge 返回一个值和一个错误。成功时错误是 nil。失败时值是 0,错误说明出了什么问题。调用方在调用之后马上检查 if err != nil,先处理错误,再去碰 age。
最后一行显示了 errors.New 真正创建的东西:一个指向未导出小结构体的指针,结构体里存着消息。你永远不需要叫出这个类型的名字,只需要 Error 方法,而 fmt.Println 会替你调用它。
Go 为什么要你检查
if err != nil { return err } 这种写法在 Go 里随处可见,这是有意为之。异常可能从任何一次调用里跳出来,所以光读函数,你看不出哪些行可能让它提前离开。在 Go 里,每一行可能失败的代码下面紧跟着它的检查。失败路径就是普通代码,你可以阅读、单步调试和测试。
代价是要多打字。好处是任何东西离开函数时,你都看得见是从哪儿离开的。而且正如讲函数的那一部分所说,你不可能不小心丢掉一个错误:想忽略它,就得写 _,评审的人一眼就能看到。
哨兵错误和 errors.Is
哨兵错误是包级别的错误值,供调用方拿来比较。按照惯例,它的名字以 Err 开头。你用 errors.New 创建一次,每次出现这种情况都返回同一个值。
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
var users = map[int]string{1: "ada", 2: "linus"}
func findUser(id int) (string, error) {
name, ok := users[id]
if !ok {
return "", ErrNotFound
}
return name, nil
}
func main() {
_, err := findUser(7)
fmt.Println(err)
fmt.Println(err == ErrNotFound)
fmt.Println(errors.Is(err, ErrNotFound))
same := errors.New("not found")
fmt.Println(errors.Is(err, same))
}
输出:
not found
true
true
false
err == ErrNotFound 为 true,因为 findUser 返回的正是这个值。errors.Is 也这么认为。要注意的是最后一行:文字相同的另一个错误不是同一个错误。errors.New 每次都创建一个新指针,错误按身份比较,而不是按消息比较。所以永远不要通过比较字符串来检查错误。
标准库大量使用哨兵错误。io.EOF 表示“没有更多输入了”,sql.ErrNoRows 表示查询什么都没找到。眼下 == 和 errors.Is 给出的答案一样。一旦有人添加了上下文,两者就不再一致,这正是下一节的内容。
包装:用 %w 添加上下文
一个大程序里冒出一句光秃秃的“not found”,几乎什么都没告诉你。在哪儿没找到?在做什么的时候?Go 的办法是包装错误:每个把错误往上传的函数,都加上一句简短的说明,讲清自己当时在做什么。fmt.Errorf 使用 %w 动词时就会包装。
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func openFile(name string) error {
return ErrNotFound
}
func readConfig(name string) error {
if err := openFile(name); err != nil {
return fmt.Errorf("open %s: %w", name, err)
}
return nil
}
func startServer() error {
if err := readConfig("config.json"); err != nil {
return fmt.Errorf("read config: %w", err)
}
return nil
}
func main() {
err := startServer()
if err != nil {
err = fmt.Errorf("start server: %w", err)
}
fmt.Println(err)
fmt.Println(err == ErrNotFound)
fmt.Println(errors.Is(err, ErrNotFound))
for e := err; e != nil; e = errors.Unwrap(e) {
fmt.Printf(" %q\n", e.Error())
}
}
输出:
start server: read config: open config.json: not found
false
true
"start server: read config: open config.json: not found"
"read config: open config.json: not found"
"open config.json: not found"
"not found"
错误往上走了三层,每一层都在前面加上了自己的话。最终的消息从左往右读,从最外层的任务一直讲到根因。这就是 Go 的风格:简短的小写短语,用冒号连起来,不在每一层都写“error:”或“failed to”。
现在 err == ErrNotFound 为 false,因为 err 是包装后的错误,不是哨兵本身。errors.Is 仍然为 true,因为它会往里面找。末尾的循环展示了它一层层找过的内容:errors.Unwrap 每次剥掉一层,直到剩下最初的那个错误。
用十岁孩子能懂的话说
想象一张纸条沿着一排人往上传。第一个孩子写下“not found”,交给下一个人。
这个人不会扔掉纸条,也不会重写。他把纸条装进信封,在外面写上:“在打开 config.json 时:”。再下一个人把这个信封装进更大的信封,写上“在读取配置时:”。等传到老师手里,最外面写着整个经过,最初的纸条还在最中间。
errors.Is 就是老师一层层拆信封,找那张写着“not found”的纸条。信封有多少层都没关系。
准确的说法
带 %w 的 fmt.Errorf 返回的错误,既存着新消息,也存着你传进去的那个错误。这个包装错误有一个 Unwrap() error 方法,返回里面的错误。整条链是一个链表:每个包装错误指向它里面的那个,最内层的错误没有 Unwrap。
errors.Is(err, target) 沿着这个链表走。每一步它都检查 err == target,如果错误有 Is(error) bool 方法,也会调用它。遇到第一个匹配就返回 true,链走完了就返回 false。errors.Unwrap 是手动走其中一步。
这个比喻的局限:真实的信封会把纸条藏在里面,Go 的包装错误却不会。它的消息里已经包含了里层的消息,所以打印外层错误就能看到整条链。而且包装不是自动的。如果函数用的是 %v 而不是 %w,它复制了文字,却把信封丢了。
%w 还是 %v
这两个动词打印出来的消息一样,但只有一个保留了链:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func main() {
wrapped := fmt.Errorf("load user 7: %w", ErrNotFound)
flattened := fmt.Errorf("load user 7: %v", ErrNotFound)
fmt.Println(wrapped)
fmt.Println(flattened)
fmt.Println(errors.Is(wrapped, ErrNotFound))
fmt.Println(errors.Is(flattened, ErrNotFound))
}
输出:
load user 7: not found
load user 7: not found
true
false
光看输出分不出两者,所以这个区别很容易被忽略。调用方可能需要检查根因时,用 %w。你有意隐藏根因时,用 %v,比如不想让存储层的某个细节变成你这个包的 API 的一部分。
一个错误只处理一次
每个错误都应该只处理一次:要么加上上下文返回它,要么记录日志并到此为止。两样都做是很常见的习惯,结果日志里同一个失败被讲了好几遍。
package main
import (
"errors"
"log"
"os"
)
func readConfig() error {
return errors.New("config.json: file not found")
}
func startServer() error {
err := readConfig()
if err != nil {
log.Println("could not read config:", err)
return err
}
return nil
}
func main() {
log.SetFlags(0)
log.SetOutput(os.Stdout)
if err := startServer(); err != nil {
log.Println("server failed:", err)
}
}
输出:
could not read config: config.json: file not found
server failed: config.json: file not found
(log.SetFlags(0) 关掉了时间戳,这样每次运行的输出都一样。)
一个失败,两行日志。在有五层的真实程序里,那就是五行,而且在日志里往往隔得很远,凌晨三点读日志的人会以为出了五个问题。更糟的是,第二行丢掉了第一行里的上下文。
解决办法是二选一。startServer 应该 return fmt.Errorf("read config: %w", err),什么日志都不记。main 是最顶层,没有地方可以再返回,所以它记一次日志,那一行是 server failed: read config: config.json: file not found,整个经过按顺序都在里面。
自定义错误类型和 errors.As
自定义错误类型携带的是调用方可以据此行动的结构化数据,而不只是一条消息。当调用方需要知道校验失败的是哪个字段,或者 HTTP 调用返回的是哪个状态码时,哨兵错误就不够用了。你定义一个带这些字段的结构体,再给它一个 Error 方法。
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Reason
}
func validate(email string) error {
if email == "" {
return &ValidationError{Field: "email", Reason: "is required"}
}
return nil
}
func createUser(email string) error {
if err := validate(email); err != nil {
return fmt.Errorf("create user: %w", err)
}
return nil
}
func main() {
err := createUser("")
fmt.Println(err)
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("field:", ve.Field)
fmt.Println("reason:", ve.Reason)
}
if ve, ok := errors.AsType[*ValidationError](err); ok {
fmt.Println("AsType found field:", ve.Field)
}
}
输出:
create user: email: is required
field: email
reason: is required
AsType found field: email
errors.Is 问的是“链里有没有这个特定的值?”。errors.As 问的是“链里有没有这种类型的错误?有的话交给我”。你声明一个想要的类型的变量,传入它的地址,errors.As 就沿着链去找。找到 *ValidationError 时,它把错误存进 ve 并返回 true。createUser 的那层包装并没有挡路。
指向 ve 的指针常让人犯错。errors.As 需要一个地方放它找到的东西,所以它接收 &ve,类型是 **ValidationError。直接传 ve 的话,go vet 会拦住你。
Go 1.26 新增了 errors.AsType,这是一个泛型版本,直接返回匹配结果和一个 bool,不用先声明变量。它走的是同样的链。现有代码里几乎都是 errors.As,随着 AsType 流行起来,新代码里会越来越多地看到它。
写 validate 这类函数时要注意一点。成功时直接返回 nil,永远不要返回存进 error 里的 nil *ValidationError。后者不等于 nil,这就是讲接口的那一部分里的 nil 接口陷阱。
errors.Join:一次多个错误
有时一次调用里有好几处互不相关的问题,只报告第一个,用户就得一个一个地改。Go 1.20 加入的 errors.Join 能把多个错误合并成一个。
package main
import (
"errors"
"fmt"
)
var ErrTooShort = errors.New("password too short")
func checkSignup(name, password string) error {
var errs []error
if name == "" {
errs = append(errs, errors.New("name is required"))
}
if len(password) < 8 {
errs = append(errs, ErrTooShort)
}
return errors.Join(errs...)
}
func main() {
err := checkSignup("", "abc")
fmt.Println(err)
fmt.Println("---")
fmt.Println(errors.Is(err, ErrTooShort))
fmt.Println(checkSignup("ada", "correct horse"))
}
输出:
name is required
password too short
---
true
<nil>
合并后的错误消息里,每个错误占一行。errors.Is 和 errors.As 会搜索每一个分支,所以对 ErrTooShort 的检查照样有效。
最后一行是最方便的地方。errors.Join 会忽略 nil 错误,什么都不剩时就返回 nil。所以你可以把问题收集到一个切片里,直接返回 errors.Join(errs...),不用检查切片是不是空的。
panic 是给 bug 用的,不是给错误用的
panic 会中断一个 goroutine 的正常流程。延迟调用仍然会执行,然后,除非有东西 recover,整个程序会带着一条消息和栈跟踪崩溃。对 bug 来说这是正确的结果:程序员写错了什么,或者代码认为不可能出现的状态出现了。文件不存在、用户输入有误、网络超时,都不是 bug。它们是错误,应该返回。
package main
import "fmt"
type Direction int
const (
North Direction = iota
South
)
func (d Direction) String() string {
switch d {
case North:
return "north"
case South:
return "south"
}
panic(fmt.Sprintf("unknown Direction %d", int(d)))
}
func main() {
fmt.Println(North.String())
fmt.Println(Direction(9).String())
}
它输出两行,然后停下:
north
panic: unknown Direction 9
这两行之后是 goroutine 1 [running]: 和一段指向 panic 那一行的栈跟踪,程序以状态码 2 退出。方向只有两个,所以 Direction(9) 说明某处代码造出了一个不该存在的值。在这里返回错误,会逼着每个调用 String 的地方去处理一种只有 bug 才会造成的情况。panic 则在出错的地方大声说:“去修代码。”
运行时出于同样的原因 panic:索引越过切片末尾、往 nil map 里写入、整数除以零。第一种你已经在讲切片的那一部分里见过了。
recover:把 panic 变回错误
recover 是一个内置函数,它能中止正在进行的 panic,并返回传给 panic 的那个值。只有在 goroutine 正在 panic、并且由延迟函数直接调用它时,它才起作用。常规用法是放在边界上:代码要运行别人的函数,又不想让别人的 bug 把一切都搞崩。
package main
import (
"errors"
"fmt"
)
func safeRun(name string, job func()) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("job %s panicked: %v", name, r)
}
}()
job()
return nil
}
func main() {
err := safeRun("ok", func() {
fmt.Println("running ok")
})
fmt.Println("result:", err)
err = safeRun("bad", func() {
var counts map[string]int
counts["x"]++
})
fmt.Println("result:", err)
err = safeRun("custom", func() {
panic(errors.New("state machine reached an impossible state"))
})
fmt.Println("result:", err)
fmt.Println("main carries on")
}
输出:
running ok
result: <nil>
result: job bad panicked: assignment to entry in nil map
result: job custom panicked: state machine reached an impossible state
main carries on
第一个任务正常运行。recover 返回 nil,所以延迟函数什么都没改。第二个任务往 nil map 里写入,导致运行时 panic。panic 从 job() 里展开出来,延迟函数执行,recover 捕获了它,闭包把一个错误放进命名返回值 err。这和讲函数的那一部分里延迟闭包的技巧一样。第三个任务故意 panic,也用同样的方式被捕获。
Go 的 net/http 服务器对每个请求做的正是这件事。处理函数 panic 了,它的请求会被丢弃并记录日志,服务器继续为其他人服务。讲 HTTP 服务器的那一部分会看到这一点。
不要用 recover 把 panic 改造成异常。函数可能以正常方式失败,就返回错误。只在崩溃比记录一次失败更糟的边界上才 recover。
recover 不起作用的地方
只有延迟函数直接调用 recover,才能中止 panic。在其他任何地方调用,它什么都不做,返回 nil。
package main
import "fmt"
func helper() {
if r := recover(); r != nil {
fmt.Println("helper recovered:", r)
}
}
func main() {
fmt.Println("recover outside a panic:", recover())
defer func() {
helper()
fmt.Println("the deferred function returns")
}()
panic("boom")
}
它输出三行,然后停下:
recover outside a panic: <nil>
the deferred function returns
panic: boom
第一个 recover 执行时没有任何东西在 panic,所以返回 nil。第二个在 helper 里,而 helper 是被延迟函数调用的,它本身不是延迟函数。多隔这一层就够了:recover 返回 nil,helper 什么都不打印,panic 继续传播,让程序崩溃。把 recover 调用直接移进延迟闭包里,就能捕获它。
另一个限制是 goroutine。延迟的 recover 只能捕获它自己所在 goroutine 里的 panic:
package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("main recovered:", r)
}
}()
go func() {
panic("worker failed")
}()
select {} // wait forever; the worker's panic ends the program first
}
它输出一行,然后停下:
panic: worker failed
main 里的 recover 写得完全没问题,却从来没执行。panic 发生在另一个 goroutine 里,那个 goroutine 自己没有延迟的 recover,而任何 goroutine 里未被 recover 的 panic 都会让整个程序崩溃。所以启动 goroutine 去运行不可信任务的代码,要在每个 goroutine 内部放上 defer–recover。讲 goroutine 的那一部分会介绍怎样正确地启动和等待它们。
重新 panic
延迟函数有时会 recover 一个 panic,看了看,发现自己终究处理不了。它可以记下自己知道的信息,然后用同一个值再次调用 panic。
package main
import "fmt"
func main() {
defer func() {
r := recover()
fmt.Println("logging, then panicking again:", r)
panic(r)
}()
panic("invariant broken: balance below zero")
}
它输出两行,然后停下:
logging, then panicking again: invariant broken: balance below zero
panic: invariant broken: balance below zero [recovered, repanicked]
看第二行的末尾。panic 被 recover 之后又用同一个值再次 panic,崩溃信息会写上 [recovered, repanicked],并且只打印一次这个值。如果你在崩溃信息里看到这个标记,说明调用栈上层有代码捕获了这个 panic,又把它放走了。
该用哪个?
归根到底,要看谁需要作出反应,以及怎么反应。
| 情况 | 用什么 | 调用方用什么检查 |
|---|---|---|
| 出了问题,调用方只需要知道出了问题 | 返回 errors.New 或 fmt.Errorf 创建的错误,用 %w 包装 |
err != nil |
| 调用方需要识别某种特定情况,比如“not found” | 哨兵错误:var ErrNotFound = errors.New(...) |
errors.Is |
| 调用方需要细节:哪个字段、哪个状态码、隔多久重试 | 带字段的自定义错误类型 | errors.As 或 errors.AsType |
| 几件互不相关的事同时失败 | errors.Join |
errors.Is 或 errors.As,它们会搜索每一部分 |
| bug 或不可能出现的状态,只有改代码才能修好 | panic |
什么都不用。去修代码。只在边界上 recover |
要点
error是只有一个方法Error() string的接口。把它作为最后一个返回值,调用之后马上检查if err != nil。- 每个错误只处理一次:要么加上上下文返回,要么记录日志,不要两样都做。
- 用
fmt.Errorf("doing X: %w", err)包装。消息一层层累积起来,而%v会丢掉整条链。 errors.Is在整条链里查找某个特定的值。用它比较错误,不要用==,更不要比较消息文字。errors.As,或 Go 1.26 起的errors.AsType,从链里取出自定义错误类型,让你读取它的字段。errors.Join合并多个错误,它们全是nil时返回nil。panic是给 bug 用的。recover只有在正在 panic 的 goroutine 的延迟函数里被直接调用时才有效,而且应该放在边界上。
错误每往上传一层,就加上你当时在做什么,并把原来的错误留在里面。