当前位置:首页 > 文章列表 > Golang > Go教程 > slog.Handler 添加请求 ID 属性的工程实现

slog.Handler 添加请求 ID 属性的工程实现

来源:17golang原创 2026-11-12 16:32:37 0浏览 收藏

在 HTTP 服务里,日志最怕“看起来都写了,串不起来”。请求已经有了唯一 ID,但业务函数里直接调用 slog.Info,最终日志仍然没有 request_id,排查一次慢请求就要靠时间和消息猜。

更稳妥的做法是:在入口把请求 ID 放进 context.Context,再用一个很薄的 slog.Handler 包装器,在 Handle 中读取它并补成属性。这样日志格式、级别过滤、WithAttrs 和 WithGroup 仍交给原 Handler 处理。

官方文档:https://pkg.go.dev/log/slog

先把请求 ID 放进 context,而不是全局变量

请求 ID 属于一次请求的生命周期,应该沿着调用链传递。使用私有 key 类型可以避免不同包之间发生 key 冲突;读函数返回 (string, bool),让缺失状态显式可见。

package requestlog

import "context"

// requestIDKey 使用私有类型,避免不同包用相同字符串造成 context 冲突。
type requestIDKey struct{}

// WithRequestID 把请求 ID 放入当前请求派生出来的 context。
func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, id)
}

// RequestID 读取请求 ID;第二个返回值区分“没有设置”和“设置了空字符串”。
func RequestID(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(requestIDKey{}).(string)
	return id, ok && id != ""
}

这里没有把请求 ID 放在包级变量中。全局变量会让并发请求互相覆盖,也无法表达一次内部调用究竟属于哪个请求。

让 Handler 只做一件事:把 context 值变成日志属性

slog.Handler 的关键入口是 Handle(ctx, record)。包装器不负责重新格式化日志,而是把请求 ID 补进 Record 后继续交给下一个 Handler。下面的实现同时转发四个行为:Enabled 决定级别是否输出,Handle 注入字段,WithAttrs 保持 Logger 的固定属性,WithGroup 保持分组路径。

请求上下文经过 slog Handler 包装器后进入结构化日志的静态说明图
图1:请求上下文经过 Handler 包装器后,request_id 进入结构化日志;这是静态说明图,不是运行截图。
package requestlog

import (
	"context"
	"log/slog"
)

const requestIDAttr = "request_id"

// handler 在不改变底层输出格式的前提下补充请求级属性。
type handler struct {
	next slog.Handler
}

// NewHandler 用 request ID 包装 TextHandler 或 JSONHandler。
func NewHandler(next slog.Handler) slog.Handler {
	return &handler{next: next}
}

// Enabled 直接交给底层 Handler,避免包装器改变日志级别策略。
func (h *handler) Enabled(ctx context.Context, level slog.Level) bool {
	return h.next.Enabled(ctx, level)
}

// Handle 从 context 读取 request ID,再把记录交给底层 Handler。
func (h *handler) Handle(ctx context.Context, record slog.Record) error {
	if id, ok := RequestID(ctx); ok && !hasRequestID(record) {
		// Clone 避免修改仍可能被其他 Handler 使用的 Record 共享状态。
		record = record.Clone()
		record.AddAttrs(slog.String(requestIDAttr, id))
	}
	// Handler 不应因 context 已取消就停止写日志,取消信息本身也需要被记录。
	return h.next.Handle(ctx, record)
}

// WithAttrs 保留 logger.With 添加的固定属性和底层 Handler 的预格式化能力。
func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler {
	return &handler{next: h.next.WithAttrs(attrs)}
}

// WithGroup 保留 logger.WithGroup 的分组语义。
func (h *handler) WithGroup(name string) slog.Handler {
	return &handler{next: h.next.WithGroup(name)}
}

// hasRequestID 避免调用方显式传值时得到两个同名字段。
func hasRequestID(record slog.Record) bool {
	found := false
	record.Attrs(func(attr slog.Attr) bool {
		if attr.Key == requestIDAttr {
			found = true
			return false
		}
		return true
	})
	return found
}

这段代码有两个容易被忽略的点。第一,修改 Record 前先 Clone,不要直接把共享记录当作自己的可变对象。第二,WithAttrs 和 WithGroup 不能返回一个“只实现 Handle”的简化包装器,否则调用方使用 logger.With(...) 时会丢失底层 Handler 的优化或分组行为。

接入 HTTP 请求:一定要把 context 传给日志方法

Handler 只能读取“传到它手里”的 context。HTTP 中间件应从请求派生新 context,再用 r.WithContext 把它交给后续处理器。记录日志时使用 InfoContext 或 LogAttrs,不要在同一位置改回无 context 的 Info。

InfoContext 传递请求上下文而 Info 缺少 request_id 的静态对比图
图2:InfoContext 会把请求 context 传到 Handler,而不带 context 的 Info 无法自动得到 request_id;这是静态说明图。
package main

import (
	"log/slog"
	"net/http"
)

// requestIDMiddleware 在请求入口建立派生 context,后续业务只读取它。
func requestIDMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		if id == "" {
			// 生产环境可替换为统一的 ID 生成器;这里仅演示入口职责。
			id = newRequestID()
		}
		ctx := requestlog.WithRequestID(r.Context(), id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func handleOrder(logger *slog.Logger) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// InfoContext 才会把 r.Context 传给 requestlog.handler。
		logger.InfoContext(r.Context(), "开始处理订单", slog.String("route", r.URL.Path))
		w.WriteHeader(http.StatusNoContent)
	})
}

如果请求头没有 ID,示例中的 newRequestID 只是占位符,生产实现应使用统一的随机或代理透传策略,并明确是否信任外部传入值。本文的重点不是 ID 算法,而是它进入 context 后如何稳定到达日志 Handler。

四个边界决定方案是否可靠

1. 显式字段和 context 字段不要无意重复

调用方可能已经写了 slog.String("request_id", "manual-id")。示例通过 hasRequestID 让显式字段优先;另一种策略是统一以 context 为准,但必须在团队规范中写清楚,不能让不同 Handler 各自决定。

2. 不要从 WithAttrs 读取请求 ID

WithAttrs 适合服务名、版本、实例等 Logger 级别固定属性。请求 ID 每条请求都不同,且只在调用现场的 context 中可用,因此应在 Handle 阶段读取。

3. 不要因为 ctx.Err() 非空就丢掉日志

Handle 收到 context 的目的之一就是让 Handler 能读取上下文值。请求取消后,取消原因、超时和失败路径仍然需要写入日志。除非底层输出本身有独立的关闭策略,否则不要把取消当成“禁止处理 Record”的信号。

4. 包装器必须保持分组和级别行为

如果底层是 JSONHandler,WithGroup("request") 仍应让字段落在对应分组下;如果底层只允许输出 WARN 以上,包装器也不应自行放行 INFO。最安全的原则是:注入字段,其他行为全部委托。

用缓冲区验证字段、级别和分组

可以用内存缓冲区测试包装器,不需要启动 HTTP 服务器。测试的重点是把同一个派生 context 传给 InfoContext,并检查输出中出现 request ID;再补一条没有 request ID 的日志,确保缺失时不会凭空造值。

func TestHandlerAddsRequestID(t *testing.T) {
	var buf bytes.Buffer
	base := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
	logger := slog.New(requestlog.NewHandler(base)).WithGroup("request")

	ctx := requestlog.WithRequestID(context.Background(), "req-42")
	logger.InfoContext(ctx, "查询完成", slog.Int("count", 2))

	output := buf.String()
	// 断言关注契约字段,避免把时间等非稳定字段写死。
	if !strings.Contains(output, `"request_id":"req-42"`) {
		t.Fatalf("missing request id: %s", output)
	}
	if !strings.Contains(output, `"count":2`) {
		t.Fatalf("missing count: %s", output)
	}
}

如果项目需要严格检查 JSON 结构,可以在测试中反序列化到 map[string]any,但不要把时间、源文件行号等动态字段作为核心断言。包装器的契约是“在满足条件时补 request_id,并保持底层 Handler 的输出策略”。

上线前的工程检查清单

  • 入口中间件是否总是为请求创建派生 context,内部 goroutine 是否接收并继续使用该 context。
  • 业务日志是否统一使用 InfoContext、WarnContext、ErrorContext 或 LogAttrs。
  • 包装器是否完整实现并转发 Enabled、WithAttrs、WithGroup。
  • 是否定义显式 request_id 与 context 值冲突时的优先级。
  • 是否避免把外部请求头原样当作可信身份信息;request ID 用于关联日志,不等于认证凭据。
  • 取消、超时、返回 5xx 等错误路径是否仍能看到同一个 request ID。

常见问题

为什么 Handler 能拿到 context?

因为 Logger.Log、LogAttrs 及其 Context 变体会把调用方 context 传入 Enabled 和 Handle。没有传递 context 的便捷方法通常使用后台 context,自然读不到请求级值。

能不能直接在每个日志调用里写 request_id?

可以,但容易漏写,而且会让业务代码承担日志关联职责。Handler 包装器适合统一注入;特殊日志仍可显式覆盖,只要团队明确优先级。

为什么不把 request ID 放进 Logger.With?

Logger.With 产生的是一个带固定属性的新 Logger。请求 ID 不是固定属性,跨请求复用它会造成串号,所以应把请求 context 传到调用现场。

Handler 里要不要检查 context 是否取消?

通常不要用取消状态阻止日志处理。取消的请求往往正是最需要诊断信息的请求;让输出层按自己的 I/O 错误策略处理更清楚。

总结:把 request ID 放进请求 context,用一个只注入字段的 slog.Handler 包装器在 Handle 中读取它,再坚持使用 Context 版本的日志方法。实现上守住私有 key、Record.Clone、完整委托和显式冲突策略四个边界,就能在不改业务日志格式的情况下建立稳定的请求关联。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
http.Transport 控制 TLS 握手超时的参数对比http.Transport 控制 TLS 握手超时的参数对比
上一篇
http.Transport 控制 TLS 握手超时的参数对比
slog.Group 将结构化日志按业务域分组的方法
下一篇
slog.Group 将结构化日志按业务域分组的方法
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    997次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    1000次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    1121次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    1096次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    937次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议 和 隐私政策
返回登录
  • 重置密码