Golang:手擼一個支援六種級別的日誌庫

Golang標準日誌庫提供的日誌輸出方法有Print、Fatal、Panic等,沒有常見的Debug、Info、Error等日誌級別,用起來不太順手。這篇文章就來手擼一個自己的日誌庫,可以記錄不同級別的日誌。

其實對於追求簡單來說,Golang標準日誌庫的三個輸出方法也夠用了,理解起來也很容易:

  • Print用於記錄一個普通的程式日誌,開發者想記點什麼都可以。
  • Fatal用於記錄一個導致程式崩潰的日誌,並會退出程式。
  • Panic用於記錄一個異常日誌,並觸發panic。

不過對於用慣了Debug、Info、Error的人來說,還是有點不習慣;對於想更細緻的區分日誌級別的需求,標準日誌庫還提供了一個通用的Output方法,開發者在要輸出的字元串中加入級別也是可以的,但總是有點彆扭,不夠直接。

目前市面上也已經有很多優秀的三方日誌庫,比如uber開源的zap,常見的還有zerolog、logrus等。不過我這裡還是想自己手擼一個,因為大多數開源產品都不會完全貼合自己的需求,有很多自己用不上的功能,這會增加系統的複雜性,有沒有隱藏的坑也很難說,當然自己入坑的可能性也很大;再者看了官方日誌庫的實現之後,感覺可以簡單封裝下即可實現自己想要的功能,能夠hold住。

初始需求

我這裡的初始需求是:

  • 將日誌寫入磁碟文件,每個月一個文件夾,每個小時一個文件。
  • 支援常見日誌級別:Trace、Debug、Info、Warn、Error、Fatal,並且程式能夠設置日誌級別。

我給這個日誌庫取名為ylog,預期的使用方法如下:

ylog.SetLevel(LevelInfo)
ylog.Debug("I am a debug log.")
ylog.Info("I am a Info log.")

技術實現

類型定義

需要定義一個結構體,保存日誌級別、要寫入的文件等資訊。

type FileLogger struct {
	lastHour int64
	file     *os.File
	Level    LogLevel
	mu       sync.Mutex
	iLogger  *log.Logger
	Path     string
}

來看一下這幾個參數:

lastHour 用來記錄創建日誌文件時的小時數,如果小時變了,就要創建新的日誌文件。

file 當前使用的日誌文件。

Level 當前使用的日誌級別。

mu 因為可能在不同的go routine中寫日誌,需要一個互斥體保證日誌文件不會重複創建。

iLogger 標準日誌庫實例,因為這裡是封裝了標準日誌庫。

Path 日誌輸出的最上層目錄,比如程式根目錄下的logs目錄,這裡就保存一個字元串:logs。

日誌級別

先把日誌級別定義出來,這裡日誌級別其實是int類型,從0到5,級別不斷升高。

如果設置為ToInfo,則Info級別及比Info級別高的日誌都能輸出。

type LogLevel int
const (
	LevelTrace LogLevel = iota
	LevelDebug
	LevelInfo
	LevelWarn
	LevelError
	LevelFatal
)

上文提到可以在Output方法的參數中加入日誌級別,這裡就通過封裝Output方法來實現不同級別的日誌記錄方法。這裡貼出其中一個方法,封裝的方式都一樣,就不全都貼出來了:

func (l *FileLogger) CanInfo() bool {
	return l.Level <= LevelInfo 
}

func (l *FileLogger) Info(v ...any) {
	if l.CanInfo() {
		l.ensureFile()
		v = append([]any{"Info "}, v...)
		l.iLogger.Output(2, fmt.Sprintln(v...))
	}
}

輸出日誌前做了三件事:

  • 判斷日誌級別,如果設置的日誌級別小於等於當前輸出級別,則可以輸出。
  • 確保日誌文件已經創建好,後邊會講如何確保。
  • 將日誌級別前插到日誌字元串中。

然後調用標準庫的Output函數輸出日誌,這裡第一個參數是為了獲取到當前正在寫日誌的程式文件名,傳入的是在程式調用棧中進行查找的深度值,這裡用2就正好。

寫到文件

標準庫的log是支援輸出到多種目標的,只要實現了io.Write介面:

type Writer interface {
	Write(p []byte) (n int, err error)
}

因為文件對象也實現了這個介面,所以這裡可以創建os.File的實例,並把它設置到內嵌的標準日誌庫實例,也就是設置到前邊創建的FileLogger中的iLogger中。這個操作在ensureFile方法中,看一下這個文件的實現:

func (l *FileLogger) ensureFile() (err error) {
	currentTime := time.Now()
	if l.file == nil {
		l.mu.Lock()
		defer l.mu.Unlock()
		if l.file == nil {
			l.file, err = createFile(&l.Path, &currentTime)
			l.iLogger.SetOutput(l.file)
			l.iLogger.SetFlags(log.Lshortfile | log.Ldate | log.Ltime | log.Lmicroseconds)
			l.lastHour = getTimeHour(&currentTime)
		}
		return
	}

	currentHour := getTimeHour(&currentTime)
	if l.lastHour != currentHour {
		l.mu.Lock()
		defer l.mu.Unlock()
		if l.lastHour != currentHour {
			_ = l.file.Close()
			l.file, err = createFile(&l.Path, &currentTime)
			l.iLogger.SetOutput(l.file)
			l.iLogger.SetFlags(log.Llongfile | log.Ldate | log.Ltime)
			l.lastHour = getTimeHour(&currentTime)
		}
	}

	return
}

這裡稍微有點複雜,基本邏輯是:如果文件實例不存在,則創建;如果需要創建新的文件,則先關閉舊的文件再創建新的文件。

更改文件實例時需要加鎖,否則可能多次操作,出現預期之外的情況。

設置輸出到文件後,標準log庫的Output方法就會將日誌輸出到這個文件了。

默認實現

經過上邊一系列操作,這個FileLogger就可以使用了:

var logger = NewFileLogger(LevelInfo, "logs")
logger.Info("This is a info.")

不過和最初設想的用法有點差別: ylog.Info(“xxxx”)

這需要在ylog包中再定義一個名為Info的公開函數,可以在這個公開函數中調用一個默認創建的FileLogger實例,程式碼是這樣的:

var stdPath = "logs"
var std = NewFileLogger(LevelInfo, stdPath)

func Trace(v ...any) {
	if std.CanTrace() {
		std.ensureFile()
		v = append([]any{"Trace"}, v...)
		std.iLogger.Output(2, fmt.Sprintln(v...))
	}
}

注意這裡沒有調用std的Trace方法,這是因為Output中的第一個參數,如果嵌套調用std.Trace,則多了一層,這個參數就得設置為3,但是自己創建實例調用Trace時這個參數需要為2,這就產生衝突了。


經過以上這些操作,就可以實現預期的日誌操作了:

ylog.SetLevel(LevelInfo)
ylog.Debug("I am a debug log.")
ylog.Info("I am a Info log.")

完整的程式已經上傳到Github,歡迎訪問://github.com/bosima/ylog/tree/v1.0.1

下篇文章將繼續改造這個日誌庫,支援輸出Json格式的日誌,以及輸出日誌到Kafka。

收穫更多架構知識,請關注微信公眾號 螢火架構。原創內容,轉載請註明出處。
掃描二維碼關注公眾號