错误处理
Go 的错误处理机制
Go 的错误处理风格与 C 一脉相承:函数通过返回值将错误显式交给调用方处理,从而迫使开发者在每个调用点都关注失败路径。不同于 C 常用“单一返回值 + 约定(如 -1/NULL)”来表示失败,Go 通过多返回值将“正常结果”和“错误”分离,通常将 error 放在返回值列表末尾,使成功路径与失败路径更清晰,也更容易形成统一的错误处理习惯。
Go 的惯用法是使用内置接口类型 error 表示错误:
func errorReturn() (int, error) {
a := 2
return a, errors.New("error")
}
错误值构造与检视
error 类型
error 是 Go 的内置接口类型,定义如下:
// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
Error() string
}
任何实现了 Error() string 方法的类型,都可以作为错误值赋给 error 接口。其优势包括:
- 统一错误抽象:提升可读性,便于形成一致的错误处理策略。
- 错误作为值传播:可以进行
err == nil 判断;但是否能用==比较两个非 nil 错误,取决于其底层动态类型是否可比较(标准库常见错误值一般可比较,但并非所有自定义错误都可比较)。 - 易扩展:可通过自定义错误类型携带上下文信息(字段、方法等)。
构造错误
标准库提供了两种常用构造方式:
-
errors.New:创建一个仅包含字符串的错误 -
fmt.Errorf:创建带格式化信息的错误(并可用于错误包装)
func main() {
err := errors.New("error")
errorWithContext := fmt.Errorf("error with context: %s", "additional info")
fmt.Printf("%T\n", err) // *errors.errorString
fmt.Println(err) // error
fmt.Printf("%T\n", errorWithContext) // *errors.errorString(不使用 %w 时通常如此)
fmt.Println(errorWithContext) // error with context: additional info
}
两者在不使用 %w 的情况下,通常返回的都是 *errors.errorString:
// errorString is a trivial implementation of error.
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
当仅靠字符串不足以表达错误信息(例如需要错误码、发生时间、操作对象等)时,通常通过自定义错误类型扩展上下文:
type MyError struct {
When time.Time
What string
Context string
}
func (e *MyError) Error() string {
return fmt.Sprintf("%s at %s: %s", e.What, e.When.Format(time.RFC3339Nano), e.Context)
}
构造错误链(错误包装)
实际工程中,错误往往跨越多层调用传播。中间层在增加上下文信息时,不应丢弃原始错误,否则会显著增加定位难度。Go 1.13 起,推荐使用错误包装(wrapping)构建错误链。
func bottomFunction(needError bool) (bool, error) {
if needError {
return needError, errors.New("error from bottomFunction")
}
return needError, nil
}
func middleFunction(needError bool) (bool, error) {
result, err := bottomFunction(needError)
if err != nil {
return result, fmt.Errorf("middleFunction failed: %w", err)
}
return result, nil
}
错误链保存了每一层增加的上下文信息以及被包装的原始错误。标准库常用的组合方式有:
-
fmt.Errorf("...: %w", err):包装单个错误形成链 -
errors.Join(err1, err2, ...):将多个错误组合为一个(它不是“链式单根因”,而是“多错误聚合”)
下面示例通过 %w 构造单根因的错误链,并提取最底层根因:
package main
import (
"errors"
"fmt"
)
func rootCause(err error) error {
for err != nil {
u, ok := err.(interface{ Unwrap() error })
if !ok {
return err
}
err = u.Unwrap()
}
return nil
}
func main() {
err1 := errors.New("error1") // 根因
err2 := fmt.Errorf("error2: %w", err1)
err3 := fmt.Errorf("error3: %w", err2)
err := fmt.Errorf("error: %w", err3)
fmt.Println(err)
fmt.Println("root cause is", rootCause(err))
fmt.Println(rootCause(err) == err1) // true
}
fmt.Errorf 的 %w 只能包装一个错误。如果需要组合多个错误,应使用 errors.Join:
package main
import (
"errors"
"fmt"
)
func main() {
err1 := errors.New("error1")
err2 := errors.New("error2")
err3 := errors.New("error3")
err := errors.Join(err1, err2, err3)
fmt.Println(err)
}
errors.Join 返回的错误实现了 Unwrap() []error,用于表示“包含多个子错误”。
检视错误值
最常见、最可靠的判断是 err == nil。对于少数被定义为“哨兵错误”(sentinel error)的值(如 io.EOF),可以直接用 == 比较:
if err == io.EOF {
// 处理读到结尾
}
但对于“错误链/错误包装”,不要用 == 去判断链上是否包含某个错误;应使用 errors.Is:
package main
import (
"errors"
"fmt"
)
func main() {
err1 := errors.New("error1")
err2 := fmt.Errorf("error2: %w", err1)
err3 := fmt.Errorf("error3: %w", err2)
err := fmt.Errorf("error: %w", err3)
fmt.Println(errors.Is(err, err1)) // true
fmt.Println(errors.Is(err, err2)) // true
fmt.Println(errors.Is(err, err3)) // true
}
若要按类型匹配(尤其是自定义错误类型、或标准库特定类型错误),使用 errors.As:
package main
import (
"errors"
"fmt"
)
type MyError struct{ e string }
func (e *MyError) Error() string { return e.e }
func main() {
var base error = &MyError{"MyError demo"}
err1 := fmt.Errorf("wrap: %w", base)
err2 := fmt.Errorf("wrap again: %w", err1)
var target *MyError
if errors.As(err2, &target) {
fmt.Println("MyError is on the chain of err2")
fmt.Println(target == base) // true
return
}
fmt.Println("MyError is not on the chain of err2")
}
Go 1.13 及后续版本中,检视错误链时应优先使用
errors.Is / errors.As,而不是靠字符串匹配或手写类型断言遍历。
异常处理
在 Go 中,异常通过 panic 表达。
panic 简介
panic 表示程序运行期间出现了无法(或不打算)在当前层级继续处理的异常情况,主要来源包括:
- 运行时触发(如数组越界、空指针解引用等)
- 程序主动调用
panic(...)
当发生 panic 时(panicking 过程):
- 触发
panic 的函数立即停止执行,但该函数已注册的defer会按“后进先出”顺序执行。 - 执行完当前函数的
defer 后,控制权回到调用者;对调用者而言,相当于调用点发生了panic,继续展开调用栈并执行各层defer。 - 若整个 goroutine 的调用栈都展开完仍未恢复(recover),程序会打印堆栈并终止(默认会导致进程退出;即使是其他 goroutine 仍在运行,也会一起退出)。
示例:
package main
import "fmt"
func main() {
foo()
}
func foo() {
fmt.Println("foo")
bar()
fmt.Println("will not execute")
}
func bar() {
fmt.Println("bar")
panic("error")
// zoo() // 不会执行
}
func zoo() {
fmt.Println("zoo")
}
由于 defer 在 panic 时仍会执行,Go 提供了在 defer 中通过 recover() 捕获并恢复的机制:
func bar() {
defer func() {
if e := recover(); e != nil {
fmt.Println("recover:", e)
}
}()
fmt.Println("bar")
panic("error")
}
recover 只能在同一 goroutine 的 defer 函数中生效;在普通代码中调用 recover 不会捕获到正在展开的 panic。
应对 panic
使用 defer + recover 虽然可行,但滥用会增加复杂度。常见策略:
- 评估系统对崩溃的容忍度:命令行程序崩溃成本较低;服务端程序稳定性要求更高,通常在入口(如 HTTP middleware、goroutine worker 边界)统一
recover,避免单个请求/任务导致整个进程退出。 - 用 panic 暴露潜在 bug(断言/不变量) :当出现“理论上不可能发生”的分支时,用
panic让问题尽早暴露并携带堆栈,便于定位。 - 不要把 panic 当作常规错误返回机制:可预期的失败(文件不存在、超时、IO 错误、业务校验失败等)应返回
error 交给调用方处理。公共 API 更应避免向外泄露panic(除非文档明确说明,或提供MustXxx风格函数让调用方显式选择“失败即崩溃”)。
用
error的典型场景
- 外部依赖导致的失败:网络、磁盘、数据库、权限、超时、第三方接口返回错误
- 输入不合法/业务校验失败(用户/请求造成)
- 任何你希望调用方“有机会处理”的失败
- 库代码几乎都应返回
error,而不是panic(除非明确写在文档里)用
panic的典型场景
- 程序员错误:数组越界、
nil指针、必然成立的类型断言却失败、逻辑上不可能发生却发生- 内部不变量被破坏:例如约定“这里 map 一定有 key”,结果没有
- 启动/初始化阶段不可继续错误:如必须配置缺失、模板编译失败(也可返回
error 并在main 中log.Fatal)- 标准库风格的
Must 函数:如template.Must(...),调用者明确选择“失败就崩”