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

Go slog 怎样把结构化字段放进 Group

来源:17golang原创

时间:2026-11-14 12:24:19 138浏览 收藏

我在把服务日志从一串文本改成结构化记录时,最先遇到的不是“怎么打印字段”,而是字段该放在哪一层:请求方法、路径和状态码属于同一组,还是应该让某个子系统的所有日志都自动带上前缀?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 的调用,就断言最终文本一定长什么样。

Go slog Attr、Group 与 TextHandler 和 JSONHandler 输出路径的静态结构图
图1:结构说明图,展示 Attr 进入 Group 后分别交给文本和 JSON Handler 的字段路径;这是静态说明图,不是运行截图。

用 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 后续发出的记录,后者只包住当前调用。

Go slog Group 与 Logger.WithGroup 单条记录和 Logger 作用域对比说明图
图2:关系说明图,对比单条记录的 slog.Group 与 Logger.WithGroup 的作用范围;这是静态说明图,不是运行结果截图。

字段多时优先用 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。掌握这四层关系后,结构化日志就不必靠拼接字符串维持层级,字段命名和模块边界也更容易长期演进。

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