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