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, ¤tTime)
l.iLogger.SetOutput(l.file)
l.iLogger.SetFlags(log.Lshortfile | log.Ldate | log.Ltime | log.Lmicroseconds)
l.lastHour = getTimeHour(¤tTime)
}
return
}
currentHour := getTimeHour(¤tTime)
if l.lastHour != currentHour {
l.mu.Lock()
defer l.mu.Unlock()
if l.lastHour != currentHour {
_ = l.file.Close()
l.file, err = createFile(&l.Path, ¤tTime)
l.iLogger.SetOutput(l.file)
l.iLogger.SetFlags(log.Llongfile | log.Ldate | log.Ltime)
l.lastHour = getTimeHour(¤tTime)
}
}
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。
收穫更多架構知識,請關注微信公眾號 螢火架構。原創內容,轉載請註明出處。