开篇: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 的优势:

  1. 每一行可能出错的地方都明明白白标出来了,没有隐藏的控制流
  2. 错误永远不会被意外吞掉(JS 里忘了 try/catch,错误就飘走了)
  3. 看函数签名就知道它会不会出错(返回值有 error 就会)

Go 的哲学是:「错误就是普通的值,必须显式处理」。理解这一点,你就从「抱怨啰嗦」转向「欣赏清晰」了。我们一步步看怎么做。


7.1 为什么 Go 不要 try/catch?

异常机制(throw/catch)有三个根本问题:

  1. 控制流隐式跳跃:异常可能从函数深处跳到几层之外的 catch,中间的代码可能没执行完。看代码你不知道哪里会「飞出去」。
  2. 错误容易被吞catch (Exception e) {} 一个宽泛捕获,所有错误都被吃掉,bug 就藏里面了。
  3. 性能开销:异常展开栈有运行时成本。

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 运行时:

  1. 停止当前函数的执行;
  2. 依次执行当前 goroutine 上的 defer 函数(后进先出);
  3. 如果 defer 中有 recover() 调用,并且它捕获了 panic,则 panic标记为已恢复,程序从该 defer 之后的代码继续执行(但当前函数已经返回,控制权会转移到调用方);
  4. 如果所有 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()
}

panicerror:什么时候用哪个?

这是 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 把错误变字符串,丢失包装关系。
修复:用 %wfmt.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 错误处理最佳实践

  1. 错误必须检查:忽略 err 是 bug 之源
  2. 错误只处理一次:要么 log+return,要么 return,不要重复
  3. 包装时加上下文fmt.Errorf("查询用户 %d: %w", id, err)
  4. 错误信息小写开头、无句号"user not found" 而非 "User not found."
  5. 用 %w 包装:便于 errors.Is/As 拆解
  6. 不要返回类型化 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 接口