Go log/slog Handler怎样给结构化日志增加自定义字段怎样完成一个可复用的最小示例
来源:17golang原创
时间:2026-11-09 14:53:37 470浏览 收藏
Go log/slog Handler怎样给结构化日志增加自定义字段怎样完成一个可复用的最小示例
如果希望每条请求日志都自动带上 request_id、tenant_id 这类字段,比较稳妥的做法是包装一个 slog.Handler:从 Handle 收到的 context.Context 中读取请求级信息,克隆 slog.Record 后补充属性,再交给底层的 JSONHandler 或 TextHandler。这样调用方仍然使用 logger.InfoContext,日志格式也继续由官方 Handler 负责。
- 请求级字段放在 context,组件级固定字段使用
Logger.With,单条事件字段直接作为日志参数。 - 自定义 Handler 的核心是
Handle:读取上下文、克隆 Record、调用AddAttrs,然后转发给底层 Handler。 Enabled、WithAttrs和WithGroup必须正确转发,否则级别过滤或属性分组可能失效。

先把字段来源分成三类
我在整理请求日志时,最容易混乱的是把所有字段都塞进同一个调用点。更容易维护的划分是:请求级字段跟随 context.Context,例如请求标识和租户标识;组件级字段通过 logger.With 固定下来,例如服务名和模块名;只对一条事件有意义的字段直接传给 InfoContext,例如本次操作的资源编号。
官方文档说明,Logger 的输出方法会创建 Record 并交给关联的 Handler;而带有 Context 的方法会把上下文传到 Handler。因此,自定义字段如果确实属于请求生命周期,就可以在 Handler 层统一处理,不必让每个调用点重复写相同键值。
| 字段稳定性 | 推荐入口 | 示例 |
|---|---|---|
| 单条日志 | InfoContext 参数 | resource_id |
| 组件生命周期 | Logger.With | component=checkout |
| 请求生命周期 | 自定义 Handler 读取 context | request_id、tenant_id |
用包装 Handler 在 Handle 中补充字段
下面这个包装器只做一件事:从 context 读取两个字符串值。读取不到时不伪造字段,避免后台任务的日志出现一个看似有效的空请求标识。真正的格式化、时间字段和 JSON 编码仍交给底层 Handler。
package main
import (
"context"
"io"
"log/slog"
)
// contextKey 使用私有类型,避免不同包之间意外碰撞 context 键名。
type contextKey string
const (
requestIDKey contextKey = "request_id"
tenantIDKey contextKey = "tenant_id"
)
// contextHandler 只负责补充请求级字段,格式化工作交给 next。
type contextHandler struct {
next slog.Handler
}
// Enabled 先询问底层 Handler,避免被过滤的日志提前做额外工作。
func (h *contextHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.next.Enabled(ctx, level)
}
// Handle 克隆 Record 后再添加字段,避免修改调用方仍可能持有的记录状态。
func (h *contextHandler) Handle(ctx context.Context, record slog.Record) error {
record = record.Clone()
if requestID, ok := ctx.Value(requestIDKey).(string); ok && requestID != "" {
record.AddAttrs(slog.String("request_id", requestID))
}
if tenantID, ok := ctx.Value(tenantIDKey).(string); ok && tenantID != "" {
record.AddAttrs(slog.String("tenant_id", tenantID))
}
// 让 JSONHandler 或 TextHandler 继续完成最终输出。
return h.next.Handle(ctx, record)
}
// WithAttrs 保留底层 Handler 的静态属性优化和返回类型语义。
func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &contextHandler{next: h.next.WithAttrs(attrs)}
}
// WithGroup 保留调用方对分组键名的约束。
func (h *contextHandler) WithGroup(name string) slog.Handler {
return &contextHandler{next: h.next.WithGroup(name)}
}
func newLogger(w io.Writer) *slog.Logger {
// 底层 Handler 负责 JSON 编码,包装器只补充上下文字段。
base := slog.NewJSONHandler(w, nil)
return slog.New(&contextHandler{next: base}).With("service", "billing")
}
这里有三个关键点。第一,Handle 收到的 Record 可能共享内部属性状态,修改前先调用 Clone 更稳妥。第二,字段缺失时直接跳过,而不是把空字符串当作真实值。第三,包装器不自行拼 JSON,也不自行决定日志级别,底层 Handler 仍是唯一的格式化出口。

正确转发 Enabled、WithAttrs 和 WithGroup
很多自定义 Handler 的问题不在 Handle,而在另外三个接口方法被忽略。Enabled 决定某个级别是否需要输出;如果包装器永远返回 true,调用方会失去底层的级别过滤。WithAttrs 负责构造带静态属性的 Handler,WithGroup 则负责给后续属性增加分组边界。
// 请求处理函数只负责把请求元数据放入 context,并使用 InfoContext 记录事件。
func handleRequest(ctx context.Context, logger *slog.Logger, userID string) {
ctx = context.WithValue(ctx, requestIDKey, "req-20261108-001")
ctx = context.WithValue(ctx, tenantIDKey, "tenant-blue")
// resource_id 只属于这一条事件,不放进请求级 context。
logger.InfoContext(ctx, "load user profile",
slog.String("user_id", userID),
slog.String("resource_id", "profile"),
)
}
在这个调用关系里,service 是组件级字段,来自 Logger.With;request_id 和 tenant_id 由包装器统一补齐;user_id 与 resource_id 是单次事件字段。即使以后把底层 JSONHandler 换成 TextHandler,调用方也不需要改字段来源。
缺失值、分组和敏感字段要有明确边界
自定义字段不是越多越好。请求标识如果没有生成,应让日志缺少该字段,而不是写入随机值;租户标识也应在进入请求链路时完成可信校验。对于密码、访问令牌、完整身份证号等敏感信息,不要因为 Handler 能统一加字段就把它们放进 context 或日志。
如果组件之间可能使用相同键名,可以在调用方为组件创建分组 Logger:
// WithGroup 让不同子系统的相同字段名保持可区分。
dbLogger := logger.WithGroup("database")
dbLogger.InfoContext(ctx, "query finished", slog.Int64("rows", 12))
cacheLogger := logger.WithGroup("cache")
cacheLogger.InfoContext(ctx, "lookup finished", slog.Int64("rows", 1))
对 JSON 输出而言,分组属性会形成嵌套对象;对文本输出而言,底层 Handler 会使用自己的分隔规则。包装器只要把 WithGroup 委托给底层 Handler,就不需要复制这些格式细节。
按字段稳定性选择实现位置
| 需求 | 实现位置 | 原因 |
|---|---|---|
| 所有请求日志都要有 request_id | 自定义 Handler 读取 context | 避免每个调用点重复传参 |
| 一个模块都要有 component | logger.With | 字段固定且可由 Handler 优化 |
| 只有当前事件需要 resource_id | InfoContext 的 Attr | 缩小字段生命周期 |
| 需要脱敏或转换某种值 | LogValuer 或受控的 Handler 逻辑 | 把敏感字段的展示策略集中管理 |
我的取舍是:Handler 只放跨调用点、确实属于请求上下文的通用字段;业务字段仍由调用方显式传入。这样日志结构既统一,又不会让 Handler 变成隐藏业务逻辑的“万能拦截器”。
相关问题
为什么必须调用 InfoContext
因为自定义 Handler 需要从调用点拿到 context。若使用不带 context 的便捷方法,包装器无法获得当前请求的 request_id 和 tenant_id。
能不能直接修改传入的 slog.Record
不建议。Record 的属性状态可能与原记录共享,先 Clone 再 AddAttrs 能把修改限制在当前 Handler 链路。
所有自定义字段都应该放在 Handler 吗
不应该。请求级字段适合 Handler,组件级字段适合 Logger.With,单条事件字段适合日志调用参数;按生命周期划分更容易维护。
-
860 收藏
-
843 收藏
-
826 收藏
-
809 收藏
-
792 收藏
-
310 收藏
-
311 收藏
-
223 收藏
-
214 收藏
-
100 收藏
-
248 收藏
-
103 收藏
-
152 收藏
-
370 收藏
-
208 收藏
-
341 收藏
-
171 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习