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

波斯馬發表於2022-05-16

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,歡迎訪問:https://github.com/bosima/ylog/tree/v1.0.1

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

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

相關文章