📖 错误处理¶
学习时间: 约 3-4 小时 | 难度: ⭐⭐⭐ 中级 | 前置知识: Go基础语法、接口概念
📚 章节概述¶
Go 语言采用了独特的显式错误处理机制——没有 try/catch/finally,而是通过返回值传递错误。这种设计强迫开发者在每个调用点思考错误情况,虽然代码看起来"啰嗦",但代码路径更清晰。本章将全面讲解 Go 的 error 接口、错误处理模式、errors 包、panic/recover 以及工程级的错误处理策略。
上图概括了 Go 中从错误返回、包装到分类处理的典型链路,可作为编写业务代码的通用模板。
🎯 学习目标¶
- 深入理解 error 接口和哨兵错误
- 掌握错误包装(Go 1.13+
%w)与 errors.Is / errors.As - 学会自定义错误类型的设计
- 理解 panic/recover 的使用场景与限制
- 掌握工程级错误处理的最佳实践
📌 概念:error 接口¶
1.1 error 是一个接口¶
任何实现了 Error() string 方法的类型都是 error。这使得错误可以携带丰富的上下文信息。
1.2 创建错误¶
package main
import (
"errors"
"fmt"
)
func main() {
// 方式1:errors.New — 创建简单错误
err1 := errors.New("something went wrong")
fmt.Println(err1) // something went wrong
// 方式2:fmt.Errorf — 格式化错误消息
name := "config.yaml"
err2 := fmt.Errorf("无法读取文件 %s: 权限不足", name)
fmt.Println(err2) // 无法读取文件 config.yaml: 权限不足
}
1.3 错误处理的基本模式¶
import (
"fmt"
"os"
"strconv"
)
func readConfig(filename string) ([]byte, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, err // 原样返回
}
return data, nil
}
func parsePort(s string) (int, error) {
port, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("解析端口号失败: %w", err) // 包装错误
}
if port < 1 || port > 65535 {
return 0, fmt.Errorf("端口号 %d 超出有效范围 [1, 65535]", port)
}
return port, nil
}
func main() {
data, err := readConfig("config.yaml")
if err != nil {
fmt.Println("配置读取失败:", err)
return
}
fmt.Println("配置内容:", string(data))
}
📌 概念:自定义错误类型¶
2.1 哨兵错误(Sentinel Errors)¶
哨兵错误是预定义的全局错误值,用于表示特定的错误条件:
import (
"errors"
"io"
)
// 标准库中的哨兵错误示例
// var EOF = errors.New("EOF")
// var ErrNotExist = errors.New("file does not exist")
// 自定义哨兵错误
var (
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized access")
ErrConflict = errors.New("resource conflict")
)
func findUser(id int) (*User, error) {
// ... 查询数据库
if user == nil {
return nil, ErrNotFound
}
return user, nil
}
func main() {
user, err := findUser(42)
if errors.Is(err, ErrNotFound) {
fmt.Println("用户不存在")
} else if err != nil {
fmt.Println("查询失败:", err)
}
_ = user
}
2.2 自定义错误结构体¶
// 携带丰富上下文的错误类型
type AppError struct {
Code int // 错误码
Message string // 用户可见的错误消息
Op string // 操作名称
Err error // 底层错误
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("[%s] %s: %v", e.Op, e.Message, e.Err)
}
return fmt.Sprintf("[%s] %s", e.Op, e.Message)
}
// 实现 Unwrap 方法以支持 errors.Is/As 链式查找
func (e *AppError) Unwrap() error {
return e.Err
}
// HTTP 友好的错误类型
type HTTPError struct {
StatusCode int
Message string
Detail string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message)
}
func NewNotFoundError(resource string) *HTTPError {
return &HTTPError{
StatusCode: 404,
Message: fmt.Sprintf("%s not found", resource),
}
}
func NewInternalError(err error) *HTTPError {
return &HTTPError{
StatusCode: 500,
Message: "internal server error",
Detail: err.Error(),
}
}
2.3 多错误聚合(Go 1.20+)¶
// Go 1.20 支持 errors.Join 合并多个错误
func validateUser(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, errors.New("name is required"))
}
if u.Email == "" {
errs = append(errs, errors.New("email is required"))
}
if u.Age < 0 || u.Age > 150 {
errs = append(errs, fmt.Errorf("invalid age: %d", u.Age))
}
return errors.Join(errs...) // 返回 nil 如果 errs 为空
}
func main() {
err := validateUser(User{})
if err != nil {
fmt.Println(err)
// name is required
// email is required
}
}
📌 概念:错误包装与检查(Go 1.13+)¶
3.1 fmt.Errorf 的 %w 动词¶
func readFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("readFile(%s): %w", path, err)
}
return data, nil
}
func loadConfig() (*Config, error) {
data, err := readFile("/etc/app/config.yaml")
if err != nil {
return nil, fmt.Errorf("loadConfig: %w", err)
}
// 解析...
return nil, nil
}
// 错误链:loadConfig: readFile(/etc/app/config.yaml): open ...: no such file
3.2 errors.Is — 判断错误链中是否包含特定错误¶
func main() {
err := loadConfig()
// errors.Is 会递归检查错误链
if errors.Is(err, os.ErrNotExist) {
fmt.Println("配置文件不存在,使用默认配置")
} else if errors.Is(err, os.ErrPermission) {
fmt.Println("权限不足,请检查文件权限")
} else if err != nil {
fmt.Println("未知错误:", err)
}
}
3.3 errors.As — 从错误链中提取特定类型的错误¶
func handleRequest() error {
return &AppError{
Code: 404,
Message: "user not found",
Op: "handleRequest",
}
}
func main() {
err := handleRequest()
// errors.As 从错误链中提取匹配类型
var appErr *AppError
if errors.As(err, &appErr) {
fmt.Printf("应用错误 Code=%d, Op=%s\n", appErr.Code, appErr.Op)
}
var httpErr *HTTPError
if errors.As(err, &httpErr) {
fmt.Printf("HTTP 状态码: %d\n", httpErr.StatusCode)
}
}
📌 概念:panic 和 recover¶
4.1 panic 的使用场景¶
Panic 应该只用于真正不可恢复的错误:
// ✅ 适合 panic 的场景
func MustCompileRegex(pattern string) *regexp.Regexp {
re, err := regexp.Compile(pattern)
if err != nil {
panic(fmt.Sprintf("invalid regex pattern: %s: %v", pattern, err)) // panic 抛出不可恢复的运行时错误
}
return re
}
// 程序初始化时使用 Must 模式
var emailRegex = MustCompileRegex(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
// ❌ 不应该用 panic 的场景
func findUser(id int) *User {
user := db.Find(id)
if user == nil {
panic("user not found") // 应该返回 error
}
return user
}
4.2 recover 捕获 panic¶
// safeExecute 使用命名返回值 err,使 defer 中可修改返回的错误
func safeExecute(fn func()) (err error) {
defer func() {
if r := recover(); r != nil { // recover 捕获panic,恢复正常执行
// 将 panic 转为 error
// 通过类型断言判断 panic 值的类型并分别处理
switch v := r.(type) {
case error:
err = fmt.Errorf("panic recovered: %w", v)
case string:
err = fmt.Errorf("panic recovered: %s", v)
default:
err = fmt.Errorf("panic recovered: %v", v)
}
// 打印堆栈信息(调试用)
debug.PrintStack()
}
}()
fn()
return nil
}
func main() {
err := safeExecute(func() {
panic("boom!")
})
if err != nil {
fmt.Println("捕获到错误:", err)
}
fmt.Println("程序继续运行")
}
4.3 HTTP 中间件中的 recover¶
func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
// 记录错误和堆栈
log.Printf("panic: %v\n%s", err, debug.Stack())
// 返回 500
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
💻 代码示例:实战应用¶
示例1:分层错误处理架构¶
// 定义不同层的错误
package apperr
type ErrorType int
const (
NotFound ErrorType = iota
Validation
Authorization
Internal
)
type Error struct {
Type ErrorType
Message string
Err error
}
func (e *Error) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
func (e *Error) Unwrap() error { return e.Err }
// 工厂函数
func NewNotFound(msg string) *Error {
return &Error{Type: NotFound, Message: msg}
}
func NewValidation(msg string) *Error {
return &Error{Type: Validation, Message: msg}
}
func Wrap(err error, msg string) *Error {
return &Error{Type: Internal, Message: msg, Err: err}
}
// --- 仓储层:将底层数据库错误转换为业务错误类型 ---
func (r *UserRepo) FindByID(id int) (*User, error) {
user, err := r.db.Query("SELECT ...")
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, apperr.NewNotFound("user not found") // 底层“没查到行”转为业务“未找到”
}
return nil, apperr.Wrap(err, "query user failed")
}
return user, nil
}
// --- 服务层:添加当前操作的上下文后继续向上传递 ---
func (s *UserService) GetUser(id int) (*User, error) {
user, err := s.repo.FindByID(id)
if err != nil {
return nil, fmt.Errorf("UserService.GetUser: %w", err)
}
return user, nil
}
// --- 处理层:根据业务错误类型映射为对应的 HTTP 状态码 ---
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
user, err := h.service.GetUser(id)
if err != nil {
var appErr *apperr.Error
if errors.As(err, &appErr) { // 从错误链中提取业务错误类型
switch appErr.Type {
case apperr.NotFound:
http.Error(w, appErr.Message, 404)
case apperr.Validation:
http.Error(w, appErr.Message, 400)
default:
http.Error(w, "internal error", 500)
}
return
}
http.Error(w, "internal error", 500)
return
}
json.NewEncoder(w).Encode(user)
}
示例2:重试机制¶
type RetryableError struct {
Err error
Retryable bool
}
func (e *RetryableError) Error() string { return e.Err.Error() }
func (e *RetryableError) Unwrap() error { return e.Err }
// WithRetry 实现带递增延迟的重试机制,支持通过 RetryableError 控制是否可重试
func WithRetry(fn func() error, maxRetries int, delay time.Duration) error {
var lastErr error
for i := 0; i <= maxRetries; i++ {
err := fn()
if err == nil {
return nil // 执行成功,直接返回
}
lastErr = err
// 检查是否可重试
var retryErr *RetryableError
if errors.As(err, &retryErr) && !retryErr.Retryable {
return err // 不可重试,直接返回
}
if i < maxRetries {
log.Printf("重试 %d/%d: %v", i+1, maxRetries, err)
time.Sleep(delay * time.Duration(i+1)) // 线性递增延迟,避免短时间内大量重试
}
}
return fmt.Errorf("达到最大重试次数 (%d): %w", maxRetries, lastErr)
}
示例3:错误收集器¶
// ErrorCollector 线程安全的错误收集器,适用于并发场景下汇总多个 goroutine 的错误
type ErrorCollector struct {
errors []error
mu sync.Mutex // 保护并发读写 errors 切片
}
func (ec *ErrorCollector) Add(err error) {
if err == nil {
return
}
ec.mu.Lock()
defer ec.mu.Unlock()
ec.errors = append(ec.errors, err)
}
func (ec *ErrorCollector) Error() error {
ec.mu.Lock()
defer ec.mu.Unlock()
return errors.Join(ec.errors...)
}
func (ec *ErrorCollector) HasErrors() bool {
ec.mu.Lock()
defer ec.mu.Unlock()
return len(ec.errors) > 0
}
// 使用示例:并发处理文件,WaitGroup 等待所有 goroutine 完成后统一返回错误
func processFiles(files []string) error {
ec := &ErrorCollector{}
var wg sync.WaitGroup
for _, f := range files {
wg.Add(1)
go func(filename string) { // 参数传入避免闭包捕获循环变量
defer wg.Done()
if err := processFile(filename); err != nil {
ec.Add(fmt.Errorf("处理 %s: %w", filename, err))
}
}(f)
}
wg.Wait()
return ec.Error()
}
✅ 最佳实践¶
1. 始终检查错误,永远不要忽略¶
// ✅ 正确
data, err := os.ReadFile("file.txt")
if err != nil {
return fmt.Errorf("读取文件失败: %w", err)
}
// ❌ 用 _ 忽略错误
data, _ := os.ReadFile("file.txt")
2. 错误消息应该提供上下文但不重复¶
// ✅ 好:添加当前操作的上下文
return fmt.Errorf("解析用户记录 (id=%d): %w", id, err)
// ❌ 坏:重复底层错误的信息
return fmt.Errorf("failed to read file: read file error: %w", err)
3. 优先使用 %w 而非 %v 包装错误¶
// ✅ 使用 %w 保留错误链
return fmt.Errorf("operation failed: %w", err)
// ❌ 使用 %v 会断开错误链
return fmt.Errorf("operation failed: %v", err)
4. panic 仅用于不可恢复的程序错误¶
// ✅ 程序初始化、不变量被破坏
func init() {
if os.Getenv("SECRET_KEY") == "" {
panic("SECRET_KEY environment variable is required")
}
}
// ❌ 业务逻辑错误不应该 panic
func createUser(name string) {
if name == "" {
panic("name is empty") // 应该返回 error
}
}
5. 在包边界转换错误类型¶
// 包内部使用特定错误类型
// 暴露给外部时包装为包的公共错误
func (s *Storage) Get(key string) ([]byte, error) {
data, err := s.redis.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, ErrKeyNotFound // 转换为包定义的哨兵错误
}
if err != nil {
return nil, fmt.Errorf("storage.Get: %w", err)
}
return data, nil
}
🎯 面试题¶
Q1: Go 为什么选择显式错误返回而不是 try/catch?¶
A: Go 的设计哲学强调显式和简洁。显式错误返回的优势:1) 代码执行路径清晰可见,每个错误都必须处理;2) 没有隐式的控制流跳转;3) 错误是值,可以用普通的编程技巧处理。缺点是代码中有大量 if err != nil 检查,但 Go 团队认为这种"啰嗦"换来的是可读性和可靠性。
Q2: errors.Is 和 errors.As 的区别?¶
A: - errors.Is(err, target) 检查错误链中是否包含特定的错误值(通过 == 或 Is 方法比较),常用于哨兵错误判断 - errors.As(err, &target) 从错误链中提取特定错误类型并赋值给 target,常用于获取自定义错误的详细信息 - 二者都会递归调用 Unwrap() 遍历整个错误链
Q3: panic 什么时候会导致程序崩溃?¶
A: 当 panic 沿调用栈向上传播到达 goroutine 栈顶时,且没有被 recover 捕获,程序会崩溃并打印堆栈信息。注意:一个 goroutine 的 panic 无法被另一个 goroutine 的 recover 捕获。HTTP 服务器框架通常在每个请求的 goroutine 中添加 recover 中间件来防止单个请求的 panic 导致整个服务崩溃。
Q4: 如何实现带堆栈信息的错误?¶
A: 标准 errors 包不提供堆栈信息。可以用 runtime.Callers() 获取调用栈,或使用第三方库如 pkg/errors。Go 1.13+ 推荐用 %w 包装错误链来提供上下文,而非依赖堆栈追踪。在需要堆栈时,可以在 recover 中使用 debug.Stack() 获取。
Q5: defer 和错误处理有什么关系?¶
A: defer 常用于确保资源清理不受错误影响。经典模式:
defer 也用于 recover 捕获 panic,以及在函数返回前修改 named return value 来统一错误处理。Q6: 如何优雅地处理多个并发操作的错误?¶
A: 使用 errgroup.Group(golang.org/x/sync/errgroup),它会在任何一个 goroutine 返回错误时取消其他任务:
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error { return task1(ctx) })
g.Go(func() error { return task2(ctx) })
if err := g.Wait(); err != nil { ... }
📋 学习检查清单¶
- 理解 error 接口及其实现方式
- 能使用 errors.New、fmt.Errorf 创建错误
- 掌握
%w错误包装和 errors.Is/As 的使用 - 能设计自定义错误类型(含 Unwrap 方法)
- 了解哨兵错误的使用场景
- 理解 panic/recover 的正确使用场景
- 能在 HTTP 服务中实现 recover 中间件
- 掌握分层错误处理架构设计