Files
notes/resource/go/Go 语言的错误与异常.md
T
2026-03-01 01:43:46 +08:00

12 KiB
Raw Blame History

一、核心概念:error vs. panic

在 Go 语言中,错误处理是一种设计哲学。它摒弃了其他语言中常见的 try-catch 异常机制,而是将 错误(error 作为一种普通的值来对待,鼓励开发者进行显式、清晰的处理。

特性 error (错误) panic (恐慌)
定义 可预期的、业务逻辑的一部分 不可预期的、程序级的严重问题
场景 文件不存在、网络超时、数据库连接失败、用户输入无效 数组越界、空指针引用、并发访问 map 时的竞态条件
处理方式 作为函数的多返回值之一,必须显式检查和处理 中断当前函数的执行,并沿着调用栈向上传播,除非被 recover 捕获,否则将导致程序崩溃
目的 保证程序的健壮性和稳定性 快速失败,暴露程序中严重的、不应存在的 Bug

核心思想:常规问题用 error,致命问题用 panic不要用 panic 来处理普通的错误

二、基本错误处理模式

Go 中最常见、最基础的错误处理模式是:函数在返回结果的同时,返回一个 error 类型的值。如果操作成功,errornil;如果失败,error 则包含具体的错误信息。

package main

import (
	"fmt"
	"os"
)

// 演示基本的错误处理流程
func main() {
	// 尝试打开一个可能不存在的文件
	file, err := os.Open("non-existent-file.txt")
	
	// 黄金法则:拿到 error 后,立即检查!
	if err != nil {
		// 根据场景选择不同的处理策略
		// 策略1:记录日志并优雅终止程序。这是最常见的做法。
		fmt.Printf("错误:打开文件失败,详情: %v\n", err)
		// 可以在此执行一些清理操作
		return

		// 策略2:如果错误不影响核心流程,可以只打印警告
		// fmt.Printf("警告:一个可选配置文件加载失败, %v\n", err)

		// 策略3:将错误包装后向上层调用者传递 (在非 main 函数中)
		// return fmt.Errorf("初始化模块失败: %w", err)
	}

	// 确保资源在函数退出前被释放,即使后续代码发生 panic
	defer file.Close()

	// --- 仅在 err == nil 时,才会执行到这里 ---
	fmt.Printf("文件 '%s' 打开成功。\n", file.Name())
	// ... 接下来可以安全地使用 file 对象进行读写操作 ...
}

三、创建与包装错误

Go 提供了灵活的方式来创建和组合错误,以便提供更丰富的上下文信息。

(一) errors.New:创建简单的静态错误

适用于创建不包含动态信息的、固定的错误信息。

// [补充知识点] 这种在包级别定义的错误变量,被称为“哨兵错误”(Sentinel Error)。
// 调用者可以使用 errors.Is() 来判断是否是这种特定错误。
var ErrInvalidPort = errors.New("端口号无效") 

(二) fmt.Errorf:创建格式化的动态错误

当错误信息需要包含变量或动态内容时,使用 fmt.Errorf

port := 80000
err := fmt.Errorf("端口 %d 超出有效范围 (1-65535)", port)

(三) fmt.Errorf%w:包装错误 (Wrapping)

这是现代 Go 错误处理的关键。使用 %w 动词可以将一个底层错误“包装”起来,形成一个错误链。这样做可以保留原始错误信息,便于上层代码进行检查和定位。

[代码示例规范化]

以下是一个综合了创建、包装和传递错误的完整示例:

package main

import (
	"errors"
	"fmt"
)

// 模拟数据库连接层
func connectDB(host string, port int) error {
	if host == "" {
		// 使用 errors.New 创建一个简单的错误
		return errors.New("数据库主机名不能为空")
	}
	if port <= 0 {
		// 使用 fmt.Errorf 创建包含动态信息的错误
		return fmt.Errorf("数据库端口 %d 无效", port)
	}
	fmt.Printf("正在尝试连接 %s:%d...\n", host, port)
	// 模拟连接失败
	return errors.New("网络超时")
}

// 模拟业务服务层
func startService() error {
	err := connectDB("localhost", 3306)
	if err != nil {
		// 使用 %w 包装底层错误,添加上层上下文信息
		// 这创建了一个错误链,保留了原始的“网络超时”错误
		return fmt.Errorf("启动服务失败: %w", err)
	}
	return nil
}

func main() {
	err := startService()
	if err != nil {
		// 输出的错误信息包含了从内到外的完整上下文
		fmt.Printf("程序启动异常: %v\n", err)
		// 输出: 程序启动异常: 启动服务失败: 网络超时
	}
}

四、检查与解包错误

错误包装后,我们需要有效的方法来检查错误链中的特定错误。Go 1.13 引入了 errors.Iserrors.As

  • errors.Is(err, target error) bool: 判断 err 的错误链中是否包含目标 target 错误。常用于检查是否为某个特定的哨兵错误(如 io.EOF)。
  • errors.As(err, target interface{}) bool: 判断 err 的错误链中是否存在某种类型的错误,如果存在,则将该错误赋值给 target。常用于处理自定义错误类型。

下面这个例子演示了如何对一个被层层包装的错误进行精确的检查。

package main

import (
	"errors"
	"fmt"
	"os"
)

// 自定义一个文件操作错误类型
type FileError struct {
	Path string // 文件路径
	Op   string // 操作类型
	Err  error  // 底层错误
}

func (e *FileError) Error() string {
	return fmt.Sprintf("文件操作失败 -> 路径: %s, 操作: %s, 原因: %v", e.Path, e.Op, e.Err)
}

// 这是底层错误,我们将使用 errors.Is 来检查它
var ErrPermissionDenied = errors.New("权限不足")

// 模拟一个深层次的读取操作
func readConfig() error {
	// 模拟因为权限问题导致失败
	return ErrPermissionDenied
}

// 包装操作
func loadConfig(path string) error {
	err := readConfig()
	if err != nil {
		// 将底层错误包装进我们的自定义错误类型中
		return &FileError{Path: path, Op: "加载配置", Err: err}
	}
	return nil
}

func main() {
	err := loadConfig("/etc/app.conf")

	if err != nil {
		fmt.Printf("发生错误: %v\n\n", err)

		// 使用 errors.Is() 检查错误链中是否包含“权限不足”这个哨兵错误
		if errors.Is(err, ErrPermissionDenied) {
			fmt.Println("检查结果: 这是一个权限问题。请检查文件权限。")
		} else {
			fmt.Println("检查结果: 不是权限问题。")
		}

		// 使用 errors.As() 尝试从错误链中提取 FileError 类型的信息
		var fileErr *FileError
		if errors.As(err, &fileErr) {
			fmt.Println("检查结果: 这是一个文件错误。")
			fmt.Printf("  - 操作路径: %s\n", fileErr.Path)
			fmt.Printf("  - 操作类型: %s\n", fileErr.Op)
		} else {
			// 如果错误链中没有 FileError 类型,os.PathError 也算是一种文件错误!
			// 这展示了 As 的灵活性,它可以检查任何实现了 error 接口的类型。
			var pathErr *os.PathError
			if errors.As(err, &pathErr) {
				fmt.Printf("检查结果: 这是一个标准的路径错误: %v\n", pathErr)
			}
		}
	}
}

五、自定义错误类型

通过定义自己的错误类型(实现 error 接口),我们可以携带更丰富的业务信息,如错误码、详细上下文等。

package main

import (
	"errors"
	"fmt"
)

// 定义一个包含业务代码和信息的错误结构体
type BusinessError struct {
	Code    int    // 业务错误码
	Message string // 用户可读的错误信息
}

// 实现 error 接口
func (e *BusinessError) Error() string {
	return fmt.Sprintf("业务异常 -> Code: %d, Message: %s", e.Code, e.Message)
}

// 模拟用户验证
func checkUserAge(age int) error {
	if age < 18 {
		// 返回自定义的错误类型实例
		return &BusinessError{
			Code:    4003,
			Message: "用户未成年",
		}
	}
	return nil
}

func main() {
	err := checkUserAge(16)

	if err != nil {
		fmt.Printf("原始错误: %v\n", err)

		var bizErr *BusinessError
		// 使用 errors.As 是检查和转换自定义错误类型的最佳方式
		if errors.As(err, &bizErr) {
			fmt.Println("检测到业务错误,可以进行特殊处理:")
			fmt.Printf("错误码: %d\n", bizErr.Code)
			fmt.Printf("信息: %s\n", bizErr.Message)
			// 例如,根据 bizErr.Code 返回不同的 HTTP 状态码
		} else {
			fmt.Println("检测到非业务类型的其他系统错误。")
		}
	}
}

六、panicrecover`

panic 用于表示程序无法继续运行的严重错误。recover 是一个内置函数,可以捕获并处理 panic,避免程序崩溃。

  • recover 只有在 defer 修饰的函数中调用时才有效。
  • recover 的主要应用场景是在一个 goroutine 的入口处,防止该 goroutine 的 panic 导致整个应用程序崩溃,特别是在网络服务器等需要长时间运行的程序中。
package main

import (
	"fmt"
)

// 模拟一个可能会发生严重错误的函数
func criticalOperation(data interface{}) {
	fmt.Println("开始执行关键操作...")
	if data == nil {
		// 遇到一个无法恢复的逻辑错误,比如一个必须存在的对象是 nil
		panic("关键数据为 nil,操作无法继续!")
	}
	// 将 data 转换为 string (如果 data 不是 string 类型,这里也会 panic)
	str := data.(string) 
	fmt.Printf("操作成功,数据: %s\n", str)
}

// 一个安全的执行器,它会捕获内部的 panic
func safeExecutor(f func(interface{}), data interface{}) {
	// defer 确保在函数退出前执行,即使发生 panic
	defer func() {
		// recover() 用于捕获 panic
		if r := recover(); r != nil {
			fmt.Printf("!!! 捕获到 Panic !!!\n")
			fmt.Printf("  - 恢复执行,避免程序崩溃。\n")
			fmt.Printf("  - Panic 信息: %v\n", r)
		}
	}()

	fmt.Println("--- 安全执行器启动 ---")
	f(data)
	fmt.Println("--- 安全执行器结束 ---")
}

func main() {
	// 案例1:触发 panic
	safeExecutor(criticalOperation, nil)

	fmt.Println("\n--------------------------\n")

	// 案例2:不触发 panic
	safeExecutor(criticalOperation, "正常数据")
	
	fmt.Println("\n程序已从 panic 中恢复,并继续执行到结束。")
}

七、错误处理最佳实践

  1. 绝不忽略错误:永远不要使用 _ 来丢弃一个 error 返回值,除非你百分之百确定调用不会失败。始终显式地检查它。
  2. 提供上下文信息:当错误向上传递时,使用 fmt.Errorf%w 进行包装,为错误添加上下文。原始错误: 连接超时 不如 上层错误: 无法连接数据库: 连接超时 有用。
  3. 只处理一次错误:一个错误应该在一个层级上被完整处理。避免在一个地方记录日志,然后又返回 error 让上层再次记录,这会导致日志重复和逻辑混乱。要么处理它(例如重试、记录并返回 nil),要么包装它并返回。
  4. 使用 errors.Iserrors.As:优先使用这两个函数来检查和解包错误,它们对错误链友好,比传统的类型断言或字符串比较更健壮。
  5. 为你的包定义哨兵错误和自定义错误类型:如果你的函数可能返回特定的、可被调用者区分的错误,请定义并导出它们。
  6. 使用 defer 确保资源释放:打开文件、建立网络连接、获取锁等操作后,立即使用 defer 编写资源释放语句,确保无论函数是正常返回还是因错误退出,资源都能被清理。