基础错误创建:errors.New、fmt.Errorf、错误字符串格式化最佳实践

错误创建,是 Go 错误处理的第一步。说白了,就是怎么把「出问题了」这个信息,包装成一个 Go 认识的 error 对象。

我见过不少新手,直接返回一个字符串,或者用 errors.New("something wrong") 草草了事。嗯,这样写,项目小的时候还行,一旦代码量上来,调试起来简直要命。今天我就把我在项目中积累的几种创建错误的方式,以及字符串格式化的最佳实践,一次性讲清楚。

1. errors.New:最基础的错误工厂

errors.New 是 Go 标准库提供的最简单的错误创建方式。它接收一个字符串,返回一个 error 接口的实现。

package main

import (
    "errors"
    "fmt"
)

func main() {
    err := errors.New("文件未找到")
    fmt.Println(err)
}

你看,就这么简单。但这里有个坑——每次调用 errors.New 都会分配一个新的 error 实例。如果你在循环里频繁创建相同的错误,内存开销会上去。

最佳实践:把常用的错误定义为包级别的变量,避免重复创建。

var (
    ErrNotFound   = errors.New("资源未找到")
    ErrPermission = errors.New("权限不足")
    ErrTimeout    = errors.New("操作超时")
)

func GetUser(id int) error {
    if id <= 0 {
        return ErrNotFound
    }
    return nil
}

我个人习惯,在每个包的开头,用 var 块声明所有「静态错误」。这样调用方可以用 errors.Is 做精确匹配,非常清晰。

小提示:错误变量的命名建议以 Err 开头,这是 Go 社区的约定俗成。比如 ErrInvalidInputErrConnectionClosed

2. fmt.Errorf:带格式的错误创建

很多时候,错误信息需要包含动态内容,比如用户 ID、文件名、时间戳。这时候 errors.New 就不够用了。fmt.Errorf 就是干这个的。

func OpenFile(path string) error {
    return fmt.Errorf("打开文件 %s 失败", path)
}

它和 fmt.Sprintf 的用法一模一样,只是返回的是 error 类型。我在项目中经常用它来包装底层错误,加上上下文信息。

举个例子,你调用数据库查询,底层返回了一个错误:

func QueryUser(db *sql.DB, id int) error {
    rows, err := db.Query("SELECT * FROM users WHERE id = ?", id)
    if err != nil {
        return fmt.Errorf("查询用户 %d 失败: %w", id, err)
    }
    defer rows.Close()
    // ...
}

注意这里的 %w 占位符。这是 Go 1.13 引入的特性,用来包装错误。用 %w 包装后,调用方可以通过 errors.Iserrors.As 解包,拿到原始错误。

警告:一个错误只能被 %w 包装一次。如果你在多个层级都用 %w,会形成错误链,但只有最外层的 %w 生效。其他层级建议用 %v%s 来格式化。

3. 错误字符串格式化的最佳实践

错误信息怎么写,其实很有讲究。我踩过不少坑,总结了几条铁律。

3.1 小写字母开头,不要句号结尾

Go 的错误信息习惯用小写字母开头,并且不加句号。因为错误经常会被拼接到更大的消息里,比如 Error: 文件未找到,如果开头大写,看起来会很奇怪。

// 不推荐
errors.New("文件未找到。")

// 推荐
errors.New("文件未找到")

3.2 错误信息要可搜索

我曾经在一个大型项目中,错误信息写得太随意,比如 "出错了""失败了"。结果线上出问题,日志里搜都搜不到。后来我定了个规矩:错误信息必须包含关键标识符

// 不推荐
return fmt.Errorf("连接失败")

// 推荐
return fmt.Errorf("redis 连接失败: %w", err)

这样你在日志里搜 redis 连接失败,就能定位到所有相关错误。

3.3 区分「用户错误」和「系统错误」

用户错误,比如输入校验失败,信息要友好。系统错误,比如数据库挂了,信息要详细,方便排查。

// 用户错误
return fmt.Errorf("邮箱格式不正确")

// 系统错误
return fmt.Errorf("写入数据库失败: %w, SQL: %s", err, sql)

注意,系统错误里不要暴露敏感信息,比如密码、密钥。我见过有人把 SQL 语句完整打印到错误里,结果被攻击者利用。

3.4 使用常量或枚举管理错误消息

如果错误消息需要国际化,或者频繁修改,建议抽成常量。

const (
    MsgFileNotFound = "文件 %s 未找到"
    MsgPermissionDenied = "用户 %s 权限不足"
)

func ReadFile(path string) error {
    return fmt.Errorf(MsgFileNotFound, path)
}

这样改起来方便,也避免到处写死字符串。

4. 实战对比:好 vs 坏的错误创建

场景 不推荐的做法 推荐的做法
静态错误 return errors.New("not found") 每次创建 var ErrNotFound = errors.New("not found") 包级变量
带参数错误 return errors.New("user " + id + " not found") return fmt.Errorf("user %d not found", id)
包装底层错误 return fmt.Errorf("query failed: " + err.Error()) return fmt.Errorf("query failed: %w", err)
错误信息风格 "查询失败。" 大写+句号 "查询失败" 小写+无句号

5. 我踩过的坑

我曾经在一个微服务项目中,所有错误都用 fmt.Errorf%v 包装,没有用 %w。结果到了排查问题时,errors.Is 永远返回 false,根本匹配不到原始错误。最后只能把所有包装改成 %w,才解决问题。

所以,如果你希望调用方能通过 errors.Iserrors.As 解包,一定要用 %w。如果只是单纯加上下文,不需要解包,用 %v 就行。

经验之谈:在编写公共库或中间件时,尽量用 %w 包装。因为调用方可能需要对错误做精细化处理。而在业务代码中,如果错误不会跨层传递,用 %v 也够用。

6. 总结

错误创建看似简单,但细节决定成败。记住三点:

  • 静态错误用 errors.New 定义成变量,避免重复分配。
  • 动态错误用 fmt.Errorf,需要解包时用 %w
  • 错误信息要规范:小写开头、无句号、可搜索、区分用户和系统错误。

嗯,这些习惯养成了,你的代码会好维护很多。下一节,我会讲错误包装与解包,到时候你会看到 %w 的真正威力。