3. 自定义错误类型:实现 error 接口、errors.New、fmt.Errorf 与 %w 动词

在 Go 语言中,错误处理不是通过异常机制,而是通过显式的返回值传递。Go 1.13 引入的 errors.Is / errors.As 以及 %w 动词,让错误包装与错误链成为业务代码的标准实践。本章将系统讲解自定义错误类型的三种核心方式,并对比它们的适用场景。

3.1 Go 错误接口与内置实现

Go 的错误模型极其简洁:任何实现了 Error() string 方法的类型都可以作为错误值传递。

type error interface {
    Error() string
}

标准库提供了两种最常用的错误构造方式:

方式 函数签名 特点 适用场景
errors.New func New(text string) error 返回静态字符串错误,不可解包 简单、固定的错误消息
fmt.Errorf(无 %w) func Errorf(format string, a ...any) error 格式化字符串,不可解包 需要动态拼接的错误消息
fmt.Errorf(含 %w) func Errorf(format string, a ...any) error 包装下层错误,支持 errors.Is/As 遍历 错误链传递与根因定位
核心原则: 业务代码中,errors.New 用于定义“哨兵错误”(sentinel errors),fmt.Errorf 配合 %w 用于包装调用栈上下文。不要将 %w 用于非 error 类型的参数。

3.2 自定义错误类型:实现 error 接口

当错误需要携带额外字段(如错误码、HTTP 状态码、业务上下文)时,必须定义结构体并实现 Error() 方法。

// BizError 业务错误,携带错误码与内部原因
type BizError struct {
    Code    int    // 业务错误码,例如 1001
    Message string // 用户可读的错误描述
    Err     error  // 内部根因(可选)
}

func (e *BizError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("code=%d, msg=%s, cause=%v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("code=%d, msg=%s", e.Code, e.Message)
}

// Unwrap 支持 errors.Is / errors.As 遍历内部错误
func (e *BizError) Unwrap() error {
    return e.Err
}
关键设计点:
  • Error() 方法只负责格式化输出,不负责逻辑判断。
  • 实现 Unwrap() error 方法,使自定义错误类型可以融入 Go 1.13+ 的错误链机制。
  • 错误码建议使用 int 常量或 iota 枚举,避免字符串比较带来的性能与拼写风险。

3.3 errors.New:定义哨兵错误

哨兵错误是包级别导出的错误变量,调用方通过 == 或 errors.Is 判断错误类型。

package user

import "errors"

var (
    ErrNotFound      = errors.New("user not found")
    ErrAlreadyExists = errors.New("user already exists")
    ErrInvalidID     = errors.New("invalid user id")
)

// GetUser 返回哨兵错误示例
func GetUser(id int) (*User, error) {
    if id <= 0 {
        return nil, ErrInvalidID
    }
    // 模拟数据库查询
    return nil, ErrNotFound
}

调用方判断:

if errors.Is(err, user.ErrNotFound) {
    // 处理用户不存在逻辑
}
避坑提示: 不要将哨兵错误定义为 const,因为 const 不支持 error 接口类型。始终使用 var 声明。另外,哨兵错误一旦导出,就成为了包公开契约的一部分,修改其文本可能导致调用方依赖字符串匹配的代码失效。

3.4 fmt.Errorf 与 %w 动词:构建错误链

%w 动词是 Go 1.13 引入的关键特性,它创建一个包装错误(wrapping error),保留对原始错误的引用,使 errors.Is 和 errors.As 可以递归遍历整个错误链。

package repository

import (
    "database/sql"
    "fmt"
)

func FindUserByEmail(email string) (*User, error) {
    user, err := db.Query("SELECT * FROM users WHERE email = ?", email)
    if err != nil {
        // 包装下层 sql.ErrNoRows 或其他数据库错误
        return nil, fmt.Errorf("find user by email %q: %w", email, err)
    }
    return user, nil
}

上层调用方可以逐层解包:

user, err := repository.FindUserByEmail("alice@example.com")
if err != nil {
    if errors.Is(err, sql.ErrNoRows) {
        // 识别出底层是"记录不存在"
    } else {
        // 其他数据库错误
    }
}

3.5 三种方式的对比与选择

维度 errors.New fmt.Errorf(无 %w) fmt.Errorf(含 %w)
错误链支持 否 否 是
动态消息 否 是 是
可被 errors.Is 遍历 是(直接比较) 否 是
可被 errors.As 赋值 否 否 是
性能开销 低 中(格式化) 中(格式化+包装)
典型用途 包级哨兵错误 日志/用户消息 错误传递与根因保留

3.6 错误类型体系架构图

下图展示了 Go 错误类型从标准库到自定义业务错误的完整体系,以及错误链的遍历路径:

Go 错误类型体系与错误链遍历 Go 错误类型体系与错误链遍历 error 接口 errors.New fmt.Errorf BizError 结构体 哨兵错误变量 包装错误(%w) errors.Is / errors.As 递归遍历 Unwrap() 方法逐层解包 说明: 自定义错误类型通过实现 Error() 满足 error 接口;实现 Unwrap() 使错误链可被 errors.Is/As 遍历。

3.7 实战:三层架构中的错误传递

在典型的 Web 业务三层架构(Handler → Service → Repository)中,错误包装的最佳实践如下:

// repository/user.go
func (r *UserRepo) FindByID(ctx context.Context, id int) (*User, error) {
    row := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = ?", id)
    var u User
    err := row.Scan(&u.ID, &u.Name, &u.Email)
    if err != nil {
        return nil, fmt.Errorf("repo.FindByID: %w", err)
    }
    return &u, nil
}

// service/user.go
func (s *UserService) GetUser(ctx context.Context, id int) (*UserDTO, error) {
    user, err := s.repo.FindByID(ctx, id)
    if err != nil {
        // 包装业务上下文,保留根因
        return nil, fmt.Errorf("service.GetUser id=%d: %w", id, err)
    }
    return &UserDTO{Name: user.Name, Email: user.Email}, nil
}

// handler/user.go
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.Atoi(r.URL.Query().Get("id"))
    user, err := h.svc.GetUser(r.Context(), id)
    if err != nil {
        // 在顶层统一判断错误类型
        if errors.Is(err, sql.ErrNoRows) {
            http.Error(w, "user not found", http.StatusNotFound)
        } else {
            http.Error(w, "internal error", http.StatusInternalServerError)
        }
        return
    }
    json.NewEncoder(w).Encode(user)
}
错误包装黄金法则:
  • 每一层只包装本层的上下文(如方法名、关键参数),不要重复包装同一层。
  • 根因错误(如 sql.ErrNoRows)只出现在 Repository 层,上层通过 errors.Is 识别。
  • 不要在 Handler 层再包装错误,Handler 是错误处理的终点。
  • 敏感信息(如 SQL 语句、堆栈详情)不要暴露给客户端,仅记录到日志。

3.8 错误包装与解包流程图

下图展示了一个错误从 Repository 层产生,经过 Service 层包装,最终在 Handler 层被解包识别的完整流程:

错误包装与解包流程 错误包装与解包流程 Repository 层 产生根因错误 Service 层 包装业务上下文 Handler 层 解包并响应 错误链展开: Handler 层调用 errors.Is(err, sql.ErrNoRows) → 遍历包装链:service.GetUser id=5: repo.FindByID: sql: no rows in result set → 匹配到 sql.ErrNoRows → 返回 HTTP 404 关键点: 每一层只包装一次,避免重复包装导致错误链膨胀。 Handler 层是错误处理的终点,负责将错误转换为 HTTP 响应或用户提示。

3.9 常见陷阱与最佳实践

陷阱 错误做法 正确做法
重复包装 每层都 %w 包装同一个下层错误 每层只包装一次,使用 %w 保留根因
丢失根因 使用 fmt.Errorf("msg: %v", err) 代替 %w 需要传递错误链时始终使用 %w
哨兵错误滥用 为每个业务分支都定义哨兵错误 仅对调用方需要特殊处理的错误定义哨兵
忽略 Unwrap 自定义结构体未实现 Unwrap() 如果内部包含 Err 字段,务必实现 Unwrap()
敏感信息泄露 将完整错误链直接返回给客户端 Handler 层只返回用户友好的消息,完整错误写入日志
推荐实践: 在团队项目中,建议统一使用一个自定义的 AppError 结构体,包含错误码、HTTP 状态码、用户消息和内部根因。所有业务错误都通过该结构体传递,上层通过 errors.As 获取 AppError 实例,直接读取错误码和状态码,避免字符串匹配。