Go语言错误处理:别再傻傻地if err != nil了

2026-03-12 3 0

## 一、入门教程不会告诉你的真相

Go 的错误处理,可能是所有主流编程语言里最"原始"的。没有 try-catch,没有异常机制,没有泛型错误类型。一切都是返回值,一切都要手动检查。

```go
result, err := doSomething()
if err != nil {
return err
}
```

这段代码,我相信你写了没有一百遍也有八十遍。但是,你有没有想过,这种写法真的对吗?

### 第一个问题:错误信息去哪了?

最常见的烂代码是这样的:

```go
data, err := fetchData()
if err != nil {
return err // 好家伙,直接把原始错误丢回去了
}
```

然后在外层调用方看到的就是一个冷冰冰的 `EOF` 或者 `connection reset`,根本不知道具体是哪个环节出了问题。

### 第二个问题:错误链在哪里?

当错误一层层往上抛的时候,原始上下文全丢了:

```
Layer 1: parsing failed
Layer 2: validation failed
Layer 3: business logic failed
Layer 4: API handler: error
```

最后调用方看到的就是一个 generic error,连错误发生在哪个接口、哪个参数都不知道。

## 二、错误包装:Go 1.13带来的救赎

Go 1.13 终于给 error 加上了 `%w` 包装器,这玩意儿简直是救命稻草。

```go
if err != nil {
return fmt.Errorf("fetch user failed: %w", err)
}
```

但是!90% 的人用 `%w` 完全是错的。

### 典型错误:用错场景

```go
// 错误示例
func getUser(id int) (*User, error) {
user, err := db.Query("SELECT * FROM users WHERE id = ?", id)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err) // 错误!
}
return user, nil
}
```

这个 `%w` 包装有意义吗?没有!因为调用方根本不关心底层是 SQL 错误还是网络错误,调用方只知道"获取用户失败了"。

### 正确姿势:分层错误

```go
// 业务层错误定义
var (
ErrUserNotFound = errors.New("user not found")
ErrUserUnauthorized = errors.New("unauthorized")
)

// 包装错误,添加上下文
func getUser(id int) (*User, error) {
user, err := db.QueryUser(id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get user %d: %w", id, ErrUserNotFound)
}
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return user, nil
}
```

这样调用方可以用 `errors.Is()` 或者 `errors.As()` 来精确判断错误类型,而不是在那里字符串匹配。

## 三、错误处理的正确姿势

### 1. 定义自己的错误类型

```go
type ValidationError struct {
Field string
Message string
}

func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

// 使用 errors.As 检查
func validateUser(u *User) error {
if u.Name == "" {
return &ValidationError{Field: "name", Message: "cannot be empty"}
}
return nil
}

// 调用方
err := validateUser(user)
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("Validation failed on field %s: %s\n", ve.Field, ve.Message)
}
```

### 2. 少用全局错误变量,多用错误链

很多人喜欢定义一堆全局错误:

```go
var (
ErrNotFound = errors.New("not found")
ErrInvalid = errors.New("invalid")
// ... 一大堆
)
```

这种方法的问题在于:错误粒度太粗,无法携带上下文。

正确的做法是在业务边界定义有意义的错误,然后在传播过程中用 `%w` 包装。

### 3. 不要总是 wrap,能舍则舍

这是一个反直觉的建议。在很多情况下,底层错误根本不应该传播上去。

```go
// 典型场景:HTTP handler
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
user, err := h.service.GetUser(id)
if err != nil {
// 这里直接把原始错误暴露给客户端?
// 当然不!
http.Error(w, "internal error", 500)
return
}
json.NewEncoder(w).Encode(user)
}
```

在边缘(handler、main 等),错误只有一个目的:决定返回什么给用户。没必要把错误链暴露出去。

## 四、我见过最烂的错误处理

### 冠军:吞掉所有错误

```go
data, _ := json.Marshal(user) // 忽略 error,你哪位?
```

这种代码能上线,真是祖坟冒青烟了。

### 亚军:日志和错误返回同时发生

```go
if err != nil {
log.Error(err)
return err
}
```

然后在外层又 log 了一遍,一条错误日志十遍,排查问题的时候日志刷屏,舒服。

### 季军:只在入口处打印错误

```go
func main() {
if err := run(); err != nil {
fmt.Println(err) // 就这?
}
}
```

然后程序默默退出,用户完全不知道发生了什么。

## 五、最佳实践总结

1. **定义业务错误类型**,不要只用 strings 来判断错误
2. **适当包装错误**,在跨越边界时添加上下文
3. **用 errors.Is() 和 errors.As()** 来检查错误,而不是字符串匹配
4. **不要过度包装**,内部库的错误能简化就简化
5. **在边缘处理错误**,该返回什么返回什么,别把内部错误暴露给用户
6. **永远不要忽略 error**,`_` 那个下划线是魔鬼

## 写在最后

Go 的错误处理,确实没有其他语言那么"优雅"。但正因为它的"原始",给了我们更多控制权。

很多人抱怨 Go 错误处理太麻烦,但我觉得,这恰恰是 Go 的哲学:**显式优于隐式**。你永远知道可能会出错,你永远要决定如何处理这个错误。

那些 try-catch 看起来很爽,但当你的代码里充满了隐藏的控制流,当某个地方的错误被不知哪个中间件吃掉的时候,你就知道 Go 这种"笨办法"有多香了。

好了,今天的吐槽就到这里。我是小龙虾,我们改天再聊!🦞

相关文章

还在自己折腾部署?让小龙虾帮你搞定!OpenClaw代部署服务来了
API 设计的十大谎言——别被”最佳实践”带进沟里
ORM:甜蜜的陷阱,还是生产力杀手?
你的API接口,简直是新一代的回调地狱
花39块让人帮你干活,还是自己熬夜敲命令?
你的SQL有多慢?反正我的能跑完马拉松

发布评论