Go 语言的日志记录功能经历了漫长的发展。过去,开发者依赖简单的标准 log 包或功能强大的第三方库(如 zap 和 zerolog)。随着 Go 1.21 中 log/slog 包的引入,Go 语言现在拥有了一个原生的、高性能的、结构化日志解决方案,旨在成为新的标准。
结构化日志 (Structured Logging) 的核心在于使用键值对(Key-Value Pairs)来记录日志,这使得日志可以被机器解析、过滤、搜索和可靠地分析。这对于观察系统的详细行为和调试问题至关重要。
slog 的设计将日志逻辑与其最终输出分离开来,提供了一个通用的 API,同时允许不同的日志实现来控制输出格式和目的地。
log/slog 包围绕三个核心类型构建:Logger、Handler 和 Record。
Logger 是日志创建的入口点(API),提供面向用户的方法,如 Info()、Debug() 和 Error()。Logger 会创建一个 Record,然后将其传递给配置好的 Handler 进行处理。slog.Info)调用默认 Logger 的对应方法。Handler 是一个接口,负责处理 Record。它是决定日志 如何 和 写入何处 的引擎。Handler 负责将 Record 格式化为特定的输出(如 JSON 或纯文本)并写入目标(如控制台或文件)。log/slog 包含内置的 TextHandler(格式化为 key=value 键值对)和 JSONHandler(格式化为 JSON)实现。Handler 接口的设计使其具有高度的可组合性,可以创建包裹其他 Handler 的“中间件”来丰富、过滤或修改日志记录。Record 代表一个独立的日志事件。INFO, WARN 等)、日志消息以及所有结构化的键值属性。Record 是日志条目在被格式化之前的原始数据。slog 提供了顶层函数来使用默认 Logger,初始默认 Logger 的输出格式与旧 log 包相似,但会包含日志级别信息。
package main
import "log/slog"
func main() {
slog.Info("hello, world")
// 默认输出类似: 2023/08/04 16:09:19 INFO hello, world
}
要显式获取默认 Logger,可以使用 slog.Default()。
通常,你会通过 slog.New() 函数创建一个自定义 Logger,并为其指定一个 Handler。
JSON Handler (生产环境推荐):
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("user logged in", "user_id", 123)
/* 输出示例:
{ "time" : "..." , "level" : "INFO" , "msg" : "user logged in" , "user_id" : 123 }
*/
Text Handler (Logfmt 格式):
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
logger.Info("database connected", "db_host", "localhost", "port", 5432)
/* 输出示例:
time=... level=INFO msg="database connected" db_host=localhost port=5432
*/
使用 slog.SetDefault() 可以替换包级别的默认 Logger。这样做会使顶层函数(如 slog.Info)使用新的配置。
更重要的是,SetDefault() 还会更新 log 包的默认 Logger,从而使现有使用 log.Printf 的应用程序也能无缝切换到结构化日志输出。
slog 提供了四个默认严重性级别,它们都是整数值:
slog.LevelDebug (-4)slog.LevelInfo (0)slog.LevelWarn (4)slog.LevelError (8)默认情况下,所有 Logger 都配置为记录 slog.LevelInfo 及更高级别的消息,这意味着 DEBUG 消息会被抑制。级别之间的差距(4)是故意的,为自定义级别留出了空间。
通过 slog.HandlerOptions 可以控制将被处理的最低级别:
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug, // 启用 DEBUG 级别日志
})
logger := slog.New(handler)
如果需要在生产环境中不重启服务的情况下更改日志详细程度,可以使用 slog.LevelVar 类型。这是一个动态的日志级别容器,可以通过其 Set() 方法随时并发安全地更新级别。
var logLevel slog.LevelVar // 默认是 INFO
// ...
// 构造 Logger 时传入 LevelVar 的指针
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: &logLevel,
}))
// 随时更新级别
logLevel.Set(slog.LevelDebug)
你可以定义新的常量来创建自定义日志级别,例如 TRACE (-8) 或 FATAL (12):
const (
LevelTrace = slog.Level(-8)
LevelFatal = slog.Level(12)
)
使用自定义级别时,必须使用通用的 logger.Log() 或 logger.LogAttrs() 方法,并显式指定级别:
logger.Log(context.Background(), LevelFatal, "database connection lost")
若要修复默认输出中显示的级别名称(例如 ERROR+4),可以使用 HandlerOptions 中的 ReplaceAttr() 函数将级别整数值映射到自定义字符串。
在记录日志之前,如果需要进行昂贵的计算来准备数据,应该先检查 Logger 是否启用了该级别,以防止不必要的性能损失:
if logger.Enabled(context.Background(), slog.LevelDebug) {
// 只有在 DEBUG 级别启用时才执行昂贵操作
logger.Debug("operation complete", "data", getExpensiveDebugData())
}
结构化日志的优势在于通过键值对(Attr)来丰富日志条目,使其可查询。
最方便的方式是传递交替的键和值序列:
logger.Info("incoming request", "method", "GET", "status", 200)
⚠️ 风险警告: 这种便利的键值对风格存在重大隐患。如果参数数量为奇数(缺少值),slog 不会 panic 或返回错误,而是静默创建一个带有特殊键 !BADKEY 的损坏日志条目。
为了保证日志的正确性和可靠性,强烈推荐使用强类型的 slog.Attr 辅助函数。这可以在编译时捕获错误,防止产生不平衡的键值对:
logger.Warn(
"permission denied",
slog.Int("user_id", 12345),
slog.String("resource", "/api/admin"),
)
slog 提供了多种构造函数来创建 Attr,如 slog.String、slog.Int、slog.Bool、slog.Time 和用于任意类型的 slog.Any。
对于频繁执行的日志语句,使用 logger.LogAttrs 方法是最有效的方式。它只接受 slog.Attr 类型的参数,从而避免了内存分配,性能更高。
// LogAttrs 仅接受 Attr 类型,效率更高
logger.LogAttrs(
context.Background(),
slog.LevelInfo,
"hello, world",
slog.String("user", os.Getenv("USER")),
)
你可以使用 slog.Group() 将多个属性收集到一个命名组下,以增加日志的结构性并避免键冲突。
JSONHandler 将组显示为嵌套的 JSON 对象。TextHandler 将组名作为前缀,用点号分隔(例如 properties.width=4000)。Go 1.25.0 增加了更高效的 slog.GroupAttrs(),它只接受 Attr 列表。
logger.Info(
"image uploaded",
slog.Int("id", 23123),
slog.Group("properties",
slog.Int("width", 4000),
slog.Int("height", 3000),
),
)
使用 logger.With() 方法可以创建一个新的 Logger,该 Logger 继承父 Logger 的所有属性,并添加新的属性。这些公共属性只会被格式化一次(在调用 With 时),这对于性能优化非常有益。
你也可以使用 logger.WithGroup(name) 创建一个子 Logger,该子 Logger 的所有后续属性(包括在日志调用点添加的属性)都将以该组名限定。这有助于在大型系统中避免命名冲突。
Handler 是 slog 灵活性的关键所在。
内置的 TextHandler 和 JSONHandler 可以通过 slog.HandlerOptions 进行配置:
Level: 设置最低日志级别。AddSource: 设置为 true 会自动包含日志语句的源代码文件、函数和行号信息。但请注意,这会带来性能开销,因为它需要调用 runtime.Caller()。ReplaceAttr: 这是一个函数,用于在日志记录之前重写每个非分组属性。它可以用于更改内置属性(如时间和级别)的键名,转换类型,或删除敏感信息。ReplaceAttr 会为每个日志记录中的每个属性调用一次,因此其逻辑应尽可能快。最佳实践通常是记录到 stdout 或 stderr,并让运行时环境管理日志流。如果需要直接写入文件,可以将 *os.File 实例传递给 Handler。对于日志文件轮换,可以使用标准的 logrotate 工具或 lumberjack 包。
Handler 接口定义了四个方法,负责处理日志记录的生命周期:
type Handler interface {
// Enabled 报告是否处理给定级别的记录,用于快速丢弃不必要的日志事件。
Enabled(context.Context, Level) bool
// Handle 处理实际的日志记录,仅在 Enabled 返回 true 时调用。
Handle(context.Context, Record) error
// WithAttrs 返回一个新的 Handler,其属性包含接收者属性和传入的属性。
WithAttrs(attrs []Attr) Handler
// WithGroup 返回一个新的 Handler,将后续属性限定在给定名称的分组下。
WithGroup(name string) Handler
}
由于 Handler 是一个接口,你可以实现自定义 Handler 来实现不同的格式化或输出目的地。社区也提供了许多有用的第三方 Handler:
slog-sampling: 用于丢弃重复日志条目,实现日志采样。tint: 用于在开发环境中将日志输出彩色化。slog-multi: 用于高级组合模式,例如扇出、缓冲、条件路由等。处理重复键: 内置 Handler 不会自动对日志中的重复键进行去重,这可能导致观测工具行为不确定。如果需要去重,你需要使用第三方“中间件” Handler,例如 slog-dedup。
slog 的设计目标之一是提供一个统一的 API ,允许开发者在不修改核心日志代码的情况下切换高性能的 Handler。这使得 slog 可以与高性能库(如 Zap 或 Zerolog)结合使用,以获得高性能和标准 API 的双重优势。
slog 提供了一组带有 context.Context 参数的方法(如 InfoContext()),这些方法允许 Handler 提取上下文中的信息,例如追踪 ID (Trace ID)。
重要说明: 内置的 slog Handler 不会自动从 context.Context 中拉取值。你必须使用上下文感知 Handler(如社区的 slog-context 包)才能实现上下文属性的传播。
使用全局 Logger 和上下文 Handler (推荐做法之一):
这种模式使用全局 Logger 并配置一个上下文感知的 Handler。请求处理中间件将上下文属性(如 correlation_id)附加到 context.Context 中(例如使用 slogctx.Prepend())。后续的日志调用(如 slog.InfoContext(r.Context(), ...))将上下文传递给全局 Logger,Handler 则从 Context 中提取并写入这些属性。
不推荐的做法: 尽管某些第三方库允许将 Logger 实例本身放入 context.Context 中,但 Go 团队最终从 slog API 中删除了相关辅助函数(如 slog.NewContext()),因为它被认为是一种隐式的依赖关系,使代码难以理解和测试。
依赖注入模式: 另一种推荐模式是将 Logger 视为正式的依赖项,通过结构体字段或函数参数显式传递,这使得代码更具可测试性和灵活性。
通过实现 slog.LogValuer 接口,你可以精确控制自定义类型在日志中的显示方式。
LogValuer 中,确保只有在日志级别启用时,LogValue() 方法才会被调用,从而避免不必要的性能开销。type Token string
// LogValue 实现 slog.LogValuer 接口,避免泄露秘密
func (Token) LogValue() slog.Value {
return slog.StringValue("REDACTED_TOKEN")
}
// 记录时,Token 值将被替换为 "REDACTED_TOKEN"
在 slog 中记录错误时,应使用 slog.Any() 来包含错误值。
err := errors.New("payment gateway unreachable")
logger.Error("Payment processing failed", slog.Any("error", err))
如果使用自定义错误类型,可以实现 LogValuer 接口来提供结构化的错误信息(如错误码和原因),这对生产系统的分析非常有价值。
堆栈跟踪 (Stack Trace) 捕获: slog 没有内置捕获堆栈跟踪的功能。你需要集成第三方包(如 go-xerrors)并使用 HandlerOptions 中的 ReplaceAttr() 函数,来提取、格式化和添加堆栈跟踪信息到日志记录中。
尽管 slog 的设计考虑了性能,但在某些基准测试中,它仍然比高度优化的第三方库(如 zerolog 和 zap)慢。这种性能差异是设计权衡的结果,Go 团队的优化集中在最常见的日志模式(超过 95% 的调用属性少于 5 个)。
性能优化策略:
Logger.With 添加通用属性,Handler 只会格式化一次,从而提高速度。logger.LogAttrs,因为它只接受 Attr 类型,可以最小化内存分配,实现最高效的日志输出。logger.Enabled() 或 LogValuer 接口,确保昂贵的计算操作仅在日志级别启用时才执行。TraceID)。这可以通过集成 OpenTelemetry 的 otelslog bridge 来实现。LogValuer 接口以标准化应用程序中自定义类型的日志表示,并确保敏感数据被省略。slog 允许混合键值对和 slog.Attr 两种风格,为保证代码库一致性,应使用 sloglint 等 Linter 工具来强制执行日志风格规则(例如,强制只使用 Attr)。slog-sampling)仅记录具有代表性的子集,以控制数据摄取成本。stdout/stderr,让运行时环境(如 Docker 或 Systemd)或专用的日志转发器(如 Vector, Fluentd)负责收集和持久化。将日志先写入本地文件可以提供备份和缓冲,以防集中式日志管理系统出现问题。总结: log/slog 是 Go 生态系统的一个重要里程碑,它提供了一个强大的基础,支持开箱即用地构建高度可观测的系统。通过理解 Logger、Handler 和 Record 的核心概念,并遵循结构化日志的最佳实践,你可以将服务从不透明的黑盒转变为透明、易于诊断和故障排除的系统。