造了个 Go 语言的日志轮子:golog

标签:Go

工作中用了半年多的 Go 语言,慢慢对这门语言熟悉起来了。虽然仍经常免不了要吐槽它的各种问题,但最令我意外的是一个发布 9 年多的语言,居然没有一款符合我心意的日志库。

我总结了一下我的需求:
  • 输出无结构的日志,方便人工读取。
  • 可同时输出到屏幕和文件。
  • 可输出日志的等级。
  • 可输出日志所在的源文件和行号。
  • 可订制日志样式。
  • 支持日志轮转。
  • 跨平台。
  • 高性能。
第一点基本能刷掉 Github 上前 3 页的日志库了,似乎都是格式化成 JSON 的。第四点也能干翻不少库,很少有支持的。所以一气之下只好自己造轮子了。

既然要造轮子,就要从我最熟悉的 Python 库抄起,于是仿照 logging 库的设计,分成了 Logger - Handler - Writer 这三层的架构:
type Logger struct {
	level    Level
	handlers []*Handler
}

type Handler struct {
	level     Level
	formatter *Formatter
	writers   []io.WriteCloser
}
Level 就是日志等级了,没达到等级的日志会被 LoggerHandler 丢弃。Formatter 是用来格式化日志的。Writer 为了方便起见,就复用了 io.WriteCloser 接口。

接着抄一个 LogRecord,用来记录一条日志的上下文:
type Record struct {
	level   Level
	time    time.Time
	file    string
	line    int
	message string
	args    []interface{}
}

然后就可以实现 Formatter 了。为了能自定义日志样式,我将其拆成了一系列的 FormatPart
type Formatter struct {
	formatParts []FormatPart
}

func (f *Formatter) Format(r *Record, buf *bytes.Buffer) {
	for _, part := range f.formatParts {
		part.Format(r, buf)
	}
}

type FormatPart interface {
	Format(r *Record, buf *bytes.Buffer)
}
这样一来,Formatter 只需要遍历它的 formatParts,依次调用它们的 Format() 方法即可完成整个格式化的过程。(实际上 Formatter 也实现了 Format() 方法,所以它也可以当成 FormatPart 使用。)
然后解析样式字符串,就可以生成一系列 FormatPart 对象,最后将其组合成一个 Formatter 对象,就完成了整个解析过程。

获取源码的文件路径和行号有个注意点:
_, file, line, _ := runtime.Caller(1)
这里传给 runtime.Caller 的参数是 1,表示只向上查 1 层调用者。如果封装了多层的话,这个参数就得增加。而这个函数非常慢,如果到了 4 的话,性能测试表明有将近一半的时间花在了这个函数上。所以不能在 SourceFormatPart 中去调用这个函数,而应该在第一层的入口函数中调用,将结果赋值给 Record 对象。

接着就可以用 Formatter 来格式化 Record 了:
func (h *Handler) Handle(r *Record) {
	if r.level >= h.level {
		buf := bufPool.Get().(*bytes.Buffer)
		buf.Reset()
		h.formatter.Format(r, buf)
		content := buf.Bytes()
		for _, w := range h.writers {
			_, err := w.Write(content)
			if err != nil {
				logError(err)
			}
		}
		bufPool.Put(buf)
	}
}

const recordBufSize = 128

var bufPool = sync.Pool{
	New: func() interface{} {
		return bytes.NewBuffer(make([]byte, 0, recordBufSize))
	},
}
因为输出每条日志都要创建一个 bytes.Buffer 对象,至少分配了 128 字节的内存,会存在很大的开销,所以我用 sync.Pool 重用了这些对象。

现在就只剩 Writer 没有实现了,先把 stdoutstderr 加上:
type ConsoleWriter struct {
	*os.File
}

func NewConsoleWriter(f *os.File) *ConsoleWriter {
	w := ConsoleWriter{
		File: f,
	}
	return &w
}

func NewStdoutWriter() *ConsoleWriter {
	return NewConsoleWriter(os.Stdout)
}

func NewStderrWriter() *ConsoleWriter {
	return NewConsoleWriter(os.Stderr)
}

func (w *ConsoleWriter) Close() error {
	w.File = nil
	return nil
}
我将其定义成了包含一个 *os.File 指针的结构体,而不是包含 io.Writer 接口,原因是这样的性能更快。我也没有直接用 type ConsoleWriter os.File 来定义一个新类型,因为这样我需要手动实现它的 Write() 方法,而这个方法需要将 *os.File 指针转换成 *ConsoleWriter 指针,我觉得 Go 语言的编译器实现这个极简单的方法也是有运行时开销的。

接着就可以实现一个 FileWriter 了:
const (
	fileFlag = os.O_WRONLY | os.O_CREATE | os.O_APPEND
	fileMode = 0644
)

func NewFileWriter(path string) (*os.File, error) {
	return os.OpenFile(path, fileFlag, fileMode)
}
你没看错,就是这么简单。os.FileWrite() 时本来就加了锁,所以不用担心多线程环境下出错。如果一次性写入的字节数较少的话,对绝大部分的操作系统和文件系统而言,syscall.Write() 在多进程环境下也是原子的。不过依赖这个特性的话会丧失可移植性,严谨地支持多进程又会极大地影响性能,所以只支持多线程就行了。

话说回来,这样实现的 FileWriter 性能很差,因为每次写入都直接对应一次系统调用。解决办法就是改为带缓冲区的版本:
type BufferedFileWriter struct {
	writer     *os.File
	buffer     *bufio.Writer
	locker     sync.Mutex
	updated    bool
	updateChan chan struct{}
	stopChan   chan struct{}
}

func NewBufferedFileWriter(path string) (*BufferedFileWriter, error) {
	f, err := os.OpenFile(path, fileFlag, fileMode)
	if err != nil {
		return nil, err
	}
	w := &BufferedFileWriter{
		writer:     f,
		buffer:     bufio.NewWriterSize(f, bufferSize),
		updateChan: make(chan struct{}, 1),
		stopChan:   make(chan struct{}),
	}
	go w.schedule()
	return w, nil
}

func (w *BufferedFileWriter) schedule() {
	locker := &w.locker
	timer := time.NewTimer(0)
	for {
		select {
		case <-w.updateChan:
			stopTimer(timer)
			timer.Reset(flushDuration)
		case <-w.stopChan:
			stopTimer(timer)
			return
		}

		select {
		case <-timer.C:
			locker.Lock()
			var err error
			if w.writer != nil { // not closed
				w.updated = false
				err = w.buffer.Flush()
			}
			locker.Unlock()
			if err != nil {
				logError(err)
			}
		case <-w.stopChan:
			stopTimer(timer)
			return
		}
	}
}

func (w *BufferedFileWriter) Write(p []byte) (n int, err error) {
	w.locker.Lock()
	n, err = w.buffer.Write(p)
	if !w.updated && n > 0 && w.buffer.Buffered() > 0 {
		w.updated = true
		w.updateChan <- struct{}{}
	}
	w.locker.Unlock()
	return
}

func (w *BufferedFileWriter) Close() error {
	w.locker.Lock()
	err := w.buffer.Flush()
	w.buffer = nil
	if err == nil {
		err = w.writer.Close()
	} else {
		e := w.writer.Close()
		if e != nil {
			logError(e)
		}
	}
	w.writer = nil
	w.locker.Unlock()
	close(w.stopChan)
	return err
}

func stopTimer(timer *time.Timer) {
	if !timer.Stop() {
		select {
		case <-timer.C:
		default:
		}
	}
}
这里先用 bufio.Writer 封装一下 os.File,这样写入时就会写到缓冲区里,达到容量上限时才会被刷新到文件中。
但若只是这样而已,会导致日志量不大时,日志很久都不会被刷新到磁盘。于是我又增加了一个定时器,一旦有写入操作,就等待 100 毫秒,然后刷新缓冲区。绝大部分情况下,这个延迟是可以接受的。
至于拆分成两个 select 块,而不合并到一起,原因是可以少监听一个 channel,性能更好。其实最初我是想把写入的数据都通过 channel 传递到一个写线程,就不需要加锁了;后来发现 channel 内部也是用锁来实现的,性能反而比直接用锁更低,所以放弃了。
顺带一提,updateChan 带了个缓冲区,原因是 Write() 线程向它发送数据时并没有解锁,如果没有缓冲区就会阻塞在这;而接收线程也没法获取锁,就造成死锁了。stopChan 则不需要缓冲区,因为 close() 它是不会阻塞的。

接着就可以实现轮转功能了:
type RotatingFileWriter struct {
	BufferedFileWriter
	path        string
	pos         uint64
	maxSize     uint64
	backupCount uint8
}

func NewRotatingFileWriter(path string, maxSize uint64, backupCount uint8) (*RotatingFileWriter, error) {
	if maxSize == 0 {
		return nil, errors.New("maxSize cannot be 0")
	}

	if backupCount == 0 {
		return nil, errors.New("backupCount cannot be 0")
	}

	f, err := os.OpenFile(path, fileFlag, fileMode)
	if err != nil {
		return nil, err
	}

	stat, err := f.Stat()
	if err != nil {
		e := f.Close()
		if e != nil {
			logError(e)
		}
		return nil, err
	}

	w := RotatingFileWriter{
		BufferedFileWriter: BufferedFileWriter{
			writer:     f,
			buffer:     bufio.NewWriterSize(f, bufferSize),
			updateChan: make(chan struct{}, 1),
			stopChan:   make(chan struct{}),
		},
		path:        path,
		pos:         uint64(stat.Size()),
		maxSize:     maxSize,
		backupCount: backupCount,
	}

	go w.schedule()
	return &w, nil
}

func (w *RotatingFileWriter) Write(p []byte) (n int, err error) {
	length := uint64(len(p))
	w.locker.Lock()
	defer w.locker.Unlock()

	if length >= w.maxSize {
		err = w.rotate()
		if err != nil {
			return
		}

		n, err = w.buffer.Write(p)
		if err != nil {
			w.pos = uint64(n)
			return
		}

		err = w.rotate()
	} else {
		pos := w.pos + length
		if pos > w.maxSize {
			err = w.rotate()
			if err != nil {
				return
			}
		}

		n, err = w.buffer.Write(p)
		if n > 0 {
			w.pos += uint64(n)
			if !w.updated && w.buffer.Buffered() > 0 {
				w.updated = true
				w.updateChan <- struct{}{}
			}
		}
	}

	return
}

func (w *RotatingFileWriter) rotate() error {
	if w.writer == nil { // was closed
		return os.ErrClosed
	}

	err := w.buffer.Flush()
	if err != nil {
		return err
	}

	err = w.writer.Close()
	w.pos = 0
	if err != nil {
		w.writer = nil
		w.buffer = nil
		return err
	}

	for i := w.backupCount; i > 1; i-- {
		oldPath := fmt.Sprintf("%s.%d", w.path, i-1)
		newPath := fmt.Sprintf("%s.%d", w.path, i)
		os.Rename(oldPath, newPath) // ignore error
	}

	err = os.Rename(w.path, w.path+".1")
	if err != nil {
		w.writer = nil
		w.buffer = nil
		return err
	}

	f, err := os.OpenFile(w.path, fileFlag, fileMode)
	if err != nil {
		w.writer = nil
		w.buffer = nil
		return err
	}

	w.writer = f
	w.buffer.Reset(f)
	return nil
}
主体部分借用了 BufferedFileWriterschedule() 方法,它自己要做的只是记录下打开文件时的位置,并在写入时计算位置,达到 maxSize 就轮转。

然后再来实现按时间轮转:
type TimedRotatingFileWriter struct {
	BufferedFileWriter
	pathPrefix     string
	rotateDuration RotateDuration
	backupCount    uint8
}

func (w *TimedRotatingFileWriter) schedule() {
	locker := &w.locker
	flushTimer := time.NewTimer(0)
	duration := nextRotateDuration(w.rotateDuration)
	rotateTimer := time.NewTimer(duration)

	for {
	updateLoop:
		for {
			select {
			case <-w.updateChan:
				stopTimer(flushTimer)
				flushTimer.Reset(flushDuration)
				break updateLoop
			case <-rotateTimer.C:
				err := w.rotate(rotateTimer)
				if err != nil {
					logError(err)
				}
			case <-w.stopChan:
				stopTimer(flushTimer)
				stopTimer(rotateTimer)
				return
			}
		}

	flushLoop:
		for {
			select {
			case <-flushTimer.C:
				locker.Lock()
				var err error
				if w.writer != nil { // not closed
					w.updated = false
					err = w.buffer.Flush()
				}
				locker.Unlock()
				if err != nil {
					logError(err)
				}
				break flushLoop
			case <-rotateTimer.C:
				err := w.rotate(rotateTimer)
				if err != nil {
					logError(err)
				}
			case <-w.stopChan:
				stopTimer(flushTimer)
				stopTimer(rotateTimer)
				return
			}
		}
	}
}

func (w *TimedRotatingFileWriter) rotate(timer *time.Timer) error {
	w.locker.Lock()
	if w.writer == nil { // was closed
		w.locker.Unlock()
		return os.ErrClosed
	}

	err := w.buffer.Flush()
	if err != nil {
		w.locker.Unlock()
		return err
	}

	err = w.writer.Close()
	if err != nil {
		w.locker.Unlock()
		return err
	}

	f, err := openTimedRotatingFile(w.pathPrefix, w.rotateDuration)
	if err != nil {
		w.buffer = nil
		w.writer = nil
		w.locker.Unlock()
		return err
	}

	w.writer = f
	w.buffer.Reset(f)

	duration := nextRotateDuration(w.rotateDuration)
	timer.Reset(duration)
	w.locker.Unlock()

	go w.purge()
	return nil
}
非关键部分的代码我就不贴了。主体部分还是两个 select 块,只是增加了是否到达轮转时间的判断。如果因为轮转而跳出 select 块,则继续留在 for 循环中。

自此为止,主体部分就算实现了,但还缺了个非常重要的步骤:命名。
官方库已经把 log 这个好名字给占用了,Github 上还有一堆五花八门的名字,我思索半天决定叫 golog 了,还是个回文。
但是如果仅此而已,我调用时就得写成 golog.Info() 了,这么长明显不符合我的风格。于是又建了个叫 golog/log 的子包,把主要的函数扔进去,就能写成 log.Info() 了,VS Code 里直接保存文件就能自动 import 完整包名了,完美!

最后测一下性能,和号称最快的结构化日志库 zerolog 对比了一下,如果让它也输出格式化的时间和源码的话,耗时比我的实现多一倍。
剩下比较影响性能的部分主要有:
  1. runtime.Caller()
  2. fmt.Sprint()fmt.Sprintf()
  3. Time.Format()
  4. time.Now()
  5. strconv.Itoa()
  6. 大量生成 Record 对象。
第一点我无能为力;第二点虽然知道它们是用反射实现的,但我实在不想再造轮子了;第三点我改成计算出时间的各部分的整数值,然后手动拼接字符串;第四点可以缓存一个格式化好的时间,然后每隔 0.1 秒更新一次,但是我觉得这样不优雅,而且丧失了部分正确性;第五点我看了下需要格式化的数值,年份在 1970 ~ 2038 之间,其他的时间部分都在 0 ~ 59 之间,行号一般少于 1000 行,所以启动时先把这些计算出来,查找时直接用数组的下标取出就好了,还省去了内存分配;第六点用 sync.Pool 来重用 Record 即可。详细的实现就不列出了,还是去 Github 看吧。
改造完再一测,已经甩开 zerolog 两倍了。

3条评论 你不来一发么↓ 顺序排列 倒序排列

    向下滚动可载入更多评论,或者点这里禁止自动加载

    想说点什么呢?