登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go log/slog 怎样给每个请求添加上下文

来源:17golang原创

时间:2026-10-14 01:44:06 136浏览 收藏

在 HTTP 服务里,一条错误日志如果只有 msg=database timeout,排查时还要回头猜它属于哪个请求。使用 Go 的 log/slog 时,比较稳妥的做法是:在请求入口建立一个带有固定请求属性的 Logger,把 context.Context 和这个 Logger 一起显式传给下游;写日志时使用 InfoContext、ErrorContext 或 LogAttrs。

这里有一个容易混淆的边界:slog 的标准 Handler 不会自动扫描 Context 并把其中的值变成日志字段。Context 负责贯穿请求和取消信号,Logger.With 负责附加结构化属性,两者要在调用代码里明确组合。

官方地址:https://pkg.go.dev/log/slog

先把请求范围内不变的字段固定下来

一条请求通常有一组在整个调用链里都不变的字段,例如请求 ID、HTTP 方法、路径和租户标识。这些字段不应该在每一次 Info 调用中重复拼接,而应该在入口处通过 Logger.With 形成一个请求级 Logger。

package main

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "log/slog"
    "net/http"
    "os"
)

// newRequestID 在没有上游请求 ID 时生成一个短的随机标识。
func newRequestID() string {
    var raw [8]byte
    if _, err := rand.Read(raw[:]); err != nil {
        // 生成失败时返回固定占位值,避免把随机错误写进响应流程。
        return "request-id-unavailable"
    }
    return hex.EncodeToString(raw[:])
}

// requestLogger 从入口请求提取稳定属性,不把整个 Request 放进日志字段。
func requestLogger(base *slog.Logger, r *http.Request) *slog.Logger {
    requestID := r.Header.Get("X-Request-ID")
    if requestID == "" {
        requestID = newRequestID()
    }
    return base.With(
        slog.Group("request",
            slog.String("id", requestID),
            slog.String("method", r.Method),
            slog.String("path", r.URL.Path),
        ),
    )
}

func handler(base *slog.Logger) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Request.Context 负责取消和超时;请求属性由 Logger.With 显式附加。
        logger := requestLogger(base, r)
        ctx := r.Context()

        logger.InfoContext(ctx, "request started")
        handleBusiness(ctx, logger)
        logger.InfoContext(ctx, "request finished")
        w.WriteHeader(http.StatusNoContent)
    })
}

func handleBusiness(ctx context.Context, logger *slog.Logger) {
    // 下游同时接收 ctx 和 logger,避免依赖隐式的全局请求状态。
    logger.InfoContext(ctx, "business handler entered", slog.String("component", "orders"))
}

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    http.ListenAndServe(":8080", handler(logger))
}

这段代码里有两个独立动作。requestLogger 把请求属性绑定到新的 Logger;InfoContext 则把当前请求的 Context 传给日志调用。这样下游函数既能响应取消,也能稳定拿到同一组字段。

HTTP请求、context.Context、请求级slog Logger与Handler之间的静态关系图
图1:请求属性进入 Logger、Context 与下游处理函数的静态关系图,不是运行截图。

为什么不能只把值塞进 Context

很多项目会把 request_id 放入 Context,然后直接调用 slog.InfoContext(ctx, "query"),期待输出自动带上这个值。这个期待并不成立。Context 值只有在你的 Handler 或调用代码主动读取、再转换为 slog.Attr 时,才会出现在日志中。

如果某个字段是日志链路的公共维度,直接绑定到 Logger 更清楚;如果它是取消、截止时间或链路传播需要的请求状态,则继续放在 Context。可以把职责简单分成下面几类:

信息推荐位置原因
request_id、method、path请求级 Logger会重复出现在多条日志中,适合一次绑定
取消信号、截止时间context.Context由下游操作共同遵守,不是日志字段仓库
当前 SQL 行数、响应状态码单次日志 Attr只在特定事件发生时有意义
密码、完整 Cookie、令牌不记录或先脱敏避免把凭据扩散到日志系统

如果确实需要让自定义 Handler 从 Context 读取链路信息,应该把这个行为写成 Handler 的明确契约,而不是让所有调用者猜测。使用内置 JSONHandler 时,最容易维护的方案仍然是入口处 With,调用处使用 InfoContext。

让下游日志保持同一组字段

请求 Logger 需要沿着调用链传递。不要在每个函数里重新调用 slog.Default(),也不要为了省一个参数把 Logger 放进 Context;显式参数能让依赖关系在函数签名上可见。

func loadOrder(ctx context.Context, logger *slog.Logger, orderID string) error {
    // 单次事件字段只在当前日志中出现,不污染整个请求 Logger。
    logger.DebugContext(ctx, "loading order", slog.String("order_id", orderID))

    order, err := findOrder(ctx, orderID)
    if err != nil {
        // 错误对象可以记录,但要确认其中没有密码、Token 等敏感内容。
        logger.ErrorContext(ctx, "load order failed",
            slog.String("order_id", orderID),
            slog.Any("err", err),
        )
        return err
    }

    logger.InfoContext(ctx, "order loaded",
        slog.String("order_id", order.ID),
        slog.Int("item_count", len(order.Items)),
    )
    return nil
}

func findOrder(ctx context.Context, orderID string) (Order, error) {
    // 真实项目中在这里执行数据库查询,并把 ctx 传给驱动以响应取消。
    return Order{ID: orderID}, nil
}

type Order struct {
    ID    string
    Items []string
}

这种写法让同一请求的日志天然共享 request.id、request.method 和 request.path,而 order_id、item_count 只附着在具体事件上。字段层级稳定后,日志查询可以按请求 ID 聚合,也可以按组件或订单号筛选。

用 Group 和 LogAttrs 保持字段结构

slog.Group 适合给相关字段划定命名边界。使用 JSONHandler 时,下面的请求字段会位于 request 对象内;使用 TextHandler 时,通常表现为带点号的键名。这样可以避免大型项目中多个子系统都使用 id 时发生歧义。

import "time"

func recordResponse(ctx context.Context, logger *slog.Logger, status int, elapsed time.Duration) {
    // LogAttrs 直接接收 Attr,适合已经确定字段类型的结构化事件。
    logger.LogAttrs(ctx, slog.LevelInfo, "request response",
        slog.Group("response",
            slog.Int("status", status),
            slog.Duration("elapsed", elapsed),
        ),
    )
}

上面的示例需要导入 time。如果日志调用非常频繁,LogAttrs 是比交替传入键和值更明确的选择;但不要为了追求形式而把可读性很差的属性列表塞进一行。先保持字段语义清楚,再针对真实的日志开销做优化。

Context、Logger.With、slog.Group、LogAttrs与JSONHandler边界关系说明图
图2:Context、请求 Logger、结构化属性和 Handler 的边界说明图,不是运行截图。

请求结束和取消时要注意什么

r.Context() 会在客户端断开、服务端超时或请求处理结束时发出取消信号。把它传给数据库、HTTP 客户端和下游函数,可以让这些操作及时停止;但日志是否写入成功仍由 Handler 和输出目标决定,不能把“调用了 ErrorContext”当成“日志已经被远端采集”。

还有三个常见边界:

  • 不要把 *http.Request、响应体或大对象整体作为日志字段;提取少量稳定属性即可。
  • 不要把用户输入直接当成字段名,也不要原样记录 Authorization、Cookie、密码和会话令牌。
  • 不要在 goroutine 中继续使用已经失效的请求 Context;如果任务有意脱离请求生命周期,应先明确它的取消策略和日志归属。

如果 HandlerOptions 设置了最低日志级别,还要记住日志参数会先被求值。昂贵的字符串拼接、序列化或调试数据构造,可能在日志被丢弃时仍然发生;可以传入结构化值、使用 LogValuer,或者在真正需要时才计算。

一份可复用的落地清单

检查点推荐做法
请求公共字段入口处用 Logger.With 一次绑定,必要时用 slog.Group 归组
Context 传递下游函数显式接收 context.Context,日志使用 InfoContext 等方法
事件字段使用类型明确的 slog.String、slog.Int 或 LogAttrs
错误记录记录可定位信息,先确认错误文本不含敏感数据
性能处理避免无条件计算昂贵字段,先观察真实日志开销再优化

几个容易混淆的问题

一定要把 Logger 放进 Context 才能跨函数传递吗?

不需要。更直观的方式是把 Logger 作为显式参数传递。Context 仍然作为第一个参数传递给需要取消、截止时间或链路信息的操作。

调用 InfoContext 后,Context 里的 request_id 会自动出现在 JSON 日志中吗?

不会。标准 Handler 不会自动枚举 Context 值。把 request_id 转成 slog.Attr 并绑定到请求级 Logger,或者实现明确读取 Context 的自定义 Handler。

每次日志都重新调用 logger.With 会有问题吗?

功能上可以,但公共字段会重复构造,也容易让字段层级失去一致性。稳定的请求字段在入口绑定,单次事件字段在具体日志调用处追加。

应该记录完整 URL 和请求头吗?

通常不应该默认记录。完整 URL 可能包含查询参数中的个人信息,部分请求头包含认证材料。按排障需要选择字段,并在进入日志 Handler 前做脱敏。

把请求 Logger 和 Context 分开理解,代码就会清楚很多:Context 负责请求生命周期,Logger 负责结构化字段,Handler 负责最终格式化和输出。入口处建立一次请求范围 Logger,下游显式传递并使用 InfoContext,就是大多数 HTTP 服务可以直接采用的起点。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>