开篇:Go 的错误处理会让你很不适应
先打预防针:从 JS/PHP 转 Go,错误处理是第一个让你想骂街的地方。
你在 JS 里习惯了这样的优雅:
async function order(id) {
const user = await getUser(id) // 不检查错误,写起来多爽
const item = await getItem()
await sendEmail(user.email)
}
到了 Go,同样的事写成这样:
func order(id int) error {
user, err := getUser(id)
if err != nil {
return err
}
item, err := getItem()
if err != nil {
return err
}
err = sendEmail(user.Email)
if err != nil {
return err
}
return nil
}
你数数几个 if err != nil?四个。Go 代码里 if err != nil 的密度高得离谱,这是新人抱怨最多的点。
但是——别急着否定。用一段时间你会发现,这种「啰嗦」恰恰是 Go 的优势:
- 每一行可能出错的地方都明明白白标出来了,没有隐藏的控制流
- 错误永远不会被意外吞掉(JS 里忘了 try/catch,错误就飘走了)
- 看函数签名就知道它会不会出错(返回值有 error 就会)
Go 的哲学是:「错误就是普通的值,必须显式处理」。理解这一点,你就从「抱怨啰嗦」转向「欣赏清晰」了。我们一步步看怎么做。
7.1 为什么 Go 不要 try/catch?
异常机制(throw/catch)有三个根本问题:
- 控制流隐式跳跃:异常可能从函数深处跳到几层之外的 catch,中间的代码可能没执行完。看代码你不知道哪里会「飞出去」。
- 错误容易被吞:
catch (Exception e) {}一个宽泛捕获,所有错误都被吃掉,bug 就藏里面了。 - 性能开销:异常展开栈有运行时成本。
Go 把错误变成普通返回值,彻底解决:
func getUser(id int) (*User, error) {
user := db.Find(id)
if user == nil {
return nil, errors.New("用户不存在") // 返回错误,不抛
}
return user, nil
}
u, err := getUser(1)
if err != nil {
fmt.Println(err)
return
}
// 用 u ...
没有隐藏的控制流——函数返回前你必须显式处理 err,编译器不会让你「假装没看见」。
7.2 error 类型
Go 的 error 是一个内置接口(还记得第 06 章吗?):
type error interface {
Error() string
}
任何实现了 Error() string 方法的类型都是 error。最常用的创建方式:
import "errors"
// 1. errors.New(简单文本)
err := errors.New("用户不存在")
// 2. fmt.Errorf(最常用,支持格式化)
err := fmt.Errorf("用户 %d 不存在", id)
7.3 标准模式:(result, error) 双返回值
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("除数不能为零")
}
return a / b, nil
}
约定:成功返回 (值, nil),失败返回 (零值, error)。
错误传递的可视化——看一个错误是怎么从底层一层层冒上来的:
调用链:main → order → getUser → db.Query
db.Query 出错:return nil, ErrTimeout
↓
getUser 收到错误,包装:return nil, fmt.Errorf("getUser(%d): %w", id, ErrTimeout)
↓
order 收到错误,包装:return fmt.Errorf("下单失败: %w", err)
↓
main 收到错误,打印:fmt.Println(err)
↓
输出:"下单失败: getUser(5): ErrTimeout"
每一层加上自己的上下文,最终的错误信息清晰可追踪。这就是 fmt.Errorf("...: %w", err) 的作用(7.4 详讲)。
7.4 错误包装(Wrap)—— Go 1.13+ 核心
错误向上传递时,每一层可以包装上下文信息:
err := fmt.Errorf("查询用户失败: %w", originalErr)
// ↑
// %w 是包装占位符(不是 %s!)
⚠️ %w vs %s 的区别(新手必踩):
%s:把错误当字符串拼接,丢失包装关系,没法用errors.Is拆解%w:保留包装关系,能一层层拆开
拆解包装错误
var ErrNotFound = errors.New("记录不存在")
func getUser(id int) (*User, error) {
return nil, fmt.Errorf("getUser(%d): %w", id, ErrNotFound)
}
func main() {
_, err := getUser(999)
// errors.Is:检查错误链里是否包含某个特定错误
if errors.Is(err, ErrNotFound) {
fmt.Println("是「记录不存在」错误") // ✅ 即使被包装了也能识别
}
// errors.As:从错误链里提取自定义错误类型
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("字段错误:", ve.Field)
}
}
import "errors"
// 定义一个错误实例
var ErrInsufficientBalance = errors.New("余额不足")
func Withdraw(balance, amount int) error {
if amount > balance {
return ErrInsufficientBalance
}
return nil
}
func main() {
err := Withdraw(100, 200)
// 判断是不是某个实例
if errors.Is(err, ErrInsufficientBalance) {
// 程序根据"这是哪种错误"走不同分支,
// 比如提示用户充值,而不是直接崩溃
fmt.Println("请充值")
}
}
对于处理不需要有额外的数据的一些固定错误的时候,使用 errors.New("xxx") 和 errors.Is(err, ErrXxx) 就能处理,但当错误需要带数据时,要升级成"自定义错误类型 + errors.As"。
7.5 自定义错误类型
当内置 error 不够用,定义带结构化信息的错误类型:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("字段 %s 校验失败: %s", e.Field, e.Message)
}
func validateEmail(email string) error {
if !strings.Contains(email, "@") {
return &ValidationError{Field: "email", Message: "缺少 @"}
}
return nil
}
// 调用方用 errors.As 提取详细信息
err := validateEmail("bad")
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("字段 %s 出错:%s\n", ve.Field, ve.Message)
}
用途:携带字段名、错误码、HTTP 状态等结构化信息,比纯字符串灵活。我个人觉得Go的错误处理丑陋至极。
7.6 defer —— 资源清理利器
defer 让函数调用在函数返回时执行(无论正常 return、错误 return、panic)。常用于资源清理(关闭文件、解锁、关闭连接等)。
示例1:
func main() {
fmt.Println("开始")
defer fmt.Println("结束") // 会在 main 函数返回前执行
fmt.Println("中间")
}
// 输出:
// 开始
// 中间
// 结束
示例2:
func readFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close() // ← 紧跟资源获取后写,保证一定关闭
data, err := io.ReadAll(file)
if err != nil {
return err // 这里 return 也会触发 defer
}
return process(data)
}
defer 的核心价值:紧跟在资源获取后写清理代码,不会忘记关闭。
多个 defer:LIFO 后进先出
多个 defer 按照栈的顺序执行,最后声明的最先执行:
func f() {
defer fmt.Println("1") // 最后执行
defer fmt.Println("2")
defer fmt.Println("3") // 最先执行
fmt.Println("正常")
}
// 输出:正常 3 2 1
参数在 defer 语句执行时就被求值
defer 后面函数的参数会立刻计算好,但函数体本身延迟执行:
func main() {
i := 0
defer fmt.Println("i 的值是:", i) // 这里的 i 立即求值为 0
i = 10
}
// 输出:i 的值是: 0
7.7 panic / recover —— Go 的「异常」(但不推荐常用)
Go 有 panic / recover 机制,但不用于常规错误处理。
当 panic 被调用时,Go 运行时:
- 停止当前函数的执行;
- 依次执行当前
goroutine上的 defer 函数(后进先出); - 如果 defer 中有
recover()调用,并且它捕获了panic,则panic被标记为已恢复,程序从该defer之后的代码继续执行(但当前函数已经返回,控制权会转移到调用方); - 如果所有
defer执行完毕仍未被recover,则程序崩溃并打印堆栈信息。
重要注意:
-
recover只能在 defer 函数中直接调用,如果将其包装在其他函数中,则无法捕获 panic。 -
recover只捕获当前 goroutine 的 panic,跨 goroutine 的 panic 无法互相捕获。 -
recover后不处理,程序可能以不一致状态继续recover只是让程序不崩溃,但当前函数已经异常返回,其局部资源可能未被正确清理(defer 已经执行,但逻辑可能中断)。因此,recover 后应尽量记录日志、返回错误,并考虑是否需要终止当前请求或任务。
func dangerous() {
panic("出大事了") // 像 throw
}
func safeCall() {
defer func() {
if r := recover(); r != nil {
fmt.Println("捕获 panic:", r)
}
}()
dangerous()
}
panic 与 error:什么时候用哪个?
这是 Go 社区最明确的指导原则:
| 场景 | 使用方式 |
|---|---|
| 用户输入错误、文件不存在、网络超时、业务校验失败等可预见的错误 | 返回 error |
数组越界、空指针解引用、类型断言失败(v.(type) 的 ok 模式除外) |
自动触发 panic |
初始化失败(如 init() 中无法满足依赖) |
手动 panic |
出现逻辑上不可能的情况(如 switch 的 default 分支本不该到达) |
panic("unreachable") |
| 程序状态被破坏,继续运行毫无意义 | panic |
核心原则:
用
error处理业务预期的错误,用panic处理程序 bug 或无法恢复的系统级故障。
7.8 报错诊断小节
问题 1:忘了检查 err(最常见的 bug)
result := divide(10, 0) // ← 少接了一个 err
风险:err 被吞掉,出错了也不知道。
修复:必须接 err 并检查:result, err := divide(10, 0); if err != nil { ... }。
用 lint 工具(staticcheck / errcheck)能自动发现。
问题 2:用 %s 包装错误,导致 errors.Is 失效
err := fmt.Errorf("失败: %s", ErrNotFound)
errors.Is(err, ErrNotFound) // false!
原因:%s 把错误变字符串,丢失包装关系。
修复:用 %w:fmt.Errorf("失败: %w", ErrNotFound)。
问题 3:函数返回了 nil,但调用方 == nil 判错
func f() error {
var e *MyError = nil
return e // 返回类型化的 nil
}
err := f()
err == nil // false!(第 06 章的接口 nil 陷阱)
修复:要么返回具体 error 值,要么显式 return nil,不要返回类型化的 nil。
问题 4:在循环里 defer,资源积压
for _, path := range paths {
f, _ := os.Open(path)
defer f.Close() // ← defer 在函数结束才执行,循环里会积压大量未关闭的文件
}
修复:把循环体抽成函数,让 defer 在每次迭代结束时执行。
7.9 错误处理最佳实践
- 错误必须检查:忽略 err 是 bug 之源
- 错误只处理一次:要么 log+return,要么 return,不要重复
- 包装时加上下文:
fmt.Errorf("查询用户 %d: %w", id, err) - 错误信息小写开头、无句号:
"user not found"而非"User not found." - 用 %w 包装:便于
errors.Is/As拆解 - 不要返回类型化 nil
Node.js / PHP ↔ Go 对照表
| 概念 | Node.js / PHP | Go | 备注 |
|---|---|---|---|
| 错误表示 | Error 对象 / Exception | error 接口(返回值) | Go 无异常 |
| 抛出错误 | throw |
return err |
Go 显式返回 |
| 捕获错误 | try/catch | if err != nil |
Go 每步都查 |
| 异步错误 | Promise.catch | 同步 return | Go 错误就是值 |
| 错误包装 | 包装 Error cause | fmt.Errorf("%w", err) |
Go 1.13+ |
| 类型检查 | instanceof |
errors.As |
|
| 错误比较 | === | errors.Is |
支持包装链 |
| 资源清理 | try/finally | defer |
defer 更优雅 |
| 致命错误 | throw 不捕获 | panic |
Go panic 是崩溃 |
| 自定义错误 | extends Error | 实现 error 接口 |