一、Go 错误哲学:errors are values
1.1 为什么 Go 选择「返回错误」而非「异常」?
Go 的设计者们在语言诞生之初就做出了一个关键决策:不使用异常机制,而是通过多返回值让错误成为一等公民。这个设计源于几个核心考量:
-
显式优于隐式:异常的控制流是隐式的,
throw和catch可能在代码任何位置发生跳转,而 Go 的错误处理要求开发者显式检查每个可能失败的操作。 -
可预测的控制流:阅读 Go 代码时,你可以清晰地看到每个函数调用后的错误处理逻辑,不存在「隐藏」的控制流转移。
-
性能考量:异常机制在异常触发时需要栈展开(stack unwinding),而 Go 的错误返回几乎零开销。
Rob Pike 在「Errors are values」一文中强调:
错误就是值,它们可以像其他值一样被程序化地处理。
这意味着错误不是「特殊情况」,而是程序逻辑的常态部分。
1.2 Go 与其他语言错误处理对比
1.3 error 接口的本质
Go 的 error 类型极其简单,仅包含一个方法:
type error interface { Error() string}这个极简设计带来几个重要特性:
- 零开销:大多数情况下,
error只是一个指针 - 可扩展:任何实现了
Error() string的类型都是error - 不可变语义:
Error()返回字符串,暗示错误信息应该是不可变的
源码中,最常用的实现是 errorString:
// src/errors/errors.go — https://github.com/golang/go/blob/go1.25.0/src/errors/errors.gotype errorString struct { s string}
func (e *errorString) Error() string { return e.s}二、error 接口与自定义错误
2.1 标准库的 errors.New
当你只需要一个简单的错误时,errors.New 是最直接的选择:
func divide(a, b int) (int, error) { if b == 0 { return 0, errors.New("division by zero") } return a / b, nil}errors.New 会返回一个指向 errorString 的指针。注意这里返回指针而非值,是为了避免在比较时出现意外——两个内容相同但地址不同的 errorString 不应该被视为相等。
2.2 fmt.Errorf:格式化错误信息
当需要动态构造错误信息时,fmt.Errorf 是更好的选择:
func openConfig(path string) (*os.File, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("failed to open config file %q: %w", path, err) } return f, nil}fmt.Errorf 支持格式化动词,其中 %w(wrap)是 Go 1.13 引入的重要特性,它将错误包装起来,保留原始错误的信息链。
2.3 自定义错误类型
当需要携带更多上下文或实现特定行为时,可以自定义错误类型:
// 业务错误码type ErrorCode int
const ( ErrNotFound ErrorCode = iota + 1 ErrUnauthorized ErrInvalidInput)
// 自定义错误类型type BusinessError struct { Code ErrorCode Message string Cause error // 原始错误}
func (e *BusinessError) Error() string { if e.Cause != nil { return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Cause) } return fmt.Sprintf("[%d] %s", e.Code, e.Message)}
// 实现 Unwrap 方法,支持 errors.Is/Asfunc (e *BusinessError) Unwrap() error { return e.Cause}
// 工厂函数func NewNotFoundError(resource string, cause error) error { return &BusinessError{ Code: ErrNotFound, Message: fmt.Sprintf("%s not found", resource), Cause: cause, }}2.4 自定义错误的最佳实践
// 好的做法:提供有意义的错误信息type ValidationError struct { Field string Value any Message string}
func (e *ValidationError) Error() string { return fmt.Sprintf("validation failed on field %q: %s (got %v)", e.Field, e.Message, e.Value)}
// 提供类型断言方法(避免导出内部字段)func IsValidationError(err error) bool { var ve *ValidationError return errors.As(err, &ve)}
// 实现 Is 方法,支持自定义相等语义func (e *ValidationError) Is(target error) bool { t, ok := target.(*ValidationError) if !ok { return false } return e.Field == t.Field}三、错误包装(Error Wrapping)
3.1 为什么需要错误包装?
在调用链中,原始错误往往缺乏上下文。比如一个「连接数据库失败」的错误,在服务层可能需要知道:
- 连接的是哪个数据库?
- 发生在哪个业务流程中?
- 具体的底层错误是什么?
错误包装通过在原始错误外层添加上下文,形成一条错误链,同时保留原始错误以便溯源。
3.2 fmt.Errorf 与 %w 动词
Go 1.13 引入了 %w 格式化动词,用于错误包装:
func processOrder(orderID string) error { order, err := fetchOrder(orderID) if err != nil { return fmt.Errorf("failed to process order %s: %w", orderID, err) } // ... return nil}
func fetchOrder(orderID string) (*Order, error) { row := db.QueryRow("SELECT * FROM orders WHERE id = ?", orderID) var order Order if err := row.Scan(&order); err != nil { return fmt.Errorf("failed to fetch order: %w", err) } return &order, nil}错误链的结构如下:
错误链遍历原理:
3.3 errors.Is 与 errors.As:遍历错误链
Go 1.13 同时引入了 errors.Is 和 errors.As,用于在错误链中查找特定错误:
// errors.Is:检查错误链中是否包含特定错误值if errors.Is(err, sql.ErrNoRows) { // 处理"记录不存在"的情况}
// errors.As:提取错误链中特定类型的错误var berr *BusinessErrorif errors.As(err, &berr) { // 根据 ErrorCode 进行不同处理 switch berr.Code { case ErrNotFound: // ... case ErrUnauthorized: // ... }}重要区别:
errors.Is用于比较值(哨兵错误)errors.As用于提取类型
3.4 errors.Join:多错误合并(Go 1.20)
Go 1.20 引入了 errors.Join,用于合并多个错误:
func validateUser(user *User) error { var errs []error
if user.Name == "" { errs = append(errs, errors.New("name is required")) } if user.Email == "" { errs = append(errs, errors.New("email is required")) } if user.Age < 0 { errs = append(errs, errors.New("age must be positive")) }
return errors.Join(errs...)}
// 使用if err := validateUser(user); err != nil { // err.Error() 会返回所有错误的组合信息 fmt.Println(err) // Output: name is required // email is required
// 注意:errors.Is 比较的是错误值(指针),不是错误消息 // 必须使用哨兵错误变量,而非 errors.New() 直接构造 // 因为 errors.New() 每次返回不同的指针,errors.Is 永远为 false var ErrNameRequired = errors.New("name is required") if errors.Is(err, ErrNameRequired) { // ... }}errors.Join 返回一个实现了 Unwrap() []error 方法的错误,errors.Is 和 errors.As 都能正确处理它。
四、哨兵错误(Sentinel Errors)
4.1 什么是哨兵错误?
哨兵错误是预定义的错误值,用于表示特定的错误条件:
// 标准库中的哨兵错误var ( ErrUnsupported = errors.New("unsupported operation") ErrNotExist = errors.New("does not exist") ErrExist = errors.New("already exists"))这些错误通常在包级别定义,命名以 Err 开头。
4.2 哨兵错误的使用场景
// 定义var ( ErrUserNotFound = errors.New("user not found") ErrUserDisabled = errors.New("user is disabled") ErrInvalidPassword = errors.New("invalid password"))
// 使用func authenticate(username, password string) error { user, err := getUser(username) if err != nil { return fmt.Errorf("authentication failed: %w", err) }
if user.Status == "disabled" { return fmt.Errorf("authentication failed: %w", ErrUserDisabled) }
if !verifyPassword(user.PasswordHash, password) { return fmt.Errorf("authentication failed: %w", ErrInvalidPassword) }
return nil}
// 调用方err := authenticate("alice", "password123")if errors.Is(err, ErrUserNotFound) { // 用户不存在} else if errors.Is(err, ErrUserDisabled) { // 用户被禁用} else if errors.Is(err, ErrInvalidPassword) { // 密码错误}4.3 哨兵错误的局限性
- 缺乏上下文:哨兵错误无法携带动态信息
- 包耦合:使用方需要导入定义哨兵错误的包
- 全局状态:可能被意外修改(虽然 Go 不鼓励这种做法)
4.4 哨兵错误 vs 自定义错误类型
| 特性 | 哨兵错误 | 自定义错误类型 |
|---|---|---|
| 上下文 | 无 | 丰富 |
| 类型检查 | errors.Is | errors.As |
| 定义方式 | 包级变量 | 结构体 |
| 使用场景 | 简单错误条件 | 需要额外信息 |
五、错误链与错误溯源
5.1 理解错误链结构
错误链是由多层包装形成的树状结构:
type wrappedError struct { msg string err error}
func (e *wrappedError) Error() string { return e.msg + ": " + e.err.Error()}
func (e *wrappedError) Unwrap() error { return e.err}错误链的数据结构:
5.2 errors.Unwrap:手动遍历
func printErrorChain(err error) { for err != nil { fmt.Println("-", err.Error()) err = errors.Unwrap(err) }}5.3 实现自定义的 Unwrap
type DetailedError struct { Op string // 操作名称 Path string // 相关路径 Err error // 原始错误}
func (e *DetailedError) Error() string { return fmt.Sprintf("%s %s: %v", e.Op, e.Path, e.Err)}
func (e *DetailedError) Unwrap() error { return e.Err}5.4 错误溯源的最佳实践
// 每一层只添加最相关的上下文func handleRequest(w http.ResponseWriter, r *http.Request) { data, err := fetchUserData(r.Context(), userID) if err != nil { // 只添加请求级别的上下文 err = fmt.Errorf("handling request for user %s: %w", userID, err) handleError(w, err) return } // ...}
func fetchUserData(ctx context.Context, userID string) (*UserData, error) { user, err := db.GetUser(ctx, userID) if err != nil { // 只添加数据访问级别的上下文 return nil, fmt.Errorf("fetching user from database: %w", err) } // ...}
// 避免在每一层都重复相同的上下文func badExample() error { err := doSomething() if err != nil { // 这里的"user foo"和下面的"user foo"重复了 return fmt.Errorf("processing user foo: %w", err) } // ...}错误处理模式决策图:
错误传播与处理层次图:
六、defer + recover 机制
6.1 panic 与 recover 的本质
Go 没有异常,但有 panic 和 recover。它们的设计目的与异常不同:
- panic:不可恢复的错误,如程序逻辑错误、不可继续的状态
- recover:在
defer中捕获 panic,用于清理或转换错误
func example() (err error) { defer func() { if r := recover(); r != nil { // 将 panic 转换为 error err = fmt.Errorf("panic recovered: %v", r) // 打印堆栈以便调试 debug.PrintStack() } }()
// 可能 panic 的代码 riskyOperation() return nil}panic/recover 执行流程:
6.2 合理使用 panic/recover
应该使用 panic 的场景:
// 1. 不可能的条件(程序错误)func MustCompile(pattern string) *Regexp { re, err := Compile(pattern) if err != nil { panic(`regexp: Compile(` + quote(pattern) + `): ` + err.Error()) } return re}
// 2. 初始化失败func init() { if os.Getenv("REQUIRED_VAR") == "" { panic("REQUIRED_VAR environment variable is required") }}应该使用 recover 的场景:
// 1. HTTP 服务器的错误隔离func middleware(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 recovered: %v", err) http.Error(w, "Internal Server Error", 500) } }() next.ServeHTTP(w, r) })}
// 2. goroutine 隔离func safeGo(fn func() error) <-chan error { errCh := make(chan error, 1) go func() { defer func() { if r := recover(); r != nil { errCh <- fmt.Errorf("panic: %v", r) } }() errCh <- fn() }() return errCh}6.3 不应该用 panic/recover 的场景
// 不要用 panic 做常规错误处理func badExample(path string) error { if !fileExists(path) { panic("file not found") // 错误!应该返回 error } return nil}
// 正确做法func goodExample(path string) error { if !fileExists(path) { return os.ErrNotExist } return nil}七、错误日志记录策略
7.1 在哪里记录错误?
一个常见的问题是:应该在错误发生处记录,还是在处理处记录?
原则:错误只记录一次,在「处理」层记录。
// 错误示范:每一层都记录func service() error { if err := repo(); err != nil { log.Printf("service error: %v", err) // 第一次记录 return err } return nil}
func repo() error { if err := db(); err != nil { log.Printf("repo error: %v", err) // 第二次记录 return err } return nil}
// 正确做法:只在最外层记录func handler(w http.ResponseWriter, r *http.Request) { if err := service(); err != nil { log.Printf("request failed: %v", err) // 唯一记录点 http.Error(w, "internal error", 500) }}
func service() error { return repo() // 只传递,不记录}7.2 结构化日志
使用结构化日志记录更多上下文:
import "log/slog"
func processOrder(ctx context.Context, orderID string) error { order, err := fetchOrder(ctx, orderID) if err != nil { // 包含完整的上下文信息 slog.Error("failed to process order", "order_id", orderID, "error", err, "trace_id", traceIDFromContext(ctx), ) return err } return nil}7.3 错误码与可观测性
type ErrorWithCode struct { Code string Message string Err error}
func (e *ErrorWithCode) Error() string { return e.Message}
func (e *ErrorWithCode) Unwrap() error { return e.Err}
// 记录时提取错误码用于监控func logError(err error) { var e *ErrorWithCode code := "UNKNOWN" if errors.As(err, &e) { code = e.Code }
metrics.Counter("errors").WithLabelValues(code).Inc() slog.Error("error occurred", "code", code, "error", err)}八、第三方错误库
8.1 pkg/errors
在 Go 1.13 之前,github.com/pkg/errors 是最流行的错误处理库:
import "github.com/pkg/errors"
func main() { err := readFile("config.json") if err != nil { // 打印带堆栈的错误 fmt.Printf("%+v\n", err) }}
func readFile(path string) error { _, err := os.ReadFile(path) if err != nil { // 自动捕获调用堆栈 return errors.Wrap(err, "failed to read config") } return nil}主要功能:
errors.Wrap:包装错误并捕获堆栈errors.WithStack:仅捕获堆栈不添加消息errors.Cause:获取根本原因
8.2 Go 1.20+ 标准库能力
Go 1.20 之后,标准库已经足够强大:
// 错误包装err = fmt.Errorf("context: %w", err)
// 多错误合并err = errors.Join(err1, err2, err3)
// 错误检查errors.Is(err, target)errors.As(err, &target)
// 遍历错误链errors.Unwrap(err)8.3 如何选择?
| 场景 | 推荐 |
|---|---|
| 新项目,Go 1.20+ | 标准库足够 |
| 需要堆栈信息 | pkg/errors 或 go-errors/errors |
| 需要丰富的错误码 | 自定义错误类型 |
| 多错误聚合 | 标准库 errors.Join |
九、防御性编程与错误处理
9.1 输入验证
func CreateUser(name, email string, age int) (*User, error) { // 在操作前验证所有输入 if name == "" { return nil, &ValidationError{ Field: "name", Message: "name is required", } } if !isValidEmail(email) { return nil, &ValidationError{ Field: "email", Message: "invalid email format", } } if age < 0 || age > 150 { return nil, &ValidationError{ Field: "age", Message: "age must be between 0 and 150", } }
// 通过验证,执行创建 return &User{Name: name, Email: email, Age: age}, nil}9.2 错误处理模式
// 模式 1:立即返回func pattern1() error { if err := step1(); err != nil { return err } if err := step2(); err != nil { return err } return nil}
// 模式 2:延迟清理func pattern2(path string) (err error) { f, err := os.Create(path) if err != nil { return err } defer func() { if cerr := f.Close(); cerr != nil && err == nil { err = cerr } }()
// 使用 f... return nil}
// 模式 3:错误分组(Go 1.20+)func pattern3(ctx context.Context) error { var g errgroup.Group
g.Go(func() error { return task1(ctx) }) g.Go(func() error { return task2(ctx) })
return g.Wait()}9.3 资源清理
func processFile(path string) error { f, err := os.Open(path) if err != nil { return fmt.Errorf("open file: %w", err) } defer f.Close()
scanner := bufio.NewScanner(f) for scanner.Scan() { // 处理每一行 if err := processLine(scanner.Text()); err != nil { // 即使出错,defer 也会确保文件关闭 return fmt.Errorf("process line: %w", err) } }
if err := scanner.Err(); err != nil { return fmt.Errorf("scan file: %w", err) }
return nil}十、实战案例:构建 Web 服务的错误处理
10.1 分层错误处理
// 错误定义(internal/errors/errors.go)type AppError struct { Code string HTTPStatus int Message string Err error}
func (e *AppError) Error() string { return e.Message}
func (e *AppError) Unwrap() error { return e.Err}
// 预定义错误var ( ErrNotFound = &AppError{ Code: "NOT_FOUND", HTTPStatus: http.StatusNotFound, Message: "resource not found", } ErrUnauthorized = &AppError{ Code: "UNAUTHORIZED", HTTPStatus: http.StatusUnauthorized, Message: "unauthorized", })
// Repository 层func (r *userRepository) GetByID(ctx context.Context, id string) (*User, error) { var user User err := r.db.GetContext(ctx, &user, "SELECT * FROM users WHERE id = ?", id) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("user %s: %w", id, ErrNotFound) } return nil, fmt.Errorf("query user: %w", err) } return &user, nil}
// Service 层func (s *userService) GetProfile(ctx context.Context, userID string) (*Profile, error) { user, err := s.repo.GetByID(ctx, userID) if err != nil { return nil, err // 直接传递,不重复包装 }
// 业务逻辑... return &Profile{User: user}, nil}
// Handler 层func (h *handler) GetProfile(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userID")
profile, err := h.service.GetProfile(r.Context(), userID) if err != nil { respondWithError(w, err) return }
respondJSON(w, http.StatusOK, profile)}
// 统一错误响应func respondWithError(w http.ResponseWriter, err error) { var appErr *AppError if errors.As(err, &appErr) { respondJSON(w, appErr.HTTPStatus, ErrorResponse{ Code: appErr.Code, Message: appErr.Message, }) return }
// 未知错误,返回 500 slog.Error("internal error", "error", err) respondJSON(w, http.StatusInternalServerError, ErrorResponse{ Code: "INTERNAL_ERROR", Message: "an internal error occurred", })}10.2 错误处理流程图
十一、常见错误模式与反模式
11.1 反模式:吞掉错误
// 错误:忽略错误func bad() { _, _ = os.Open("file.txt") // 忽略错误}
// 错误:记录但不返回func alsoBad() error { if err := doSomething(); err != nil { log.Printf("error: %v", err) // 只记录不返回 // 继续执行... } return nil}
// 正确:处理并返回func good() error { if err := doSomething(); err != nil { return fmt.Errorf("do something: %w", err) } return nil}11.2 反模式:过度包装
// 每一层都添加相似的信息func bad() error { if err := service(); err != nil { return fmt.Errorf("failed to process order: %w", err) } return nil}
func service() error { if err := repo(); err != nil { return fmt.Errorf("failed to process order: %w", err) // 重复! } return nil}11.3 反模式:使用 panic 做流程控制
// 错误func validate(input string) { if input == "" { panic("input is empty") }}
// 正确func validate(input string) error { if input == "" { return errors.New("input is empty") } return nil}11.4 良好模式总结
// 1. 错误只传递不处理,在最外层统一处理// 2. 每层只添加自己层级的上下文// 3. 使用 errors.Is/As 而非 == 比较// 4. 结构化日志,包含 trace_id 等上下文// 5. 区分可恢复错误和不可恢复错误总结:Go 错误处理的核心原则
12.1 核心原则
- 显式处理:每个错误都应该被显式处理或显式传递
- 错误是值:可以编程处理,可以存储,可以比较
- 只记录一次:在最外层统一记录,避免日志泛滥
- 保留上下文:使用
%w包装错误,形成可追溯的错误链 - 区分层次:不同层次使用不同的错误表示方式
12.2 决策树
需要处理错误?├── 能处理 → 处理并返回 nil 或新错误├── 需要添加上下文 → fmt.Errorf("context: %w", err)└── 不能处理 → 直接返回 err12.3 工具箱
| 功能 | 标准库 | 第三方 |
|---|---|---|
| 创建错误 | errors.New | - |
| 格式化错误 | fmt.Errorf | - |
| 错误包装 | fmt.Errorf + %w | pkg/errors.Wrap |
| 错误比较 | errors.Is | - |
| 类型断言 | errors.As | - |
| 多错误 | errors.Join | hashicorp/go-multierror |
| 堆栈捕获 | - | pkg/errors.WithStack |
Go 的错误处理虽然需要更多代码,但它带来的可预测性和可维护性是值得的。掌握这些最佳实践,能让你的 Go 代码更加健壮和专业。
十二、常见问题
Q1:errors.Is 和 == 比较错误有什么区别?
errors.Is 会遍历错误链(通过 Unwrap),可以匹配被包装的底层错误。== 只比较顶层错误值。哨兵错误必须用 errors.Is 检查。
Q2:什么时候该用 panic 而不是 error?
panic 用于不可恢复的程序错误(如索引越界、空指针解引用)。error 用于可恢复的预期错误(如文件不存在、网络超时)。库代码不应 panic,除非在 Must 前缀的函数中。
Q3:fmt.Errorf 的 %w 和 %v 有什么区别?
%w 包装错误(实现 Unwrap 方法),允许 errors.Is/As 遍历错误链。%v 只是格式化错误消息,不保留错误链。需要错误链时用 %w。
Q4:Go 1.20 的 errors.Join 有什么用?
errors.Join 合并多个错误为一个,实现了 Unwrap() []error。适合验证场景:收集所有错误后一次性返回,而不是遇到第一个错误就返回。
小结
- Go 错误哲学:errors are values,通过多返回值显式处理错误
- Go 1.13 引入 %w 包装 + errors.Is/As 遍历错误链,是现代 Go 错误处理的基石
- 哨兵错误用于特定错误条件,自定义错误类型用于携带上下文
- 错误只记录一次,在最外层统一处理,避免日志泛滥
- defer + recover 用于不可恢复错误的隔离,不应替代常规错误处理
参考资料
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






