Go 1.21 slog:标准库的结构化日志

深入探索 Go 1.21 引入的 slog 结构化日志包,从基础用法到高级特性,全面掌握现代日志方案

Go 1.21 slog:标准库的结构化日志

日志,是每个后端工程师绕不开的话题。你可能用过 logruszapzerolog 这些第三方库,也可能还在用标准库的 log 包拼接字符串。不管怎样,Go 1.21 给你带来了一个"迟到但正确"的礼物——log/slog 包。

slog 是 structured log 的缩写,即结构化日志。它不是简单地把文本丢到终端,而是以键值对的方式组织日志信息,让日志变得可搜索、可过滤、可机器解析。想想看,当你面对每天几十 GB 的日志数据,拿着 grep 去找一行 user_id=42 的记录时,结构化日志就是你的救命稻草。

这个包的提案最早可以追溯到 2022 年初,由 Go 团队的核心成员 Jonathan Amsterdam 主导设计。在设计过程中,团队广泛参考了社区中 zapzerologlogrus 等优秀日志库的经验教训,最终提炼出了一套简洁而强大的 API。slog 的核心设计原则是:性能足够好、API 足够简洁、可扩展性强、与标准库深度整合。这些原则贯穿了整个包的设计,从 Value 的内部表示到 Handler 接口的抽象,每一处都体现了 Go 团队对"少即是多"哲学的坚持。

本文将从零开始,带你全面掌握 slog 的方方面面。无论你是刚接触结构化日志的新手,还是已经熟练使用 zapzerolog 的老手,都能在这篇文章中找到有价值的内容。

为什么需要结构化日志?

在开始写代码之前,先让我们聊聊"为什么"。

传统日志的痛点

让我们先看一段再熟悉不过的代码:

package main

import (
    "fmt"
    "time"
)

func main() {
    userID := 42
    action := "login"
    ip := "192.168.1.100"
    
    // 传统文本日志——全靠字符串拼接
    fmt.Printf("[%s] INFO: user %d performed action %s from ip %s\n",
        time.Now().Format(time.RFC3339), userID, action, ip)
    // 输出:[2023-03-10T10:45:00+08:00] INFO: user 42 performed action login from ip 192.168.1.100
}

这段日志看起来还行,对吧?在开发阶段,你一眼就能看懂。但当你把它放到生产环境中,问题就来了:

  1. 解析困难:如果你想找出所有 user_id=42 的日志,得用正则表达式去匹配 user 42 这个模式。但如果日志里还有 user 420user 4200,你的正则就会误匹配。更糟糕的是,如果某天有人改了日志格式,把 user 42 改成了 userId=42,你的所有查询脚本都会失效。
  2. 格式不统一:不同开发者写的日志格式五花八门,有人用 user=42,有人用 user_id: 42,还有人用 userId 42。在一个大型项目中,这种不一致性会让日志分析变成一场噩梦。
  3. 无法聚合:当日志被收集到 ELK(Elasticsearch + Logstash + Kibana)、Loki、Splunk 这样的系统中,非结构化的文本几乎无法高效查询。你想要的是 SELECT * FROM logs WHERE user_id = 42 AND action = 'login',但实际得到的是 grep "user 42" logs/*.log | grep "login"
  4. 缺乏上下文:传统日志往往只记录了"发生了什么",但没有记录"在什么上下文中发生"。比如一个请求经过了 10 个微服务,你怎么把这些日志串联起来?靠 request_id?那你的每条日志都得手动加上这个字段。

结构化日志的解决方案

现在让我们看看 slog 是如何解决这些问题的:

package main

import (
    "log/slog"
    "os"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    
    logger.Info("user action",
        "user_id", 42,
        "action", "login",
        "ip", "192.168.1.100",
    )
    // 输出:{"time":"2023-03-10T10:45:00+08:00","level":"INFO","msg":"user action","user_id":42,"action":"login","ip":"192.168.1.100"}
}

看到了吗?输出是标准的 JSON 格式,每个字段都有明确的键名。这样的日志可以直接被日志系统索引、查询和分析。你可以在 Kibana 中写 user_id: 42 AND action: "login",在 Loki 中写 {user_id="42", action="login"},都能精确匹配。

更重要的是,结构化日志让你的日志变得可预测。无论谁写的代码,输出的格式都是一致的。这种一致性对于团队协作和系统运维来说,价值巨大。想象一下,当你半夜被报警叫醒,打开日志系统,看到整齐划一的 JSON 日志,每个字段都清晰可辨——那种感觉,就像在黑暗中找到了手电筒。

slog 核心概念

slog 的设计非常简洁,核心只有三个概念:

  • Logger:日志记录器,你调用它的方法来写日志。Logger 是面向开发者的 API,它提供了 DebugInfoWarnError 四个级别的日志方法,以及 WithWithGroup 等用于预设上下文的方法。
  • Handler:处理器,决定日志怎么输出(JSON?纯文本?写到文件?发到远程?)。Handler 是 slog 可扩展性的核心,你可以通过实现 Handler 接口来创建自定义的输出目标。
  • Attr:属性,键值对形式的日志字段。Attr 由一个字符串键和一个 Value 组成,Value 内部使用了高效的类型表示,避免了接口分配。

三者的关系很直观:

你的代码 → Logger(决定"记什么") → Handler(决定"怎么输出") → 输出目标

这种分离设计让 slog 既简单又灵活。你可以使用默认的 Handler 快速上手,也可以根据业务需求定制自己的 Handler。比如,你可以写一个 Handler 把日志发送到 Kafka,另一个 Handler 把日志写入数据库,还有一个 Handler 把日志推送到监控系统。

基础用法

使用默认 Logger

slog 提供了包级别的便捷函数,开箱即用。这对于快速原型开发和简单的命令行工具来说非常方便,你不需要显式地创建 Logger 对象,直接调用包级别的函数即可。

package main

import "log/slog"

func main() {
    // 默认使用 TextHandler,输出到 os.Stderr
    slog.Info("server started", "port", 8080)
    slog.Warn("disk usage high", "usage", "85%")
    slog.Error("connection failed", "host", "db.example.com", "error", "timeout")
    slog.Debug("this won't show by default") // 默认级别是 Info,Debug 不会输出
}

输出结果:

time=2023-03-10T10:45:00.000+08:00 level=INFO msg="server started" port=8080
time=2023-03-10T10:45:00.000+08:00 level=WARN msg="disk usage high" usage=85%
time=2023-03-10T10:45:00.000+08:00 level=ERROR msg="connection failed" host=db.example.com error=timeout

默认输出格式是 key=value 的文本形式,适合开发调试。

四种日志级别

slog 内置了四个日志级别:

package main

import "log/slog"

func main() {
    slog.Debug("调试信息——开发时才需要看")
    slog.Info("普通信息——正常运行状态")
    slog.Warn("警告信息——需要注意但不影响运行")
    slog.Error("错误信息——出了问题需要处理")
}

级别从低到高分别是 Debug < Info < Warn < Error。默认级别是 Info,也就是说 Debug 级别的日志会被丢弃。

自定义 Logger

默认的 Logger 往往不够用,我们需要自己创建:

package main

import (
    "log/slog"
    "os"
)

func main() {
    // 创建一个 JSON 格式的 Logger
    jsonLogger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelDebug, // 开启 Debug 级别
    }))
    
    jsonLogger.Debug("cache miss", "key", "user:42", "cache_name", "redis-01")
    jsonLogger.Info("request processed",
        "method", "GET",
        "path", "/api/users/42",
        "status", 200,
        "duration_ms", 15,
    )
}

输出:

{"time":"2023-03-10T10:45:00+08:00","level":"DEBUG","msg":"cache miss","key":"user:42","cache_name":"redis-01"}
{"time":"2023-03-10T10:45:00+08:00","level":"INFO","msg":"request processed","method":"GET","path":"/api/users/42","status":200,"duration_ms":15}

Handler 详解

Handler 是 slog 的灵魂。它实现了 slog.Handler 接口:

type Handler interface {
    Enabled(context.Context, Level) bool
    Handle(context.Context, Record) error
    WithAttrs(attrs []Attr) Handler
    WithGroup(name string) Handler
}

TextHandler vs JSONHandler

slog 内置了两个 Handler:

package main

import (
    "log/slog"
    "os"
)

func main() {
    // TextHandler——人类友好的键值对格式
    textLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
    textLogger.Info("hello", "name", "world", "count", 42)
    // 输出:time=2023-03-10T10:45:00+08:00 level=INFO msg=hello name=world count=42
    
    // JSONHandler——机器友好的 JSON 格式
    jsonLogger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    jsonLogger.Info("hello", "name", "world", "count", 42)
    // 输出:{"time":"...","level":"INFO","msg":"hello","name":"world","count":42}
}

选择建议

  • 开发环境用 TextHandler,终端阅读方便。
  • 生产环境用 JSONHandler,方便日志采集系统解析。

HandlerOptions 配置

package main

import (
    "log/slog"
    "os"
)

func main() {
    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level:       slog.LevelDebug,       // 最低日志级别
        AddSource:   true,                   // 添加源代码位置
        ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
            // 自定义属性替换——比如修改时间格式
            if a.Key == slog.TimeKey {
                t := a.Value.Time()
                a.Value = slog.StringValue(t.Format("2006-01-02 15:04:05"))
            }
            // 修改级别名称
            if a.Key == slog.LevelKey {
                level := a.Value.Any().(slog.Level)
                switch level {
                case slog.LevelDebug:
                    a.Value = slog.StringValue("🔍 DEBUG")
                case slog.LevelInfo:
                    a.Value = slog.StringValue("📋 INFO")
                case slog.LevelWarn:
                    a.Value = slog.StringValue("⚠️ WARN")
                case slog.LevelError:
                    a.Value = slog.StringValue("❌ ERROR")
                }
            }
            return a
        },
    })
    
    logger := slog.New(handler)
    logger.Info("server ready", "port", 8080)
    logger.Debug("loading config", "file", "config.yaml")
}

ReplaceAttr 是一个非常强大的功能,让你可以在日志输出前对任何属性做转换。比如脱敏、格式化、甚至直接删掉某个字段。

实战:多 Handler 输出

一个常见需求是:普通日志输出到 stdout,错误日志输出到 stderr。

package main

import (
    "context"
    "io"
    "log/slog"
    "os"
)

// MultiHandler 将日志分发到不同的 Handler
type MultiHandler struct {
    handlers []slog.Handler
}

func NewMultiHandler(handlers ...slog.Handler) *MultiHandler {
    return &MultiHandler{handlers: handlers}
}

func (h *MultiHandler) Enabled(ctx context.Context, level slog.Level) bool {
    for _, handler := range h.handlers {
        if handler.Enabled(ctx, level) {
            return true
        }
    }
    return false
}

func (h *MultiHandler) Handle(ctx context.Context, record slog.Record) error {
    for _, handler := range h.handlers {
        if handler.Enabled(ctx, record.Level) {
            if err := handler.Handle(ctx, record); err != nil {
                return err
            }
        }
    }
    return nil
}

func (h *MultiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
    newHandlers := make([]slog.Handler, len(h.handlers))
    for i, handler := range h.handlers {
        newHandlers[i] = handler.WithAttrs(attrs)
    }
    return NewMultiHandler(newHandlers...)
}

func (h *MultiHandler) WithGroup(name string) slog.Handler {
    newHandlers := make([]slog.Handler, len(h.handlers))
    for i, handler := range h.handlers {
        newHandlers[i] = handler.WithGroup(name)
    }
    return NewMultiHandler(newHandlers...)
}

func main() {
    // Info 及以上 → stdout
    stdoutHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    })
    
    // Error 及以上 → stderr
    stderrHandler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
        Level: slog.LevelError,
    })
    
    // 同时写入日志文件
    logFile, _ := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
    defer logFile.Close()
    
    fileHandler := slog.NewJSONHandler(logFile, &slog.HandlerOptions{
        Level: slog.LevelDebug,
    })
    
    multi := NewMultiHandler(stdoutHandler, stderrHandler, fileHandler)
    logger := slog.New(multi)
    
    logger.Info("this goes to stdout and file")
    logger.Error("this goes to stdout, stderr, and file")
    logger.Debug("this only goes to file")
}

这个例子展示了 slog 的扩展性——通过实现 Handler 接口,你可以把日志发送到任何地方:Kafka、Loki、Datadog,甚至数据库。

Attr:结构化日志的基石

slog.Attr 是键值对的核心抽象:

type Attr struct {
    Key   string
    Value Value
}

创建 Attr 的多种方式

package main

import (
    "log/slog"
    "os"
    "time"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    
    // 方式一:直接传键值对(最常用)
    logger.Info("request",
        "method", "GET",
        "path", "/api/users",
        "status", 200,
    )
    
    // 方式二:使用类型化的构造函数
    logger.Info("request",
        slog.String("method", "GET"),
        slog.String("path", "/api/users"),
        slog.Int("status", 200),
        slog.Duration("latency", 15*time.Millisecond),
        slog.Bool("cached", false),
    )
    
    // 方式三:使用 Any(任意类型)
    logger.Info("request",
        slog.Any("headers", map[string]string{
            "Content-Type":  "application/json",
            "Authorization": "Bearer xxx",
        }),
    )
    
    // 方式四:使用 Attr 结构体
    logger.Info("request",
        slog.Attr{Key: "method", Value: slog.StringValue("GET")},
        slog.Attr{Key: "status", Value: slog.IntValue(200)},
    )
}

Value 的类型系统

slog.Value 不是简单的 interface{},它有自己内部的类型系统,这是 slog 性能的关键:

package main

import (
    "fmt"
    "log/slog"
    "time"
)

func main() {
    // slog.Value 有专门的 Kind 来标识类型
    kinds := []slog.Value{
        slog.StringValue("hello"),
        slog.IntValue(42),
        slog.Float64Value(3.14),
        slog.BoolValue(true),
        slog.TimeValue(time.Now()),
        slog.DurationValue(time.Second),
        slog.GroupValue(
            slog.String("name", "Alice"),
            slog.Int("age", 30),
        ),
    }
    
    for _, v := range kinds {
        fmt.Printf("Kind: %-10v String: %s\n", v.Kind(), v.String())
    }
}

这种设计的好处是:slog 不需要在运行时做反射(reflection),类型信息直接编码在 Kind 里,所以性能非常好。

Group:给日志分组

当你的日志字段很多时,Group 可以帮你组织层次结构:

package main

import (
    "log/slog"
    "os"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    
    // 使用 Group 嵌套结构
    logger.Info("http request",
        slog.Group("request",
            slog.String("method", "GET"),
            slog.String("path", "/api/users/42"),
            slog.String("ip", "192.168.1.100"),
        ),
        slog.Group("response",
            slog.Int("status", 200),
            slog.Int("bytes", 1024),
            slog.String("duration", "15ms"),
        ),
    )
    // 输出:
    // {"time":"...","level":"INFO","msg":"http request",
    //  "request":{"method":"GET","path":"/api/users/42","ip":"192.168.1.100"},
    //  "response":{"status":200,"bytes":1024,"duration":"15ms"}}
}

命名 Group

你也可以给 Logger 预设一个 Group,后续所有日志字段都会嵌套在里面:

package main

import (
    "log/slog"
    "os"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    
    // 创建一个带 request group 的 logger
    reqLogger := logger.WithGroup("request")
    reqLogger.Info("incoming", "method", "GET", "path", "/api")
    // 输出:{"time":"...","level":"INFO","msg":"incoming","request":{"method":"GET","path":"/api"}}
    
    // 再嵌套一层
    httpLogger := reqLogger.WithGroup("http")
    httpLogger.Info("detail", "proto", "HTTP/2.0", "host", "example.com")
    // 输出:{"time":"...","level":"INFO","msg":"detail","request":{"http":{"proto":"HTTP/2.0","host":"example.com"}}}
}

With:预设公共字段

在很多场景下,你希望某些字段出现在所有日志中,比如 request_idservice_name 等。With 方法正是为此设计的:

package main

import (
    "log/slog"
    "os"
)

func handleRequest(logger *slog.Logger, requestID string) {
    // 给当前请求的 logger 添加 request_id
    reqLogger := logger.With("request_id", requestID)
    
    reqLogger.Info("handling request")
    reqLogger.Debug("parsing body")
    reqLogger.Info("request completed", "status", 200)
    
    // 所有日志都会带上 request_id:
    // {"time":"...","level":"INFO","msg":"handling request","service":"user-api","request_id":"abc-123"}
    // {"time":"...","level":"DEBUG","msg":"parsing body","service":"user-api","request_id":"abc-123"}
    // {"time":"...","level":"INFO","msg":"request completed","service":"user-api","request_id":"abc-123","status":200}
}

func main() {
    // 全局 logger,带上 service 信息
    baseLogger := slog.New(slog.NewJSONHandler(os.Stdout, nil)).
        With("service", "user-api", "version", "1.2.3")
    
    handleRequest(baseLogger, "abc-123")
}

With 返回的是一个新的 Logger,不会影响原来的 Logger,这点非常重要——它是不可变的(immutable)。

实战场景

场景一:HTTP 中间件

package main

import (
    "log/slog"
    "net/http"
    "os"
    "time"
)

// responseWriter 包装器,用于捕获状态码
type responseWriter struct {
    http.ResponseWriter
    statusCode int
    bytes      int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

func (rw *responseWriter) Write(b []byte) (int, error) {
    n, err := rw.ResponseWriter.Write(b)
    rw.bytes += n
    return n, err
}

func LoggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            
            // 为每个请求生成唯一 logger
            reqID := r.Header.Get("X-Request-ID")
            if reqID == "" {
                reqID = "unknown"
            }
            
            reqLogger := logger.With(
                "request_id", reqID,
                "method", r.Method,
                "path", r.URL.Path,
                "remote_addr", r.RemoteAddr,
            )
            
            reqLogger.Info("request started")
            
            // 包装 ResponseWriter
            wrapped := &responseWriter{ResponseWriter: w, statusCode: 200}
            
            // 调用下一个 handler
            next.ServeHTTP(wrapped, r)
            
            duration := time.Since(start)
            
            reqLogger.Info("request completed",
                "status", wrapped.statusCode,
                "bytes", wrapped.bytes,
                "duration", duration.String(),
                "duration_ms", duration.Milliseconds(),
            )
        })
    }
}

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }))
    
    mux := http.NewServeMux()
    mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello, World!"))
    })
    
    handler := LoggingMiddleware(logger)(mux)
    
    logger.Info("server starting", "port", 8080)
    http.ListenAndServe(":8080", handler)
}

场景二:实现 LogValuer 接口

对于复杂的结构体,你可以实现 slog.LogValuer 接口,让 slog 知道如何序列化它:

package main

import (
    "log/slog"
    "os"
)

// User 用户模型
type User struct {
    ID       int64  `json:"id"`
    Name     string `json:"name"`
    Email    string `json:"email"`
    Password string `json:"password"` // 敏感信息!
    Role     string `json:"role"`
}

// LogValue 实现 slog.LogValuer 接口
// 注意:这里绝对不能把密码写进日志!
func (u User) LogValue() slog.Value {
    return slog.GroupValue(
        slog.Int64("user_id", u.ID),
        slog.String("name", u.Name),
        slog.String("role", u.Role),
        // 故意不记录 email 和 password
    )
}

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    
    user := User{
        ID:       42,
        Name:     "Alice",
        Email:    "alice@example.com",
        Password: "super-secret-password", // 绝对不会出现在日志中
        Role:     "admin",
    }
    
    logger.Info("user logged in", "user", user)
    // 输出:{"time":"...","level":"INFO","msg":"user logged in","user":{"user_id":42,"name":"Alice","role":"admin"}}
    // 看!密码和邮箱都没有出现在日志中
}

这是一个非常实用的模式。在任何生产系统中,你都需要对敏感信息做脱敏处理,LogValuer 接口让这个过程变得优雅而安全。

场景三:替换默认 Logger

slog 可以替换标准库的默认 logger:

package main

import (
    "log"
    "log/slog"
    "os"
)

func main() {
    // 创建自定义 slog handler
    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    })
    
    // 用 slog 替换标准库的默认 logger
    logger := slog.NewLogLogger(handler, slog.LevelInfo)
    log.SetDefault(logger)
    
    // 现在标准库的 log 也会输出结构化日志
    log.Println("this is now structured!")
    // 输出:{"time":"...","level":"INFO","msg":"this is now structured!"}
}

这意味着你的项目中即使有遗留代码使用 log.Println,也能享受到结构化日志的好处。

性能分析

性能是日志库的核心指标之一。来看看 slog 的表现。

基准测试

package main

import (
    "io"
    "log/slog"
    "testing"
)

func BenchmarkSlogJSON(b *testing.B) {
    logger := slog.New(slog.NewJSONHandler(io.Discard, nil))
    b.ResetTimer()
    b.ReportAllocs()
    
    for i := 0; i < b.N; i++ {
        logger.Info("request completed",
            "method", "GET",
            "path", "/api/users",
            "status", 200,
            "duration_ms", 15,
        )
    }
}

func BenchmarkSlogText(b *testing.B) {
    logger := slog.New(slog.NewTextHandler(io.Discard, nil))
    b.ResetTimer()
    b.ReportAllocs()
    
    for i := 0; i < b.N; i++ {
        logger.Info("request completed",
            "method", "GET",
            "path", "/api/users",
            "status", 200,
            "duration_ms", 15,
        )
    }
}

func BenchmarkSlogWithAttrs(b *testing.B) {
    base := slog.New(slog.NewJSONHandler(io.Discard, nil)).
        With("service", "user-api", "version", "1.0")
    b.ResetTimer()
    b.ReportAllocs()
    
    for i := 0; i < b.N; i++ {
        base.Info("request completed",
            "method", "GET",
            "path", "/api/users",
            "status", 200,
        )
    }
}

在我的机器上(Apple M1 Pro),结果大致如下:

BenchmarkSlogJSON-10      5000000    240 ns/op    1 allocs/op
BenchmarkSlogText-10      4000000    280 ns/op    2 allocs/op
BenchmarkSlogWithAttrs-10 4500000    260 ns/op    1 allocs/op

与其他日志库对比

ns/opallocs/op特点
log/slog (JSON)~2401标准库,零依赖
go.uber.org/zap~1500极致性能,API 略复杂
rs/zerolog~1700性能好,链式 API
sirupsen/logrus~280024功能丰富,但性能差

slog 的性能介于 zap/zerologlogrus 之间。它不如 zapzerolog 那么极致(因为后两者用了大量 unsafe 和对象池优化),但比 logrus 快了一个数量级。对于绝大多数应用场景,slog 的性能绰绰有余。

关键点slogValue 类型避免了接口分配(interface allocation),这是它能做到 1 alloc/op 的核心原因。而 logrus 大量使用 map[string]interface{},每次日志调用都会产生大量的堆分配。

与第三方日志库的对比

功能对比矩阵

功能slogzapzerologlogrus
结构化日志
JSON 输出
自定义级别
Context 支持
标准库兼容部分
零依赖
高性能良好极致极致一般
学习曲线

何时用 slog?何时用第三方?

选 slog 的理由

  • 标准库自带,不需要额外依赖
  • API 设计简洁,团队学习成本低
  • 对于中小规模项目,性能完全够用
  • 官方维护,长期稳定有保障

选 zap/zerolog 的理由

  • 高流量服务,日志是性能瓶颈
  • 需要零分配(zero-allocation)的极致性能
  • 已经深度使用了这些库的生态

一个务实的建议:如果你的项目刚刚开始,没有历史包袱,优先使用 slog。它可能不会是最快的,但一定是最"正统"的。随着 Go 生态的发展,slog 的 Handler 生态会越来越丰富。

最佳实践

1. 统一的字段命名

package main

import (
    "log/slog"
    "os"
)

// 定义常量,确保字段名一致
const (
    FieldRequestID = "request_id"
    FieldUserID    = "user_id"
    FieldMethod    = "method"
    FieldPath      = "path"
    FieldStatus    = "status"
    FieldDuration  = "duration_ms"
    FieldError     = "error"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    
    // 使用常量而不是字符串字面量
    logger.Info("request",
        FieldMethod, "GET",
        FieldPath, "/api/users",
        FieldStatus, 200,
        FieldDuration, 15,
    )
}

2. 利用 Context 传递 Logger

package main

import (
    "context"
    "log/slog"
    "os"
)

// contextKey 是自定义的 context key 类型
type contextKey string

const loggerKey contextKey = "logger"

// LoggerFromContext 从 context 中获取 logger
func LoggerFromContext(ctx context.Context) *slog.Logger {
    if logger, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
        return logger
    }
    return slog.Default()
}

// WithLogger 将 logger 放入 context
func WithLogger(ctx context.Context, logger *slog.Logger) context.Context {
    return context.WithValue(ctx, loggerKey, logger)
}

func processOrder(ctx context.Context, orderID string) {
    logger := LoggerFromContext(ctx)
    logger.Info("processing order", "order_id", orderID)
    // 无需显式传递 logger,通过 context 自然传递
}

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)).
        With("service", "order-service")
    
    ctx := WithLogger(context.Background(), logger)
    
    // 在调用链的深处也能拿到带公共字段的 logger
    processOrder(ctx, "ORD-2023-001")
}

3. 延迟求值(Lazy Evaluation)

当计算日志字段代价较高时,使用 slog.LogValuer 做延迟求值:

package main

import (
    "log/slog"
    "os"
)

// LazyValue 延迟计算的值
type LazyValue struct {
    fn func() any
}

func (l LazyValue) LogValue() slog.Value {
    return slog.AnyValue(l.fn())
}

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }))
    
    // 假设 GetSystemStats() 很昂贵
    // 只有日志真正被输出时才会调用
    logger.Info("system status",
        "stats", LazyValue{fn: func() any {
            return map[string]int{
                "goroutines": 150,
                "heap_mb":    256,
            }
        }},
    )
}

4. 设置全局默认 Logger

package main

import (
    "log/slog"
    "os"
)

func init() {
    // 在 init 或 main 中设置全局默认 logger
    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
        ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
            // 移除源码位置,生产环境不需要
            if a.Key == slog.SourceKey {
                return slog.Attr{}
            }
            return a
        },
    })
    
    slog.SetDefault(slog.New(handler))
}

func main() {
    // 任何地方都可以直接使用 slog.Info
    slog.Info("application started")
}

小结

slog 的引入,标志着 Go 标准库在日志领域终于"追上了时代"。它不追求最极致的性能,也不追求最丰富的功能,而是追求正确的抽象

回顾一下核心要点:

  1. Logger 负责"记什么",Handler 负责"怎么输出",Attr 负责"记什么内容"。
  2. 开发环境用 TextHandler,生产环境用 JSONHandler
  3. With 预设公共字段,用 Group 组织嵌套结构。
  4. 实现 LogValuer 接口做敏感信息脱敏。
  5. 性能优于 logrus,接近 zap/zerolog,对大多数项目完全够用。

如果你正在开始一个新的 Go 项目,不妨直接拥抱 slog。它就像 Go 语言本身一样——简单、够用、可靠。

延伸阅读与资源

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页