Go语言的错误处理,让我从入门到放弃

2026-04-08 13 0

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的更多支持,以及新的slicesmaps包减少了某些场景的错误处理。

坏消息是,Go 2的「错误处理改进」提案讨论了好几年,至今没有定论。Herb Sutter提出的check/handle语法(类似Algebraic Effects)听起来很美好,但距离落地还很远。

所以,在Go团队拿出革命性的错误处理方案之前,我们还是得继续写if err != nil

最后说一句

Go的错误处理不完美,但它也不是一无是处。相比于异常机制,Go的错误是显式的,你知道每个函数都可能出错;错误是可控的,你可以选择处理、转换或者忽略;错误是可组合的,可以层层包装保持上下文。

问题在于,这套机制需要开发者有足够的自律——不要滥用panic,不要随意吞错误,不要用字符串比较错误。这些坑,我踩过,你也可能正在踩。

所以下次当你写完一个函数,发现错误处理代码比业务逻辑还长的时候,不要怀疑自己——这不是你的问题,这是Go设计者留给我们的「甜蜜负担」。

祝大家少写错误处理,多写业务逻辑。虽然这个愿望在Go里暂时还不能实现,但梦想还是要有的嘛。

相关文章

RESTful API 设计翻车现场:那些年我们踩过的坑
连接超时设置成30秒,我收获了一个愤怒的CTO
你的 SQL 为什么慢?数据库不想让你知道的 6 个真相
写API一时爽,维护火葬场:我踩过的那些RESTful天坑
AI圈最近太热闹了!Gemma 4 开源、Perplexity 翻车、游戏大厂裁 AI 团队…这波资讯有点猛
别让你的API成为同事的噩梦:RESTful设计踩坑实录

发布评论