引子:当你写下的第一个 Go 程序,错误已经在暗处等着你
好了,你学了几天 Go,能写 if err != nil 了,感觉得到了宇宙真理。然后你打开某个生产项目,看到 thousand lines 里面遍布着:
if err != nil {
return err
}
if err != nil {
return err
}
if err != nil {
return err
}
// 等等,你是不是在 ctrl+v?
有没有觉得哪里不对劲?有没有觉得自己写了个寂寞?
这就是我当年踩过的坑。今天把这个坑挖出来,给你看看里面到底埋了什么。
第一章:error 接口,你以为你懂,其实你不懂
所有教程都会告诉你 Go 的 error 是一个接口:
type error interface {
Error() string
}
然后它们会说:"你看,多简单!"
是挺简单。简单到让你误以为你掌握了 error 的全部真相。
但如果你真正在生产环境写过 Go,你会发现这个接口只告诉你"出了错",从来不告诉你"为什么出错了"、"怎么解决的"、"需不需要人工介入"。
error 接口本质上是一个字符串信封。你往里面塞什么字符串,它就返回什么字符串。但这个字符串是给人看的,不是给程序看的。程序想知道这个错误是不是网络超时,对不起,error 接口不提供这个能力。
这就是为什么 Go 社区后来搞出了那么多错误处理库——wrapped errors、pkg/errors、errors.Is、errors.As。官方甚至在 Go 1.13 把 errors 包升级了。为什么?因为原生的 error 接口实在太裸了,裸到生产代码没法用。
第二章:if err != nil 是止痛药,不是解药
现在我们来解剖一个典型的 Go 代码片段:
func GetUser(id string) (*User, error) {
user, err := db.Query("SELECT * FROM users WHERE id = ?", id)
if err != nil {
return nil, err // 经典操作:把错误原封不动往上扔
}
return user, nil
}
这段代码有什么问题?表面上没有问题。错误被正确地返回了。但如果你写过一段时间后端,你会发现一个致命问题:这个函数的错误返回值毫无信息量。
调用方拿到 error,能知道什么呢?最多知道"查数据库出错了"。但这是网络问题?还是 SQL 语法错误?还是表不存在?还是连接池耗尽了?调用方一无所知。
结果呢?调用方只能做最蠢的事情:
user, err := GetUser("123")
if err != nil {
// 我能怎么办?我也很绝望啊!
log.Error(err.Error())
return err // 继续往上扔,扔到 main 函数崩掉
}
这个错误链从头到尾没有任何有价值的上下文,只有错误在层层穿越,每穿越一层就丢失一层信息。这不是我说的,这是我在三个 Go 项目里亲眼看到的现实。
第三章:Wrapped Errors 解决了问题,但也带来了新问题
Go 1.13 引入的 fmt.Errorf 和 %w 包装,官方说:"现在你可以携带上下文了!"
if err != nil {
return fmt.Errorf("查询用户失败: %w", err)
}
好,现在错误链有了上下文。但问题来了——你有多少次见过这样的代码?
if err != nil {
return fmt.Errorf("查询用户失败: %w", err)
}
// 然后调用方:
user, err := GetUser("123")
if err != nil {
if strings.Contains(err.Error(), "查询用户失败") { // 别笑,我真的见过
// 哦,是用户查询的问题
}
}
用字符串包含来判断错误类型,这是 Go 新手的经典操作,也是最糟糕的操作。因为错误文本随时可能变,一改就崩,测试也没法写。
正确做法是用 errors.Is 和 errors.As:
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
// 或者自定义错误类型
var notFound *NotFoundError
if errors.As(err, ¬Found) {
// 知道是什么类型的错了
}
但问题是——你知道这些,你团队里的 junior 知道这些吗?当代码库里充斥着 if err != nil { return err } 的时候,errors.Is 和 errors.As 就成了一个没人用的摆设。
第四章:Sentinel Errors,命名即陷阱
Sentinel errors(哨兵错误)是什么?就是预定义的错误值,比如 sql.ErrNoRows、io.EOF。
var ErrUserNotFound = errors.New("user not found")
var ErrPermissionDenied = errors.New("permission denied")
听起来很美好对吧?用 errors.Is 来判断,类型安全,有 IDE 自动补全。
但现实是:一旦你的系统有多个层级(service 层、repository 层、handler 层),sentinel errors 就会变成一场命名灾难。
// repository 层
var ErrUserNotFound = errors.New("user not found")
// service 层
var ErrUserNotFound = errors.New("user not found") // 什么?又要定义一遍?
// 或者
import (
"myapp/repository"
)
// service 层直接用 repository.ErrUserNotFound?
// 等等,这两个错误是同一个值吗?
// 不,它们不是。因为 errors.New 每次调用都创建新实例。
所以如果你在 A 包定义了 ErrUserNotFound,然后在 B 包也定义了 ErrUserNotFound,用 errors.Is 去判断——会返回 false。因为它们不是同一个 error 实例。
这是一个极其隐蔽的坑。我在第二个项目才意识到这个问题。解决方案是什么?在一个公共包里定义所有 sentinel errors,或者用自定义错误类型替代 sentinel errors。但无论哪种方案,都需要团队有统一的约定,否则就是各写各的,最后串不起来。
第五章:Panic 和 Recover,你真的需要它们吗?
Go 还有一个其他语言不太有的机制:panic 和 recover。
panic 是什么?是让程序崩溃。一般用在"完全不应该发生的错误"上,比如程序初始化失败、配置文件格式错误这种。
但我见过有人把 panic 用在业务逻辑里:
func ProcessOrder(order *Order) {
if order.Status != "pending" {
panic("order status must be pending")
}
// ...业务逻辑
}
这种代码写的时候很爽,调试的时候想哭。因为 panic 不是 return,它会层层往上传播,直到被 recover 捕获,或者程序崩溃。在微服务架构里,这意味着一个订单处理失败可能导致整个服务重启。
我的建议是:panic 只用在真正的程序级错误上,永远不要用在业务逻辑里。业务逻辑的错误,永远用 error。
第六章:最反直觉的一点——错误是返回值,但处理方式决定架构
写了这么多,最想告诉你的其实是这一条。
Go 的错误处理之所以让人觉得"啰嗦",不是因为语法本身,而是因为大多数人对错误的处理方式是在逃避。
什么叫逃避?就是你看到一个 error,第一反应是 return err,而不是思考:这个错误应该往上传吗?应该被记录吗?应该触发告警吗?应该重试吗?应该返回给用户吗?应该降级吗?
一个真正好的错误处理,是把错误当作数据来处理,而不是当作异常来逃避。
举一个具体例子:
// 差的写法:遇到错误直接扔
func GetUser(id string) (*User, error) {
user, err := repo.FindUser(id)
if err != nil {
return nil, err
}
return user, nil
}
// 好的写法:理解错误的语义,做出正确决策
func GetUser(ctx context.Context, id string) (*User, error) {
user, err := repo.FindUser(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
// 用户不存在不是程序错误,是业务预期情况
return nil, nil
}
if errors.Is(err, repository.ErrNetwork) {
// 网络问题需要告警
metrics.IncNetworkError()
// 可以考虑重试
return getUserWithRetry(ctx, id)
}
// 未知错误,记录详情但对用户隐藏
log.Error("get user failed", "id", id, "err", err)
return nil, ErrInternalServer
}
return user, nil
}
看出来了吗?好的错误处理,需要你在写代码的时候就把每个错误类型的处理策略想清楚,而不是写 if err != nil { return err } 然后假装自己处理了错误。
尾声:错误处理是 Go 的最大哲学课
很多人吐槽 Go 的 if err != nil 太啰嗦。但我现在越来越觉得,这种"啰嗦"恰恰是 Go 设计者的高明之处。
它逼你面对每一个可能的失败点。它不让你用 try-catch 把错误藏起来,然后用 finally 假装什么都没发生。它让你在每一个错误出现的地方,都必须做出选择。
你选择沉默,错误就沉默。你选择忽略,错误就被忽略。你选择认真对待每一个 error,你才能写出真正健壮的系统。
所以别再 ctrl+v 你的 if err != nil 了。停下来想一想,这个错误到底是什么,应该怎么处理。
这个习惯,比学会任何 error 包装技巧都重要。
写完这篇我发现一个可怕的事实:教人怎么写好 error 的文章本身,错误处理往往也不怎么样。所以如果你发现这篇文章里有没说清楚的地方——那就是故意的,留给你自己思考的(不是)。