go 框架 logger 不侵入业务代码 用 slog 替换 zap

243 天前
 websong188

快速体验

以下是 项目中 已经用 slog 替换 zap 后的 logger 使用方法,与替换前使用方式相同,无任何感知

package main

import "github.com/webws/go-moda/logger"

func main() {
	// 格式化打印 {"time":"2023-09-08T01:25:21.313463+08:00","level":"INFO","msg":"info hello slog","key":"value","file":"/Users/xxx/w/pro/go-moda/example/logger/main.go","line":6}
	logger.Infow("info hello slog", "key", "value")   // 打印 json
	logger.Debugw("debug hello slog", "key", "value") // 不展示
	logger.SetLevel(logger.DebugLevel)                // 设置等级
	logger.Debugw("debug hello slog", "key", "value") // 设置了等级之后展示 debug
	// with
	newLog := logger.With("newkey", "newValue")
	newLog.Debugw("new hello slog") // 会打印 newkey:newValue
	logger.Debugw("old hello slog") // 不会打印 newkey:newValue
}

slog 基础使用

Go 1.21 版本中 将 golang.org/x/exp/slog 引入了 go 标准库 路径为 log/slog 。 新项目的 如果不使用第三方包,可以直接用 slog 当你的 logger

slog 简单示例:

默认 输出级别是 info 以上,所以 debug 是打印不出来.

import "log/slog"
func main() {
	slog.Info("finished", "key", "value")
	slog.Debug("finished", "key", "value")
}

输出

2023/09/08 00:27:24 INFO finished key=value
slog 格式化

HandlerOptions Level:设置日志等级 AddSource:打印文件相关信息

func main() {
	opts := &slog.HandlerOptions{AddSource: true, Level: slog.LevelInfo}
	logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
	logger.Info("finished", "key", "value")
}

输出

{"time":"2023-09-08T00:34:22.035962+08:00","level":"INFO","source":{"function":"callvis/slog.TestLogJsonHandler","file":"/Users/websong/w/pro/go-note/slog/main_test.go","line":39},"msg":"finished","key":"value"}

slog 切换日志等级

看 slog 源码 HandlerOptions 的 Level 是一个 interface,slog 自带的 slog.LevelVar 实现了这个 interface,也可以自己定义实现 下面是部分源码

type Leveler interface {
	Level() Level
}
type LevelVar struct {
	val atomic.Int64
}
// Level returns v's level.
func (v *LevelVar) Level() Level {
	return Level(int(v.val.Load()))
}

// Set sets v's level to l.
func (v *LevelVar) Set(l Level) {
	v.val.Store(int64(l))
}

通过 slog.LevelVar 设置 debug 等级后,第二次的 debug 日志是可以打印出来

func main() {
	levelVar := &slog.LevelVar{}
	levelVar.Set(slog.LevelInfo)

	opts := &slog.HandlerOptions{AddSource: true, Level: levelVar}
	logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
	logger.Info("finished", "key", "value")

	levelVar.Set(slog.LevelDebug)
	logger.Debug("finished", "key", "value")
}

想要实现 文章开头 通过 logger.SetLevel(logger.DebugLevel) 快速切换等级,可以选择将 slog.Logger 与 slog.LevelVar 封装到同一结构,比如

type SlogLogger struct {
	logger *slog.Logger
	level  *slog.LevelVar
}

下文 slog 替换 zap 有详细代码体现

原有 logger zap 实现

原有项目已经实现了一套 logger,使用 zap log 以下代码都是在 logger 包下 github.com/webws/go-moda/logger

原 zap 代码

logger interface LoggerInterface

package logger

type LoggerInterface interface {
	Debugw(msg string, keysAndValues ...interface{})
	Infow(msg string, keysAndValues ...interface{})
	Errorw(msg string, keysAndValues ...interface{})
	Fatalw(msg string, keysAndValues ...interface{})
	SetLevel(level Level)
	With(keyValues ...interface{}) LoggerInterface
}

zap log 实现 LoggerInterface

type ZapSugaredLogger struct {
	logger    *zap.SugaredLogger
	zapConfig *zap.Config
}

func buildZapLog(level Level) LoggerInterface {
	encoderConfig := zapcore.EncoderConfig{
		TimeKey:        "ts",
		LevelKey:       "level",
		NameKey:        "logger",
		CallerKey:      "caller",
		MessageKey:     "msg",
		StacktraceKey:  "stacktrace",
		LineEnding:     zapcore.DefaultLineEnding,
		EncodeDuration: zapcore.SecondsDurationEncoder,
		EncodeTime:     zapcore.ISO8601TimeEncoder,
		EncodeLevel:    zapcore.LowercaseLevelEncoder,
		EncodeCaller:   zapcore.ShortCallerEncoder,
	}
	zapConfig := &zap.Config{
		Level:             zap.NewAtomicLevelAt(zapcore.Level(level)),
		Development:       true,
		DisableCaller:     false,
		DisableStacktrace: true,
		Sampling:          &zap.SamplingConfig{Initial: 100, Thereafter: 100},
		Encoding:          "json",
		EncoderConfig:     encoderConfig,
		OutputPaths:       []string{"stderr"},
		ErrorOutputPaths:  []string{"stderr"},
	}
	l, err := zapConfig.Build(zap.AddCallerSkip(2))
	if err != nil {
		fmt.Printf("zap build logger fail err=%v", err)
		return nil
	}
	return &ZapSugaredLogger{
		logger:    l.Sugar(),
		zapConfig: zapConfig,
	}

    func (l *ZapSugaredLogger) Debugw(msg string, keysAndValues ...interface{}) {
	l.logger.Debugw(msg, keysAndValues...)
    }

    func (l *ZapSugaredLogger) Errorw(msg string, keysAndValues ...interface{}) {
	    l.logger.Errorw(msg, keysAndValues...)
    }
    // ...省略 info 之类其他实现接口的方法 
}

全局初始化 logger,因代码量太大,以下是伪代码,主要提供思路

package logger

// 全局 log,也可以单独 NewLogger 获取新的实例
var globalog = newlogger(DebugLevel)

func newlogger(level Level) *Logger {
	l := &Logger{logger: buildZapLog(level)}
	return l
}
func Infow(msg string, keysAndValues ...interface{}) {
	globalog.logger.Infow(msg, keysAndValues...)
}
// ...省略其他全局方法,比如 DebugW 之类

在项目中通过 如下使用 logger

import "github.com/webws/go-moda/logger"

func main() {
	logger.Infow("hello", "key", "value")   // 打印 json
}

slog 不侵入业务 替换 zap

logger interface 接口保持不变

slog 实现 代码

package logger

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

var _ LoggerInterface = (*SlogLogger)(nil)

type SlogLogger struct {
	logger *slog.Logger
	level  *slog.LevelVar
	// true 代表使用 slog 打印文件路径,false 会使用自定的方法给日志 增加字段 file line
	addSource bool
}

// newSlog
func newSlog(level Level, addSource bool) LoggerInterface {
	levelVar := &slog.LevelVar{}
	levelVar.Set(slog.LevelInfo)
	opts := &slog.HandlerOptions{AddSource: addSource, Level: levelVar}
	logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
	return &SlogLogger{
		logger: logger,
		level:  levelVar,
	}
}
func (l *SlogLogger) Fatalw(msg string, keysAndValues ...interface{}) {
	keysAndValues = l.ApppendFileLine(keysAndValues...)
	l.logger.Error(msg, keysAndValues...)
	os.Exit(1)
}

func (l *SlogLogger) Infow(msg string, keysAndValues ...interface{}) {
	keysAndValues = l.ApppendFileLine(keysAndValues...)
	l.logger.Info(msg, keysAndValues...)
}
// 省略继承接口的其他方法 DebugW 之类的
func (l *SlogLogger) SetLevel(level Level) {
	zapLevelToSlogLevel(level)
	l.level.Set(slog.Level(zapLevelToSlogLevel(level)))
}
// 
func (l *SlogLogger) With(keyValues ...interface{}) LoggerInterface {
	newLog := l.logger.With(keyValues...)
	return &SlogLogger{
		logger: newLog,
		level:  l.level,
	}
}

// ApppendFileLine 获取调用方的文件和文件号
// slog 原生 暂不支持 callerSkip,使用此函数啃根会有性能问题,最好等 slog 提供 CallerSkip 的参数
func (l *SlogLogger) ApppendFileLine(keyValues ...interface{}) []interface{} {
	l.addSource = false
	if !l.addSource {
		var pc uintptr
		var pcs [1]uintptr
		// skip [runtime.Callers, this function, this function's caller]
		runtime.Callers(4, pcs[:])
		pc = pcs[0]
		fs := runtime.CallersFrames([]uintptr{pc})
		f, _ := fs.Next()
		keyValues = append(keyValues, "file", f.File, "line", f.Line)
		return keyValues

	}
	return keyValues
}

全局初始化 logger,以下伪代码

package logger
// 全局 log,也可以单独 NewLogger 获取新的实例
var globalog = newlogger(DebugLevel)

func newlogger(level Level) *Logger {
	l := &Logger{logger: newSlog(level, false)}
	return l
}
func Infow(msg string, keysAndValues ...interface{}) {
	globalog.logger.Infow(msg, keysAndValues...)
}
// ...省略其他全局方法,比如 DebugW 之类

一样可以 通过 如下使用 logger,与使用 zap 时一样

import "github.com/webws/go-moda/logger"

func main() {
	logger.Infow("hello", "key", "value")   // 打印 json
}

slog 实现 callerSkip 功能

slog 的 addsource 参数 会打印文件名和行号,但 并不能像 zap 那样支持 callerSkip,也就是说 如果将 slog 封装在 logger 目录的 log.go 文件下,使用 logger 进行打印,展示的文件会一只是 log.go

看了 slog 的源码, 使用了 runtime.Callers 在内部实现了 callerSkip 功能,但是没有对外暴露 callerSkip 参数

可以看我上面代码 自己封装了一个方法: ApppendFileLine, 使用 runtime.Callers 获取到 文件名 和 行号,增加 file 和 line 的 key value 到日志

可能会有性能问题,希望 slog 能对外提供一个 callerSkip 参数

说明

文章中贴的代码不多,主要提供思路,虽然省略了一些方法和 全局 logger 的实现方式

如要查看 logger 实现细节,可查看 在文章开头 快速体验 引用的包 github.com/webws/go-moda/logger

也可以直接看下我这个 仓库 go-moda 里使用 slog 和 zap 的封装

2238 次点击
所在节点    Go 编程语言
25 条回复
pennai
243 天前
不侵入业务代码是指啥?看下来也没发现怎么不侵入
wwek
243 天前
用自带的能满足需求的情况下。就不用第三方
wwek
243 天前
感谢分享
lilei2023
243 天前
为啥要换,zap 感觉用起来还行啊
mikurasa
243 天前
感觉这个库的 API 没有标准库 log 的好用
我现在用的 zerolog 封装的日志库
func log.Infof(format string, a ...interface{})
项目里的 API 非常好用跟打印一样
mainjzb
243 天前
前排提示:1.20 是最后一个支持 win7 的版本 (逃
websong188
243 天前
@pennai 我理解的不侵入是在自己项目里引用 logger 包,那个 logger 包 内部实现 是使用 zap,现在改成了 slog
使用方的业务代码 打印日志依然可以用 原来的方法 比如 logger.infow
websong188
243 天前
@lilei2023 zap 其实用起来很行,我在 slog 替换的时候发现,slog 没法 像 zap 那样支持 callerSkip,目前自己实现了一个.
不知道后面 slog 会不会扩展
zeromake
243 天前
@lilei2023 #4 应该指的是 zap 的 zapcore.Field 这些导入,应该是不希望业务里强制导入一个 zap 库,因为有可能出现 zap.Field 格式改动导致所有的业务代码失效(虽然这种情况应该不会发生),什么你不用 zapcore.Field ?那也用不着用 zap 了……
websong188
243 天前
@zeromake zapcore.Field 指的是 zap 的 输出字段 key 吗,zap.Config.EncoderConfig 应该是可以指定 key
这是我之前集成 zap 的代码,不知道是不是你担心的点 https://github.com/webws/go-moda/blob/main/logger/zap_log.go
websong188
243 天前
@wwek 是的,没有需求不要制造需求.但自带的 log,用起来是有点一言难尽哦
zeromake
243 天前
@websong188 不是说的这个,说的是 zap.String 这些不好直接入侵到业务代码里,你这边不是直接用 any 遮蔽了吗
logger.Info("failed to fetch URL",
zap.String("url", url),
zap.Int("attempt", 3),
zap.Duration("backoff", time.Second),
)
xiaocaiji111
243 天前
自己封装一层,业务里使用自己封装的接口,底层 log 想换就换,可以放心用不会影响业务。
ikaros
243 天前
zap 的高性能代价就是用 field 强类型换的吧,全部用 any 的话应该性能也和其他的没啥区别,另外我也觉得 zap 用着挺好
dacapoday
243 天前
@mikurasa 非常赞同,而且即使在 zap 的 benchmark 里,zerolog 也是最快的。
linauror
243 天前
老哥们,借楼问一下。如果想要那种一个请求下来,所有记录的日志都可以记下某一个指定的追踪码,日志中方便查询是同一个请求产生的,不管是在 service, controller 或者 helper 之类的地方都可以记录,但是又不想把 ctx 一直传递下去,有什么好的方式吗。
jiangwei2222
243 天前
@linauror php 可以,Go 的话必须得有一个变量传下去,无论是 ctx 还是啥。
monkeyWie
243 天前
slog 好像还是不能像 sl4j 一样统一日志门面吧?每个第三方库都一套日志系统真的挺恶心的
wfhtqp
243 天前
没有 ctx 办不了,有歪门获取 gid ,但是需要动源码
virusdefender
243 天前
@linauror 可以用 ctx ,但是 slog 默认有没有输出,得自己处理,我写了一个小库 https://github.com/virusdefender/slogctx

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/971958

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX