slog 基础教程
- uvim管理员
log/slog 是 Go 1.21 版本引入的官方结构化日志标准库 [1]。在它诞生之前,Go 标准库的 log 包功能过于单一,迫使社区走向了 Zap、Logrus 等第三方库。
slog 的出现统一了 Go 的日志生态。它的一大核心设计是前端与后端分离:你可以在业务代码中统一使用官方的 slog API(前端),而在底层自由更换不同的 Handler(后端,如原生的 JSON 处理器,或对接 Uber Zap 等)。1. 快速上手:两种核心 API
slog 提供了两种记录日志的方式,分别对应不同的使用习惯。
方式 A:松散的 Key-Value 键值对(最常用、最符合直觉)
日志级别函数(如 Info, Debug, Error 等)交替接收 Key(必须是字符串)和 Value(可以是任意类型)。
package main
import "log/slog"
func main() {
// 使用全局默认的 Logger 打印 Info 日志
slog.Info("用户登录成功",
"user_id", "U1024",
"status", "active",
"retry_count", 3,
)
}输出(默认是文本格式):
2026-09-06T10:00:00.000+08:00 INFO 用户登录成功 user_id=U1024 status=active retry_count=3
方式 B:强类型的 slog.Attr(性能更高)
通过 slog.Int、slog.String 等强类型函数传递属性。这能避免反射,减少内存分配,性能表现接近 Zap。
package main
import "log/slog"
func main() {
slog.LogAttrs(
nil, // context.Context,不需要时传 nil
slog.LevelInfo,
"文件上传成功",
slog.String("filename", "avatar.png"),
slog.Int("size_bytes", 2048),
)
}2. 配置内置的 Handler(控制台 vs JSON)
slog 内置了两种 Handler,可以通过 slog.SetDefault 来改变全局日志的输出格式。
package main
import (
"log/slog"
"os"
)
func main() {
// 1. 创建一个输出 JSON 格式的 Handler,直接写入标准输出
handler := slog.NewJSONHandler(os.Stdout, nil)// 2. 将其设置为全局默认的 Logger
logger := slog.New(handler)
slog.SetDefault(logger)// 3. 之后的日志都会以标准 JSON 格式输出
slog.Info("系统初始化完成", "version", "v1.2.0")
}输出(JSON 格式):
{"time":"2026-09-06T10:00:00Z","level":"INFO","msg":"系统初始化完成","version":"v1.2.0"}
3. 高级配置:设置日志级别与自定义时间格式
如果想要修改默认的日志级别(默认是 Info,即 Debug 日志会被隐藏),或者改变时间格式、Key 的名称,可以通过 slog.HandlerOptions 进行配置。
package main
import (
"log/slog"
"os"
)
func main() {
opts := &slog.HandlerOptions{
// A. 修改日志级别为 Debug(这样 slog.Debug 就能打印出来了)
Level: slog.LevelDebug,// B. 自定义修改日志的 Key 或 Value
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
// 将官方默认的 "time" 键名修改为 "timestamp"
if a.Key == slog.TimeKey {
return slog.String("timestamp", a.Value.Time().Format("2006-01-02 15:04:05"))
}
// 将官方默认的 "level" 键名修改为大写的 "LEVEL"
if a.Key == slog.LevelKey {
return slog.String("LEVEL", a.Value.String())
}
return a
},
}logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
slog.SetDefault(logger)slog.Debug("这是一条调试日志", "module", "auth")
}输出:
{"timestamp":"2026-09-06 10:00:00","LEVEL":"DEBUG","msg":"这是一条调试日志","module":"auth"}
4. 属性分组:slog.Group
在微服务或模块化开发中,你可能希望把某些相关的日志字段归类在一起(例如把硬件指标组合进一个 db 或 system 的分类中)。slog 提供了优雅的分组支持:
package main
import "log/slog"
func main() {
slog.Info("数据库连接指标",
slog.Group("database",
"host", "127.0.0.1",
"port", 3306,
"idle_conns", 10,
),
"status", "healthy",
)
}输出(JSON 格式下会自动嵌套):
{"time":"...","level":"INFO","msg":"数据库连接指标","database":{"host":"127.0.0.1","port":3306,"idle_conns":10},"status":"healthy"}
5. 绑定上下文:slog.With
类似于 Zap 的克隆,如果你在一个特定的业务流程里(比如处理某个特定用户的订单),你可以创建一个自带这些属性的新 Logger,避免每次打印都手动传参。
package main
import "log/slog"
func main() {
// 创建一个固定带有请求 ID 的 Logger
reqLogger := slog.With("request_id", "REQ-8888")// 在接下来的调用中,直接使用该实例
reqLogger.Info("用户下单")
reqLogger.Warn("库存紧张")
}💡 核心避坑指南
1. 小心 Key-Value 的配对失衡:
如果你写成 slog.Info("msg", "key1", "value1", "key2")(最后多出一个 key,没有对应 value),Go 编译不会报错,但输出时 key2 的值会被强制转换为 "!BADKEY"。
2. 正确透传 Context:
如果你要在日志中关联链路追踪(TraceID),应当使用 slog.InfoContext(ctx, "msg", ...) 带有 Context 后缀的方法。接下来,你希望了解如何编写自定义的 slog.Handler 以实现诸如向日志里自动注入 Context 中的 TraceID 等进阶功能,还是了解如何把老代码的 log 库无缝迁移到 slog?
在掌握了 log/slog 的基本配置和两种 API 后,要在企业级生产环境落地,通常需要解决以下核心痛点:自定义 Handler 实现复杂输出、在 Context 中隐式传递 TraceID、动态调整日志级别、以及与传统 log 包或第三方库的平滑迁移。
本进阶教程将带你逐一攻克这些高级应用场景。1. 终极实战:自定义 Handler(自动从 Context 提取 TraceID)
在微服务架构中,我们通常需要把 TraceID 打印到每条日志中。由于 slog 提供了 InfoContext(ctx, ...) 方法,我们可以通过自定义 Handler 在日志输出前拦截并自动注入 Context 中的信息,避免手动传参。
package main
import (
"context"
"log/slog"
"os"
)
type ctxKey stringconst traceKey ctxKey = "trace_id"
// TraceHandler 包装了一个现有的 Handlertype TraceHandler struct {
slog.Handler
}
// Handle 拦截每条日志的写入func (h *TraceHandler) Handle(ctx context.Context, r slog.Record) error {
if ctx != nil {
// 1. 从 Context 中提取 TraceID
if traceID, ok := ctx.Value(traceKey).(string); ok {
// 2. 将 TraceID 作为一个属性追加到当前的 Record 中
r.AddAttrs(slog.String("trace_id", traceID))
}
}
// 3. 交给底层的 Handler 真正执行输出
return h.Handler.Handle(ctx, r)
}
func main() {
// 组合:让 TraceHandler 包装内置的 JSONHandler
baseHandler := slog.NewJSONHandler(os.Stdout, nil)
logger := slog.New(&TraceHandler{Handler: baseHandler})
slog.SetDefault(logger)// 模拟一条带有 TraceID 的 Context
ctx := context.WithValue(context.Background(), traceKey, "TRACE-992384")// 注意:必须调用带 Context 后缀的方法(如 InfoContext / ErrorContext)
slog.InfoContext(ctx, "订单支付成功", "amount", 100)
}输出(JSON 格式,自动带上 trace_id):
{"time":"2026-09-06T10:00:00Z","level":"INFO","msg":"订单支付成功","amount":100,"trace_id":"TRACE-992384"}
2. 动态日志级别(不重启服务切换 Debug/Info)
在生产环境中,默认通常开启 Info 级别。但当系统出现故障时,我们希望在不重启服务的情况下,通过配置中心或 HTTP 接口临时将日志级别切换为 Debug。
slog 提供了 slog.LevelVar 来优雅地实现这一点。package main
import (
"log/slog"
"net/http"
"os"
)
func main() {
// 1. 创建一个原子级别变量(线程安全)
lvl := &slog.LevelVar{}
lvl.Set(slog.LevelInfo) // 默认为 Info// 2. 绑定到 Handler
opts := &slog.HandlerOptions{Level: lvl}
logger := slog.New(slog.NewTextHandler(os.Stdout, opts))
slog.SetDefault(logger)// 这条 Debug 日志此时【不会】打印
slog.Debug("这是一条调试日志(现在看不到)")// 3. 模拟通过 HTTP 接口动态修改级别
http.HandleFunc("/set-level", func(w http.ResponseWriter, r *http.Request) {
queryLevel := r.URL.Query().Get("level")
if queryLevel == "debug" {
lvl.Set(slog.LevelDebug) // 动态切换到 Debug
w.Write([]byte("已切换到 DEBUG 级别"))
} else {
lvl.Set(slog.LevelInfo)
w.Write([]byte("已切换到 INFO 级别"))
}
})// 启动后台服务(仅作演示,实际运行可调用 http 触发)
go http.ListenAndServe(":8080", nil)
}3. 让结构体支持高性能序列化:实现 LogValue
如果一个结构体非常庞大,或者带有敏感信息(如密码、银行卡号),直接用 slog.Info("user", "data", user) 会带来:
1. 性能损耗(底层使用反射深度遍历结构体)。
2. 安全风险(敏感字段被意外打印)。通过让结构体实现 slog.LogValuer 接口,可以自定义该结构体被打印时的行为和格式。
package main
import (
"log/slog"
"os"
)
type User struct {
Name string
Password string // 敏感字段
Age int
}
// LogValue 实现了 slog.LogValuer 接口func (u User) LogValue() slog.Value {
// 自定义返回一个 Group,隐藏密码,且避免了全对象的反射解析
return slog.GroupValue(
slog.String("name", u.Name),
slog.Int("age", u.Age),
slog.String("password", "******"), // 脱敏处理
)
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))u := User{Name: "张三", Password: "secret_password_123", Age: 25}
// 打印时会自动调用其 LogValue 方法
logger.Info("用户信息", "user_data", u)
}4. 兼容老代码:接管传统 log 包的输出
如果你的老项目中大量使用了官方旧的 log.Printf(...),或者你使用的第三方开源库还在使用原生的 log 包,你可以使用 slog 直接接管它们的输出,让它们全部规范化。
package main
import (
"log"
"log/slog"
"os"
)
func main() {
// 1. 设置 slog 的默认输出为 JSON
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))// 2. 将旧的 log 包桥接到当前的默认 slog Logger
// 指定旧 log 打印出来的日志在 slog 中对应 INFO 级别
log.SetFlags(0) // 禁用旧 log 的时间和文件前缀,完全交给 slog 控制
log.SetOutput(slog.NewLogLogger(slog.Default().Handler(), slog.LevelInfo).Writer())// 3. 此时调用传统 log 包,输出的却成了标准的 JSON 结构化日志!
log.Println("这是一条来自旧 log 包的日志")
}输出:
{"time":"2026-09-06T10:00:00Z","level":"INFO","msg":"这是一条来自旧 log 包的日志\n"}
🚀 Slog 进阶性能优化指南
1. 在高并发路径避免频繁使用松散的 kv 对:
slog.Info("msg", "key", val) 的传参底层依然会涉及 interface{} 的转换与隐式切片分配。在极其追求性能的循环中,请换用 slog.LogAttrs 配合 slog.Attr 强类型字段(如 slog.Int),其性能可追平 Zap。
2. 巧妙利用 With() 进行多级分流:
如果你在写一个数据库组件,可以在组件初始化时:
dbLogger := slog.Default().With(slog.String("component", "mysql"))
后续该组件内所有打印都不再需要手动写 component=mysql,减少了重复代码,且 With 内部对公共字段做了预序列化优化。你目前是否已经开始在项目中将原有的日志库(如 Logrus 或 Zap)往 slog 进行重构了?如果需要,我可以为你提供:
- 一个完整的 Gin 框架集成 Slog 中间件的实现代码。
- 演示如何通过编写一个简单的 Bridge(桥接器),把 slog 的前端流量无缝导向 Zap 的高效率后端。
在 Gin 框架中,默认的 gin.Logger() 会将日志以固定格式(文本、带颜色)输出到控制台。但在生产环境中,我们通常需要结构化的 JSON 日志,以便收集到 ELK、Loki 或 阿里云 SLS 等日志中心。
将官方的 log/slog 与 Gin 配合使用的最佳实践是:编写自定义的 Gin 中间件,接管请求日志(Logger)和异常崩溃(Recovery)。
下面是完整的、可直接复制到项目中的详细教程。1. 编写 Gin 的 Slog 中间件
在项目中创建一个 middleware/logger.go 文件。我们将编写两个中间件:
1. Logger:替换 Gin 默认的访问日志,记录请求方法、路径、状态码、耗时和客户端 IP。
2. Recovery:替换 Gin 默认的崩溃拦截器,在发生 panic 时捕获异常,并使用 slog.Error 记录详细的堆栈信息。package middleware
import (
"context"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"os"
"runtime/debug"
"strings"
"time""://github.com"
)
// Logger 返回一个使用 slog 记录请求日志的 Gin 中间件func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery// 执行后续的业务逻辑
c.Next()// 结束时间与耗时
cost := time.Since(start)// 收集日志字段
// 如果你使用了链路追踪,可以在这里从 c.Request.Context() 获取 trace_id
slog.InfoContext(c.Request.Context(), path,
slog.Int("status", c.Writer.Status()),
slog.String("method", c.Request.Method),
slog.String("path", path),
slog.String("query", query),
slog.String("ip", c.ClientIP()),
slog.String("user_agent", c.Request.UserAgent()),
slog.Duration("cost", cost),
slog.Int("errors", len(c.Errors)),
)
}
}
// Recovery 返回一个使用 slog 记录 Panic 堆栈的 Gin 中间件func Recovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
// 检查是否是断开连接的连接(broken pipe)
var brokenPipe bool
if ne, ok := err.(*net.OpError); ok {
if se, ok := ne.Err.(*os.SyscallError); ok {
if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") {
brokenPipe = true
}
}
}// 获取原始 HTTP 请求内容
httpRequest, _ := httputil.DumpRequest(c.Request, false)if brokenPipe {
slog.ErrorContext(c.Request.Context(), "broken pipe error",
slog.Any("error", err),
slog.String("request", string(httpRequest)),
)
// 如果连接已断开,我们无法写入状态码
c.Error(err.(error))
c.Abort()
return
}// 正常 Panic,记录错误和完整的堆栈信息
slog.ErrorContext(c.Request.Context(), "panic recovered",
slog.Any("error", err),
slog.String("request", string(httpRequest)),
slog.String("stack", string(debug.Stack())), // 捕获堆栈
)// 返回 500 状态码
c.AbortWithStatus(http.StatusInternalServerError)
}
}()
c.Next()
}
}2. 在项目启动中进行集成
在你的 main.go 中,首先初始化全局的 slog 后端(如 JSON 格式),然后显式创建一个干净的 Gin 路由(不带默认日志中间件的 gin.New()),并把我们写好的中间件挂载上去。
package main
import (
"log/slog"
"net/http"
"os""://github.com"
"your_project/middleware" // 替换为你的项目实际包路径
)
func main() {
// 1. 初始化全局 slog:在生产环境使用 JSON 格式
// 如果是开发环境,可以换成 slog.NewTextHandler
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo, // 只打印 Info 及以上级别
})
slog.SetDefault(slog.New(handler))// 2. 设置 Gin 的运行模式 (生产环境记得改为 gin.ReleaseMode)
gin.SetMode(gin.DebugMode)// 3. 注意:必须使用 gin.New(),不要用 gin.Default() 避免引入原生的 Logger
r := gin.New()// 4. 挂载自定义的 slog 中间件
r.Use(middleware.Logger(), middleware.Recovery())// 5. 编写一个测试路由
r.GET("/ping", func(c *gin.Context) {
// 在 Controller 中,你可以直接使用全局的 slog 打印业务日志
slog.InfoContext(c.Request.Context(), "正在处理用户请求", "user_id", 9527)c.JSON(http.StatusOK, gin.H{"message": "pong"})
})// 6. 编写一个测试 Panic 的路由
r.GET("/panic", func(c *gin.Context) {
panic("这是故意抛出的服务崩溃异常!")
})// 启动服务
slog.Info("Gin 服务正在启动,端口: 8080")
r.Run(":8080")
}3. 运行与效果演示
运行你的服务并访问这两个测试路由,控制台会输出标准的结构化 JSON 日志:
访问 /ping 的普通请求日志:
{"time":"2026-09-06T10:00:00Z","level":"INFO","msg":"正在处理用户请求","user_id":9527}
{"time":"2026-09-06T10:00:00Z","level":"INFO","msg":"/ping","status":200,"method":"GET","path":"/ping","query":"","ip":"127.0.0.1","user_agent":"Mozilla/5.0...","cost":152430,"errors":0}访问 /panic 的异常崩溃日志:
{"time":"2026-09-06T10:01:00Z","level":"ERROR","msg":"panic recovered","error":"这是故意抛出的服务崩溃异常!","request":"GET /panic HTTP/1.1\r\nHost: localhost:8080\r\n...","stack":"goroutine 6 [running]:\nruntime/debug.Stack()\n\t/usr/local/go/src/runtime/debug/stack.go:24 +0x5e\nyour_project/middleware.Recovery.func1.1()\n\t..."}
💡 高级进阶技巧:带上 TraceID(链路追踪)
在上面的代码中,我们统一使用了 slog.InfoContext(c.Request.Context(), ...)。这意味着,如果你在前置中间件中,为每个请求生成了一个 TraceID 注入到了 c.Request.Context() 中,并且你配置了前一章讲过的 TraceHandler(自定义 Handler),那么 Gin 生成的每一条访问日志和你的业务日志里,都会自带该请求的 TraceID。
极简链路串联流程:1. 编写一个 TraceMiddleware,生成一个 UUID,并使用 context.WithValue 存入 c.Request.Context()。
2. 结合上一章的 TraceHandler 作为默认的 slog 驱动。
3. 挂载到 Gin 后,你的所有 API 日志瞬间拥有了全链路追踪能力。你是否需要我为你提供:
- 完整的 TraceID 注入中间件 + 注入 slog 的全套闭环代码?
- 如何结合 Lumberjack 库,让 Gin 与 slog 的日志按大小和日期自动切分归档?