Featured image of post Go 开发里的几个小技巧:defer、context、错误处理和日志边界

Go 开发里的几个小技巧:defer、context、错误处理和日志边界

整理 Go 项目里几个影响长期维护的小习惯:defer 使用边界、context 传参、错误包装和日志记录位置。

这篇不是系统教程,只是整理几个 Go 项目里经常会用到的小习惯。

这些点都不复杂,但如果一开始没想清楚,项目变大之后会很难受。

我以前觉得 Go 项目的“工程味”主要靠目录结构,后来发现更常出问题的是这些细节:资源释放放在哪、错误在哪一层补上下文、日志谁来打、context 到底传什么。它们单独看都很小,叠起来就是项目维护成本。

defer 不只是少写一行 Close

defer 最常见的用法是释放资源:

1
2
3
4
5
f, err := os.Open(name)
if err != nil {
    return err
}
defer f.Close()

这当然没问题,但有两个细节我后来比较注意。

第一个是循环里不要随手 defer。比如处理很多文件时,如果在循环里 defer close,文件句柄会等函数返回才释放,不是每次循环结束就释放。

1
2
3
4
5
for _, name := range names {
    if err := handleFile(name); err != nil {
        return err
    }
}

把单次处理拆到小函数里,反而更清楚。

第二个是 defer 里如果有错误,不要完全忽略。比如写文件时,Close 也可能返回错误。日志可以记,或者在需要时合并返回。

写文件时我会更谨慎一点,因为有些错误可能在 flush/close 时才暴露:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func writeFile(name string, data []byte) (err error) {
    f, err := os.Create(name)
    if err != nil {
        return err
    }
    defer func() {
        if closeErr := f.Close(); err == nil && closeErr != nil {
            err = closeErr
        }
    }()

    _, err = f.Write(data)
    return err
}

这个写法不是每个地方都需要,但在写配置、写导出文件、写临时产物时,我会比读文件更认真处理 Close

context 只放请求生命周期相关的东西

context.Context 很好用,但也很容易被当成万能参数袋。

我现在比较坚持一个边界:context 里放取消信号、超时、trace id、request id 这类请求生命周期相关信息,不放业务参数。

不太推荐这样:

1
ctx = context.WithValue(ctx, "user_id", userID)

如果 userID 是业务逻辑必须显式依赖的东西,直接传参数更清楚:

1
2
3
func ListOrders(ctx context.Context, userID int64) ([]Order, error) {
    // ...
}

context 的价值是把取消、超时、跨调用链信息传下去,不是把函数签名藏起来。

错误处理要留住上下文

Go 里错误处理最容易写成这样:

1
2
3
if err != nil {
    return err
}

小函数里问题不大,但跨几层调用之后,最终日志只看到 connection refused,就不知道是哪一步失败了。

我更习惯在边界位置包一层上下文:

1
2
3
if err != nil {
    return fmt.Errorf("query user by id %d: %w", id, err)
}

然后在上层需要判断错误类型时,用 errors.Iserrors.As

1
2
3
if errors.Is(err, sql.ErrNoRows) {
    return nil, ErrUserNotFound
}

这样既能保留原始错误,又能让日志里有足够线索。

日志边界不要太散

日志不是越多越好。很多项目的问题不是没日志,而是日志到处打,真正出问题时看不出主线。

我的习惯是:

  1. 入口层记录请求开始、结束、耗时和 trace id。
  2. 业务层只记录关键状态变化。
  3. 基础设施层返回带上下文的错误,不随便打 error 日志。
  4. 最外层统一把错误打出来。

否则一个错误从 DAO 层、service 层、handler 层各打一遍,日志系统里看起来像出了三个问题。

Go 1.21 之后标准库里有 log/slog,结构化日志更方便了。即使不用 slog,也建议日志字段固定下来,比如 request_iduser_idmethodpathlatency_ms

我会尽量避免这种日志:

1
log.Printf("failed: %v", err)

它的问题不是不能看,而是上下文太少。更好的方式是把动作、对象和链路字段带上:

1
2
3
4
5
logger.Error("create order failed",
    "user_id", userID,
    "request_id", requestID,
    "err", err,
)

日志的边界想清楚以后,错误包装和日志字段会互相配合,而不是每一层都打一遍。

小结

这些技巧都不算高级,但很影响项目长期维护:

  • defer 适合资源释放,但注意循环和 Close 错误。
  • context 传生命周期,不传业务参数。
  • 错误返回要带动作上下文。
  • 日志尽量在边界统一记录,不要每层都打一遍。

Go 的代码很多时候不是写不出来,而是写久了之后还能不能看懂。这个区别挺大。

参考资料

本文采用 CC BY-NC-SA 4.0 协议,转载请注明出处。
记录工程实践和排障过程。
使用 Hugo 构建
主题 StackJimmy 设计