Go语言的错误处理,让我从入门到放弃
写Go语言几年了,有个问题我至今没想明白:为什么Go团队要把错误处理做得这么……让人又爱又恨?
爱的是它简单,没有try-catch-finally那种嵌套地狱;恨的是,当你写完一个生产环境的微服务,回头看代码,发现60%的篇幅都是在if err != nil,而你真正的业务逻辑,反而被挤到角落里瑟瑟发抖。
今天不聊虚的,聊聊Go错误处理的真实面目,以及我在生产环境中踩过的那些坑。
错误即值:听起来很美,用起来很苦
Go的错误处理哲学是「错误就是返回值」。每个可能出错的函数,都返回一个error。调用方检查它,处理它,或者往上抛。这套逻辑简单得像个理想国。
但现实是怎样的?
看这段代码:
result, err := doSomething()
if err != nil {
return err
}
value, err := doAnotherThing(result)
if err != nil {
return err
}
final, err := doFinalThing(value)
if err != nil {
return err
}
这是Go里最经典的错误处理代码。我敢说10个Go开发者里有9个写过几乎一模一样的东西。它正确,它清晰,它……让人想睡觉。
更可怕的是,这种写法会在你的代码里疯狂繁殖。一个函数调用链稍微深一点,你的代码就变成了:
func handleRequest() error {
user, err := getUser()
if err != nil {
logger.Error("getUser failed", "error", err)
return fmt.Errorf("handleRequest: %w", err)
}
order, err := getOrder(user.ID)
if err != nil {
logger.Error("getOrder failed", "error", err, "userID", user.ID)
return fmt.Errorf("handleRequest: %w", err)
}
items, err := getItems(order.ID)
if err != nil {
logger.Error("getItems failed", "error", err, "orderID", order.ID)
return fmt.Errorf("handleRequest: %w", err)
}
// 业务逻辑在这里,只有3行
return calculateAndSave(items)
}
有没有一种窒息感?错误处理代码20行,真正的业务逻辑3行。读代码的人要翻过一座错误处理的垃圾山,才能看到一点点业务逻辑的闪光。
包装错误:一个让人又爱又痛的设计
Go 1.13引入了fmt.Errorf的%w包装符,号称可以「保留错误链」。听起来很美好:你可以把底层错误包装成高层错误,调用方可以解包看到根本原因。
但问题是,这玩意儿用起来真的太容易踩雷了。
func readConfig() error {
data, err := os.ReadFile("config.json")
if err != nil {
return fmt.Errorf("readConfig: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("readConfig: %w", err)
}
return nil
}
看起来很正常对吧?问题来了——如果json.Unmarshal报错了,你能区分是「文件不存在」还是「文件格式错误」还是「字段类型不匹配」吗?你不能。因为它们都被包装成了同一种错误。
于是你开始写解包代码:
err := readConfig()
if err != nil {
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
// 文件路径出错了
fmt.Println("配置文件不存在或路径错误:", pathErr.Path)
} else if errors.Is(err, io.EOF) {
// 空文件
fmt.Println("配置文件是空的")
} else {
// 其他错误
fmt.Println("读取配置失败:", err)
}
}
这下好了,你的错误处理代码比业务逻辑还长。
哨兵错误:Go社区的「约定俗成」
为了让错误处理稍微有点章法,Go社区发明了「哨兵错误」(Sentinel Errors)——也就是预定义的错误变量,供调用方做精确比较。
var (
ErrNotFound = errors.New("record not found")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
)
然后:
err := findUser(id)
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("user %d not found", id)
}
听起来很规范对吧?但问题是,随着项目变大,你会发现自己陷入了「错误定义通货膨胀」的困境:
- 每个层都定义自己的哨兵错误
- service层有
ErrUserNotFound,repository层有ErrRecordNotFound - 跨层调用时,你需要做错误转换,或者直接暴露底层错误
- 最后你的项目里有50个哨兵错误,但真正能精确处理的只有3个
更骚的是,有些人开始用字符串比较错误:
if strings.Contains(err.Error(), "connection refused") {
// 呵呵
}
我理解写这种代码的人,但我真的不认同他。这种比较方式脆弱得像纸糊的,一旦错误文案变了,代码就悄悄失效了。
Panic和Recover:被滥用的危险武器
Go里有panic和recover机制,官方说这是「用于真正不可恢复的错误」。但现实是,很多Go新手甚至老手都会滥用panic。
最典型的滥用场景:
func init() {
if err := loadConfig(); err != nil {
panic("failed to load config: " + err.Error()) // 别这样写!
}
}
这种写法在init阶段也许还能忍,但如果在业务代码里panic,那就是灾难。
我在一个遗留项目里见过这种代码:
func handleRequest(req Request) {
defer func() {
if r := recover(); r != nil {
log.Printf("recovered from panic: %v", r)
}
}()
// 一大堆业务逻辑,可能panic的地方藏得到处都是
}
这个defer recover看起来像是安全网,但实际上它把所有的panic都吞掉了,包括那些本不该panic的业务错误。结果是:请求失败了,但日志里只有一句「recovered from panic」,根本不知道是什么错误、哪行代码触发的。
正确的做法是:永远不要在业务逻辑里panic。panic是留给真正不可能发生的事情的,比如索引越界(这说明你的代码有bug)、数据类型断言失败(同样说明bug)。
我的生产环境实践:让错误处理有点尊严
说了这么多吐槽,也说说我自己的实践。
1. 用错误类型而非哨兵错误做精细化处理
type NotFoundError struct {
Resource string
ID string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s not found: %s", e.Resource, e.ID)
}
// 在需要的地方返回
func findUser(id string) (*User, error) {
user, ok := users[id]
if !ok {
return nil, &NotFoundError{Resource: "user", ID: id}
}
return user, nil
}
// 调用方可以精确处理
err := findUser(id)
if _, ok := err.(*NotFoundError); ok {
// 只处理「找不到」这种情况
return ErrUserNotFound
}
2. 错误日志要带上下文,但不要过度包装
// ✅ 好的写法:记录足够的信息,但不丢失原始错误
logger.Error("query failed",
"sql", query,
"args", args,
"error", err,
)
// ❌ 差的写法:过度包装,堆砌无用信息
return fmt.Errorf("QueryUserByID: %w", err)
3. 在边界做错误转换,而不是处处包装
我的习惯是:只在API边界(HTTP handler、gRPC handler)做错误转换,内部业务代码直接传递错误,不做任何包装。这样日志里看到的是原始错误,调试起来反而更清楚。
// API层:只在这里决定返回什么HTTP状态码
func handler(w http.ResponseWriter, r *http.Request) {
user, err := service.GetUser(id)
if err != nil {
switch {
case errors.Is(err, service.ErrNotFound):
http.Error(w, "user not found", 404)
case errors.Is(err, service.ErrUnauthorized):
http.Error(w, "unauthorized", 401)
default:
http.Error(w, "internal error", 500)
}
return
}
// ...
}
4. 考虑使用第三方错误处理库
如果你觉得Go标准库的错误处理还是不够用,可以试试github.com/hashicorp/go-multierror(合并多个错误)或者github.com/pkg/errors(更丰富的错误栈信息)。但我的忠告是:不要为了用库而用库,先把标准库的玩法玩透了再说。
Go团队听到了抱怨,但改变很慢
好消息是,Go团队确实在倾听社区的抱怨。Go 1.20开始,errors包增加了errors.Join,可以合并多个错误。Go 1.21引入了errors.Join的更多支持,以及新的slices和maps包减少了某些场景的错误处理。
坏消息是,Go 2的「错误处理改进」提案讨论了好几年,至今没有定论。Herb Sutter提出的check/handle语法(类似Algebraic Effects)听起来很美好,但距离落地还很远。
所以,在Go团队拿出革命性的错误处理方案之前,我们还是得继续写if err != nil。
最后说一句
Go的错误处理不完美,但它也不是一无是处。相比于异常机制,Go的错误是显式的,你知道每个函数都可能出错;错误是可控的,你可以选择处理、转换或者忽略;错误是可组合的,可以层层包装保持上下文。
问题在于,这套机制需要开发者有足够的自律——不要滥用panic,不要随意吞错误,不要用字符串比较错误。这些坑,我踩过,你也可能正在踩。
所以下次当你写完一个函数,发现错误处理代码比业务逻辑还长的时候,不要怀疑自己——这不是你的问题,这是Go设计者留给我们的「甜蜜负担」。
祝大家少写错误处理,多写业务逻辑。虽然这个愿望在Go里暂时还不能实现,但梦想还是要有的嘛。