第87课:错误处理最佳实践
学习目标
- 掌握Go中错误处理的几种常见模式及其适用场景
- 理解哨兵错误(Sentinel Errors)和自定义错误类型的区别与应用
- 学会使用
fmt.Errorf配合%w动词进行错误包装与解包 - 掌握在并发环境下安全、清晰的错误处理策略
核心概念
在Go中,错误处理不是通过异常(exception),而是通过函数返回值显式进行。这种设计鼓励开发者在调用出错的地方附近立即处理错误,使代码逻辑更清晰。
1. 哨兵错误(Sentinel Errors)
哨兵错误是包级导出的错误变量,调用方通过 == 或 errors.Is 进行比较。
var ErrUserNotFound = errors.New("user not found")
func GetUser(id int) (*User, error) {
// ...数据库查询
if user == nil {
return nil, ErrUserNotFound // 预定义错误
}
return user, nil
}
2. 自定义错误类型
当需要携带额外上下文信息时,可定义实现了 error 接口的结构体。
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed: %s - %s", e.Field, e.Message)
}
3. 错误包装(Error Wrapping)
Go 1.13 引入了错误包装,允许在保留原始错误的同时添加上下文。
func ReadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
// %w 会将原始错误包装进新错误中
return nil, fmt.Errorf("读取配置文件失败: %w", err)
}
// ...
}
4. 错误判断最佳实践
- 优先使用
errors.Is判断特定错误(包括包装链中的错误) - 使用
errors.As将错误转换为特定类型以获取更多信息
代码示例
示例1:综合错误处理模式
package main
import (
"errors"
"fmt"
"log"
)
// 哨兵错误
var (
ErrNotFound = errors.New("记录未找到")
ErrUnauthorized = errors.New("未授权访问")
)
// 自定义错误类型
type DatabaseError struct {
Code int
Message string
Query string
}
func (e *DatabaseError) Error() string {
return fmt.Sprintf("数据库错误[%d]: %s (查询: %s)", e.Code, e.Message, e.Query)
}
// 模拟数据库查询
func QueryUser(id int) (string, error) {
if id < 0 {
return "", &DatabaseError{
Code: 1001,
Message: "无效的用户ID",
Query: fmt.Sprintf("SELECT * FROM users WHERE id=%d", id),
}
}
if id > 100 {
return "", ErrNotFound
}
return "张三", nil
}
// 业务层函数,包装底层错误
func GetUserProfile(id int) (string, error) {
name, err := QueryUser(id)
if err != nil {
// 包装错误,添加业务上下文
return "", fmt.Errorf("获取用户档案失败: %w", err)
}
return fmt.Sprintf("用户档案: %s", name), nil
}
func main() {
// 测试不同的错误情况
testCases := []int{-1, 50, 150}
for _, id := range testCases {
profile, err := GetUserProfile(id)
if err != nil {
// 判断是否是特定的哨兵错误
if errors.Is(err, ErrNotFound) {
log.Printf("用户 %d 不存在\n", id)
} else {
// 尝试断言为特定错误类型
var dbErr *DatabaseError
if errors.As(err, &dbErr) {
log.Printf("数据库错误: 代码=%d, 查询=%s\n", dbErr.Code, dbErr.Query)
} else {
log.Printf("其他错误: %v\n", err)
}
}
} else {
log.Printf("用户 %d 的档案: %s\n", id, profile)
}
}
}
示例2:并发环境下的错误处理
package main
import (
"context"
"fmt"
"sync"
"time"
)
func processTask(ctx context.Context, id int) error {
// 模拟耗时操作
select {
case <-time.After(time.Duration(id) * 100 * time.Millisecond):
if id%3 == 0 {
return fmt.Errorf("任务 %d 处理失败", id)
}
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var wg sync.WaitGroup
errChan := make(chan error, 10) // 带缓冲的错误通道
// 启动多个并发任务
for i := 1; i <= 10; i++ {
wg.Add(1)
go func(taskID int) {
defer wg.Done()
if err := processTask(ctx, taskID); err != nil {
errChan <- err // 将错误发送到通道
}
}(i)
}
// 等待所有任务完成并关闭错误通道
go func() {
wg.Wait()
close(errChan)
}()
// 收集并处理所有错误
var errs []error
for err := range errChan {
errs = append(errs, err)
}
// 统计和处理错误
if len(errs) > 0 {
fmt.Printf("发现 %d 个错误:\n", len(errs))
for i, err := range errs {
fmt.Printf(" %d. %v\n", i+1, err)
}
} else {
fmt.Println("所有任务成功完成")
}
}
实践练习
练习1:基础错误处理(简单)
编写一个函数 Divide(a, b int) (int, error),实现整数除法。要求:
- 当除数为0时返回自定义错误
ErrDivisionByZero - 在main函数中调用该函数并正确处理错误
- 预期输出:
结果: 5 错误: 除数不能为零
练习2:错误包装与解包(中等)
基于示例1的代码,完成以下要求:
- 在
GetUserProfile函数中添加上下文超时检查 - 当底层返回
DatabaseError时,将其包装为业务错误,添加用户ID信息 - 在main函数中解包错误并提取
DatabaseError的详细信息
- 预期输出:
处理用户 -1 时出错: 业务层错误[获取用户档案失败] 底层数据库错误: 代码=1001, 查询=SELECT * FROM users WHERE id=-1
练习3:并发错误聚合(困难)
编写一个函数,并发下载多个URL的内容。要求:
- 使用
errgroup或手动管理goroutine - 收集所有错误,即使部分成功也返回成功的结果和错误列表
- 定义一个包含成功结果和错误的结构体
- 预期输出:
下载完成: 成功 3 个, 失败 2 个 错误详情: - url1.example.com: 连接超时 - url3.example.com: HTTP 404
常见错误
-
错误断言失败:使用
errors.As时未考虑错误包装链,应该始终使用errors.As而不是类型断言。// 错误做法 if dbErr, ok := err.(*DatabaseError); ok { ... } // 正确做法 var dbErr *DatabaseError if errors.As(err, &dbErr) { ... } -
忽略错误包装:在添加上下文时使用
%v而不是%w,导致无法追踪原始错误。// 错误做法 - 丢失了原始错误 return fmt.Errorf("操作失败: %v", err) // 正确做法 - 保留错误链 return fmt.Errorf("操作失败: %w", err) -
错误信息不清晰:错误消息过于笼统,没有提供足够的诊断信息。
// 不清晰 return fmt.Errorf("invalid input") // 清晰具体 return fmt.Errorf("用户名长度不能超过 %d 个字符,收到 %d 个字符", maxLen, len(username)) -
并发下的数据竞争:在并发goroutine中直接修改共享的错误变量。
// 危险 - 可能发生数据竞争 var globalErr error go func() { globalErr = doWork() }() // 安全 - 使用通道或mutex errChan := make(chan error, 1) go func() { errChan <- doWork() }() -
过度使用哨兵错误:为每个可能的错误情况都定义哨兵错误,导致API脆弱。
// 过度设计 var ( ErrInvalidUsername = errors.New("...") ErrInvalidEmail = errors.New("...") ErrPasswordTooShort = errors.New("...") ) // 更好 - 使用自定义错误类型 type ValidationError struct { Field string Rule string Message string }
小结
-
选择合适的错误模式:
- 哨兵错误:用于调用方需要判断的、有限的几种错误情况
- 自定义错误类型:需要携带额外结构化信息时
- 错误包装:在不同层次添加上下文,保持错误链完整
-
Go 1.13+ 错误处理关键API:
errors.Is(err, target):判断错误链中是否包含目标错误errors.As(err, &target):在错误链中查找并转换为特定类型fmt.Errorf("...: %w", err):包装错误同时保留原始错误
-
并发错误处理原则:
- 使用通道收集多个goroutine的错误
- 考虑使用
errgroup简化并发错误处理 - 区分可恢复错误和不可恢复错误
- 考虑超时和上下文取消对错误处理的影响
-
工程最佳实践:
- 在包边界处使用自定义错误类型
- 提供清晰的错误文档
- 避免在错误消息中泄露敏感信息
- 记录错误但只在适当的地方处理错误
记住:好的错误处理不是避免错误,而是让错误变得可理解、可追踪、可处理。在并发环境中,清晰的错误处理更是系统可靠性的关键保障。
练习编辑器
go
Loading...