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 是四,因此如果您的日志记录系统在这两者之间有一个级别,您可以使用二。

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% 的日志记录方法调用传递了五个或更少的属性。我们还对属性类型进行了分类,发现少数常见类型占了大多数。然后,我们编写了捕捉常见情况的基准测试,并以此为指导来查看时间花在了哪里。最大的收益来自于对内存分配的仔细关注。

设计过程

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

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

我们首先研究了现有结构化日志包的设计方式。我们还利用存储在 Go 模块代理上的大量开源 Go 代码来了解这些包的实际使用情况。我们的第一个设计受到了这项研究以及 Go 追求简洁精神的影响。我们希望有一个 API,它在代码中轻巧易懂,同时又不牺牲性能。

我们从未有过替换现有第三方日志包的目标。它们各自都做得很好,替换现有的运行良好的代码很少能成为开发人员时间的好去处。我们将 API 分为调用后端接口 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 程序员。我们还知道,像 logrgo-kit/logzap(带有其 SugaredLogger)等几个 Go 日志包成功地使用了交替的键和值。我们添加了一个vet 检查来捕获常见错误,但没有更改设计。

2023 年 3 月 15 日,提案获得通过,但仍有一些小问题悬而未决。在接下来的几周里,又提出了并解决了十项附加更改。到 7 月初,log/slog 包的实现以及用于验证处理程序的 testing/slogtest 包和用于正确使用交替键值的 vet 检查都已完成。

8 月 8 日,Go 1.21 发布,slog 也随之发布。希望您发现它很有用,并且使用起来像构建它一样有趣。

非常感谢所有参与讨论和提案过程的人。您的贡献极大地改进了 slog

资源

log/slog 包的文档解释了如何使用它并提供了几个示例。

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

如果您想编写处理程序,请参阅处理程序编写指南

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