87·工程实践进阶

错误处理最佳实践

errorpatternsentinelwrap

第87课:错误处理最佳实践

学习目标

  1. 掌握Go中错误处理的几种常见模式及其适用场景
  2. 理解哨兵错误(Sentinel Errors)和自定义错误类型的区别与应用
  3. 学会使用 fmt.Errorf 配合 %w 动词进行错误包装与解包
  4. 掌握在并发环境下安全、清晰的错误处理策略

核心概念

在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的代码,完成以下要求:

  1. GetUserProfile 函数中添加上下文超时检查
  2. 当底层返回 DatabaseError 时,将其包装为业务错误,添加用户ID信息
  3. 在main函数中解包错误并提取 DatabaseError 的详细信息
  • 预期输出:
    处理用户 -1 时出错: 业务层错误[获取用户档案失败]
    底层数据库错误: 代码=1001, 查询=SELECT * FROM users WHERE id=-1
    

练习3:并发错误聚合(困难)

编写一个函数,并发下载多个URL的内容。要求:

  • 使用 errgroup 或手动管理goroutine
  • 收集所有错误,即使部分成功也返回成功的结果和错误列表
  • 定义一个包含成功结果和错误的结构体
  • 预期输出:
    下载完成: 成功 3 个, 失败 2 个
    错误详情:
    - url1.example.com: 连接超时
    - url3.example.com: HTTP 404
    

常见错误

  1. 错误断言失败:使用 errors.As 时未考虑错误包装链,应该始终使用 errors.As 而不是类型断言。

    // 错误做法
    if dbErr, ok := err.(*DatabaseError); ok { ... }
    
    // 正确做法
    var dbErr *DatabaseError
    if errors.As(err, &dbErr) { ... }
    
  2. 忽略错误包装:在添加上下文时使用 %v 而不是 %w,导致无法追踪原始错误。

    // 错误做法 - 丢失了原始错误
    return fmt.Errorf("操作失败: %v", err)
    
    // 正确做法 - 保留错误链
    return fmt.Errorf("操作失败: %w", err)
    
  3. 错误信息不清晰:错误消息过于笼统,没有提供足够的诊断信息。

    // 不清晰
    return fmt.Errorf("invalid input")
    
    // 清晰具体
    return fmt.Errorf("用户名长度不能超过 %d 个字符,收到 %d 个字符", maxLen, len(username))
    
  4. 并发下的数据竞争:在并发goroutine中直接修改共享的错误变量。

    // 危险 - 可能发生数据竞争
    var globalErr error
    go func() { globalErr = doWork() }()
    
    // 安全 - 使用通道或mutex
    errChan := make(chan error, 1)
    go func() { errChan <- doWork() }()
    
  5. 过度使用哨兵错误:为每个可能的错误情况都定义哨兵错误,导致API脆弱。

    // 过度设计
    var (
        ErrInvalidUsername = errors.New("...")
        ErrInvalidEmail    = errors.New("...")
        ErrPasswordTooShort = errors.New("...")
    )
    
    // 更好 - 使用自定义错误类型
    type ValidationError struct {
        Field   string
        Rule    string
        Message string
    }
    

小结

  1. 选择合适的错误模式

    • 哨兵错误:用于调用方需要判断的、有限的几种错误情况
    • 自定义错误类型:需要携带额外结构化信息时
    • 错误包装:在不同层次添加上下文,保持错误链完整
  2. Go 1.13+ 错误处理关键API

    • errors.Is(err, target):判断错误链中是否包含目标错误
    • errors.As(err, &target):在错误链中查找并转换为特定类型
    • fmt.Errorf("...: %w", err):包装错误同时保留原始错误
  3. 并发错误处理原则

    • 使用通道收集多个goroutine的错误
    • 考虑使用 errgroup 简化并发错误处理
    • 区分可恢复错误和不可恢复错误
    • 考虑超时和上下文取消对错误处理的影响
  4. 工程最佳实践

    • 在包边界处使用自定义错误类型
    • 提供清晰的错误文档
    • 避免在错误消息中泄露敏感信息
    • 记录错误但只在适当的地方处理错误

记住:好的错误处理不是避免错误,而是让错误变得可理解、可追踪、可处理。在并发环境中,清晰的错误处理更是系统可靠性的关键保障。

练习编辑器

go
Loading...

继续学习

完成本课后,建议继续学习下一课「日志策略与实践」 以巩固所学知识。