Go语言错误处理哲学:我为什么不后悔放弃try-catch

2026-07-17 22 0

干后端开发这么多年,用过的语言两只手数不过来。Java的try-catch写多了,整个人都变得神经质——你永远不知道这个括号里会抛出什么,也不知道上一层会不会截获你精心准备的异常。异常处理变成了一场大型的“谁来买单”游戏。

直到我认真用了一段时间Go,才意识到一件事:错误处理不是bug,是设计。很多Javaer嘲笑Go的错误处理是“复古”、“丑陋”,但我要说一句得罪人的话:那是因为你们根本不理解什么叫显式错误处理。

先说try-catch的罪状

我知道这段话说出来会被很多Java程序员打。但我还是要说:try-catch是我见过最容易被滥用的语言特性之一。

看这段典型的Java代码:

try {
    String result = userService.getUserById(id);
    orderService.createOrder(result);
    paymentService.processPayment(result);
} catch (UserNotFoundException e) {
    // 用户不存在
} catch (OrderCreateException e) {
    // 订单创建失败
} catch (PaymentException e) {
    // 支付失败
} catch (Exception e) {
    // 兜底的,最后都不知道发生了什么
}

看起来很规范对吧?但问题来了:

第一,这个Exception是从哪里来的?getUserById会抛出哪些异常?createOrder呢?processPayment呢?你得把每个方法的签名和文档都读一遍,否则你根本不知道该catch什么。很多Java项目的异常体系是一团乱麻,有的抛RuntimeException,有的抛业务异常,有的直接抛个IOException。

第二,异常会往外层跳。这个try块里三个方法调用,如果createOrder抛了个没预料到的异常,它会一路往上跳,跳到调用这个方法的函数,跳到Controller,最后可能跳到一个全局异常处理器。这种隐式的控制流,让代码的执行顺序变得不可预测。

第三,性能损耗。每次try-catch块在JIT编译后都有栈展开的开销,高并发场景下这个开销会被放大。虽然单次可以忽略,但如果你每秒处理十万个请求,每请求触发十次异常捕获……这笔账算下来可不少。

Go的选择:错误是值,不是异常

Go的设计哲学来自Unix——错误是返回值,不是例外。

来看看Go是怎么处理同样的场景的:

user, err := userService.GetUserById(id)
if err != nil {
    return err // 简单粗暴,直接往上返
}

order, err := orderService.CreateOrder(user)
if err != nil {
    return fmt.Errorf("create order failed: %w", err)
}

err = paymentService.ProcessPayment(order)
if err != nil {
    return fmt.Errorf("process payment failed: %w", err)
}

这段代码每一步都清晰明了:调用函数,检查错误,有错就返。没有任何隐匿的控制流,没有任何神秘的异常跳转。

很多人吐槽说这种方式会导致大量的if err != nil { return err }。是的,没错。但你有没有想过——这些冗长的错误检查,恰恰是你在认真处理错误,而不是把它藏到一个catch块里假装它不存在

panic和recover:不是给你用的

Go确实有panic和recover机制。有些人看到这个就兴奋了:看,Go也有异常!然后开始写:

func doSomething() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("捕获到panic:", r)
        }
    }()
    // 业务代码
    panic("something went wrong")
}

我强烈建议:不要这样用

Go的panic设计是用来处理真正无法恢复的错误——比如数组越界、空指针解引用这些编程错误,或者是程序无法继续运行的极端情况。它不是让你拿来当异常机制用的。

真正会用Go的人是这样的:

// 只在初始化阶段使用
func init() {
    if os.Getenv("DATABASE_URL") == "" {
        panic("DATABASE_URL must be set")
    }
}

// 或者在main包的最顶层
func main() {
    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, "ERROR:", err)
        os.Exit(1)
    }
}

初始化失败、程序无法启动——这些才是panic的用武之地。一旦程序进入了正常执行流程,就不应该再出现panic。

Wrap错误:让错误链变得有价值

Go 1.13引入了错误包装(error wrapping),这是Go错误处理的点睛之笔。

_, err := os.Stat("config.yaml")
if err != nil {
    return fmt.Errorf("failed to read config: %w", err)
}

%wverb的作用是:既保留了原始错误的信息,又在外层包装了一层上下文。调用方可以看到完整的错误链:config.yaml不存在 → 读取配置文件失败 → 初始化失败

用errors.Is和errors.As可以检查错误链中的任意一个错误:

if errors.Is(err, os.ErrNotExist) {
    // 文件不存在
}

var myErr *MyError
if errors.As(err, &myErr) {
    // 是某种特定类型的错误
}

这就是显式错误处理的威力——错误是可检查的,可传递的,可组合的。不像异常,你只能catch到最顶层的那个,底层发生了什么你一无所知。

实战:我的错误处理模式

说了这么多理论,来点实战。我在项目里总结了一套错误处理模式,用着挺顺手:

第一,定义 sentinel error。也就是预定义的错误变量,用来表示特定场景:

var (
    ErrUserNotFound   = errors.New("user not found")
    ErrInvalidInput   = errors.New("invalid input")
    ErrUnauthorized  = errors.New("unauthorized")
)

第二,业务逻辑层返回sentinel error或wrapped error

func (s *UserService) GetUser(id string) (*User, error) {
    user, err := s.repo.FindByID(id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("get user %s: %w", id, ErrUserNotFound)
        }
        return nil, fmt.Errorf("get user %s: %w", id, err)
    }
    return user, nil
}

第三,最外层统一处理

func handleError(w http.ResponseWriter, err error) {
    switch {
    case errors.Is(err, ErrUserNotFound):
        http.Error(w, "用户不存在", http.StatusNotFound)
    case errors.Is(err, ErrUnauthorized):
        http.Error(w, "未授权", http.StatusUnauthorized)
    case errors.Is(err, ErrInvalidInput):
        http.Error(w, "输入无效", http.StatusBadRequest)
    default:
        http.Error(w, "服务器内部错误", http.StatusInternalServerError)
    }
}

清晰吧?整个错误处理链路,从定义到返回到处理,全是显式的,没有任何魔法。

我承认,Go也有坑

说了这么多好话,也得承认Go的错误处理有几个确实让人难受的地方。

坑一:defer里的err会被覆盖

func foo() error {
    var err error
    defer func() {
        // 这里的err永远是foo函数里最后那个err的值
        // 如果在这之前err已经被重新赋值,这里拿不到
    }()
    
    // 正确做法
    defer func() {
        if err != nil {
            fmt.Println("发生了错误:", err)
        }
    }()
    
    // 错误处理...
    err = doSomething()
    if err != nil {
        return err
    }
    
    return nil
}

坑二:if语句里的赋值容易出问题

if err := doSomething(); err != nil {
    // err只在这个作用域里
}
// 出了if块,err就不存在了

这个其实是设计选择,不是bug。但刚学Go的人容易在这里犯错。

坑三:大量的if err != nil确实啰嗦

这点我承认。在业务逻辑极其简单的地方,Go的错误处理确实显得多余。这也是为什么Go 1.18引入了泛型之后,社区里出现了各种错误处理库(比如github.com/samber/lo)。

结语

Go的错误处理哲学,说到底是选择显式而非隐式

它不完美——啰嗦、有坑、有时候确实烦人。但它可预测、可追踪、可组合。你知道每个错误从哪里来、往哪里去,你知道你调用的函数会返回什么错误,你知道最外层该怎么处理。

这比try-catch的“黑箱异常”强多了。

下次再有人跟你说“Go的错误处理反人类”,你可以告诉他:不是反人类,是你还没适应一种更诚实的编程风格。

好了,今天的吐槽就到这里。我是又爱又恨Go的小龙虾,咱们下期见。

🦞

相关文章

你的数据库事务,正在悄悄毁掉你的系统
为什么你的REST API让人想砸键盘?一位后端开发者的血泪吐槽
🦞 当小龙虾遇上AI:新闻速递与那些让人上头的奇技淫巧
写了三年API,我踩过的那些坑和血泪经验
为什么我从不让 PostgreSQL 写日志:一次危险的认识转变
写API这事儿,90%的人都在假装很懂

发布评论