前面聊到 context 和 panic/recover 的时候,我其实刻意绕开了一个更日常的问题:Go 的错误到底应该怎么传?
刚开始写 Go,很容易把错误处理理解成一堆重复的:
if err != nil { return err}写久了会发现,真正难的不是这三行,而是这几个判断:
• 这个错误要不要让上层能识别? • 要不要把底层错误包出去? • 调用方应该用字符串、哨兵值,还是错误类型判断? • 多个错误同时发生时,返回哪个? • panic 到底算不算错误处理?
这些问题不解决,项目大了以后错误会变成一团文本。日志里看着有信息,代码里却没法做判断。
Go 的 error 很简单:
type error interface { Error() string}只要一个类型实现了 Error() string,它就是 error。没有异常栈展开,没有 try/catch,函数把失败作为返回值交给调用方。
func LoadUser(ctx context.Context, id int64) (*User, error) { user, err := repo.FindUser(ctx, id) if err != nil { return nil, err } return user, nil}这看起来啰嗦,但好处也明显:失败路径就在代码路径上。调用方不能假装看不见,除非它故意写 _。
我的经验是,Go 的错误处理要先问一句:调用方拿到这个错误后,能做什么不同的动作?
如果只能打印日志,错误字符串就够了。如果要分支处理,就要给它可判断的形状。
不要用字符串判断错误
最糟糕的写法是这样:
if strings.Contains(err.Error(), "not found") { // ...}这个判断非常脆。错误文案换一下、加个前缀、翻译一下,业务逻辑就坏了。
如果调用方真的需要知道“没找到”,最简单的办法是哨兵错误:
var ErrUserNotFound = errors.New("user not found")func FindUser(ctx context.Context, id int64) (*User, error) { user, err := dbFindUser(ctx, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound } return nil, fmt.Errorf("find user %d: %w", id, err) } return user, nil}调用方不要直接 err == ErrUserNotFound,用 errors.Is:
user, err := FindUser(ctx, id)if err != nil { if errors.Is(err, ErrUserNotFound) { return nil, nil } return nil, err}errors.Is 的意义是:它不只看当前 error,也会沿着包装链往里找。
%w 不是更好看的 %v
Go 1.13 给 fmt.Errorf 加了 %w。从输出上看,%w 和 %v 很像:
return fmt.Errorf("load config: %w", err)但语义完全不同。
%v 只是把错误文本拼进去:
return fmt.Errorf("load config: %v", err)调用方只能看到字符串。
%w 会把原始错误包在新错误里:
return fmt.Errorf("load config: %w", err)调用方可以继续这样判断:
if errors.Is(err, os.ErrNotExist) { // 配置文件不存在}所以 %w 不是“更现代的写法”,而是在声明 API 边界:我允许调用方看见里面这个错误。
这句话很重要。假设你的包内部用的是 database/sql:
func GetUser(ctx context.Context, id int64) (*User, error) { user, err := queryUser(ctx, id) if err != nil { return nil, fmt.Errorf("query user: %w", err) } return user, nil}如果这里把 sql.ErrNoRows 包出去了,外部就可能写:
if errors.Is(err, sql.ErrNoRows) { // ...}以后你把存储从 MySQL 换成 Redis、HTTP、ES,外部代码还依赖 sql.ErrNoRows,这就是你自己暴露出去的兼容包袱。
更稳的写法是把内部错误翻译成自己的错误:
var ErrUserNotFound = errors.New("user not found")func GetUser(ctx context.Context, id int64) (*User, error) { user, err := queryUser(ctx, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("%w: id=%d", ErrUserNotFound, id) } return nil, fmt.Errorf("query user %d: %w", id, err) } return user, nil}对外承诺的是 ErrUserNotFound,不是 sql.ErrNoRows。
sentinel error 适合表达稳定分类
哨兵错误适合表达“稳定、少量、调用方真的会处理”的分类。
比如:
var ( ErrUserNotFound = errors.New("user not found") ErrUserDisabled = errors.New("user disabled"))这些错误代表业务状态,调用方看到后会走不同分支。
不适合把所有错误都做成哨兵:
var ( ErrOpenFileFailed = errors.New("open file failed") ErrDecodeJSONFailed = errors.New("decode json failed") ErrValidateNameFailed = errors.New("validate name failed"))如果调用方不会针对它们做分支,只是日志里想知道发生在哪一步,用上下文包装就够了:
if err := dec.Decode(&cfg); err != nil { return fmt.Errorf("decode config %s: %w", path, err)}错误分类越多,API 承诺越重。不是不能做,是要确认调用方真的需要。
custom error type 适合携带结构化信息
有些错误不是一个分类能说清楚的。比如限流错误,调用方除了知道“被限流”,还想知道多久后重试。
这时用自定义错误类型:
type RateLimitError struct { RetryAfter time.Duration}func (e *RateLimitError) Error() string { return fmt.Sprintf("rate limited, retry after %s", e.RetryAfter)}返回时:
func CallAPI(ctx context.Context, client *http.Client, url string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return fmt.Errorf("build request: %w", err) } resp, err := client.Do(req) if err != nil { return fmt.Errorf("call api: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests { return &RateLimitError{RetryAfter: time.Minute} } if resp.StatusCode >= 400 { return fmt.Errorf("call api: status %d", resp.StatusCode) } return nil}调用方用 errors.As 拿到具体类型:
if err := CallAPI(ctx, client, url); err != nil { var rateErr *RateLimitError if errors.As(err, &rateErr) { timer := time.NewTimer(rateErr.RetryAfter) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return CallAPI(ctx, client, url) } } return err}errors.As 跟 errors.Is 一样,也会沿着错误链往里找。区别是:
• errors.Is问的是:这是不是某个错误分类?• errors.As问的是:这条链里有没有某种错误类型?如果有,把它取出来。
包装错误时,把动作写清楚
我以前写过这种错误:
return fmt.Errorf("failed: %w", err)这种信息基本没用。上层日志可能变成:
failed: failed: failed: EOF好的错误包装应该说明当时在做什么:
return fmt.Errorf("read request body: %w", err)return fmt.Errorf("decode user payload: %w", err)return fmt.Errorf("insert user %d: %w", user.ID, err)不要首字母大写,不要加句号。因为错误经常会继续被前缀包装:
return fmt.Errorf("create order: %w", err)最后输出会像一句从外到内的调用链:
create order: insert user 42: context deadline exceeded这比“系统异常”强太多。
errors.Join 解决的是多错误,不是主错误
Go 1.20 加了 errors.Join。它适合这种场景:你做了几件相互独立的事,可能有多个失败,任何一个都不该被吞掉。
比如批量关闭资源:
func CloseAll(closers ...io.Closer) error { var errs []error for _, c := range closers { if err := c.Close(); err != nil { errs = append(errs, err) } } return errors.Join(errs...)}errors.Join 会忽略 nil。如果全部都是 nil,它返回 nil。返回的错误里可以被 errors.Is 和 errors.As 检查:
if err := CloseAll(a, b, c); err != nil { if errors.Is(err, os.ErrClosed) { // 其中至少有一个错误匹配 os.ErrClosed } return err}不要把 errors.Join 当成“顺便多塞一点上下文”的工具。
如果一个错误是主因,另一个只是清理失败,我更倾向于保留主因,再把清理失败写进日志,或者在确实需要时明确 join:
func WriteFile(path string, data []byte) (err error) { f, err := os.Create(path) if err != nil { return fmt.Errorf("create file %s: %w", path, err) } deferfunc() { if closeErr := f.Close(); closeErr != nil { err = errors.Join(err, fmt.Errorf("close file %s: %w", path, closeErr)) } }() if _, err := f.Write(data); err != nil { return fmt.Errorf("write file %s: %w", path, err) } return nil}这里 join 的含义很明确:写入可能失败,关闭也可能失败,两者都和调用方有关。
context 错误不要吃掉
阻塞操作要带 context.Context,这个前面已经聊过。错误处理里还有一个配套原则:context.Canceled 和 context.DeadlineExceeded 不要被改造成普通业务失败。
func SyncProfile(ctx context.Context, id int64) error { profile, err := remote.LoadProfile(ctx, id) if err != nil { return fmt.Errorf("load remote profile %d: %w", id, err) } if err := repo.SaveProfile(ctx, profile); err != nil { return fmt.Errorf("save profile %d: %w", id, err) } return nil}上层可以判断:
err := SyncProfile(ctx, id)switch {case err == nil: return nilcase errors.Is(err, context.Canceled): return errcase errors.Is(err, context.DeadlineExceeded): return errdefault: return fmt.Errorf("sync profile: %w", err)}超时和取消不是“同步失败”这么简单。它们通常意味着请求生命周期结束了,后续重试、告警、日志级别都可能不同。
panic 不是 error 的高级形态
上一篇讲过 panic/recover 的机制。放到错误处理里,我的边界很简单:
• 用户输入错了,返回 error • 网络超时了,返回 error • 文件不存在,返回 error • 数据库没查到,返回 error • 调用方传了不可能接受的参数,可以 panic • 程序走到了理论上不可能的分支,可以 panic • 初始化阶段的硬依赖缺失,可以 panic 或直接退出
比如这种不要 panic:
func ParseAge(s string) int { age, err := strconv.Atoi(s) if err != nil { panic(err) } return age}用户传错年龄是正常错误:
func ParseAge(s string) (int, error) { age, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("parse age %q: %w", s, err) } if age < 0 { return 0, fmt.Errorf("age must be non-negative: %d", age) } return age, nil}panic 更适合表达程序员错误:
func MustRegister(name string, h Handler) { if name == "" { panic("handler name must not be empty") } if h == nil { panic("handler must not be nil") } registry[name] = h}这种 API 名字里带 Must,调用方知道它会在不满足前置条件时 panic。
recover 应该在边界兜底,不要偷偷继续
HTTP 服务里常见的 recover 中间件是合理的:
func Recover(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { deferfunc() { if v := recover(); v != nil { log.Printf("panic: %v\n%s", v, debug.Stack()) http.Error(w, "internal server error", http.StatusInternalServerError) } }() next.ServeHTTP(w, r) })}它的作用是保护进程,不是把 panic 当成业务错误吞掉。
不要这样写:
func Do() (err error) { deferfunc() { if v := recover(); v != nil { err = fmt.Errorf("do failed: %v", v) } }() // 大量正常业务逻辑 return nil}这会让调用方分不清是普通失败,还是代码已经进入了不可信状态。
还有一个老坑:recover 只能捕获同一个 goroutine 里的 panic。你在父 goroutine 里 defer recover,挡不住子 goroutine 崩掉。
gofunc() { deferfunc() { if v := recover(); v != nil { log.Printf("worker panic: %v\n%s", v, debug.Stack()) } }() runWorker()}()每个 goroutine 的边界要自己兜。
我现在写错误处理的顺序
现在我基本按这个顺序想:
1. 这个失败是不是调用方能处理的稳定分类?是的话,定义 sentinel error,并承诺 errors.Is。2. 这个失败是否需要携带结构化字段?是的话,定义 custom error type,并让调用方用 errors.As。3. 底层错误是不是我的 API 契约?是才用 %w暴露,不是就翻译成自己的错误。4. 多个独立错误是否都需要返回?是才用 errors.Join。5. 这是正常失败还是程序员错误?正常失败返回 error,程序员错误才考虑 panic。
Go 的错误处理看起来朴素,其实是在逼你把 API 边界想清楚。
下一篇继续看边界,不过换一个入口:JSON。几乎所有 HTTP API 都绕不开 encoding/json,它那些看似小的兼容细节,最后都会变成线上接口契约。
参考资料
• Working with Errors in Go 1.13[1] • Package errors[2] • Go 1.20 Release Notes: Wrapping multiple errors[3] • Defer, Panic, and Recover[4]
引用链接
[1] Working with Errors in Go 1.13: https://go.dev/blog/go1.13-errors[2] Package errors: https://pkg.go.dev/errors[3] Go 1.20 Release Notes: Wrapping multiple errors: https://go.dev/doc/go1.20#errors[4] Defer, Panic, and Recover: https://go.dev/blog/defer-panic-and-recover
夜雨聆风