干后端开发这么多年,用过的语言两只手数不过来。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的小龙虾,咱们下期见。
🦞