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

Go log/slog 怎样为 slog 添加请求上下文字段

来源:17golang原创

时间:2026-10-18 07:31:21 252浏览 收藏

为 slog 添加请求字段,可以在请求入口从 context.Context 取出约定的请求标识,再用 base.With(slog.String("request_id", id)) 派生请求专属 Logger,向下传递并调用 InfoContext。传入 Context 与输出它里面的值是两件事:内置 JSONHandler 不会自动把任意 Context 值变成日志字段。

这个做法适合请求标识在入口确定、多个业务函数需要复用它的服务。下面使用标准库实现明确的字段绑定,不包装自定义 Handler,也不依赖第三方日志库。官方文档:https://pkg.go.dev/log/slog。

先确定谁负责把请求字段变成日志属性

把职责分成三层:Context 携带请求数据;入口函数选择允许记录的字段;Logger 复用已经绑定的属性。事件本身的属性仍在具体日志调用处补充。例如 service 属于基础 Logger,request_id 属于请求 Logger,operation 属于某条事件。

动作负责什么不负责什么
Context.Value按约定键读取请求值不是日志属性枚举器
Logger.With返回绑定属性的 Logger不会修改原 Logger 的请求字段
InfoContext把 Context 交给日志处理链不承诺自动提取所有业务值
Context 约定字段与请求 Logger 属性绑定的静态关系
图1:请求字段绑定结构图。数据约定与日志对象分组表示职责边界,无箭头连线只表示读取或绑定关系,不表示实测执行过程。

在请求入口派生 Logger 并向下传递

保存下面的完整程序为 main.go。requestIDKey 是本包私有键类型;字段提取函数只认识这一种键。实际服务应由可信入口取得或生成请求标识,再通过同一组封装函数设置和读取;不要让各业务包各自定义一个看似同名、实际不同的键类型。

package main

import (
    "context"
    "log/slog"
    "os"
)

// 私有键类型避免与其他包常用的字符串键发生碰撞。
type requestIDKey struct{}

func withRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, requestIDKey{}, id)
}

func requestLogger(ctx context.Context, base *slog.Logger) *slog.Logger {
    // 只提取约定字段,不尝试枚举整个 Context。
    id, ok := ctx.Value(requestIDKey{}).(string)
    if !ok || id == "" {
        return base
    }
    return base.With(slog.String("request_id", id))
}

func handle(ctx context.Context, base *slog.Logger) {
    // 每个请求绑定自己的值;base 始终不变。
    logger := requestLogger(ctx, base)
    logger.InfoContext(ctx, "请求进入")
    lookup(ctx, logger)
}

func lookup(ctx context.Context, logger *slog.Logger) {
    // 事件字段只属于本条日志,请求字段由 logger 复用。
    logger.InfoContext(ctx, "查询准备", slog.String("operation", "lookup"))
}

func main() {
    // 演示写到标准输出;日志器不拥有也不关闭这个输出流。
    base := slog.New(slog.NewJSONHandler(os.Stdout, nil)).
        With(slog.String("service", "catalog-demo"))

    // 两个固定标识仅供示例,不是生产请求编号生成方案。
    ctxA := withRequestID(context.Background(), "demo-A")
    ctxB := withRequestID(context.Background(), "demo-B")
    handle(ctxA, base)
    handle(ctxB, base)
}

handle 同时接收 Context 与 Logger:前者保留请求取消和期限等语义,后者声明日志依赖。lookup 没有重新读取请求字段,因而不会在每个事件上重复实现键名、缺失值和字段白名单规则。若现有代码本来就显式传递请求 ID,也可以直接用该参数派生 Logger,不必为了日志把所有参数塞进 Context。

用两组请求观察字段是否串用

# 在 main.go 所在目录执行;本文未实际运行此示例。
go run main.go

按代码结构,正常输出时应有四条日志事件:demo-A 的两条带同一 request_id,demo-B 的两条带另一个 request_id,每条均带 service;只有“查询准备”事件带 operation。时间值由运行环境产生,不应与固定样本逐字比较。这是供读者运行后的判断条件,本文不提供虚构输出。

检查时重点看属性归属:若下层日志缺少 request_id,先看是否误用了 base 或包级 slog.InfoContext;若 A 的字段出现在 B 的记录里,先看请求 Logger 是否被保存成共享变量。示例故意顺序调用两个请求,不声称已经覆盖并发压测或输出故障。

基础服务属性请求标识事件属性与 JSON 输出的结构
图2:日志属性归属结构图。service、request_id 与 operation 分别对应不同作用域,JSONHandler 负责格式化,不负责业务字段提取。

避免把请求状态写进全局 Logger

不要在每个请求里调用 slog.SetDefault 来“切换当前请求”。默认 Logger 是进程范围的选择,不是请求局部变量。也不要忽略 base.With(...) 的返回值,然后继续使用 base 并期待它已经包含请求字段。把派生结果保存在局部变量中,依赖关系会更直接。

这种设计的代价是函数签名多一个 Logger 参数;收益是字段来源可见,避免隐式依赖。适合在入口固定的请求属性只绑定一次。如果认证后才得到新的可记录信息,应在那个边界派生新的 Logger 并传给后续函数;之前的 Logger 不会自动更新,之前的日志也不会被补写。

示例只放字符串属性。不要把整个请求、认证头、Cookie、请求体或可能被继续修改的业务对象直接交给 slog.Any。先确定字段白名单和脱敏策略;请求关联标识也不应直接当作高基数指标标签。本方案只做日志关联,不自动提供分布式追踪或日志写入可靠性保证。

请求日志的几个边界问题

没有 request_id 时怎么办?

示例返回 base,不伪造一个“unknown”冒充真实关联值。业务可以改为显式缺失标记,但要统一约定;错误类型或空字符串都会按缺失处理。

Context 取消后,日志一定不再输出吗?

不要把日志调用当作业务取消检查。需要停止业务时应单独检查取消信号;传 Context 的目的,是给处理链保留上下文,不保证它等同于日志开关。

后续再传一个 request_id 会覆盖原值吗?

不要依赖重复键实现覆盖。入口绑定后,事件层应禁止再写同名请求键,否则可能产生重复 JSON 键和下游解析歧义。需要新作用域时,明确字段名或派生策略。

落地时可以沿三条线检查自己的代码:入口是否只有一个字段提取约定;业务函数是否接收并使用正确 Logger;事件层是否只补充事件属性。Context 键与值的用途可参阅 https://pkg.go.dev/context#WithValue,slog 的设计背景见 https://go.dev/blog/slog。

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