学完这篇你能得到一套可落地的 Go 错误处理写法:什么时候返回 error、什么时候 panic、自定义错误怎么设计、怎么包装和判定,都有具体代码可抄。
第一步:先立规矩——error 是值,不是异常
Go 里 error 就是一个接口:
type error interface {
Error() string
}
这意味着错误和普通返回值没区别,可以传递、可以比较、可以存进结构体。核心心法一句话:能被调用方处理的,用 error 返回;程序无法继续的,才 panic。
func ReadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取配置 %s: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("解析配置: %w", err)
}
return &cfg, nil
}
注意:错误信息不要大写开头、不要加句号(golint/staticcheck 会提示),因为错误常被拼接进更大的句子,如 fmt.Errorf("读取配置: %w", err)。
第二步:用 %w 包装,用 errors.Is / errors.As 判定
Go 1.13 之后错误是一棵可以逐层解包的树。包装用 %w,判定用 errors.Is,取类型用 errors.As。
var ErrNotFound = errors.New("记录不存在")
func GetUser(id int) (*User, error) {
if id <= 0 {
return nil, fmt.Errorf("id=%d: %w", id, ErrNotFound)
}
// ...
}
// 调用方
u, err := GetUser(0)
if errors.Is(err, ErrNotFound) { // 穿透多层包装
// 走 404 逻辑
}
取具体错误类型时用 errors.As:
var pathErr *os.PathError
if errors.As(err, &pathErr) {
log.Printf("出错路径: %s", pathErr.Path)
}
注意:只要用 %v 包装,链条就断了,errors.Is 再也找不到底层的 ErrNotFound。需要保留因果关系的场合必须写 %w;不想暴露底层错误(比如给用户的响应)才用 %v。
第三步:设计自定义错误类型
预定义错误值(var ErrXxx = errors.New(...))适合「调用方只需要知道是哪类错」的场景。需要携带上下文字段时,定义结构体:
type ValidationError struct {
Field string
Value any
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("字段 %s 校验失败,值=%v", e.Field, e.Value)
}
func ValidateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{Field: "age", Value: age}
}
return nil
}
要点三条:
- 用指针接收者实现
Error(),errors.As 时才能稳定匹配;
- 如果结构体包含可比较字段,可再加一个
Is(target error) bool 方法,让 errors.Is 也支持;
- 别把错误类型定义在调用方包里,谁产生错误谁定义类型。
注意:自定义错误里不要塞 error 字段做「继承」,那会变成两套错误信息,日志里重复且难以判定。要嵌套就用 %w。
第四步:panic 只在真正异常时用
panic 的定位是「程序进入不可恢复状态」,不是控制流。可以用 panic 的场合:
- 包初始化时配置非法,比如正则写死却编译失败:
regexp.MustCompile;
- 编程错误,比如空 map 写入、数组越界由运行时自动 panic;
- 写库的内部不变量被破坏。
不该用的场合:文件不存在、网络超时、用户输入错误、数据库查询为空——这些全是 error。
recover 只放在 goroutine 或 HTTP 中间件的最外层,用来兜住意外并记录:
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
log.Printf("panic: %v\n%s", v, debug.Stack())
http.Error(w, "服务器内部错误", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
注意:recover() 只在 defer 中直接调用才有效;子 goroutine 里的 panic 不会影响主 goroutine,也不会被外层的 recover 捕获,所以每个自建 goroutine 都要自己 defer recover。
第五步:错误只处理一次
最常见的坏味道是「层层打印、层层返回」,日志里同一个错误出现五遍。原则是:要么处理(记录、降级、返回给用户),要么返回,不要既记录又返回。
// 反例
if err != nil {
log.Println(err)
return err // 上层还会再打一次
}
// 正例:在能决定「怎么办」的那一层记录
if err != nil {
return fmt.Errorf("保存订单: %w", err)
}
对外 API 响应时,用 errors.Is/As 映射成状态码,而不是把原始错误字符串直接吐给用户:
switch {
case errors.Is(err, ErrNotFound):
http.Error(w, "资源不存在", 404)
case errors.As(err, &validationErr):
http.Error(w, validationErr.Error(), 400)
default:
http.Error(w, "服务器内部错误", 500)
}
第六步:哨兵错误 + 包装 + 判定,串起来看
一个完整闭环:底层定义哨兵错误,中层用 %w 加上下文,顶层判定并决定策略。
// 底层包
var ErrInsufficientBalance = errors.New("余额不足")
// 中层
func Pay(uid int, amount float64) error {
if balance < amount {
return fmt.Errorf("用户 %d 支付 %.2f: %w", uid, amount, ErrInsufficientBalance)
}
return nil
}
// 顶层
if err := Pay(1, 100); err != nil {
if errors.Is(err, ErrInsufficientBalance) {
// 引导充值
} else {
log.Printf("支付失败: %v", err)
}
}
注意:哨兵错误变量名以 Err 开头,错误类型以 Error 结尾,这是 Go 社区的硬约定,go vet 和各类 linter 都按这个识别。
小结
- 能恢复的用
error,不可恢复的才 panic;recover 只放最外层,每个 goroutine 都要自己兜底。
- 包装用
%w,判定用 errors.Is,取类型用 errors.As;用 %v 会切断错误链。
- 自定义错误用指针接收者实现
Error(),谁产生谁定义类型。
- 错误只处理一次:要么记录,要么返回,别两头都做。
- 对外响应时用
errors.Is/As 映射状态码,不要直接暴露原始错误。