我的论坛
登录

slog 基础教程

162
  • 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?

  • uvim
    管理员
    #1

    在掌握了 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 的高效率后端。
  • uvim
    管理员
    #2

    在 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 的日志按大小和日期自动切分归档?

发表回复

登录后回复