Go实现日志

0.前言

该日志的实现主要是参考自Go日志包logurs。

实现的日志包主要有以下几个功能:

  • 支持文件名和行号。
  • 支持多日志级别。
  • 支持输出到本地文件和标准输出。
  • 支持JSON和TEXT格式的日志输出,支持自定义日志格式。
  • 支持自定义配置。
  • 支持选项模式。

未来可能实现:支持结构化输出, 支持Hook.................

日志包名称为sulog。代码保存在https://github.com/liwook/Go-projects/tree/main/log

1.定义日志等级和日志选项

那首先是会有日志等级,一共6个等级。

在日志输出时候,是会判别输出的等级是否符合设定的日志等级,那就需要比较,所以日志类型用数值类型就好。

在输出中,也要输出日志等级,那肯定不是输出数字的,那是要输出例如"DEBUG"这种的,可以用数组或者切片来映射。

Go 复制代码
//option.go
type Level uint8

const (
	DebugLevel Level = iota
	InfoLevel
	WarnLevel
	ErrorLevel
	PanicLevel
	FatalLevel
)

var LevelNameMapping = []string{
	DebugLevel: "DEBUG",
	InfoLevel:  "INFO",
	WarnLevel:  "WARN",
	ErrorLevel: "ERROR",
	PanicLevel: "PAINC",
	FatalLevel: "FATAL",
}

那接着来看日志选项,一个日志中会有很多选项,比如输出地方,输出格式,日志等级,是否输出行号等等。这里我们就把这些选项打包封装到一个结构体options中。

Go 复制代码
// 日志选项结构体
type options struct {
	output        io.Writer
	level         Level
	formatter     Formatter //格式,比如json格式
	disableCaller bool      //设置是否打印文件名和行号
}

//格式定义,在formatter.go文件中
//Formatter 作为接口
type Formatter interface {
	Format(entry *Entry) error
}

有了选项后,那就需要进行设置选项。提供了initOptions函数来初始化日志选项,参数是传入函数。

默认输出是os.Stderr,输出格式是text文本格式,默认日志等级是0(即是DebugLevel)。

Go 复制代码
type Option func(*options)

func initOptions(opts ...Option) (o *options) {
	o = &options{}

	for _, opt := range opts {
		opt(o)
	}
	if o.output == nil {
		o.output = os.Stderr
	}
	if o.formatter == nil {
		o.formatter = &TextFormatter{}
	}
	return
}

对每一个日志选项创建设置函数 WithXXXX 。该日志支持如下选项设置

  • WithOutput(output io.Writer):设置输出位置。
  • WithLevel(level Level):设置输出级别。
  • WithFormatter(formatter Formatter):设置输出格式。
  • WithDisableCaller(caller bool):设置是否打印文件名和行号。
Go 复制代码
func WithOutput(output io.Writer) Option {
	return func(o *options) {
		o.output = output
	}
}

func WithLevel(levle Level) Option {
	return func(o *options) {
		o.level = levle
	}
}

func WithFormatter(formatter Formatter) Option {
	return func(o *options) {
		o.formatter = formatter
	}
}

func WithDisableCaller(caller bool) Option {
	return func(o *options) {
		o.disableCaller = caller
	}
}

结合initOptions函数用法如下:(比如使用WithOutput)

Go 复制代码
initOptions(WithOutput(os.Stderr))

这里还没有实现logger结构体,所以还没有到使用设置的地方,讲到logger结构体的时候会再讲解。

2.创建Logger

有了前面的铺垫,可以快速定义结构体logger。

logger中有options类型变量opt。那接着来看看创建Logger的函数New,其内部使用initOptions来初始化logger的opt变量。

而std是日志包的默认使用对象,没有设置参数。

日志包会有一个默认的全局Logger,通过 var std = New() 创建了一个全局的默认Logger。

sulog.Debug、sulog.Info和sulog.Warnf等函数,则是通过调用std Logger所提供的方法来打印日志的。

Go 复制代码
//logger.go
type logger struct {
	opt       *options
    mu        sync.Mutex//为了可以同步多协程写日志
}

var std = New()

func New(opts ...Option) *logger {
	logger := &logger{opt: initOptions(opts...)}
	return logger
}

func SetOptions(opts ...Option) {
	std.SetOptions(opts...)
}

func (l *logger) SetOptions(opts ...Option) {
	l.mu.Lock()
	defer l.mu.Unlock()
    //这里加锁也是为了可以多协程设置,写日志时刻就不能修改选项
	for _, opt := range opts {
		opt(l.opt)
	}
}

这时修改日志的选项,就可以使用

Go 复制代码
sulog.SetOptions(sulog.WithLevel(sulog.DebugLevel))

定义了一个Logger之后,还需要给该Logger添加最核心的日志打印方法,要提供所有支持级别的日志打印方法。

如果日志级别是xxx,则通常会提供两类方法,分别是非格式化方法xxx(args ...any)格式化方法xxxf(format string, args ...any),例如:

Go 复制代码
func (l *logger) Debug(args ...any) {
	l.log(DebugLevel, args...)
}

func (l *logger) Debugf(format string, args ...any) {
	l.logf(DebugLevel, format, args...)
}

//Debug()调用log()
func (l *logger) log(level Level, args ...any) {
	if l.opt.level > level { //日志等级不符合
		return
	}
	newEntry := l.entry()
	defer l.releaseEntry(newEntry)
	newEntry.Log(level, args...)
}

//Debugf()调用logf()
func (l *logger) logf(level Level, fomat string, args ...any) {
	if l.opt.level > level { //日志等级不符合
		return
	}
	newEntry := l.entry()
	defer l.releaseEntry(newEntry)
	newEntry.Logf(level, fomat, args...)
}

本日志也实现了如下方法:Debug、Debugf、Info、Infof、Warn、Warnf、Error、Errorf、Panic、Panicf、Fatal、Fatalf。这里没有详细的展示。

需要注意的是,Panic、Panicf要调用panic()函数,Fatal、Fatalf函数要调用 os.Exit(1) 函数。

3.日志内容和输出

日志内容

我们定义一个Entry结构体类型。其日志内容数据和日志配置,都保存在Entry对象中。

日志内容对象Entry,主要功能是存储日志内容以及进行日志内容写入。

Go 复制代码
type Entry struct {
	logger  *logger
	Buffer  *bytes.Buffer    //日志内容的存储地
	DataMap map[string]any //为了日志是json格式使用的
	Level   Level
	Time    time.Time
	File    string
	Line    int
	Func    string
	Message string    //日志数据

}

//创建entry
func entry(logger *logger) *Entry {
	return &Entry{
		logger:  logger,
		Buffer:  new(bytes.Buffer),
		DataMap: make(map[string]any, 5)}
}

为什么要有Entry类型呢?首先这样可以简化了logger结构体的打印方法代码 (如上面的代码),也方便管理日志内容,而且这样也方便做日志内容结构化

输出

用Entry的Log 或Logf 方法来完成日志的写入,自带格式的使用logf,其余使用Log,最终是使用log方法。

在log方法中,还会判断是否需要记录文件名和行号,如果需要则调用 runtime.Caller() 来获取文件名和行号,调用 runtime.Caller() 时,要注意传入正确的栈深度。

Go 复制代码
func (e *Entry) Log(level Level, args ...any) {
	e.log(level, fmt.Sprint(args...))
}

func (e *Entry) Logf(level Level, format string, args ...any) {
	e.log(level, fmt.Sprintf(format, args...))
}

func (e *Entry) log(level Level, msg string) {
	e.Time = time.Now()
	e.Level = level
	e.Message = msg

	if !e.logger.opt.disableCaller {
		if pc, file, line, ok := runtime.Caller(4); !ok {
			e.File = "???"
			e.Func = "???"
		} else {
			e.File, e.Line, e.Func = file, line, runtime.FuncForPC(pc).Name()
			e.Func = e.Func[strings.LastIndex(e.Func, "/")+1:]
		}
	}

	e.write()
}

log方法中调用 e.write来格式化日志(格式化后,会把内容写到e.Buffer中),并写入日志。

output类型为 io.Writer,在e.writeh中,调用e.logger.opt.output.Write(e.Buffer.Bytes())即可将日志写入到指定的位置中。

Go 复制代码
func (e *Entry) write() {
	e.logger.mu.Lock()
	defer e.logger.mu.Unlock()
    e.logger.opt.formatter.Format(e)
	e.logger.opt.output.Write(e.Buffer.Bytes())
}

看回Debug方法中,其实是只要写一次日志,就需要创建一个Entry结构体(logger.entry()就是创建entry),写完后就释放,这是比较耗性能的。

所以,这块在创建一个新的Entry对象时,使用sync.Pool对象 entryPool,主要保存Entry指针空对象,使用完后再放回sync.Pool中,防止在记录日志的时候大量开辟内存空间,触发GC操作,从而提高日志记录的速度。

使用sync.Pool的关键就是对象的复用,避免重复创建、销毁,而且其是协程安全的,这对于使用者来说是极其方便的

sync.Pool的用法:使用前,设置好对象的 New 函数,用于在 Pool 里没有缓存的对象时,创建一个。之后,在程序的任何地方、任何时候仅通过 Get()Put() 方法就可以取、还对象了。

所以需要再logger结构体中添加sync.Pool类型变量entryPool。在New函数中也需要添加对entryPool的初始化。

Go 复制代码
type logger struct {
    //..........
	entryPool *sync.Pool  //新添加,存放临时的Entry对象,减少GC对Entry对象的内存回收,提高Entry对象复用,提高效率
}

func New(opts ...Option) *logger {
	logger := &logger{opt: initOptions(opts...)}
	logger.entryPool = &sync.Pool{New: func() any { return entry(logger) }}    //新添加的
	return logger
}

//获取entry对象
func (l *logger) entry() *Entry {
	return l.entryPool.Get().(*Entry)
}
func (l *logger) releaseEntry(e *Entry) {
    e.DataMap = map[string]any{}
	e.Line, e.File, e.Func = 0, "", ""
	e.Buffer.Reset()
	l.entryPool.Put(e)
}

4.自定义日志输出格式

前面的(Entry).format方法使用了自定义格式输出。有多种格式类型,我们可以定义成接口类型Formatter。

将Entry中的数据内容,如DataMap字段存储的有结构的数据键值对,Message中存储的无结构数据,以及Time存储的日志记录时间等等,格式化成我们想要的数据格式

Go 复制代码
//formatter.go
type Formatter interface {
	Format(entry *Entry) error
}

那么接着实现两种格式:JSON和TEXT格式。

JSON格式

json的编解码使用字节开发的"github.com/bytedance/sonic"。

通过sonic.ConfigDefault.NewEncoder(e.Buffer).Encode(e.DataMap)来把日志内容写到e.Buffer。

Go 复制代码
type JsonFormatter struct {
	DisableTimestamp bool
	TimestampFormat  string
}

func (f *JsonFormatter) Format(e *Entry) error {
	if !f.DisableTimestamp {
		if f.TimestampFormat == "" {
			f.TimestampFormat = time.RFC3339
		}
		e.DataMap["time"] = e.Time.Format(f.TimestampFormat)
	}

	e.DataMap["level"] = LevelNameMapping[e.Level]

	if e.File != "" {
		e.DataMap["file"] = e.File + ":" + strconv.Itoa(e.Line)
		e.DataMap["func"] = e.Func
	}

	e.DataMap["message"] = e.Message

	return sonic.ConfigDefault.NewEncoder(e.Buffer).Encode(e.DataMap)
}

TEXT格式的就不展示了。

5.测试

Go 复制代码
func main() {
	//使用默认全局变量
	sulog.Info("std info log")
	sulog.SetOptions(sulog.WithLevel(sulog.ErrorLevel))
	sulog.Info("std can not info") //设置了ErrorLevel等级,那InfoLevle就输出不了

	sulog.SetOptions(sulog.WithFormatter(&sulog.JsonFormatter{}))
	sulog.Error("bad error")
	sulog.Errorf("%s %d", "myhome", 111) //用户自定义message输出格式

	//输出到文件
	file, err := os.OpenFile("./test.log", os.O_CREATE|os.O_APPEND, 0666)
	if err != nil {
		log.Fatal("create file test.log failed")
	}
	defer file.Close()

	//自定义logger变量,New函数中设置选项
	l := sulog.New(sulog.WithLevel(sulog.InfoLevel),
		sulog.WithOutput(file))
	l.SetOptions(sulog.WithFormatter(&sulog.JsonFormatter{IgnoreBasicFields: true}))
	l.Info("log with json")
}
相关推荐
Clown9521 分钟前
go-zero(十九)使用Prometheus监控ES指标
elasticsearch·golang·prometheus
wowocpp1 小时前
spring boot Controller 和 RestController 的区别
java·spring boot·后端
后青春期的诗go1 小时前
基于Rust语言的Rocket框架和Sqlx库开发WebAPI项目记录(二)
开发语言·后端·rust·rocket框架
freellf1 小时前
go语言学习进阶
后端·学习·golang
全栈派森3 小时前
云存储最佳实践
后端·python·程序人生·flask
CircleMouse3 小时前
基于 RedisTemplate 的分页缓存设计
java·开发语言·后端·spring·缓存
獨枭4 小时前
使用 163 邮箱实现 Spring Boot 邮箱验证码登录
java·spring boot·后端
维基框架5 小时前
Spring Boot 封装 MinIO 工具
java·spring boot·后端
秋野酱5 小时前
基于javaweb的SpringBoot酒店管理系统设计与实现(源码+文档+部署讲解)
java·spring boot·后端
☞无能盖世♛逞何英雄☜5 小时前
Flask框架搭建
后端·python·flask