Go 博客

使用 slog 进行结构化日志记录

Jonathan Amsterdam
2023 年 8 月 22 日

Go 1.21 中的新的 log/slog 包为标准库带来了结构化日志记录。结构化日志使用键值对,以便可以快速可靠地解析、过滤、搜索和分析它们。对于服务器来说,日志记录是开发人员观察系统详细行为的重要方法,通常也是他们调试问题的首选位置。因此,日志往往内容繁多,快速搜索和过滤它们的能力至关重要。

标准库自 Go 首次发布以来,已有十多年了,一直都有一个日志记录包 log。随着时间的推移,我们了解到结构化日志记录对于 Go 程序员来说很重要。它在我们年度调查中一直排名靠前,并且 Go 生态系统中的许多软件包都提供了它。其中一些非常受欢迎:Go 的第一个结构化日志记录包之一 logrus,在 100,000 多个其他软件包中使用。

由于有许多结构化日志记录包可供选择,大型程序通常最终会通过其依赖项包含多个包。主程序可能必须配置每个日志记录包,以确保日志输出一致:它们都转到同一个位置,使用相同的格式。通过在标准库中包含结构化日志记录,我们可以提供一个所有其他结构化日志记录包都可以共享的通用框架。

slog 概览

以下是使用 slog 的最简单程序

package main

import "log/slog"

func main() {
    slog.Info("hello, world")
}

截至撰写本文时,它会打印

2023/08/04 16:09:19 INFO hello, world

Info 函数使用默认记录器在 Info 日志级别打印一条消息,在本例中,默认记录器是 log 包中的默认记录器 - 与您在编写 log.Printf 时获得的记录器相同。这解释了为什么输出看起来如此相似:只有“INFO”是新的。开箱即用,slog 和原始 log 包协同工作,让您轻松上手。

除了 Info 之外,还有三个其他级别的函数 - DebugWarnError - 以及一个更通用的 Log 函数,它将级别作为参数。在 slog 中,级别只是整数,因此您不限于四个命名级别。例如,Info 为零,Warn 为 4,因此如果您的日志记录系统在它们之间有一个级别,您可以使用 2 来表示它。

log 包不同,我们可以轻松地在输出中添加键值对,方法是在消息后写下它们

slog.Info("hello, world", "user", os.Getenv("USER"))

输出现在看起来像这样

2023/08/04 16:27:19 INFO hello, world user=jba

正如我们提到的,slog 的顶级函数使用默认记录器。我们可以显式地获取此记录器,并调用其方法

logger := slog.Default()
logger.Info("hello, world", "user", os.Getenv("USER"))

每个顶级函数都对应于 slog.Logger 上的方法。输出与以前相同。

最初,slog 的输出通过默认的 log.Logger 传递,生成我们上面看到的输出。我们可以通过更改记录器使用的 *处理程序* 来更改输出。slog 带有两个内置处理程序。TextHandlerkey=value 的形式发出所有日志信息。此程序使用 TextHandler 创建一个新记录器,并对 Info 方法进行相同的调用

logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
logger.Info("hello, world", "user", os.Getenv("USER"))

现在输出看起来像这样

time=2023-08-04T16:56:03.786-04:00 level=INFO msg="hello, world" user=jba

一切都已转换为键值对,字符串根据需要进行引号以保留结构。

对于 JSON 输出,请改用内置的 JSONHandler

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("hello, world", "user", os.Getenv("USER"))

现在我们的输出是 JSON 对象的序列,每个日志调用一个

{"time":"2023-08-04T16:58:02.939245411-04:00","level":"INFO","msg":"hello, world","user":"jba"}

您不限于内置处理程序。任何人都可以通过实现 slog.Handler 接口来编写处理程序。处理程序可以生成特定格式的输出,或者可以包装另一个处理程序以添加功能。slog 文档中的一个 示例 显示了如何编写包装处理程序,该处理程序会更改显示日志消息的最低级别。

我们一直在使用的用于属性的交替键值语法很方便,但是对于经常执行的日志语句,使用 Attr 类型并调用 LogAttrs 方法可能效率更高。它们协同工作以最大程度地减少内存分配。有函数用于使用字符串、数字和其他常见类型构建 Attr。对 LogAttrs 的此调用会生成与上面相同的输出,但速度更快

slog.LogAttrs(context.Background(), slog.LevelInfo, "hello, world",
    slog.String("user", os.Getenv("USER")))

slog 还拥有更多功能

  • 正如对 LogAttrs 的调用所示,您可以将 context.Context 传递给某些日志函数,以便处理程序可以提取上下文信息,如跟踪 ID。(取消上下文不会阻止写入日志条目。)

  • 您可以调用 Logger.With 向记录器添加属性,这些属性将出现在其所有输出中,有效地将多个日志语句的公共部分分解出来。这不仅很方便,而且还可以提高性能,如下所述。

  • 属性可以组合成组。这可以为您的日志输出添加更多结构,并有助于区分本来会相同的键。

  • 您可以通过使用 LogValue 方法提供值的类型来控制值在日志中的显示方式。这可以用于 将结构体的字段作为组记录屏蔽敏感数据,以及其他许多操作。

了解所有 slog 功能的最佳位置是 软件包文档

性能

我们希望 slog 速度很快。为了获得大规模的性能提升,我们设计了 Handler 接口,以提供优化机会。Enabled 方法在每个日志事件开始时调用,让处理程序有机会快速丢弃不需要的日志事件。WithAttrsWithGroup 方法让处理程序能够对 Logger.With 添加的属性进行一次格式化,而不是在每次日志记录调用时进行格式化。当将大型属性(如 http.Request)添加到 Logger 并在许多日志记录调用中使用时,这种预格式化可以显着提高速度。

为了为我们的性能优化工作提供信息,我们调查了现有开源项目中典型的日志记录模式。我们发现,超过 95% 的对日志记录方法的调用传递了 5 个或更少的属性。我们还对属性类型进行了分类,发现少数常见类型占了大多数。然后,我们编写了基准测试来捕捉常见情况,并使用它们作为指南来查看时间都花在了哪里。最大的收益来自对内存分配的细致关注。

设计过程

slog 包是自 2012 年 Go 1 发布以来,对标准库的最大增补之一。我们希望花时间设计它,并且知道社区反馈将至关重要。

到 2022 年 4 月,我们已经收集了足够的数据来证明结构化日志记录对 Go 社区的重要性。Go 团队决定探索将其添加到标准库中。

我们首先查看了现有结构化日志记录包的设计方式。我们还利用存储在 Go 模块代理上的大量开源 Go 代码集合,来了解这些包的实际使用方式。我们的第一个设计基于此研究以及 Go 的简单精神。我们希望 API 既轻量级又易于理解,同时又不牺牲性能。

替换现有的第三方日志记录包绝不是我们的目标。它们都在各自擅长的领域表现出色,替换现有的有效代码很少是开发人员时间的最佳利用方式。我们将 API 分为前端 Logger 和调用后端接口 Handler 的前端 Logger。这样,现有的日志记录包就可以与通用后端进行对话,因此使用它们的包可以实现互操作,而无需重写。许多常见日志记录包的处理程序正在编写或已在进行中,包括 Zaplogrhclog

我们在 Go 团队内部以及其他拥有丰富日志记录经验的开发人员之间分享了我们的初步设计。我们根据他们的反馈进行了修改,到 2022 年 8 月,我们认为我们已经有了可行的设计。在 8 月 29 日,我们公开了我们的 实验性实现,并开始了一场 GitHub 讨论,以了解社区的意见。反响热烈,总体上积极。感谢其他结构化日志记录包的设计者和用户的深刻评论,我们进行了一些更改并添加了一些功能,例如组和 LogValuer 接口。我们两次更改了从日志级别到整数的映射。

经过两个月和大约 300 条评论,我们认为我们已准备好进行实际的 提案 并附带相应的 设计文档。提案问题获得了超过 800 条评论,并对 API 和实现进行了许多改进。以下是有两个 API 更改示例,这两个更改都与 context.Context 相关

  1. 最初,API 支持将日志记录器添加到上下文。许多人认为这是一种方便的方式,可以通过不关心它的代码级别轻松地传递日志记录器。但其他人认为它会暗中引入依赖项,使代码更难理解。最终,我们删除了这个功能,因为它太有争议了。

  2. 我们还与将上下文传递给日志记录方法的类似问题作斗争,尝试了多种设计。我们最初抵制了将上下文作为第一个参数传递的标准模式,因为我们不想让每个日志记录调用都要求一个上下文,但最终创建了两套日志记录方法,一套带上下文,一套不带上下文。

我们没有进行的一个更改与用于表达属性的交替键值语法有关。

slog.Info("message", "k1", v1, "k2", v2)

许多人强烈认为这是一个坏主意。他们发现它难以阅读,并且容易出错,例如省略键或值。他们更喜欢显式属性来表达结构。

slog.Info("message", slog.Int("k1", v1), slog.String("k2", v2))

但我们认为,更轻量级的语法对于保持 Go 易于使用和有趣至关重要,特别是对于新的 Go 程序员。我们还知道,几个 Go 日志记录包(如 `logr`、`go-kit/log` 和 `zap`(及其 `SugaredLogger`))成功地使用了交替键值。我们添加了一个 vet 检查 来捕获常见错误,但没有更改设计。

2023 年 3 月 15 日,该提案获得通过,但仍有一些未解决的小问题。在接下来的几周内,提出了并解决了 10 个额外的更改。到 7 月初,`log/slog` 包的实现已经完成,以及用于验证处理程序的 `testing/slogtest` 包,以及用于正确使用交替键值的对账检查。

8 月 8 日,Go 1.21 发布,其中包含 `slog`。我们希望您发现它有用,而且使用起来和构建它一样有趣。

感谢所有参与讨论和提案过程的人。你们的贡献极大地改善了 `slog`。

资源

有关 `log/slog` 包的 文档 说明了如何使用它并提供了几个示例。

wiki 页面 包含 Go 社区提供的其他资源,包括各种处理程序。

如果您想编写一个处理程序,请参考 处理程序编写指南

下一篇文章:完全可重现、经过验证的 Go 工具链
上一篇文章:Go 1.21 中的向前兼容性和工具链管理
博客索引