基础错误创建: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 社区的约定俗成。比如 ErrInvalidInput、ErrConnectionClosed。
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.Is 或 errors.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.Is 或 errors.As 解包,一定要用 %w。如果只是单纯加上下文,不需要解包,用 %v 就行。
经验之谈:在编写公共库或中间件时,尽量用 %w 包装。因为调用方可能需要对错误做精细化处理。而在业务代码中,如果错误不会跨层传递,用 %v 也够用。
6. 总结
错误创建看似简单,但细节决定成败。记住三点:
- 静态错误用 errors.New 定义成变量,避免重复分配。
- 动态错误用 fmt.Errorf,需要解包时用
%w。 - 错误信息要规范:小写开头、无句号、可搜索、区分用户和系统错误。
嗯,这些习惯养成了,你的代码会好维护很多。下一节,我会讲错误包装与解包,到时候你会看到 %w 的真正威力。