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 是 0,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,产生我们上面看到的输出。我们可以通过更改日志记录器使用的 handler 来更改输出。slog 带有两个内置的 handler。一个 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"}

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

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

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

slog 还有更多功能

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

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

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

  • 您可以通过为其类型提供 LogValue 方法来控制值在日志中如何显示。这可以用于将结构体的字段记录为组屏蔽敏感数据等。

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

性能

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

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

设计过程

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

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

我们首先研究了现有结构化日志记录包的设计方式。我们还利用 Go module proxy 上存储的大量开源 Go 代码,了解这些包的实际使用情况。我们的第一个设计参考了这项研究以及 Go 的简洁精神。我们想要一个易于理解、简洁明了的 API,同时又不牺牲性能。

取代现有的第三方日志记录包从来不是目标。它们在各自领域都做得很好,替换工作良好的现有代码很少能有效利用开发者的时间。我们将 API 分为前端 Logger,它调用后端接口 Handler。这样,现有的日志记录包就可以与一个通用的后端通信,因此使用它们的包无需重写即可相互协作。许多常见日志记录包(包括 Zaplogrhclog)的 handler 已经编写完成或正在进行中。

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

经过两个月和大约 300 条评论,我们觉得已经可以提出正式的提案和随附的设计文档了。提案问题收到了超过 800 条评论,并对 API 和实现进行了许多改进。以下是两个关于 context.Context 的 API 更改示例

  1. 最初的 API 支持将日志记录器添加到 context 中。许多人认为这是在不关心日志的代码层级中方便地传递日志记录器的一种方式。但另一些人认为这偷偷引入了隐式依赖,使代码更难理解。最终,我们因其争议过大而移除了该功能。

  2. 我们还纠结于向日志方法传递 context 的相关问题,尝试了多种设计。最初我们抵制将 context 作为第一个参数的标准模式,因为我们不希望每个日志调用都需要一个 context,但最终创建了两套日志方法,一套带有 context,一套不带。

我们未进行的一项更改涉及用于表达属性的交替键值语法

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

许多人强烈认为这是一个糟糕的主意。他们觉得这种方式难以阅读,而且容易因遗漏键或值而出错。他们更喜欢使用显式属性来表达结构

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

但我们认为,更轻量的语法对于保持 Go 易于使用且有趣非常重要,特别是对于 Go 新手而言。我们也知道,一些 Go 日志记录包,如 logrgo-kit/logzap(及其 SugaredLogger),成功地使用了交替键值。我们添加了一个vet 检查来捕获常见错误,但没有改变设计。

2023 年 3 月 15 日,提案被接受,但仍有一些轻微的未解决问题。在接下来的几周里,又提出并解决了十项额外的更改。到 7 月初,log/slog 包的实现以及用于验证 handler 的 testing/slogtest 包和用于检查交替键值正确用法的 vet check 全部完成。

2023 年 8 月 8 日,Go 1.21 发布,slog 也随之发布。我们希望您觉得它有用,并且使用它像构建它一样有趣。

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

资源

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

维基页面包含 Go 社区提供的额外资源,包括各种 handler。

如果您想编写一个 handler,请查阅handler 编写指南

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