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

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

来源:17golang原创

时间:2026-11-12 16:32:37 278浏览 收藏

在 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删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>