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

328 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 一、核心概念:`error` vs. `panic`
在 Go 语言中,错误处理是一种设计哲学。它摒弃了其他语言中常见的 `try-catch` 异常机制,而是将 **错误(error** 作为一种普通的值来对待,鼓励开发者进行显式、清晰的处理。
| 特性 | `error` (错误) | `panic` (恐慌) |
| :------- | :------------------------ | :---------------------------------------------- |
| **定义** | 可预期的、业务逻辑的一部分 | 不可预期的、程序级的严重问题 |
| **场景** | 文件不存在、网络超时、数据库连接失败、用户输入无效 | 数组越界、空指针引用、并发访问 `map` 时的竞态条件 |
| **处理方式** | 作为函数的多返回值之一,必须显式检查和处理 | 中断当前函数的执行,并沿着调用栈向上传播,除非被 `recover` 捕获,否则将导致程序崩溃 |
| **目的** | 保证程序的健壮性和稳定性 | 快速失败,暴露程序中严重的、不应存在的 Bug |
**核心思想**:常规问题用 `error`,致命问题用 `panic`。**不要用 `panic` 来处理普通的错误**。
# 二、基本错误处理模式
Go 中最常见、最基础的错误处理模式是:函数在返回结果的同时,返回一个 `error` 类型的值。如果操作成功,`error``nil`;如果失败,`error` 则包含具体的错误信息。
```go
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`:创建简单的静态错误
适用于创建不包含动态信息的、固定的错误信息。
```go
// [补充知识点] 这种在包级别定义的错误变量,被称为“哨兵错误”(Sentinel Error)。
// 调用者可以使用 errors.Is() 来判断是否是这种特定错误。
var ErrInvalidPort = errors.New("端口号无效")
```
## (二) `fmt.Errorf`:创建格式化的动态错误
当错误信息需要包含变量或动态内容时,使用 `fmt.Errorf`
```go
port := 80000
err := fmt.Errorf("端口 %d 超出有效范围 (1-65535)", port)
```
## (三) `fmt.Errorf` 与 `%w`:包装错误 (Wrapping)
这是现代 Go 错误处理的关键。使用 `%w` 动词可以将一个底层错误“包装”起来,形成一个错误链。这样做可以保留原始错误信息,便于上层代码进行检查和定位。
**[代码示例规范化]**
以下是一个综合了创建、包装和传递错误的完整示例:
```go
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`。常用于处理自定义错误类型。
下面这个例子演示了如何对一个被层层包装的错误进行精确的检查。
```go
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` 接口),我们可以携带更丰富的业务信息,如错误码、详细上下文等。
```go
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` 导致整个应用程序崩溃,特别是在网络服务器等需要长时间运行的程序中。
```go
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.Is` 和 `errors.As`**:优先使用这两个函数来检查和解包错误,它们对错误链友好,比传统的类型断言或字符串比较更健壮。
5. **为你的包定义哨兵错误和自定义错误类型**:如果你的函数可能返回特定的、可被调用者区分的错误,请定义并导出它们。
6. **使用 `defer` 确保资源释放**:打开文件、建立网络连接、获取锁等操作后,立即使用 `defer` 编写资源释放语句,确保无论函数是正常返回还是因错误退出,资源都能被清理。