Go slog 怎样把结构化字段放进 Group
我在把服务日志从一串文本改成结构化记录时,最先遇到的不是“怎么打印字段”,而是字段该放在哪一层:请求方法、路径和状态码属于同一组,还是应该让某个子系统的所有日志都自动带上前缀?Go 的 log/slog 把这两种场景分别交给 slog.Group 和 Logger.WithGroup。
可以先记住一个判断:单条日志临时收拢字段,用 slog.Group;某个 Logger 后续产生的字段都要进入同一命名空间,用 WithGroup。Attr 表示一个键值属性,Handler 决定这个层级最终怎样输出。
一句话结论:Group 是一条记录里的结构,WithGroup 是 Logger 的字段路径;TextHandler 通常把路径写成点号,JSONHandler 通常把它写成嵌套对象。
先把 Attr、Group 和 Handler 分成三层
slog.Attr 是单个结构化字段,例如 slog.String("method", "GET")。它本身不负责把字段写到终端或文件,只携带键和值。slog.Group("request", ...) 返回的仍然是一个 Attr,只是这个 Attr 的值是一个带名字的属性组。
Handler 才是输出端。标准库自带的 TextHandler 和 JSONHandler 接收同一条记录,但会采用不同的展示方式。也就是说,不能只看 Group 的调用,就断言最终文本一定长什么样。

用 slog.Group 把一条记录的相关字段收在一起
请求日志常常同时包含方法、URL 和状态码。它们属于同一个事件,可以用一个 request 组表达这层关系:
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("request finished",
slog.Group("request",
slog.String("method", "GET"), // 把请求方法放进 request 组。
slog.String("path", "/orders/42"), // 路径与方法保持同一字段层级。
slog.Int("status", 200), // 状态码仍是结构化数字,而不是拼进文本。
),
)
}
使用 JSON Handler 时,这组字段会以 request 为键形成嵌套对象,概念上接近:
{"level":"INFO","msg":"request finished","request":{"method":"GET","path":"/orders/42","status":200}}
这里的重点不是某个时间字段或空格,而是 request.method 和 request.status 具有清晰的层级。下游系统可以按对象路径筛选,调用方也不必把字段名写成难以维护的 request_method、request_path。
TextHandler 和 JSONHandler 会怎样显示同一个 Group
把上面的 Handler 换成文本输出,字段仍然属于 request 组,只是展示会变成点号路径:
func newTextLogger() *slog.Logger {
// TextHandler 用 key=value 输出,组名会参与字段限定。
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
AddSource: false, // 示例只关注 Group 的字段路径。
}))
}
同一条记录的关键部分通常会接近:
level=INFO msg="request finished" request.method=GET request.path=/orders/42 request.status=200
因此,选择 Handler 时要把消费方考虑进去:人肉阅读、简单 grep 和日志行传输可以偏向 TextHandler;需要稳定解析、按对象路径检索或传给 JSON 日志系统时,JSONHandler 更直观。Group 的语义没有改变,改变的是 Handler 对字段路径的呈现。
用 Logger.WithGroup 给子系统建立固定命名空间
我在一个服务里同时接入解析器和存储模块时,两个模块都可能输出 id、status 这样的通用键。若每次调用都手动改成 parser_id 或 storage_id,字段命名很快会散落在业务代码里。WithGroup 可以把命名空间固定在 Logger 上:
func parseInput(logger *slog.Logger, input string) {
// 子系统拿到自己的 Logger,后续字段自动位于 parser 组。
parserLogger := logger.WithGroup("parser")
parserLogger.LogAttrs(
context.Background(),
slog.LevelInfo,
"input parsed",
slog.String("id", input), // 这里仍可使用通用键名 id。
slog.Int("status", 200), // 输出路径会带上 parser 前缀。
)
}
上例需要补上 context 导入。调用 WithGroup 会得到一个使用同一 Handler 的新 Logger,之后添加到这个 Logger 的字段会按 parser 进行限定。这样解析器和存储模块都可以使用自然的局部键名,却不会在最终日志里互相覆盖。
如果使用 JSON Handler,结果的结构会接近 {"parser":{"id":"...","status":200}};如果使用 TextHandler,则会接近 parser.id=...。它和单次 slog.Group("parser", ...) 的输出关系相似,但作用范围不同:前者覆盖这个 Logger 后续发出的记录,后者只包住当前调用。

字段多时优先用 LogAttrs 表达 Attr
Logger.Info("msg", "key", value) 这种交替键值写法很方便,但在字段经常出现、又需要明确分组时,LogAttrs 只接收 slog.Attr,代码意图更清楚:
func logJob(logger *slog.Logger, jobID string, retry int) {
ctx := context.Background() // 示例使用无取消语义的上下文。
logger.LogAttrs(
ctx,
slog.LevelInfo,
"job finished",
slog.Group("job",
slog.String("id", jobID), // 任务标识属于 job 组。
slog.Int("retry", retry), // 重试次数保持整数类型。
),
slog.String("component", "worker"), // 该字段属于记录顶层。
)
}
这里 job.id 和 job.retry 在组内,component 在记录顶层。把所有字段都塞进一个 Group 并不一定更好:顶层字段是跨模块通用的上下文,模块专属字段再进入自己的组,查询时通常更容易维护。
空组、重复键和嵌套组是三个边界
实际写日志时,下面三个细节比“能否打印出来”更值得提前约定。
- 空组名:空名字的 Group 不会凭空增加一层有意义的路径,组内字段通常会被内联到当前层。如果组名来自配置,不要把空字符串误当作正常命名空间。
- 重复键:同一层出现重复键时,具体如何展示或由下游如何处理,不应当成为业务逻辑依赖。用 Group 或 WithGroup 设计稳定路径,才是避免冲突的根本办法。
- 嵌套组:可以把一个 Group 放进另一个 Group,或连续调用 WithGroup;但层级不宜为了“看起来结构化”而无限加深,查询和阅读都会变重。
func scopedLogger(base *slog.Logger) *slog.Logger {
// 连续 WithGroup 会形成 worker.pipeline 这样的字段路径。
return base.WithGroup("worker").WithGroup("pipeline")
}
func emit(logger *slog.Logger) {
// 空组名不创建可见的额外命名层,适合有条件地拼接属性时谨慎使用。
logger.LogAttrs(
context.Background(),
slog.LevelInfo,
"step finished",
slog.Group("", slog.String("phase", "flush")),
)
}
如果业务需要动态决定是否带组,建议在组名和字段集合都明确时再构造 Attr;不要把未经约束的用户输入直接作为组名,否则日志字段路径会变得不可预测。
按这张表选择 Group 还是 WithGroup
| 场景 | 优先选择 | 输出关注点 |
|---|---|---|
| 一条请求记录的相关字段 | slog.Group | 只影响当前记录,适合 request、job、cache 等对象。 |
| 某个模块的所有日志 | Logger.WithGroup | 为后续字段统一加命名空间,减少重复改键。 |
| 字段在运行时动态拼装 | []slog.Attr + LogAttrs | 在明确组边界后再用 slog.GroupAttrs 或组合 Attr。 |
| 人读文本日志 | TextHandler | 重点看点号路径和键名稳定性。 |
| 机器解析的日志管道 | JSONHandler | 重点看嵌套对象、类型和下游字段映射。 |
我最后会检查这几个结果
写完一组 slog 调用后,我通常不会只看“有没有输出”,而会按字段路径做四个快速检查:第一,属于同一对象的字段是否放进同一个 Group;第二,模块 Logger 是否通过 WithGroup 固定了命名空间;第三,TextHandler 和 JSONHandler 的差异是否被下游接受;第四,状态码、重试次数这类数字有没有被意外拼成字符串。
如果只需要一条记录的结构,使用 slog.Group;如果需要让一个子系统持续共享路径,使用 WithGroup;如果字段需要在循环或条件分支中累积,先用 []slog.Attr,最后交给 LogAttrs。这套分工能让日志结构留在 API 边界内,而不是散落到每个字符串格式化语句里。
常见疑问
Group 会修改 Attr 的键名吗?不会把原 Attr 永久改名,它提供的是一层带名字的字段结构,最终如何呈现由 Handler 决定。
WithGroup 会修改原来的 Logger 吗?不会。它返回一个新的 Logger,使用同一 Handler,但后续属性会带上新的组路径。
TextHandler 和 JSONHandler 能混用同一套日志调用吗?可以。调用侧使用同样的 Attr、Group 和 WithGroup,差异集中在 Handler 的输出格式。
为什么不把所有字段都放进一个总 Group?总 Group 会让通用字段也多一层路径,增加查询成本。更好的做法是按对象和子系统划分,保持层级有实际含义。
小结
Attr 是字段,Group 是单条记录里的字段集合,WithGroup 是 Logger 的持续字段路径,Handler 决定它们如何落成文本或 JSON。掌握这四层关系后,结构化日志就不必靠拼接字符串维持层级,字段命名和模块边界也更容易长期演进。
Go flag.FlagSet.Lookup 怎样按名称查找已注册参数
- 上一篇
- Go flag.FlagSet.Lookup 怎样按名称查找已注册参数
- 下一篇
- Go scanner.ErrorList.Reset 怎样清空上一轮诊断
-
- Golang · Go教程 | 27分钟前 | Go 命令行参数 flag.FlagSet lookup Go flag
- Go flag.FlagSet.Lookup 怎样按名称查找已注册参数
- 429浏览 收藏
-
- Golang · Go教程 | 28分钟前 | Go URL编码 net/url url.URL 字节缓冲区 AppendBinary
- Go url.URL.AppendBinary 怎样把 URL 追加到已有缓冲区
- 375浏览 收藏
-
- Golang · Go教程 | 34分钟前 | Go 单元测试 Go test testing.T.Logf 诊断日志
- Go testing.T.Logf 怎样格式化多行诊断信息
- 487浏览 收藏
-
- Golang · Go教程 | 36分钟前 | 正则表达式 Go 子测试 Go test -run testing.T.Run
- Go test -run 精确匹配带斜杠的嵌套子测试
- 313浏览 收藏
-
- Golang · Go教程 | 41分钟前 | Go 语法树 ast.Unparen ParenExpr ParseExpr
- Go ast.Unparen 怎样提取多层括号内的表达式
- 206浏览 收藏
-
- Golang · Go教程 | 56分钟前 |
- Go scanner.ErrorList 为空时怎样返回 nil 错误
- 469浏览 收藏
-
- Golang · Go教程 | 1小时前 | 调试 · 时间处理 · Go教程 · Go string time.Time time.Time.GoString fmt.GoStringer 调试输出
- Go time.Time.GoString 怎样输出可读的调试时间值
- 321浏览 收藏
-
- Golang · Go教程 | 1小时前 | 错误处理 · Go教程 · Go io.MultiWriter io.Writer ErrShortWrite 短写
- Go io.MultiWriter 写入失败后的短写处理与目标顺序设计
- 370浏览 收藏
-
- Golang · Go教程 | 1小时前 |
- Go atomic.Int64 在旧架构上为何关注对齐
- 355浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 1021次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 1023次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 1160次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 1126次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 966次使用
-
- Go语言Handler详细说明
- 2023-01-01 207浏览
-
- Go error wrapping 实战:别让错误日志只剩一句 failed
- 2026-06-01 151浏览
-
- Go pprof 排查慢接口:别只会看火焰图,先把问题问对
- 2026-06-01 101浏览
-
- Go Flight Recorder 实战:线上偶发卡顿,别再只靠日志碰运气
- 2026-06-01 323浏览
-
- Go testing/synctest 实战:别再用 time.Sleep 赌并发测试会过
- 2026-06-01 428浏览

