12 KiB
12 KiB
一、核心概念:error vs. panic
在 Go 语言中,错误处理是一种设计哲学。它摒弃了其他语言中常见的 try-catch 异常机制,而是将 错误(error) 作为一种普通的值来对待,鼓励开发者进行显式、清晰的处理。
| 特性 | error (错误) |
panic (恐慌) |
|---|---|---|
| 定义 | 可预期的、业务逻辑的一部分 | 不可预期的、程序级的严重问题 |
| 场景 | 文件不存在、网络超时、数据库连接失败、用户输入无效 | 数组越界、空指针引用、并发访问 map 时的竞态条件 |
| 处理方式 | 作为函数的多返回值之一,必须显式检查和处理 | 中断当前函数的执行,并沿着调用栈向上传播,除非被 recover 捕获,否则将导致程序崩溃 |
| 目的 | 保证程序的健壮性和稳定性 | 快速失败,暴露程序中严重的、不应存在的 Bug |
核心思想:常规问题用 error,致命问题用 panic。不要用 panic 来处理普通的错误。
二、基本错误处理模式
Go 中最常见、最基础的错误处理模式是:函数在返回结果的同时,返回一个 error 类型的值。如果操作成功,error 为 nil;如果失败,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.Is 和 errors.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("检测到非业务类型的其他系统错误。")
}
}
}
六、panic与recover`
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 中恢复,并继续执行到结束。")
}
七、错误处理最佳实践
- 绝不忽略错误:永远不要使用
_来丢弃一个error返回值,除非你百分之百确定调用不会失败。始终显式地检查它。 - 提供上下文信息:当错误向上传递时,使用
fmt.Errorf和%w进行包装,为错误添加上下文。原始错误: 连接超时不如上层错误: 无法连接数据库: 连接超时有用。 - 只处理一次错误:一个错误应该在一个层级上被完整处理。避免在一个地方记录日志,然后又返回
error让上层再次记录,这会导致日志重复和逻辑混乱。要么处理它(例如重试、记录并返回nil),要么包装它并返回。 - 使用
errors.Is和errors.As:优先使用这两个函数来检查和解包错误,它们对错误链友好,比传统的类型断言或字符串比较更健壮。 - 为你的包定义哨兵错误和自定义错误类型:如果你的函数可能返回特定的、可被调用者区分的错误,请定义并导出它们。
- 使用
defer确保资源释放:打开文件、建立网络连接、获取锁等操作后,立即使用defer编写资源释放语句,确保无论函数是正常返回还是因错误退出,资源都能被清理。