Go 错误处理之道:别再到处 return fmt.Errorf 了,你的代码正在失控

团队代码库里搜了一下 fmt.Errorf,237 处。

其中 189 处是纯粹的字符串拼接,没有任何错误类型信息。出了问题查日志,只能靠肉眼去 grep 关键词定位。更糟的是,这些错误一旦被多层函数包装,原始错误类型彻底丢失,errors.Iserrors.As 形同虚设。

这不仅是代码洁癖问题,它直接拖慢了线上故障排查效率。


错误包装的正确姿势

很多人用 fmt.Errorf 包装错误,图的是能加一层上下文信息。这没问题,但 %v%w 的区别直接决定了你的错误能不能被上游正确识别。

go 复制代码
// ❌ 错误类型丢失,上游无法判断
if err := doSomething(); err != nil {
    return fmt.Errorf("failed to process user: %v", err)
}

// ✅ 保留错误链,支持 errors.Is / errors.As
if err := doSomething(); err != nil {
    return fmt.Errorf("failed to process user: %w", err)
}

%w 是 Go 1.13 引入的动词,它把原始错误嵌入返回的 error 中,形成错误链。errors.Is 沿着这条链逐个比对,直到找到匹配项。用 %v 包装后,原始错误变成纯字符串,链断了。

实际项目中最常见的坑是混用。同一套代码里有人用 %w,有人用 %v,错误链路在某些分支上完好,某些分支上断裂。排查问题时你根本不知道错误是在哪一层断掉的。


自定义错误类型:什么场景才需要?

不是每个错误都要定义一个 struct 实现 Error() string。过度设计比不用更糟。

判断标准只有一个:上游调用方需要根据这个错误做不同的逻辑分支

go 复制代码
// 合理的自定义错误 ------ 调用方需要根据错误类型走不同逻辑
type NotFoundError struct {
    Resource string
    ID       string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s not found: %s", e.Resource, e.ID)
}

func GetUser(id string) (*User, error) {
    user, err := db.Find(id)
    if err == sql.ErrNoRows {
        return nil, &NotFoundError{Resource: "user", ID: id}
    }
    if err != nil {
        return nil, fmt.Errorf("db query failed: %w", err)
    }
    return user, nil
}

// 调用方
func HandleRequest(id string) error {
    user, err := GetUser(id)
    if errors.As(err, &NotFoundError{}) {
        return echo.NewHTTPError(http.StatusNotFound, "user not found")
    }
    if err != nil {
        return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
    }
    // 正常处理 user...
    return nil
}

上面的场景里,NotFoundError 驱动了 HTTP 状态码的选择。这就是自定义错误类型存在的意义。

反过来,如果错误只是用来记录和展示,不需要任何分支逻辑,fmt.Errorf 就足够了。


错误值哨兵:简单场景的最优解

对于不需要携带额外信息的错误,直接用包级变量:

go 复制代码
package auth

var (
    ErrTokenExpired     = errors.New("token has expired")
    ErrInvalidSignature = errors.New("invalid token signature")
    ErrMissingClaim     = errors.New("required claim is missing")
)

func ValidateToken(token string) error {
    // ...
    if expired {
        return ErrTokenExpired
    }
    // ...
}

// 调用方
if err := auth.ValidateToken(tok); errors.Is(err, auth.ErrTokenExpired) {
    return refreshToken()
}

这种方式零开销,类型安全,errors.Is 直接做指针比较。Go 标准库里 io.EOFsql.ErrNoRows 都是这种模式。


错误带结构化数据:当字符串不够用的时候

有时候错误需要携带更多信息,不只是给人类看,还要给程序用。比如需要知道是哪个参数非法、当前值是什么、期望范围是什么。

go 复制代码
type ValidationError struct {
    Field   string
    Value   any
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on %s: %s (got %v)",
        e.Field, e.Message, e.Value)
}

// 支持链式调用,一次性收集多个验证错误
type ValidationErrors []*ValidationError

func (es ValidationErrors) Error() string {
    msgs := make([]string, len(es))
    for i, e := range es {
        msgs[i] = e.Error()
    }
    return strings.Join(msgs, "; ")
}

这种设计在 API 网关、表单校验层很实用。调用方可以用 errors.As 拿到具体字段,返回给前端做精确的字段级错误提示。


错误分类中间件:统一处理,别到处写 switch

HTTP 服务里最常见的模式是把错误映射到 HTTP 状态码。如果每个 handler 都写一遍映射逻辑,很快失控。

go 复制代码
// 定义业务错误分类
type ErrorCode int

const (
    ErrCodeUnknown ErrorCode = iota
    ErrCodeNotFound
    ErrCodeUnauthorized
    ErrCodeValidation
    ErrCodeInternal
)

type AppError struct {
    Code    ErrorCode
    Message string
    Cause   error
}

func (e *AppError) 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)
}

func (e *AppError) Unwrap() error {
    return e.Cause
}

// HTTP 状态码映射集中在一个地方
func HTTPStatusFromErrorCode(code ErrorCode) int {
    switch code {
    case ErrCodeNotFound:
        return http.StatusNotFound
    case ErrCodeUnauthorized:
        return http.StatusUnauthorized
    case ErrCodeValidation:
        return http.StatusBadRequest
    case ErrCodeInternal:
        return http.StatusInternalServerError
    default:
        return http.StatusInternalServerError
    }
}

// 中间件统一处理
func errorMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 这里假设 handler 把 AppError 写入了 context
        // 或者通过 panic recovery 捕获
        // 实际项目中可以用 gin/echo 的错误处理机制
        next.ServeHTTP(w, r)
    })
}

核心思路:错误的分类逻辑和 HTTP 映射逻辑各放一处,业务代码只负责抛正确的错误类型,中间件统一消费。


线上排查利器:给错误加调用栈

fmt.Errorf 包装的错误没有调用栈信息。日志里打印 err.Error(),你看到的是错误消息,但不知道错误是从哪条调用链冒出来的。

github.com/pkg/errors 提供了 errors.Wrap 自动记录调用栈。虽然 Go 1.13 之后标准库已经能处理错误链,但 pkg/errors 的栈追踪能力仍然有价值。

go 复制代码
import "github.com/pkg/errors"

func processOrder(orderID string) error {
    order, err := fetchOrder(orderID)
    if err != nil {
        return errors.Wrapf(err, "fetching order %s", orderID)
    }

    if err := validateOrder(order); err != nil {
        return errors.WithMessage(err, "order validation failed")
    }

    return nil
}

// 打印完整栈
func logError(err error) {
    if stackErr, ok := err.(interface{ StackTrace() errors.StackTrace }); ok {
        fmt.Printf("%+v\n", err) // %+v 会打印调用栈
    }
}

生产环境中,建议在 panic recovery 和日志中间件里统一打印错误栈,而不是在每个出错点手动加。


哨兵值 + 包装的组合模式

实际项目中,哨兵值和错误包装经常一起用:

go 复制代码
var ErrUserNotFound = errors.New("user not found")

func FindUserByEmail(email string) (*User, error) {
    user, err := db.QueryUser(email)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrUserNotFound
        }
        return nil, fmt.Errorf("query user by email %s: %w", email, err)
    }
    return user, nil
}

// 调用方既能用哨兵值精确匹配
// 也能用 errors.As 拿到包装后的错误信息
func Handler(email string) error {
    user, err := FindUserByEmail(email)
    if errors.Is(err, ErrUserNotFound) {
        return echo.NewHTTPError(http.StatusNotFound)
    }
    if err != nil {
        // 这里拿到的 error 保留了完整的错误链和上下文
        log.Printf("unexpected error: %+v", err)
        return echo.NewHTTPError(http.StatusInternalServerError)
    }
    // ...
}

这种组合模式是我在多个项目里的标准做法:定义关键错误作为哨兵值,非关键错误用 %w 包装保留链路。


实战案例:一个电商系统的错误处理架构

看一个完整的例子。电商系统里,错误分三层:基础设施层、业务逻辑层、HTTP 层。

go 复制代码
// === 基础设施层 errors.go ===
package infra

var (
    ErrConnectionLost = errors.New("database connection lost")
    ErrTimeout        = errors.New("operation timed out")
)

// === 业务层 service/order.go ===
package service

var (
    ErrOrderNotFound  = errors.New("order not found")
    ErrInsufficientStock = errors.New("insufficient stock")
)

type OrderService struct {
    db *infra.DB
}

func (s *OrderService) CreateOrder(req CreateOrderRequest) error {
    tx, err := s.db.BeginTx(context.Background())
    if err != nil {
        return fmt.Errorf("begin transaction: %w", err)
    }
    defer tx.Rollback()

    for _, item := range req.Items {
        stock, err := s.checkStock(tx, item.ProductID)
        if err != nil {
            return fmt.Errorf("checking stock for product %d: %w", item.ProductID, err)
        }
        if stock < item.Quantity {
            return ErrInsufficientStock
        }
    }

    if err := tx.Commit(); err != nil {
        return fmt.Errorf("commit transaction: %w", err)
    }
    return nil
}

// === HTTP 层 handler/order.go ===
package handler

func (h *Handler) CreateOrder(c echo.Context) error {
    var req service.CreateOrderRequest
    if err := c.Bind(&req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{
            "error": "invalid request body",
        })
    }

    err := h.svc.CreateOrder(req)
    switch {
    case errors.Is(err, service.ErrInsufficientStock):
        return c.JSON(http.StatusConflict, map[string]string{
            "error": "部分商品库存不足",
        })
    case errors.Is(err, infra.ErrTimeout):
        return c.JSON(http.StatusGatewayTimeout, map[string]string{
            "error": "服务响应超时,请稍后重试",
        })
    case err != nil:
        h.logger.Error("create order failed", "err", err)
        return c.JSON(http.StatusInternalServerError, map[string]string{
            "error": "internal server error",
        })
    }
    return c.NoContent(http.StatusCreated)
}

三层职责清晰:

  • 基础设施层定义底层错误(连接、超时)
  • 业务层定义业务错误(库存不足、订单不存在),包装基础设施错误
  • HTTP 层只做错误到 HTTP 响应的映射,不关心错误的产生原因

避坑清单

  1. 永远不要吞掉错误if err != nil { return nil } 是定时炸弹。要么返回错误,要么用 log.Printf 记录后继续(仅限非关键路径)。

  2. 不要在错误消息里拼接敏感信息。用户 ID、邮箱可以,但密码、token 绝对不行。错误日志通常会被收集到第三方平台(Sentry、ELK),暴露敏感信息等于裸奔。

  3. errors.Iserrors.As 不要混用做同一件事 。哨兵值用 errors.Is,自定义错误类型用 errors.As。两者职责不同。

  4. 包装错误时上下文案要精确"failed" 太模糊,"creating order for user 12345" 才有排查价值。格式建议:<操作> <目标标识>: %w

  5. 不要在 error 里塞业务数据然后让调用方解析字符串 。如果需要结构化数据,定义带字段的结构体实现 error 接口,让调用方用 errors.As 安全获取。


错误处理是 Go 最容易被低估的语言特性。写得好的项目,线上故障排查时间能从小时级压缩到分钟级。写得不好的项目,每个错误都像在黑盒里摸鱼。

相关推荐
止语Lab18 小时前
你写的Go代码,编译器能"看懂"多少
go
刀法如飞2 天前
Go数组去重的20种实现方式,AI时代解决问题的不同思路
后端·算法·go
AI编程探险者2 天前
Go 编译的二进制突然跑不起来了?凶手是 macOS 的 syspolicyd
go
用户398346161202 天前
10 个示例快速入门 Go-Spring|v1.3.0 正式发布
go
zhouwy1133 天前
Golang 基础与实战笔记:从语法到微服务的全面指南
开发语言·go
日火4 天前
Go:实现基于mutex的环形缓冲区
go
审判长烧鸡5 天前
GO错误处理【7】层层递进,环环相扣
go·报错处理
审判长烧鸡6 天前
Go结构体与指针【3】自动解引用
go·指针·结构体·自动解引用
审判长烧鸡6 天前
【GO VS PHP】之 指针/引用传递
go·php·指针·引用传递