团队代码库里搜了一下 fmt.Errorf,237 处。
其中 189 处是纯粹的字符串拼接,没有任何错误类型信息。出了问题查日志,只能靠肉眼去 grep 关键词定位。更糟的是,这些错误一旦被多层函数包装,原始错误类型彻底丢失,errors.Is 和 errors.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.EOF、sql.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 响应的映射,不关心错误的产生原因
避坑清单
-
永远不要吞掉错误 。
if err != nil { return nil }是定时炸弹。要么返回错误,要么用log.Printf记录后继续(仅限非关键路径)。 -
不要在错误消息里拼接敏感信息。用户 ID、邮箱可以,但密码、token 绝对不行。错误日志通常会被收集到第三方平台(Sentry、ELK),暴露敏感信息等于裸奔。
-
errors.Is和errors.As不要混用做同一件事 。哨兵值用errors.Is,自定义错误类型用errors.As。两者职责不同。 -
包装错误时上下文案要精确 。
"failed"太模糊,"creating order for user 12345"才有排查价值。格式建议:<操作> <目标标识>: %w。 -
不要在 error 里塞业务数据然后让调用方解析字符串 。如果需要结构化数据,定义带字段的结构体实现 error 接口,让调用方用
errors.As安全获取。
错误处理是 Go 最容易被低估的语言特性。写得好的项目,线上故障排查时间能从小时级压缩到分钟级。写得不好的项目,每个错误都像在黑盒里摸鱼。