在今天的软件开发中,日志关于定位和解决问题至关重要。Go 社区有许多优异的日志库供咱们挑选,其间有一款来自 Uber 公司的开源 Go 语言日志库 —— zap,十分盛行,且以快著称。但与此同时,相较于诸如 Go log 规范库、Logrus 第三方日志库等,zap 在运用上就没有那么直观和舒适了。因此,在本文中,咱们将深化探讨如何基于 zap 日志库封装一个更易用、更有用的日志东西,然后协助开发者更轻松地管理日志,提高工作效率。

笔记:本文是对《Go 第三方 log 库之 zap 运用》一文的填坑,如果你还没有看过这篇文章,强烈建议看完后再来阅读此篇文章。

zap 运用示例

现在咱们想打印一条日志到控制台。

运用 zap 完结办法如下:

package main
import "go.uber.org/zap"
func main() {
	logger, _ := zap.NewProduction()
	defer logger.Sync()
	logger.Info("log info")
}

履行以上代码会输出一条 Info 等级的日志到规范过错输出 stderr

如果运用 Go log 规范库完结,则能够这么写:

package main
import "log"
func main() {
	log.Print("log info")
}

履行以上代码同样会输出一条日志到规范过错输出 stderr

虽然 Go log 规范库没有日志等级的概念,但 zap 需求三行代码才干完结的功用,Go log 规范库只需求一行代码就能够,运用体验更好。

再比如,咱们想设置日志等级。

运用 zap 完结办法如下:

package main
import "go.uber.org/zap"
func main() {
	config := zap.NewProductionConfig()
	config.Level = zap.NewAtomicLevelAt(zap.ErrorLevel)
	logger, _ := config.Build()
	defer logger.Sync()
	logger.Error("log error")
}   

在 zap 中想设置日志等级,首要需求先结构一个 zap.Config 目标 config,然后更改 config 的日志等级特点 Level 的值,再经过 config.Build() 构建 zap.Logger 目标,之后才干运用。

而在 Logrus 日志库中,则只需求一行代码即可完结,运用 logrus.SetLevel 办法即可完结。

package main
import "github.com/sirupsen/logrus"
func main() {
	logrus.SetLevel(logrus.ErrorLevel)
	logrus.Error("log error")
}

以上两个简单的示例,足以表现 zap 运用门槛相对来说的确更高一些。

更多关于 zap 的运用办法,能够参阅《Go 第三方 log 库之 zap 运用》一文。

封装 zap

上面演示了 Go log 规范库开箱即用的运用体验,以及 Logrus 日志库提供的便利快捷 API。接下来咱们要对 zap 日志库进行封装改造,使其愈加好用。

界说默许日志目标

Go log 规范库是经过界说了一个默许日志目标 std,来完结开箱即用的作用。咱们这儿就仿照 Go log 规范库来对 zap 进行封装。

github.com/jianghushin…

package zap
import (
	"io"
	"os"
	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
)
type Level = zapcore.Level
const (
	DebugLevel = zapcore.DebugLevel
	InfoLevel  = zapcore.InfoLevel
	WarnLevel  = zapcore.WarnLevel
	ErrorLevel = zapcore.ErrorLevel
	PanicLevel = zapcore.PanicLevel
	FatalLevel = zapcore.FatalLevel
)
type Logger struct {
	l *zap.Logger
	al *zap.AtomicLevel
}
func New(out io.Writer, level Level) *Logger {
	if out == nil {
		out = os.Stderr
	}
	al := zap.NewAtomicLevelAt(level)
	cfg := zap.NewProductionEncoderConfig()
	cfg.EncodeTime = zapcore.RFC3339TimeEncoder
	core := zapcore.NewCore(
		zapcore.NewJSONEncoder(cfg),
		zapcore.AddSync(out),
		al,
	)
	return &Logger{l: zap.New(core), al: &al}
}
func (l *Logger) SetLevel(level Level) {
	if l.al != nil {
		l.al.SetLevel(level)
	}
}
type Field = zap.Field
func (l *Logger) Debug(msg string, fields ...Field) {
	l.l.Debug(msg, fields...)
}
func (l *Logger) Info(msg string, fields ...Field) {
	l.l.Info(msg, fields...)
}
func (l *Logger) Warn(msg string, fields ...Field) {
	l.l.Warn(msg, fields...)
}
func (l *Logger) Error(msg string, fields ...Field) {
	l.l.Error(msg, fields...)
}
func (l *Logger) Panic(msg string, fields ...Field) {
	l.l.Panic(msg, fields...)
}
func (l *Logger) Fatal(msg string, fields ...Field) {
	l.l.Fatal(msg, fields...)
}
func (l *Logger) Sync() error {
	return l.l.Sync()
}
var std = New(os.Stderr, InfoLevel)
func Default() *Logger         { return std }
func ReplaceDefault(l *Logger) { std = l }
func SetLevel(level Level) { std.SetLevel(level) }
func Debug(msg string, fields ...Field) { std.Debug(msg, fields...) }
func Info(msg string, fields ...Field)  { std.Info(msg, fields...) }
func Warn(msg string, fields ...Field)  { std.Warn(msg, fields...) }
func Error(msg string, fields ...Field) { std.Error(msg, fields...) }
func Panic(msg string, fields ...Field) { std.Panic(msg, fields...) }
func Fatal(msg string, fields ...Field) { std.Fatal(msg, fields...) }
func Sync() error { return std.Sync() }

如果你看过我写的《深化探求 Go log 规范库》一文,那么对这份代码一定会十分熟悉,想必不必我讲也能过理解其意义,这份代码彻底参阅了 Go log 规范库的设计思路

首要为了运用便利,我为 zapcore.Level 类型界说了别号 Level,这样用户在运用咱们封装的 zap 包设置日志等级时,就只需求引进封装好的日志包,而无需引进原始的 zap 包了。

然后我界说了 Logger 结构体,用来表明日志目标。它只包括两个字段,分别是 *zap.Logger 目标和日志等级 *zap.AtomicLevel(zap 经过 zap.AtomicLevel 操作 zapcore.Level 来保证操作的原子性)。

经过 New 函数能够结构一个 Logger 目标,New 函数接纳两个参数分别用来设置日志输出方位和日志等级。

同样的为了运用便利,我还为 zap.Field 类型界说了别号 Field,并将所有 zap 中界说的类型都拷贝到 field.go 中。

github.com/jianghushin…

package zap
import "go.uber.org/zap"
var (
	Skip        = zap.Skip
	Binary      = zap.Binary
	Bool        = zap.Bool
	...
)

接下来为 Logger 结构体界说了 DebugInfo 等日志输出办法,这些办法也仅是对 zap.Logger 目标对应办法的一层包装。

然后就到了界说默许日志目标的环节,经过 var std = New(os.Stderr, InfoLevel) 咱们界说了 std 日志目标,尽管它是不行导出的变量,但咱们完结了 DebugInfo 等公开函数,其内部正是调用了 std 对应的办法,完结日志输出。

咱们能够依照如下办法,运用这个封装后的 zap 包。

package main
import (
	log "github.com/jianghushinian/gokit/log/zap"
)
func main() {
	defer log.Sync()
	log.Info("Info msg")
	log.SetLevel(log.ErrorLevel)
	log.Info("Info msg")
	log.Error("Error msg")
}

履行示例代码后,得到如下输出:

{"level":"info","ts":"2023-04-16T16:08:01+08:00","msg":"Info msg"}
{"level":"error","ts":"2023-04-16T16:08:01+08:00","msg":"Error msg"}

能够发现,咱们完结了像 Go log 规范库相同的开箱即用作用。在运用前,不再需求实例化一个 zap.Logger 目标,而是能够直接调用包等级的 Info 函数输出日志。

而且咱们能够只运用一行代码 log.SetLevel(log.ErrorLevel),将日志等级设置为 Error

用户也能够经过 New 函数来结构自己的 Logger 目标。

package main
import (
	"os"
	log "github.com/jianghushinian/gokit/log/zap"
)
func main() {
	logger := log.New(os.Stderr, log.ErrorLevel)
	defer logger.Sync()
	logger.Info("Info msg")
	logger.Error("Error msg")
}

此外,代码中还提供了 ReplaceDefault 函数,供用户替换默许的 std 目标,这样用户在结构自己的 Logger 目标后,仍然能够运用包等级的日志函数。

package main
import (
	"os"
	log "github.com/jianghushinian/gokit/log/zap"
)
func main() {
	logger := log.New(os.Stderr, log.ErrorLevel)
	log.ReplaceDefault(logger)
	defer log.Sync()
	log.Info("Info msg")
	log.Error("Error msg")
}

指定 Encoder

上面介绍的 New 函数界说如下:

func New(out io.Writer, level Level) *Logger {
	if out == nil {
		out = os.Stderr
	}
	al := zap.NewAtomicLevelAt(level)
	cfg := zap.NewProductionEncoderConfig()
	cfg.EncodeTime = zapcore.RFC3339TimeEncoder
	core := zapcore.NewCore(
		zapcore.NewJSONEncoder(cfg),
		zapcore.AddSync(out),
		al,
	)
	return &Logger{l: zap.New(core), al: &al}
}

其内部经过调用 zapcore.NewCore 取得一个 zapcore.Core 目标,这是 zap 日志库的中心目标,将它传递给 zap.New 就能够拿到 zap.Logger 目标。

zapcore.NewCore 接纳三个参数,EncoderWriteSyncerLevelEnabler,其功用如下:

  • Encoder: 编码器,用来界说日志的输出格局。

  • WriteSyncer: 指定日志输出方位。

  • LevelEnabler: 指定日志等级。

这三个参数,正是用来控制一个日志库的中心功用。

其间,日志输出方位和日志等级都是经过函数参数传递进来的,而编码器则是固定的。咱们首要经过 zap.NewProductionEncoderConfig() 拿到一个编码器装备,然后运用 cfg.EncodeTime = zapcore.RFC3339TimeEncoder 指定时刻格局化为 RFC3339 格局,终究经过 zapcore.NewJSONEncoder(cfg) 的形式结构了一个 JSON 格局的 Encoder 并传递给 zapcore.NewCore

终究,咱们得到的日志格局长这样:

{"level":"info","ts":"2023-04-16T16:08:01+08:00","msg":"Info msg"}

这儿 Encoder 之所没有当作参数传递进来,是因为我想界说一个规范,使得引进此日志库的项目所打印出来的日志格局是共同的。这在微服务项目开发中尤其有用,保证了各个模块间日志格局统一,便利收集、解析、和排查问题。

支撑日志选项

zap 在运用 zap.NewProduction() 创立 logger 时,其实是支撑选项参数的:

package main
import "go.uber.org/zap"
func main() {
	logger, _ := zap.NewProduction(zap.WithCaller(false))
	defer logger.Sync()
	logger.Info("log info")
}

以上示例代码中,咱们就经过 zap.NewProduction(zap.WithCaller(false)) 的办法封闭了输出日志时携带函数调用信息的功用。

zap 支撑的所有选项你能够在这儿查看。

所以咱们封装的日志包也要支撑选项功用。

界说 options.go 如下:

github.com/jianghushin…

package zap
import "go.uber.org/zap"
type Option = zap.Option
var (
	WrapCore      = zap.WrapCore
	Hooks         = zap.Hooks
	Fields        = zap.Fields
	ErrorOutput   = zap.ErrorOutput
	Development   = zap.Development
	AddCaller     = zap.AddCaller
	WithCaller    = zap.WithCaller
	AddCallerSkip = zap.AddCallerSkip
	AddStacktrace = zap.AddStacktrace
	IncreaseLevel = zap.IncreaseLevel
	WithFatalHook = zap.WithFatalHook
	WithClock     = zap.WithClock
)

跟日志等级的做法相同,我为 zap.Option 界说了类型别号Option

修改 New 函数界说如下:

func New(out io.Writer, level Level, opts ...Option) *Logger {
	if out == nil {
		out = os.Stderr
	}
	al := zap.NewAtomicLevelAt(level)
	cfg := zap.NewProductionEncoderConfig()
	cfg.EncodeTime = zapcore.RFC3339TimeEncoder
	core := zapcore.NewCore(
		zapcore.NewJSONEncoder(cfg),
		zapcore.AddSync(out),
		al,
	)
	return &Logger{l: zap.New(core, opts...), al: &al}
}

改动很小,只需求加上可选参数 opts 并将其原样传给 zap.New 就完结了选项功用的支撑。

现在,能够依照如下办法敞开日志包记载函数调用信息功用:

package main
import (
	"os"
	log "github.com/jianghushinian/gokit/log/zap"
)
func main() {
	logger := log.New(os.Stderr, log.InfoLevel, log.AddCaller(), log.AddCallerSkip(1))
	defer logger.Sync()
	logger.Info("Info msg")
}

其间 log.AddCaller() 选项用来敞开记载,log.AddCallerSkip(1) 用来设置经过调用栈获取文件名和行号时越过的调用深度。

履行以上示例代码,将得到如下日志输出:

{"level":"info","ts":"2023-04-16T17:27:11+08:00","caller":"main.go:12","msg":"Info msg"}

支撑将不同等级日志输出到不同方位

有时候,为了便利对不同等级日志进行分开管理,咱们可能想要将不同等级的日志输出到不同方位。

在 zap 中能够经过 zapcore.NewTee() 完结,它回来一个切片 []zapcore.Core,这样每一个 zapcore.Core 对应一种日志等级,就能够完结将不同等级日志输出到不同方位了。

界说 tee.go 如下:

github.com/jianghushin…

package zap
import (
	"io"
	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
)
type LevelEnablerFunc func(Level) bool
type TeeOption struct {
	Out io.Writer
	LevelEnablerFunc
}
func NewTee(tees []TeeOption, opts ...Option) *Logger {
	var cores []zapcore.Core
	for _, tee := range tees {
		cfg := zap.NewProductionEncoderConfig()
		cfg.EncodeTime = zapcore.RFC3339TimeEncoder
		core := zapcore.NewCore(
			zapcore.NewJSONEncoder(cfg),
			zapcore.AddSync(tee.Out),
			zap.LevelEnablerFunc(tee.LevelEnablerFunc),
		)
		cores = append(cores, core)
	}
	return &Logger{l: zap.New(zapcore.NewTee(cores...), opts...)}
}

咱们为这种状况,专门界说了一个 NewTee 函数来结构 Logger 目标。

它接纳一个 tees []TeeOption 参数,其间 TeeOption 包括两个特点,分别是日志输出方位和日志等级,当满足界说的日志等级时将日志输出到指定方位。

这儿的日志等级是一个函数而不是常量,这样能够增加灵活性,只需函数回来值为 true 就会记载日志。

这样,经过界说如下函数,能够完结只要 Info 等级才会记载日志:

func (level log.Level) bool {
	return level == log.InfoLevel
}

而如下函数的界说,则能够完结 Info 及以上等级日志都会记载:

func (level log.Level) bool {
	return level >= log.InfoLevel
}

运用示例如下:

package main
import (
	"os"
	log "github.com/jianghushinian/gokit/log/zap"
)
func main() {
	file, _ := os.OpenFile("test-warn.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
	tees := []log.TeeOption{
		{
			Out: os.Stdout,
			LevelEnablerFunc: func(level log.Level) bool {
				return level == log.InfoLevel
			},
		},
		{
			Out: file,
			LevelEnablerFunc: func(level log.Level) bool {
				return level == log.WarnLevel
			},
		},
	}
	logger := log.NewTee(tees)
	defer logger.Sync()
	logger.Info("Info tee msg")
	logger.Warn("Warn tee msg")
	logger.Error("Error tee msg") // 不会输出
}

履行以上示例代码,控制台输出 Info 等级日志:

{"level":"info","ts":"2023-04-16T17:46:35+08:00","msg":"Info tee msg"}

test-warn.log 日志文件则输出 Warn 等级日志:

{"level":"warn","ts":"2023-04-16T17:46:35+08:00","msg":"Warn tee msg"}

Error 等级日志因为不满足条件,不会被输出。

日志轮转

日志轮转功用是一个日志库必不行少的功用,但是 zap 库本身其实并不支撑日志轮转,咱们能够借助 file-rotatelogslumberjack 第三方库来完结。

界说 rotate.go 如下:

github.com/jianghushin…

package zap
import (
	"io"
	"strings"
	"time"
	rotatelogs "github.com/lestrrat-go/file-rotatelogs"
	"gopkg.in/natefinch/lumberjack.v2"
)
type RotateConfig struct {
	// 共用装备
	Filename string // 完整文件名
	MaxAge   int    // 保存旧日志文件的最大天数
	// 按时刻轮转装备
	RotationTime time.Duration // 日志文件轮转时刻
	// 按巨细轮转装备
	MaxSize    int  // 日志文件最大巨细(MB)
	MaxBackups int  // 保存日志文件的最大数量
	Compress   bool // 是否对日志文件进行紧缩归档
	LocalTime  bool // 是否运用本地时刻,默许 UTC 时刻
}
// NewProductionRotateByTime 创立按时刻轮转的 io.Writer
func NewProductionRotateByTime(filename string) io.Writer {
	return NewRotateByTime(NewProductionRotateConfig(filename))
}
// NewProductionRotateBySize 创立按巨细轮转的 io.Writer
func NewProductionRotateBySize(filename string) io.Writer {
	return NewRotateBySize(NewProductionRotateConfig(filename))
}
func NewProductionRotateConfig(filename string) *RotateConfig {
	return &RotateConfig{
		Filename: filename,
		MaxAge:   30, // 日志保存 30 天
		RotationTime: time.Hour * 24, // 24 小时轮转一次
		MaxSize:    100, // 100M
		MaxBackups: 100,
		Compress:   true,
		LocalTime:  false,
	}
}
func NewRotateByTime(cfg *RotateConfig) io.Writer {
	opts := []rotatelogs.Option{
		rotatelogs.WithMaxAge(time.Duration(cfg.MaxAge) * time.Hour * 24),
		rotatelogs.WithRotationTime(cfg.RotationTime),
		rotatelogs.WithLinkName(cfg.Filename),
	}
	if !cfg.LocalTime {
		rotatelogs.WithClock(rotatelogs.UTC)
	}
	filename := strings.SplitN(cfg.Filename, ".", 2)
	l, _ := rotatelogs.New(
		filename[0]+".%Y-%m-%d-%H-%M-%S."+filename[1],
		opts...,
	)
	return l
}
func NewRotateBySize(cfg *RotateConfig) io.Writer {
	return &lumberjack.Logger{
		Filename:   cfg.Filename,
		MaxSize:    cfg.MaxSize,
		MaxAge:     cfg.MaxAge,
		MaxBackups: cfg.MaxBackups,
		LocalTime:  cfg.LocalTime,
		Compress:   cfg.Compress,
	}
}

咱们运用 file-rotatelogs 包来支撑依照时刻轮转日志,运用 lumberjack 包来支撑依照日志文件巨细轮转日志。

界说 RotateConfig 结构体用来装备日志轮转条件,NewProductionRotateByTime 函数回来一个能够按时刻轮转的 io.WriterNewProductionRotateBySize 函数则回来一个能够按日志文件巨细轮转的 io.Writer。拿到 io.Writer 目标,就能够当作日志输出传递给 New 函数了。

咱们能够结合 NewTee 来运用日志轮转功用,示例如下:

package main
import (
	log "github.com/jianghushinian/gokit/log/zap"
)
func main() {
	tees := []log.TeeOption{
		{
			Out: log.NewProductionRotateBySize("rotate-by-size.log"),
			LevelEnablerFunc: log.LevelEnablerFunc(func(level log.Level) bool {
				return level < log.WarnLevel
			}),
		},
		{
			Out: log.NewProductionRotateByTime("rotate-by-time.log"),
			LevelEnablerFunc: log.LevelEnablerFunc(func(level log.Level) bool {
				return level >= log.WarnLevel
			}),
		},
	}
	lts := log.NewTee(tees)
	defer lts.Sync()
	lts.Debug("Debug msg")
	lts.Info("Info msg")
	lts.Warn("Warn msg")
	lts.Error("Error msg")
}

此示例将 Warn 以下等级日志按巨细轮转,Warn 及以上等级日志按时刻轮转。你能够自己履行以上示例代码,观察日志输出成果。

总结

本文算是一个填坑,我在《Go 第三方 log 库之 zap 运用》一文中讲解了如何运用咱们基于 zap 封装的日志库,本文讲解了这个日志库的设计思路。

主要思路学习了 Go log 规范库以及 Logrus 日志库,咱们首要对比了 zap 日志库在运用时的下风,然后根据别的两个日志库的优点,对 zap 进行了二次封装。

咱们封装的日志包完结了开箱即用的作用,而且固定了日志输出格局,同时日志包还支撑选项形式、将不同等级日志输出到不同方位,终究我还结合 file-rotatelogslumberjack 第三方库完结了日志轮转功用。

本文源码完结在这儿,你能够点击链接进去查看。

联络我:

  • 微信:jianghushinian
  • 邮箱:jianghushinian007@outlook.com
  • 博客地址:jianghushinian.cn/

参阅

  • 基于 zap 开发的日志库: github.com/jianghushin…
  • Go log 规范库: github.com/golang/go/t…