image

Golang slog 日志包工程化配置

  • WORDS 20139

Go 1.21 之前,一直没有一个标准的结构化日志包,其默认的 log 包只有简单的记录功能,不能满足结构化和工程化的需要。在此背景下,出现许多优秀的日志框架,如 zapzerolog 等, 包括许多 Web 框架也集成了其私有的日志包。这样带来的问题就是没有类似于 Javaslf4j 那样的统一日志前端,如果发现使用的日志框架无法满足新功能的需求,那么需要对代码中所有的日志输出调用进行重写,非常麻烦。在 Go 1.21 之后,Go 官方终于推出了其结构化的日志包 slog,具备简单的日志结构化输出功能,但是 Go 官方更多是想让 slog 做为一个统一的日志前端,通过实现其 Handler 来适配不同的后端,包括但不限于 zapzerolog 等。

简单使用

slog 包提供了 slog.Infoslog.Warnslog.Errorslog.Debug 进行简单的日志结构化输出

slog.Info("hello world")
slog.Error("hello world")

其默认的输出为

2025/07/01 17:56:22 INFO hello world
2025/07/01 17:56:22 ERROR hello world

slog 并没有提供类似于 Infof 这样的方法用于做字符串格式化,而是推荐使用 key、value 的形式将需要记录的数据单独输出,这样的好处就是参数输出可以更加直观并且在使用 Json 格式进行日志输出时,可以更加的结构化。

slog.Info("hello world", slog.String("name", "xin"),  
    slog.Int64("age", 1),  
    slog.Bool("is_student", false),  
    slog.Any("roles", []string{"admin", "user"}))

其输出内容如下

2025/07/01 18:00:35 INFO hello world name=xin age=1 is_student=false roles="[admin user]"

此外,也支持使用 Group 对参数进行分组,方便更加的结构化。

slog.Info("hello world", slog.Group("user", slog.String("name", "1"), slog.Int("gender", 1)))

slog.Group 第一个参数为 Group 的名称,后续的参数都为 keyvalue 的形式,其输出也更加直观。

2025/07/02 10:15:24 INFO hello world user.name=1 user.gender=1

携带固定参数

slog 提供了 With 函数用于添加自定义的参数,在某个 Logger 对象中调用了 With 函数之后,会返回一个新的 Logger 对象,被称作为子 Logger,其参数会一直存在于这个子 Logger 中,同时,也会附带父 Logger 中所有的固定参数,这在一些场景下非常有用,例如 Web 项目中的链路追踪。

s1 := slog.Default().With(slog.String("requestId", "1111"))  
s1.Info("hello world")  
s2 := s1.With(slog.String("userId", "1"))  
s2.Info("hello world")

Logger 中打印的日志会带上其自有的和父 Logger 中的所有固定参数

2025/07/02 10:26:28 INFO hello world requestId=1111
2025/07/02 10:26:28 INFO hello world requestId=1111 userId=1

如果不想使用这种子 Logger 的形式去传递参数,在 Go 中这种实现需要 Logger 对象从上到下依次传递,会与业务逻辑造成一定的耦合。那么也可以通过更加通用的 Context 对象和 slog.xxxxContext 函数来从 Context 中获取指定的固定参数并输出。

// 定义个处理Context的Handler
type ContextHandler struct {  
    slog.Handler  
    keys []string // 需要从 Context 中读取的字段  
}  
  
func NewContextHandler(parent slog.Handler, keys []string) slog.Handler {  
    return &ContextHandler{  
       keys:    keys,  
       Handler: parent,  
    }  
}  
  
func (self *ContextHandler) Handle(ctx context.Context, r slog.Record) error {  
    for _, key := range self.keys {  
       value := ctx.Value(key)  
       if value == nil {  
          continue  
       }  
       // 适配各种可能的value类型  
       switch v := value.(type) {  
       case string:  
          r.AddAttrs(slog.String(key, v))  
       case int64:  
          r.AddAttrs(slog.Int64(key, v))  
       case uint64:  
          r.AddAttrs(slog.Uint64(key, v))  
       case bool:  
          r.AddAttrs(slog.Bool(key, v))  
       default:  
          r.AddAttrs(slog.Any(key, v))  
       }  
    }  
    return self.Handler.Handle(ctx, r)  
}

func main() {
	parent := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{  
	    AddSource: false,  
	    Level:     slog.LevelInfo,  
	})  
	logger := slog.New(NewContextHandler(parent, []string{"requestId"}))  
	slog.SetDefault(logger)  
	ctx := context.WithValue(context.Background(), "requestId", "11111")  
	slog.InfoContext(ctx, "hello world")
}

只需要传入需要从 Context 中读取的 keys,那么就可以在传递 Context 时自动读取参数

time=2025-07-02T13:18:56.077+08:00 level=INFO msg="hello world" requestId=11111

简单的格式自定义

在一些场景下,可能需要自定义日志时间、定义日志字段的间隔或者带上日志的 source 等等自定义的需求,在 slog 中,可以通过自定义其 Handler 实现。

// 简单的使用 replace 实现,但是不能自定义细节
options := &slog.HandlerOptions{  
    AddSource: true,  
    Level:     slog.LevelInfo,  
    ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {  
       switch attr.Key {  
       case slog.TimeKey:  
          time := attr.Value.Time()  
          attr.Value = slog.StringValue(time.Format("2006-02-01 15:04:05"))  
       }  
       return attr  
    },  
}  
handler := slog.NewTextHandler(os.Stdout, options)  
logger := slog.New(handler)  
logger.Info("hello world")

其输出格式符合 Json 的风格

time="2025-01-07 18:12:01" level=INFO source=/home/cola/Documents/Code/Goland/learning/main.go:23 msg="hello world"

通过自定义 Handler 可以实现更加精细的控制

// 自定义的 slog.Handler
type CustomHandler struct {  
    w     io.Writer  
    level slog.Level  
}  
  
func NewCustomHandler(w io.Writer, level slog.Level) *CustomHandler {  
    return &CustomHandler{  
       w:     w,  
       level: level,  
    }  
}  
  
func (h *CustomHandler) Enabled(_ context.Context, level slog.Level) bool {  
    return level >= h.level  
}  
  
func (h *CustomHandler) Handle(_ context.Context, r slog.Record) error {  
    ts := r.Time.Format("2006-01-02 15:04:05") // 自定义时间格式  
    level := r.Level.String()  
    msg := r.Message  
  
    // 输出时间、级别、消息  
    fmt.Fprintf(h.w, "[%s] [%s] %s", ts, level, msg)  
  
    internalKey := map[string]struct{}{  
       slog.TimeKey:    {},  
       slog.LevelKey:   {},  
       slog.MessageKey: {},  
       slog.SourceKey:  {},  
    }  
    r.Attrs(func(attr slog.Attr) bool {  
       if _, ok := internalKey[attr.Key]; ok {  
          fmt.Fprintf(h.w, " %v", attr.Value)  
       } else {  
          fmt.Fprintf(h.w, " %s=%v", attr.Key, attr.Value)  
       }  
       return true  
    })  
    fmt.Fprintln(h.w)  
    return nil  
}  
  
func (h *CustomHandler) WithAttrs(_ []slog.Attr) slog.Handler {  
    return h // 可拓展支持 context attr}  
  
func (h *CustomHandler) WithGroup(_ string) slog.Handler {  
    return h // 分组暂不处理  
}

// 使用自定义的 handler
handler := NewCustomHandler(os.Stdout, slog.LevelInfo)  
logger := slog.New(handler)  
logger.Info("hello world", slog.Any("names", []string{"1", "2"}))

其输出格式如下

[2025-07-01 18:20:07] [INFO] hello world names=[1 2]

其它日志格式

slog 默认的日志格式为文本格式,对于开发环境比较友好,但是对于测试和线上环境来说,其不利用做日志收集和日志可视化。所有,在项目中通常会配置 TextJson 两个日志格式化器,分别用于本地开发和其他环境。

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{  
    Level:     slog.LevelInfo,  
    AddSource: true,  
})  
logger := slog.New(handler)  
// 将其设置为全局logger,后续可以直接通过 slog.xxx 调用
slog.SetDefault(logger)  
slog.Info("hello world", slog.String("name", "xin"), slog.Any("names", []string{"1", "2"}))

其输出为 Json 格式

{  
    "time": "2025-07-01T18:27:12.702428062+08:00",  
    "level": "INFO",  
    "source": {  
        "function": "main.main",  
        "file": "/home/cola/Documents/Code/Goland/learning/main.go",  
        "line": 15  
    },  
    "msg": "hello world",  
    "name": "xin",  
    "names": [  
        "1",  
        "2"  
    ]  
}

多个输出

slog 默认只会输出到 os.Stdout 控制台中,对于本地开发来说是够了,但是在其它环境中,为了方便的查询日志和收集日志,通常需要将日志输出到文件和日志收集平台或 MQ 中。slog 也可以方便的集成这些功能,只需要实现 io.Writer 接口即可。

file, err := os.Open("app.log")  
if err != nil && !os.IsExist(err) {  
    file, _ = os.Create("app.log")  
}  
defer file.Close()  
handler := slog.NewTextHandler(io.MultiWriter(os.Stdout, file), &slog.HandlerOptions{  
    Level:     slog.LevelInfo,  
    AddSource: true,  
})  
logger := slog.New(handler)  
slog.SetDefault(logger)  
slog.Info("hello world", slog.String("name", "xin"), slog.Any("names", []string{"1", "2"}))

此时会将日志同时输出到控制台和指定的日志文件中

使用其它后端

如果需要结构化日志也就是输出为 Json 格式,推荐使用 zerolog 后端,如果是在本地开发环境,需要美观和更好可读性,可以使用 zapconsole 后端。

// zap适配slog的Handler  
func newZapHandler() slog.Handler {  
    encode := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())  
    stdout := zapcore.AddSync(os.Stdout)  
    core := zapcore.NewCore(encode, stdout, zapcore.InfoLevel)  
    return zapslog.NewHandler(core)  
}  
  
// zarolog 适配slog的Handler  
func newZerologHandler() slog.Handler {  
    // TODO  
    panic("TODO")  
}  
  
func main() {
	// 可以使用各种适配的日志后端
    logger := slog.New(newZapHandler())  
    slog.SetDefault(logger)  
    slog.Info("hello world", slog.String("name", "name"))  
}

完整配置

在开发环境下使用 Console 后端以获得更好的可读性,而在其它环境使用 Json 后端来更方便做日志采集和分析,这是目前日志工程化中的常用做法,此外还可以搭配其它日志轮转和切割等功能。

// zap适配slog的Handler  
func newZapHandler(env string, writers ...io.Writer) (slog.Handler, func()) {  
    var encode zapcore.Encoder  
    // dev模式使用Console, 否则都使用Json
    if env == "dev" {  
       encode = zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())  
    } else {  
       encode = zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig())  
    }  
    // 添加所有传递的writer  
    syncWriters := make([]zapcore.WriteSyncer, 0)  
    for _, wr := range writers {  
       syncWriters = append(syncWriters, zapcore.AddSync(wr))  
    }  
    stdout := zapcore.AddSync(zapcore.NewMultiWriteSyncer(syncWriters...))  
    core := zapcore.NewCore(encode, stdout, zapcore.InfoLevel)  
    // 返回闭包函数,用于调用所有writer的sync函数进行持久化  
    return zapslog.NewHandler(core), func() {  
       for _, wr := range syncWriters {  
          wr.Sync()  
       }  
    }  
}  
  
// 创建日志轮转和切割的Writer  
func newLumberjackLogger(logFilePath string) (io.Writer, error) {  
    if err := os.MkdirAll(logFilePath, 0o777); err != nil {  
       return nil, err  
    }  
    filename := time.Now().Format("2006-01-02") + ".log"  
    fullFilename := path.Join(logFilePath, filename)  
    if _, err := os.Stat(fullFilename); err != nil {  
       if _, err := os.Create(fullFilename); err != nil {  
          return nil, err  
       }  
    }  
    return &lumberjack.Logger{  
       Filename:   fullFilename,  
       MaxSize:    20,  
       MaxBackups: 5,  
       MaxAge:     10,  
       Compress:   true,  
    }, nil  
}  
  
func main() {  
    file, _ := newLumberjackLogger("./logs/")  
    handler, done := newZapHandler("dev", os.Stdout, file) 
    // 主函数执行完成调用所有日志输出的Sync()函数进行强制刷写 
    defer done()  
    logger := slog.New(handler)  
    slog.SetDefault(logger)  
    slog.Info("hello world", slog.String("name", "name"))  
}

更新:slog 包在最新的 benchmark 中性能已经和 zap 等广泛使用的日志库没有太大差距,完全可以不使用其它其它日志后端了。

但是 slog 默认的 TextHandler 在开发环境下几乎没有可读性,为了在开发时更加的方法,便封装了一个支持颜色显示的 ConsoleHandler ,以下是在生产环境中使用的详细配置。

首先是 ContextHandler ,用于自动解析 context 中包含的 key,方便链路追踪。

const (  
    contextKey = "context" // 强烈建议将ContextKey的类型改为结构体,不然会被覆盖
)  
  
type contextHandler struct {  
    slog.Handler  
    keys []string  
}  
  
func newContextHandler(parent slog.Handler, keys ...string) slog.Handler {  
    return &contextHandler{  
       keys:    keys,  
       Handler: parent,  
    }  
}  
  
func (self *contextHandler) Handle(ctx context.Context, record slog.Record) error {  
    if self.keys == nil || len(self.keys) == 0 {  
       return self.Handler.Handle(ctx, record)  
    }  
    attrs := make([]slog.Attr, 0, len(self.keys))  
    for _, key := range self.keys {  
       value := ctx.Value(key)  
       if value == nil {  
          continue  
       }  
       // 适配各种可能的value类型  
       switch v := value.(type) {  
       case string:  
          attrs = append(attrs, slog.String(key, v))  
       case int64:  
          attrs = append(attrs, slog.Int64(key, v))  
       case uint64:  
          attrs = append(attrs, slog.Uint64(key, v))  
       case bool:  
          attrs = append(attrs, slog.Bool(key, v))  
       case time.Duration:  
          attrs = append(attrs, slog.Duration(key, v))  
       case time.Time:  
          attrs = append(attrs, slog.Time(key, v))  
       default:  
          attrs = append(attrs, slog.Any(key, v))  
       }  
    }  
    record.AddAttrs(slog.Any(contextKey, slog.GroupValue(attrs...)))  
    return self.Handler.Handle(ctx, record)  
}

然后是 ConsoleHandler 实现,用于日志的拼接和颜色格式化

const (  
    reset = "\033[0m"  
  
    black        = "30"  
    red          = "31"  
    green        = "32"  
    yellow       = "33"  
    blue         = "34"  
    magenta      = "35"  
    cyan         = "36"  
    lightGray    = "37"  
    darkGray     = "90"  
    lightRed     = "91"  
    lightGreen   = "92"  
    lightYellow  = "93"  
    lightBlue    = "94"  
    lightMagenta = "95"  
    lightCyan    = "96"  
    white        = "97"  
)  
  
type consoleHandler struct {  
    slog.Handler  
    pool  *sync.Pool    // 使用对象池重复使用buffer
    wr    io.Writer  
    mutex *sync.Mutex  
}  
  
func newConsoleHandler(parent slog.Handler, wr io.Writer) slog.Handler {  
    return &consoleHandler{  
       Handler: parent,  
       pool: &sync.Pool{  
          New: func() any {  
             return &bytes.Buffer{}  
          },  
       },  
       wr:    wr,  
       mutex: &sync.Mutex{},  
    }  
}  
  
func (self *consoleHandler) Handle(_ context.Context, record slog.Record) error {  
    builder := self.pool.Get().(*bytes.Buffer)  
    // 格式化时间
    self.colorize(builder, lightGray, record.Time.Format("2006-01-02 15:04:05.000"))  
    builder.WriteByte(' ')  
    // 根据日志的 Level 格式化为不同的颜色
    switch record.Level {  
    case slog.LevelDebug:  
       self.colorize(builder, lightGray, record.Level.String())  
    case slog.LevelInfo:  
       self.colorize(builder, cyan, record.Level.String())  
    case slog.LevelWarn:  
       self.colorize(builder, lightYellow, record.Level.String())  
    case slog.LevelError:  
       self.colorize(builder, lightRed, record.Level.String())  
    default:  
       builder.WriteString(record.Level.String())  
    }  
    builder.WriteByte(' ')  
    // 如果开启了 source 通过runtime获取打印日志的函数和行数
    if config.ViperGet[bool]("logger.source", false) {  
       sr := source(&record)  
       _, file := path.Split(sr.Function)  
       self.colorize(builder, lightBlue, file+":"+strconv.Itoa(sr.Line))  
       builder.WriteString(" - ")  
    }  
    // 对打印的消息进行颜色格式化
    self.colorize(builder, green, record.Message)  
    record.Attrs(func(attr slog.Attr) bool {  
       switch attr.Key {  
       case slog.TimeKey, slog.LevelKey, slog.MessageKey:  
          return true  
       default:  
          builder.WriteByte(' ')  
          builder.WriteString(attr.String())  
       }  
       return true  
    })  
    builder.WriteByte('\n')  
    self.mutex.Lock()  
    // 回收buffer 释放锁
    defer func() {  
       self.mutex.Unlock()  
       builder.Reset()  
       self.pool.Put(builder)  
    }()  
    _, err := self.wr.Write(builder.Bytes())  
    return err  
}  
  
func source(record *slog.Record) *slog.Source {  
    fs := runtime.CallersFrames([]uintptr{record.PC})  
    f, _ := fs.Next()  
    return &slog.Source{  
       Function: f.Function,  
       File:     f.File,  
       Line:     f.Line,  
    }  
}  
// 文字染色 
func (self *consoleHandler) colorize(buffer *bytes.Buffer, colorCode, value string) {  
    buffer.WriteString("\033[")  
    buffer.WriteString(colorCode)  
    buffer.WriteByte('m')  
    buffer.WriteString(value)  
    buffer.WriteString(reset)  
}

最后是 logger 实现,在开发环境中使用 ConsoleHandler ,线上环境和测试环境使用 JsonHandler

// 用于日志配置和slog日志级别的映射
var loggerLevelMap = map[string]slog.Level{  
    "DEBUG": slog.LevelDebug,  
    "INFO":  slog.LevelInfo,  
    "WARN":  slog.LevelWarn,  
    "ERROR": slog.LevelError,  
}  
  
func NewLogger() (*slog.Logger, error) {  
    env := config.ViperGet[string]("server.environment", "dev")  
    var handler slog.Handler  
    levelStr := strings.ToUpper(config.ViperGet[string]("logger.level", "INFO"))  
    level, ok := loggerLevelMap[levelStr]  
    if !ok {  
       level = slog.LevelInfo  
    }  
    options := &slog.HandlerOptions{  
       Level:     level,  
       AddSource: config.ViperGet[bool]("logger.source", false),  
    }
    // 开发环境使用ConsoleHandler 只输出到控制台
    // 其它环境使用JsonHandler 输出到控制台和文件中  
    if env == "dev" {  
       handler = newConsoleHandler(slog.NewTextHandler(io.Discard, options), os.Stdout)  
    } else {  
       fileWrite, err := newLumberjackLogger()  
       if err != nil {  
          return nil, err  
       }  
       handler = slog.NewJSONHandler(io.MultiWriter(os.Stdout, fileWrite), options)  
    }  
    return slog.New(handler), nil  
}  
  
func NewLoggerWithContext(keys ...string) (*slog.Logger, error) {  
    logger, err := NewLogger()  
    if err != nil {  
       return nil, err  
    }  
    handler := newContextHandler(logger.Handler(), keys...)  
    return slog.New(handler), nil  
}  
// 输出日志到文件,并做日志切割  
func newLumberjackLogger() (io.Writer, error) {  
    fileDir := config.ViperGet[string]("logger.file-path", "./logs/")  
    if err := os.MkdirAll(fileDir, 0o777); err != nil {  
       return nil, err  
    }  
    filename := time.Now().Format("2006-01-02") + ".log"  
    fullFilename := path.Join(fileDir, filename)  
    if _, err := os.Stat(fullFilename); err != nil {  
       if _, err := os.Create(fullFilename); err != nil {  
          return nil, err  
       }  
    }  
    return &lumberjack.Logger{  
       Filename:   fullFilename,  
       MaxSize:    config.ViperGet[int]("logger.max-size", 50),  
       MaxBackups: config.ViperGet[int]("logger.max-backups", 5),  
       MaxAge:     config.ViperGet[int]("logger.max-age", 10),  
       Compress:   config.ViperGet[bool]("logger.compress", false),  
    }, nil  
}

如果还需要添加一些其它的日志输出,例如 MQKafka 等,也是只需要添加 Writer 即可。

最后进行 Benchmark 测试,不输出,只测试其解析日志的性能。

func BenchmarkConsoleHandler(b *testing.B) {  
    handler := newConsoleHandler(nil, io.Discard) // 不输出  
    rec := slog.NewRecord(time.Now(), slog.LevelInfo, "message", 0)  
    rec.AddAttrs(slog.String("user", "alice"), slog.Int("id", 42))  
    for i := 0; i < b.N; i++ {  
       _ = handler.Handle(context.Background(), rec)  
    }  
}  
  
func BenchmarkContextHandlerAndConsole(b *testing.B) {  
    handler := newContextHandler(newConsoleHandler(nil, io.Discard), "traceId")  
    rec := slog.NewRecord(time.Now(), slog.LevelInfo, "message", 0)  
    rec.AddAttrs(slog.String("user", "alice"), slog.Int("id", 42))  
    ctx := context.WithValue(context.Background(), "traceId", "231231")  
    for i := 0; i < b.N; i++ {  
       _ = handler.Handle(ctx, rec)  
    }  
}  
func BenchmarkContextHandlerAndJSON(b *testing.B) {  
    handler := newContextHandler(slog.NewJSONHandler(io.Discard, nil), "traceId")  
    rec := slog.NewRecord(time.Now(), slog.LevelInfo, "message", 0)  
    rec.AddAttrs(slog.String("user", "alice"), slog.Int("id", 42))  
    ctx := context.WithValue(context.Background(), "traceId", "231231")  
    for i := 0; i < b.N; i++ {  
       _ = handler.Handle(ctx, rec)  
    }  
}

测试结果如下,其性能完全可以满足本地开发和生产环境的需求。

❯ go test -bench=. -benchmem -cpu=1,2,4,8,16
goos: linux
goarch: amd64
pkg: github.com/wnnce/fserv-template/logging
cpu: AMD Ryzen 7 6800H with Radeon Graphics     
BenchmarkConsoleHandler                          1515848               706.3 ns/op           120 B/op          6 allocs/op
BenchmarkConsoleHandler-2                        1680400               692.4 ns/op           120 B/op          6 allocs/op
BenchmarkConsoleHandler-4                        1690282               709.7 ns/op           120 B/op          6 allocs/op
BenchmarkConsoleHandler-8                        1687108               716.2 ns/op           120 B/op          6 allocs/op
BenchmarkConsoleHandler-16                       1700002               699.3 ns/op           120 B/op          6 allocs/op
BenchmarkContextHandlerAndConsole                 898359              1251 ns/op             328 B/op         14 allocs/op
BenchmarkContextHandlerAndConsole-2              1018027              1195 ns/op             328 B/op         14 allocs/op
BenchmarkContextHandlerAndConsole-4               943408              1184 ns/op             328 B/op         14 allocs/op
BenchmarkContextHandlerAndConsole-8               903339              1214 ns/op             328 B/op         14 allocs/op
BenchmarkContextHandlerAndConsole-16              942187              1204 ns/op             328 B/op         14 allocs/op
BenchmarkContextHandlerAndJSON                   1887498               650.4 ns/op            88 B/op          3 allocs/op
BenchmarkContextHandlerAndJSON-2                 2035083               595.8 ns/op            88 B/op          3 allocs/op
BenchmarkContextHandlerAndJSON-4                 1997014               595.6 ns/op            88 B/op          3 allocs/op
BenchmarkContextHandlerAndJSON-8                 1982528               614.4 ns/op            88 B/op          3 allocs/op
BenchmarkContextHandlerAndJSON-16                1978699               603.3 ns/op            88 B/op          3 allocs/op

关联文章

0 条评论