熟悉 Go 的同学都知道 Go 语言标准库 log 有许多痛点,比如没有日志分级、没有结构化(没有 JSON 格式)、扩展性差等,为了解决这些问题 Go 官方推出了结构化日志包 slog,目前这个库正在开发阶段,已经进入了实验库:golang.org/x/exp/slog,目前版本是 v0.0.0。
这篇文章我们就来看下 slog 包怎么用?
使用下面的命令安装:
go get golang.org/x/exp/slog
func main() {
slog.Info("Go is best language!", "公众号", "Golang来啦")
}
输出:
2023/01/23 10:23:37 INFO Go is best language! 公众号=Golang来啦
看输出有点类似标准库 log 的输出。slog 库里一个非常重要结构体就是 Logger,通过它就可以调用日志记录函数 Info()、Debug() 等。这个我们没有创建 Logger,会使用默认的,大家可以点进去看下源码。
Handler 定义成一个接口,这可以让 slog 的扩展性更强,slog 提供了两个内置的 Handler 实现:TextHandler 和 JSONHandler,另外我们可以基于第三方 log 包定义或者自己定义 Handler 的实现,这个我们后面会讲到。
type Handler interface {
Enabled(Level) bool
Handle(r Record) error
WithAttrs(attrs []Attr) Handler
WithGroup(name string) Handler
}
TextHandler 会像标准库 log 包那样将日志以一行文本那样输出。
func main() {
textHandler := slog.NewTextHandler(os.Stdout)
logger := slog.New(textHandler)
logger.Info("Go is best language!", "公众号", "Golang来啦")
}
输出:
time=2023-01-23T10:48:41.365+08:00 level=INFO msg="Go is best language!" 公众号=Golang来啦
我们看到,输出的日志以“key1=value1 key2=value2 … keyN=valueN”形式呈现。
我们将上面的 NewTextHandler() 换成 NewJSONHandler()
func main() {
textHandler := slog.NewJSONHandler(os.Stdout)
logger := slog.New(textHandler)
logger.Info("Go is best language!", "公众号", "Golang来啦")
}
输出:
{"time":"2023-01-23T11:02:27.1606485+08:00","level":"INFO","msg":"Go is best language!","公众号":"Golang来啦"}
从输出可以看到,日志已 json 格式记录,这样的结构化日志非常适合机器解析。
日常开发中我们一般都会在日志里面记录在哪个文件哪一行记录了这条日志,这样有利于排查问题。或者,有时候需要更改日志级别,那这些该怎么实现呢?
如果我们翻看源码就能发现,上面提到的 TextHandler 和 JSONHandler 都使用默认的 HandlerOptions,它是一个结构体。
type HandlerOptions struct {
AddSource bool
Level Leveler
ReplaceAttr func(groups []string, a Attr) Attr
}
通过 slog 的源代码注释可以看出,如果 AddSource 设置为 true,则记录日志时会以 ("source", "file:line") 的方式记录来源;Level 用于调整日志级别。
默认情况下,slog 只会记录 Info 及以上级别的日志,不会记录 Debug 级别的日志。
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout))
logger.Debug("记录日志-debug",
"公众号", "Golang来啦",
"time", time.Since(time.Now()))
logger.Info("记录日志-info",
"公众号", "Golang来啦",
"time", time.Since(time.Now()))
}
输出:
{"time":"2023-01-23T15:36:14.8610328+08:00","level":"INFO","msg":"记录日志-info","公众号":"Golang来啦","time":0}
这样的话,我们就可以自定义 option。
func main() {
opt := slog.HandlerOptions{ // 自定义option
AddSource: true,
Level: slog.LevelDebug, // slog 默认日志级别是 info
} logger := slog.New(opt.NewJSONHandler(os.Stdout))
logger.Debug("记录日志-debug",
"公众号", "Golang来啦",
"time", time.Since(time.Now()))
logger.Info("记录日志-info",
"公众号", "Golang来啦",
"time", time.Since(time.Now()))
}
输出:
{"time":"2023-01-23T15:38:45.3747228+08:00","level":"DEBUG","source":"D:/examples/context/demo1/demo1.go:81","msg":"记录日志-debug","公众号":"Golang来啦","time":0}
{"time":"2023-01-23T15:38:45.3949544+08:00","level":"INFO","source":"D:/examples/context/demo1/demo1.go:84","msg":"记录日志-info","公众号":"Golang来啦","time":0}
从输出可以看到记录日志的时候显示了来源,同时也记录了 debug 级别的日志。
有一点值得注意的是,slog.SetDefault() 会将传进来的 logger 作为默认的 Logger,所以下面这两行输出是一样的:
func main() {
textHandler := slog.NewJSONHandler(os.Stdout)
logger := slog.New(textHandler)
slog.SetDefault(logger) logger.Info("Go is best language!", "公众号", "Golang来啦")
slog.Info("Go is best language!", "公众号", "Golang来啦")
}
输出:
{"time":"2023-01-23T11:17:32.7518696+08:00","level":"INFO","msg":"Go is best language!","公众号":"Golang来啦"}
{"time":"2023-01-23T11:17:32.7732035+08:00","level":"INFO","msg":"Go is best language!","公众号":"Golang来啦"}
另外,如果设置里默认的 Logger,调用 log 包方法时也会使用默认的:
func main() {
textHandler := slog.NewJSONHandler(os.Stdout)
logger := slog.New(textHandler)
slog.SetDefault(logger) log.Print("something went wrong")
log.Fatalln("something went wrong")
}
输出:
{"time":"2023-01-23T11:18:31.5850509+08:00","level":"INFO","msg":"something went wrong"}
{"time":"2023-01-23T11:18:31.6043829+08:00","level":"INFO","msg":"something went wrong"}
exit status 1
通过 slog 包记录日志除了上面提到的这种方式:
logger.Info("Go is best language!", "公众号", "Golang来啦")
这种方式会涉及到额外的内存分配,主要是为了简介设计的。
另外一种记录日志方式就像下面这样:
logger.LogAttrs(slog.LevelInfo, "Go is best language!", slog.String("公众号", "Golang来啦"))
这两种输出日志格式都是一样的,第二种为了提高记录日志的性能而设计的,需要自己指定日志级别、参数属性(以键值对的方式指定)。
目前 slog 包支持下面这些属性:
String
Int64
Int
Uint64
Float64
Bool
Time
Duration
我们还可以多指定一些属性:
logger.LogAttrs(slog.LevelInfo, "Go is best language!", slog.String("公众号", "Golang来啦"), slog.Int("age", 18))
输出:
{"time":"2023-01-23T11:45:11.7921124+08:00","level":"INFO","msg":"Go is best language!","公众号":"Golang来啦","age":18}
学到这里我就在想,假如我想在一个 key 下面绑定一组 key-value 值该怎么做呢?这种需求在日常开发中是很常见的,我翻了翻源码,slog 还真的提供了相关方法 -- slog.Group()。
func main() {
textHandler := slog.NewJSONHandler(os.Stdout)
logger := slog.New(textHandler)
slog.SetDefault(logger) logger.Info("Usage Statistics",
slog.Group("memory",
slog.Int("current", 50),
slog.Int("min", 20),
slog.Int("max", 80)),
slog.Int("cpu", 10),
slog.String("app-version", "v0.0.0"),
)
}
输出:
{"time":"2023-01-23T13:45:26.9179901+08:00","level":"INFO","msg":"Usage Statistics","memory":{"current":50,"min":20,"max":80},"cpu":10,"app-version":"v0.0.0"}
memory 元素下面对应不同的 key-value。
日常开发中,可能会遇到每一条日志需要记录一些相同的公共信息,比如 app-version。
...logger.Info("Usage Statistics",
slog.Group("memory",
slog.Int("current", 50),
slog.Int("min", 20),
slog.Int("max", 80)),
slog.Int("cpu", 10),
slog.String("app-version", "v0.0.0"),
)
logger.Info("记录日志",
"公众号", "Golang来啦",
"time", time.Since(time.Now()), slog.String("app-version", "v0.0.0"))
...
如果想上面这样,每次都记录一次 app-version 的话就有点繁琐了。好在 slog 自带的 TextHandler 和 JSONHandler 提供了 WithAttrs() 方法可以实现绑定公共属性。
func main() {
textHandler := slog.NewJSONHandler(os.Stdout).WithAttrs([]slog.Attr{slog.String("app-version", "v0.0.0")})
logger := slog.New(textHandler)
slog.SetDefault(logger) logger.Info("Usage Statistics",
slog.Group("memory",
slog.Int("current", 50),
slog.Int("min", 20),
slog.Int("max", 80)),
slog.Int("cpu", 10),
)
logger.Info("记录日志",
"公众号", "Golang来啦",
"time", time.Since(time.Now()))
}
输出:
{"time":"2023-01-23T14:01:46.2845325+08:00","level":"INFO","msg":"Usage Statistics","app-version":"v0.0.0","memory":{"current":50,"min":20,"max":80},"cpu":10}
{"time":"2023-01-23T14:01:46.303597+08:00","level":"INFO","msg":"记录日志","app-version":"v0.0.0","公众号":"Golang来啦","time":0}
从输出可以看到两条日志都记录了 app-version,这种记录方式就简洁多了。
slog 的 Logger 还与 context.Context 结合在一起,比如通过 slog.WithContext() 存储 Logger、通过 slog.FromContext() 提取 Logger。这样我们就可以在不同函数之间通过 context 传递 Logger。
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout))
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
l := logger.With("path", r.URL.Path).With("user-agent", r.UserAgent()) // With() 绑定额外的信息 ctx := slog.NewContext(r.Context(), l) // 生成 context
handleRequest(w, r.WithContext(ctx))
})
http.ListenAndServe(":8080", nil)
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
logger := slog.FromContext(r.Context()) // 提取 Logger
logger.Info("handling request",
"status", http.StatusOK)
w.Write([]byte("Hello World"))
}
执行程序并访问地址: http://127.0.0.1:8080/hello
输出:
{"time":"2023-01-23T14:36:26.6303067+08:00","level":"INFO","msg":"handling request","path":"/hello","user-agent":"curl/7.83.1","status":200}
上面这种使用 Logger 的方式是不是还挺方便的,不过很遗憾的是,在最新的 slog 包里,这两个方法已经被作者移除掉了。
我很好奇作者为什么把这两个方法移除掉,后面翻到 slog 提案[1] 下面作者留言[2],大意是说这种使用方式有比较大的争议(主要是函数之间能否使用 context),而且如果使用者喜欢这种使用方式的话,也可以自己实现,所以把这两个方法移除了。
如果需要自己实现通过 context 储存和提取 Logger,你知道怎么实现吗?欢迎留言区交流,嘻嘻。
在讲 Handler 那一节时提到过,如果我们实现了 Handler 接口,就可以将第三方 log 与 Logger 集成,那该怎么实现呢?我们就拿 logrus 日志包举例吧。
package mainimport (
"fmt"
"github.com/sirupsen/logrus"
"golang.org/x/exp/slog"
"net"
"net/http"
"os"
)
func init() {
// 设置logrus
logrus.SetFormatter(&logrus.JSONFormatter{})
logrus.SetOutput(os.Stdout)
logrus.SetLevel(logrus.DebugLevel)
}
func main() {
// 将 Logrus 与 Logger 集成在一块
logger := slog.New(&LogrusHandler{
logger: logrus.StandardLogger(),
})
logger.Error("something went wrong", net.ErrClosed,
"status", http.StatusInternalServerError)
}
type LogrusHandler struct {
logger *logrus.Logger
}
func (h *LogrusHandler) Enabled(_ slog.Level) bool {
return true
}
func (h *LogrusHandler) Handle(rec slog.Record) error {
fields := make(map[string]interface{}, rec.NumAttrs())
rec.Attrs(func(a slog.Attr) {
fields[a.Key] = a.Value.Any()
})
entry := h.logger.WithFields(fields)
switch rec.Level {
case slog.LevelDebug:
entry.Debug(rec.Message)
case slog.LevelInfo:
entry.Info(rec.Message)
case slog.LevelWarn:
entry.Warn(rec.Message)
case slog.LevelError:
entry.Error(rec.Message)
}
fmt.Println("测试是否走了这个方法:记录日志")
return nil
}
func (h *LogrusHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
// 为了演示,此方法就没有实现,但不影响效果
return h
}
func (h *LogrusHandler) WithGroup(name string) slog.Handler {
// 为了演示,此方法就没有实现,但不影响效果
return h
}
输出:
{"err":"use of closed network connection","level":"error","msg":"something went wrong","status":500,"time":"2023-01-23T16:07:40+08:00"}
测试是否走了这个方法:记录日志
追查代码发现,通过调用 slog 的方法记录日志时都会调用 logPC() 方法生成一条 Record,最终会交给 Handler 接口的具体实现方法 Handle(),这里就是我们自己实现的方法
func (h *LogrusHandler) Handle(rec slog.Record) error {}
从输出就可以看出,最终调用了自己实现的 Handle() 方法,走的是 logrus 包的方法 entry.Error()。
这篇文章主要介绍了 slog 包的一些主要方法的使用,简单说了下里面一些函数、方法的实现,更详细的细节大家可以自行查看源码。目前中文社区关于 slog 的文章不多(可能是我没发现,欢迎补充),我发现比较好的已经在底部的参考文章里列出来了,作为补充可以深入了解 slog 包。另外感兴趣的同学可以看下关于 slog 的提案(里面会实时更新一些信息以及社区开发者的讨论)和 slog 包的设计文档,具体链接看参考文章。欢迎留言交流,一起学习成长。
提案: https://github.com/golang/go/issues/56345
[2]留言: https://github.com/golang/go/issues/56345#issuecomment-1381910606
[3]slog:Go 官方版结构化日志包: https://tonybai.com/2022/10/30/first-exploration-of-slog/
[4]proposal: log/slog: structured, leveled logging: https://github.com/golang/go/issues/56345
[5]Proposal: Structured Logging: https://go.googlesource.com/proposal/+/master/design/56345-structured-logging.md
推荐阅读