大家好,我是极客老墨。
写代码时,错误处理往往占了一半的工作量。文件打不开、网络连不上、数据格式不对,这些都是常态。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 个关键点:
- 错误是值:error 是接口,通过返回值传递,必须显式检查
- 卫语句风格:先处理错误,正常逻辑保持在最左侧,避免嵌套
- 错误包装:使用
%w包装错误,用errors.Is和errors.As检查 - 自定义错误:可以携带更多信息,方便调用方处理
- 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 的错误处理。
要求:
- 实现
Try和Catch函数 - 支持多个 Catch 处理不同类型的错误
- 支持 Finally 清理资源
练习题 5:错误包装(⭐⭐⭐)
实现一个错误包装函数,自动添加时间戳和调用栈信息。
要求:
- 使用
runtime.Caller获取调用栈 - 添加时间戳
- 保留原始错误信息
练习题 6:HTTP 错误处理(⭐⭐⭐)
创建一个 HTTP 请求函数,根据不同的状态码返回不同的哨兵错误(如 ErrNotFound、ErrUnauthorized)。
要求:
- 定义常见的 HTTP 错误
- 根据状态码返回对应错误
- 支持错误重试
思考题
- 为什么 Go 选择返回值而不是异常? 这种设计有什么优势和劣势?
- 什么时候应该使用 panic,什么时候用 error? 有没有明确的判断标准?
- 错误包装会不会导致性能问题? 在高并发场景下如何优化?
- 如何设计一个好的错误体系? 哨兵错误、自定义错误、错误码如何选择?
快来评论区秀出你的想法,大家一起讨论!
你在项目中是怎么处理错误的?有没有踩过坑?欢迎评论区聊聊。
极客老墨,继续折腾!
如果有任何问题,欢迎在评论区留言或关注公众号「极客老墨」交流。
完整示例代码在 go-tutorial-code/09-error-handling。