大家好,我是极客老墨。

写代码时,错误处理往往占了一半的工作量。文件打不开、网络连不上、数据格式不对,这些都是常态。Go 的错误处理很直接:错误就是一个返回值,你必须显式检查它。

这篇就聊聊 Go 的错误处理机制,看看它是怎么让代码更健壮的。

错误是值

Go 的核心理念:错误是值(Errors are values),不是异常。

error 接口

Go 内置的 error 是一个接口,只有一个方法:

1type error interface {
2    Error() string
3}

任何实现了 Error() string 方法的类型都是 error。

创建错误

 1import "errors"
 2
 3// 方式 1:使用 errors.New
 4err1 := errors.New("something went wrong")
 5
 6// 方式 2:使用 fmt.Errorf(支持格式化)
 7err2 := fmt.Errorf("failed to open file: %s", filename)
 8
 9// 方式 3:自定义错误类型
10type MyError struct {
11    Code int
12    Msg  string
13}
14
15func (e MyError) Error() string {
16    return fmt.Sprintf("error %d: %s", e.Code, e.Msg)
17}

要点

  • errors.New 用于简单错误消息
  • fmt.Errorf 用于格式化错误消息
  • 自定义错误类型可以携带更多信息

基本错误处理

Go 的函数通常返回两个值:结果和错误。

标准模式

 1func divide(a, b int) (int, error) {
 2    if b == 0 {
 3        return 0, errors.New("cannot divide by zero")
 4    }
 5    return a / b, nil
 6}
 7
 8// 使用
 9result, err := divide(10, 0)
10if err != nil {
11    fmt.Println("Error:", err)
12    return
13}
14fmt.Println("Result:", result)

要点

  • 错误作为最后一个返回值
  • 如果没有错误,返回 nil
  • 调用方必须检查 err != nil

卫语句风格

Go 推荐使用卫语句(Guard Clause)处理错误。

 1func processFile(filename string) error {
 2    // 先处理错误情况
 3    file, err := os.Open(filename)
 4    if err != nil {
 5        return err
 6    }
 7    defer file.Close()
 8
 9    data, err := io.ReadAll(file)
10    if err != nil {
11        return err
12    }
13
14    // 正常逻辑保持在最左侧
15    fmt.Println(string(data))
16    return nil
17}

好处

  • 正常逻辑不嵌套,保持在最左侧
  • 错误处理清晰,容易阅读
  • 避免了深层嵌套的 if-else

错误包装

Go 1.13+ 支持错误包装,可以添加上下文信息。

使用 %w 包装错误

 1func readConfig(filename string) error {
 2    file, err := os.Open(filename)
 3    if err != nil {
 4        // 使用 %w 包装原始错误
 5        return fmt.Errorf("failed to read config: %w", err)
 6    }
 7    defer file.Close()
 8    // ...
 9    return nil
10}

检查错误类型

 1import "errors"
 2
 3// 检查错误是否是特定类型
 4if errors.Is(err, os.ErrNotExist) {
 5    fmt.Println("File does not exist")
 6}
 7
 8// 提取特定类型的错误
 9var pathErr *os.PathError
10if errors.As(err, &pathErr) {
11    fmt.Println("Failed path:", pathErr.Path)
12}

要点

  • %w 用于包装错误,保留原始错误信息
  • errors.Is 检查错误链中是否包含特定错误
  • errors.As 提取错误链中的特定类型

自定义错误类型

自定义错误类型可以携带更多信息。

定义错误类型

 1// 定义错误类型
 2type ValidationError struct {
 3    Field string
 4    Value interface{}
 5    Msg   string
 6}
 7
 8// 实现 error 接口
 9func (e ValidationError) Error() string {
10    return fmt.Sprintf("validation failed on field '%s': %s (value: %v)",
11        e.Field, e.Msg, e.Value)
12}
13
14// 使用
15func validateAge(age int) error {
16    if age < 0 || age > 150 {
17        return ValidationError{
18            Field: "age",
19            Value: age,
20            Msg:   "age must be between 0 and 150",
21        }
22    }
23    return nil
24}

检查自定义错误

1err := validateAge(200)
2if err != nil {
3    // 类型断言
4    if ve, ok := err.(ValidationError); ok {
5        fmt.Printf("Field: %s, Value: %v\n", ve.Field, ve.Value)
6    }
7}

Panic 和 Recover

Panic 用于不可恢复的错误,Recover 用于捕获 panic。

Panic 的使用

1func mustOpen(filename string) *os.File {
2    file, err := os.Open(filename)
3    if err != nil {
4        // 无法继续执行,触发 panic
5        panic(err)
6    }
7    return file
8}

什么时候用 panic

  • 程序初始化失败(如配置文件缺失)
  • 不可能发生的情况(如数组越界)
  • 程序无法继续执行

什么时候不用 panic

  • 正常的错误处理(用 error)
  • 可以恢复的错误
  • 库代码(应该返回 error,让调用方决定)

Recover 捕获 Panic

Recover 只能在 defer 函数中使用。

 1func safeCall(fn func()) {
 2    defer func() {
 3        if r := recover(); r != nil {
 4            fmt.Println("Recovered from panic:", r)
 5        }
 6    }()
 7
 8    fn() // 执行可能 panic 的函数
 9}
10
11// 使用
12safeCall(func() {
13    panic("something went wrong")
14})
15fmt.Println("Program continues")

要点

  • recover() 只在 defer 函数中有效
  • 如果没有 panic,recover() 返回 nil
  • 捕获 panic 后,程序可以继续执行

Panic 的传播

Panic 会沿着调用栈向上传播,直到被 recover 或程序崩溃。

 1func level3() {
 2    panic("error at level 3")
 3}
 4
 5func level2() {
 6    level3()
 7}
 8
 9func level1() {
10    defer func() {
11        if r := recover(); r != nil {
12            fmt.Println("Caught at level 1:", r)
13        }
14    }()
15    level2()
16}
17
18level1() // 输出: Caught at level 1: error at level 3

错误处理最佳实践

1. 不要忽略错误

1// 不好:忽略错误
2file, _ := os.Open("config.json")
3
4// 好:处理错误
5file, err := os.Open("config.json")
6if err != nil {
7    return err
8}
9defer file.Close()

2. 添加上下文信息

1// 不好:直接返回原始错误
2return err
3
4// 好:添加上下文
5return fmt.Errorf("failed to process user %d: %w", userID, err)

3. 在合适的层级处理错误

 1// 底层:返回错误
 2func readFile(path string) ([]byte, error) {
 3    return os.ReadFile(path)
 4}
 5
 6// 中层:包装错误
 7func loadConfig(path string) (*Config, error) {
 8    data, err := readFile(path)
 9    if err != nil {
10        return nil, fmt.Errorf("load config: %w", err)
11    }
12    // ...
13}
14
15// 顶层:处理错误(日志、重试、返回给用户)
16func main() {
17    cfg, err := loadConfig("config.json")
18    if err != nil {
19        log.Fatal(err)
20    }
21    // ...
22}

4. 使用哨兵错误

定义可导出的错误变量,方便调用方判断。

 1var (
 2    ErrNotFound    = errors.New("not found")
 3    ErrUnauthorized = errors.New("unauthorized")
 4    ErrInvalidInput = errors.New("invalid input")
 5)
 6
 7func getUser(id int) (*User, error) {
 8    if id < 0 {
 9        return nil, ErrInvalidInput
10    }
11    // ...
12    return nil, ErrNotFound
13}
14
15// 调用方可以判断具体错误
16user, err := getUser(-1)
17if errors.Is(err, ErrInvalidInput) {
18    fmt.Println("Invalid user ID")
19}

完整示例

把前面的知识点串起来,看个完整的例子:

 1package main
 2
 3import (
 4    "errors"
 5    "fmt"
 6    "os"
 7)
 8
 9// 自定义错误类型
10type FileError struct {
11    Path string
12    Op   string
13    Err  error
14}
15
16func (e *FileError) Error() string {
17    return fmt.Sprintf("%s %s: %v", e.Op, e.Path, e.Err)
18}
19
20// 哨兵错误
21var ErrEmptyFile = errors.New("file is empty")
22
23// 读取文件
24func readFile(path string) ([]byte, error) {
25    data, err := os.ReadFile(path)
26    if err != nil {
27        return nil, &FileError{
28            Path: path,
29            Op:   "read",
30            Err:  err,
31        }
32    }
33
34    if len(data) == 0 {
35        return nil, ErrEmptyFile
36    }
37
38    return data, nil
39}
40
41// 安全执行
42func safeExecute(fn func() error) {
43    defer func() {
44        if r := recover(); r != nil {
45            fmt.Printf("Recovered from panic: %v\n", r)
46        }
47    }()
48
49    if err := fn(); err != nil {
50        fmt.Printf("Error: %v\n", err)
51    }
52}
53
54func main() {
55    // 示例 1:正常错误处理
56    safeExecute(func() error {
57        data, err := readFile("config.json")
58        if err != nil {
59            // 检查特定错误
60            if errors.Is(err, ErrEmptyFile) {
61                return fmt.Errorf("config file is empty")
62            }
63
64            // 检查错误类型
65            var fileErr *FileError
66            if errors.As(err, &fileErr) {
67                return fmt.Errorf("failed to access %s: %w", fileErr.Path, err)
68            }
69
70            return err
71        }
72
73        fmt.Printf("Read %d bytes\n", len(data))
74        return nil
75    })
76
77    // 示例 2:捕获 panic
78    safeExecute(func() error {
79        panic("unexpected error")
80    })
81
82    fmt.Println("Program continues")
83}

这个例子展示了:

  • 自定义错误类型
  • 哨兵错误
  • 错误包装和检查
  • Panic 和 Recover
  • 错误处理的最佳实践

老墨踩过的坑

坑 1:忘记检查 defer 中的错误

老墨刚开始写 Go 时,经常这样写:

 1func processFile(filename string) error {
 2    file, err := os.Open(filename)
 3    if err != nil {
 4        return err
 5    }
 6    defer file.Close()  // ❌ 忽略了 Close 的错误
 7
 8    // 处理文件...
 9    return nil
10}

问题是 file.Close() 也可能返回错误(比如磁盘满了,写缓冲失败),但被忽略了。

正确做法

 1func processFile(filename string) (err error) {
 2    file, err := os.Open(filename)
 3    if err != nil {
 4        return err
 5    }
 6    defer func() {
 7        closeErr := file.Close()
 8        if closeErr != nil && err == nil {
 9            err = closeErr  // 如果没有其他错误,返回 Close 错误
10        }
11    }()
12
13    // 处理文件...
14    return nil
15}

坑 2:错误包装后丢失原始错误

 1// ❌ 错误:使用 %v 而不是 %w
 2func readConfig() error {
 3    data, err := os.ReadFile("config.json")
 4    if err != nil {
 5        return fmt.Errorf("read config failed: %v", err)  // 丢失了原始错误
 6    }
 7    return nil
 8}
 9
10// 调用方无法判断具体错误
11err := readConfig()
12if errors.Is(err, os.ErrNotExist) {  // false!因为原始错误被丢失了
13    fmt.Println("Config not found")
14}

正确做法

 1// ✅ 正确:使用 %w 包装错误
 2func readConfig() error {
 3    data, err := os.ReadFile("config.json")
 4    if err != nil {
 5        return fmt.Errorf("read config failed: %w", err)  // 保留原始错误
 6    }
 7    return nil
 8}
 9
10// 调用方可以判断
11err := readConfig()
12if errors.Is(err, os.ErrNotExist) {  // true!
13    fmt.Println("Config not found")
14}

坑 3:在 goroutine 中 panic 导致程序崩溃

1func main() {
2    go func() {
3        panic("goroutine panic")  // ❌ 整个程序崩溃
4    }()
5
6    time.Sleep(time.Second)
7    fmt.Println("This won't print")
8}

教训:goroutine 中的 panic 无法被外部 recover,必须在 goroutine 内部处理。

正确做法

 1func safeGo(fn func()) {
 2    go func() {
 3        defer func() {
 4            if r := recover(); r != nil {
 5                fmt.Printf("Recovered in goroutine: %v\n", r)
 6            }
 7        }()
 8        fn()
 9    }()
10}
11
12func main() {
13    safeGo(func() {
14        panic("goroutine panic")  // ✅ 被捕获
15    })
16
17    time.Sleep(time.Second)
18    fmt.Println("Program continues")
19}

坑 4:错误判断的顺序问题

 1func divide(a, b int) (int, error) {
 2    if b == 0 {
 3        return 0, errors.New("division by zero")
 4    }
 5    return a / b, nil
 6}
 7
 8// ❌ 错误:先使用结果,再检查错误
 9result, err := divide(10, 0)
10fmt.Println(result)  // 使用了无效的结果
11if err != nil {
12    return err
13}

正确做法

1// ✅ 正确:先检查错误,再使用结果
2result, err := divide(10, 0)
3if err != nil {
4    return err
5}
6fmt.Println(result)  // 只在没有错误时使用结果

实战建议

1. 错误处理的层次结构

 1// 底层:返回简单错误
 2func readFile(path string) ([]byte, error) {
 3    return os.ReadFile(path)
 4}
 5
 6// 中层:添加业务上下文
 7func loadUserConfig(userID int) (*Config, error) {
 8    path := fmt.Sprintf("/configs/user_%d.json", userID)
 9    data, err := readFile(path)
10    if err != nil {
11        return nil, fmt.Errorf("load config for user %d: %w", userID, err)
12    }
13
14    var cfg Config
15    if err := json.Unmarshal(data, &cfg); err != nil {
16        return nil, fmt.Errorf("parse config for user %d: %w", userID, err)
17    }
18
19    return &cfg, nil
20}
21
22// 顶层:决定如何处理错误(日志、重试、返回给用户)
23func handleRequest(w http.ResponseWriter, r *http.Request) {
24    userID := getUserID(r)
25    cfg, err := loadUserConfig(userID)
26    if err != nil {
27        // 根据错误类型决定响应
28        if errors.Is(err, os.ErrNotExist) {
29            http.Error(w, "Config not found", http.StatusNotFound)
30        } else {
31            log.Printf("Error loading config: %v", err)
32            http.Error(w, "Internal error", http.StatusInternalServerError)
33        }
34        return
35    }
36
37    // 使用配置...
38}

2. 使用错误变量而不是字符串比较

 1// ❌ 不好:字符串比较
 2if err.Error() == "file not found" {
 3    // 脆弱,容易出错
 4}
 5
 6// ✅ 好:使用哨兵错误
 7var ErrNotFound = errors.New("not found")
 8
 9if errors.Is(err, ErrNotFound) {
10    // 类型安全,不会拼写错误
11}

3. 错误日志的最佳实践

 1// ❌ 不好:重复记录错误
 2func processData() error {
 3    data, err := fetchData()
 4    if err != nil {
 5        log.Printf("Error: %v", err)  // 记录一次
 6        return err
 7    }
 8    return nil
 9}
10
11func main() {
12    if err := processData(); err != nil {
13        log.Printf("Error: %v", err)  // 又记录一次,重复了
14    }
15}
16
17// ✅ 好:只在顶层记录
18func processData() error {
19    data, err := fetchData()
20    if err != nil {
21        return fmt.Errorf("process data: %w", err)  // 只包装,不记录
22    }
23    return nil
24}
25
26func main() {
27    if err := processData(); err != nil {
28        log.Printf("Error: %v", err)  // 只在这里记录一次
29    }
30}

4. 实现重试机制

 1func retry(attempts int, sleep time.Duration, fn func() error) error {
 2    var err error
 3    for i := 0; i < attempts; i++ {
 4        if i > 0 {
 5            time.Sleep(sleep)
 6            sleep *= 2  // 指数退避
 7        }
 8
 9        err = fn()
10        if err == nil {
11            return nil
12        }
13
14        log.Printf("Attempt %d failed: %v", i+1, err)
15    }
16
17    return fmt.Errorf("after %d attempts, last error: %w", attempts, err)
18}
19
20// 使用
21err := retry(3, time.Second, func() error {
22    return makeHTTPRequest()
23})

5. 错误聚合

处理多个错误时,可以聚合它们:

 1type MultiError struct {
 2    Errors []error
 3}
 4
 5func (m *MultiError) Error() string {
 6    var msgs []string
 7    for _, err := range m.Errors {
 8        msgs = append(msgs, err.Error())
 9    }
10    return strings.Join(msgs, "; ")
11}
12
13func (m *MultiError) Add(err error) {
14    if err != nil {
15        m.Errors = append(m.Errors, err)
16    }
17}
18
19func (m *MultiError) HasErrors() bool {
20    return len(m.Errors) > 0
21}
22
23// 使用
24func validateUser(user *User) error {
25    var errs MultiError
26
27    if user.Name == "" {
28        errs.Add(errors.New("name is required"))
29    }
30    if user.Age < 0 {
31        errs.Add(errors.New("age must be positive"))
32    }
33    if user.Email == "" {
34        errs.Add(errors.New("email is required"))
35    }
36
37    if errs.HasErrors() {
38        return &errs
39    }
40    return nil
41}

老墨总结

Go 错误处理的 5 个关键点:

  1. 错误是值:error 是接口,通过返回值传递,必须显式检查
  2. 卫语句风格:先处理错误,正常逻辑保持在最左侧,避免嵌套
  3. 错误包装:使用 %w 包装错误,用 errors.Iserrors.As 检查
  4. 自定义错误:可以携带更多信息,方便调用方处理
  5. Panic/Recover:只用于不可恢复的错误,Recover 只在 defer 中有效

实战建议

  • 不要忽略错误,即使是 defer file.Close()
  • 使用 %w 包装错误,不要用 %v
  • 在 goroutine 内部处理 panic
  • 先检查错误,再使用结果
  • 只在顶层记录日志,避免重复
  • 使用哨兵错误而不是字符串比较

练习题

练习题 1:文件操作错误处理(⭐)

编写一个函数打开文件,如果文件不存在返回自定义错误,包含文件路径和错误原因。

要求:

  • 定义 FileError 类型
  • 实现 Error() 方法
  • 正确处理 defer file.Close() 的错误

练习题 2:重试机制(⭐⭐)

实现一个 Retry 函数,接收一个可能失败的函数,最多重试 3 次,每次失败记录错误。

要求:

  • 支持指数退避
  • 记录每次重试的错误
  • 返回最后一次的错误

练习题 3:验证错误(⭐⭐)

定义一个 ValidationError 类型,包含多个字段错误,实现 error 接口。

要求:

  • 支持添加多个字段错误
  • Error() 方法返回所有错误的汇总
  • 实现 HasErrors() 方法

练习题 4:Panic 和 Recover(⭐⭐⭐)

编写一个函数,使用 panic 和 recover 实现类似 try-catch 的错误处理。

要求:

  • 实现 TryCatch 函数
  • 支持多个 Catch 处理不同类型的错误
  • 支持 Finally 清理资源

练习题 5:错误包装(⭐⭐⭐)

实现一个错误包装函数,自动添加时间戳和调用栈信息。

要求:

  • 使用 runtime.Caller 获取调用栈
  • 添加时间戳
  • 保留原始错误信息

练习题 6:HTTP 错误处理(⭐⭐⭐)

创建一个 HTTP 请求函数,根据不同的状态码返回不同的哨兵错误(如 ErrNotFoundErrUnauthorized)。

要求:

  • 定义常见的 HTTP 错误
  • 根据状态码返回对应错误
  • 支持错误重试

思考题

  1. 为什么 Go 选择返回值而不是异常? 这种设计有什么优势和劣势?
  2. 什么时候应该使用 panic,什么时候用 error? 有没有明确的判断标准?
  3. 错误包装会不会导致性能问题? 在高并发场景下如何优化?
  4. 如何设计一个好的错误体系? 哨兵错误、自定义错误、错误码如何选择?

快来评论区秀出你的想法,大家一起讨论!


你在项目中是怎么处理错误的?有没有踩过坑?欢迎评论区聊聊。

极客老墨,继续折腾!

如果有任何问题,欢迎在评论区留言或关注公众号「极客老墨」交流。

完整示例代码在 go-tutorial-code/09-error-handling


相关阅读