请求路径、用户、错误码都焊在一句 log.Printf 里,事后想按某个键过滤,只能靠正则去拆那行文本。线上日志还在拼字符串,排查时不好按字段筛。
Go 1.21 起,标准库里的 log/slog 把结构化日志做成官方包。Go 博客介绍 slog 的文章 把它的目标写成:日志用键值对,便于解析、过滤和检索。包文档给出的模型很直接:有一个 Logger,它的 Info、Error 等方法会根据参数做成一条 Record,再交给与之绑定的 Handler 决定怎么写出去。包级的 Info、Error 这类函数走的是默认 logger。一条记录包含时间、级别、消息,以及一组键值。例如 slog.Info("hello", "count", 3) 就会留下级别为 Info、消息为 hello、以及一对 count=3 的记录。
刚接上时,默认 handler 仍把这些内容格式化成字符串,交给原来的 log 包,所以输出看起来和 log.Printf 很近,只是多了级别。博客也写了,一开始 slog 和原来的 log 就能一起工作。若要把字段真正写成机器能拆的结构,需要换 handler。内置的 TextHandler 输出一串 key=value。JSONHandler 则每条日志一行 JSON。用 slog.New(slog.NewJSONHandler(os.Stdout, nil)) 新建一个 logger 之后,同样的 Info 调用就会变成带 time、level、msg 和自定义字段的对象。slog.SetDefault 会把这个 logger 设为默认,并且同步改掉 log 包自己的默认 logger,已有的 log.Printf 不必立刻全部改写,也会走到新 handler 上。
多个调用都要带同一组字段时,可以用 Logger.With 派生出一个已经带上这些属性的新 logger,不必每行再抄一遍。属性还能收进命名分组:TextHandler 用点号拼出 request.method 这种键,JSONHandler 则把分组写成嵌套对象。若某个子系统可能和别处用到相同的键,可以用 Logger.WithGroup 给它的全部输出加上分组前缀,避免撞名。级别是整数,包里预置了 Debug、Info、Warn、Error,并不限于这四档。博客举过 Info 为 0、Warn 为 4,中间若还要一档可以用 2。内置 handler 可通过 HandlerOptions.Level 设定最低输出级别,默认是 Info。若希望运行中再改门槛,文档写的是把该字段设成一个 LevelVar,它本身可以在多个 goroutine 里安全读写。调用处若已经有 context.Context,文档建议改走 InfoContext 这类带上下文的方法,好让 handler 有机会取出追踪标识。博客也注明,取消这个上下文并不会拦住这条记录被写出去。
任何人都可以实现 slog.Handler 来换一种输出格式,或在已有 handler 外包一层能力。博客写明,引入这个包并不是为了替换已经在用的第三方日志库,而是把前端的 Logger 和后端的 Handler 拆开,让不同来源的日志能接到同一套后端。类型若实现了 LogValuer,记录时会用它的 LogValue 返回值,文档举过把密钥打码、或把结构体字段收成一组的用法。写得更紧的路径是构造 Attr 再走 LogAttrs,少一次分配。另外,一条日志的参数总会先被求值,即便这条最终会被级别门槛丢掉。文档的建议是能推迟的计算就不要写在参数里,例如不要预先调用 URL.String(),把值本身交给 handler,需要时再格式化。内置 handler 在写入前会加锁,保证一条记录被完整写出,但它们并不按时间戳重排输出顺序,自定义 handler 要自己处理并发写入。它管的是这一条日志怎么带上可检索的字段,并不是整套观测平台。和站内那篇 本地多模块用 go.work 也不是同一件事:那是一次构建里同时启用哪些模块,这里只谈到标准库怎么把日志写成结构化记录。
你们是准备新代码直接走 slog.Info 加字段,还是用 SetDefault 让旧的 log.Printf 也进同一条输出?