一文教你打造一個簡易的Golang日志庫
前言
最近去一家大公司面試,我遇到一道有趣的golang筆試題:
當時,由于筆試時間緊張,我來不及多想,這道題就空著?;貋砗?,思來想去,自己決定實現(xiàn)一個簡易的golang日志庫,完成這個缺憾的夢,也和大家一起對“golang的并發(fā)之道”進行研究。
你可以收獲
- channel在高并發(fā)場景下的使用;
- for-select-case對channel的優(yōu)雅遍歷處理;
- 定時器在select中的使用;
- golang如何用結(jié)束信號讓程序優(yōu)雅退出。
打造一個簡易的golang日志庫
接下來,我們用不超過130行的代碼,通過一系列g(shù)olang的特性,來打造一個簡易的golang日志庫。
內(nèi)容脈絡(luò)
為了幫助大家有個大致的輪廓,我先把后面的大綱展示出來。

標準日志庫長啥樣
一個標準的日志庫一般有以下特點:
- 將日志內(nèi)容持久化到文件中,并同時注意磁盤io。上述面試題涉及到的就是這個。
- 日志的基本信息需要盡量詳細,需要包含文件,函數(shù)名,時間等等。
- 支持不同的日志級別。我們所熟知的DEBUG/INFO/ERROR等等,說的就是這個。
- 支持日志切割。支持的維度一般是時間,當然也有根據(jù)文件大小的。
然而,在這里,我們只實現(xiàn)第一個訴求。
我們要做什么
經(jīng)過對需求的拆解,我們希望完成以下幾個功能:
- 定時刷盤:每隔1s,將這1s內(nèi)的日志全部刷盤。
- 超限刷盤:日志條數(shù)積壓到100條,將這100條日志日志全部刷盤。
- 退出刷盤:程序(或服務(wù))退出時,積壓在內(nèi)存中的日志全部刷盤。
自己動手,豐衣足食
數(shù)據(jù)流
用戶先調(diào)用日志庫的New()。
此時程序會開啟一個異步協(xié)程,循環(huán)監(jiān)聽logger對象的buf。
logger對象返回用戶。
用戶通過調(diào)用logger對象的Info(),將日志輸出到日志庫。
logger對象的buf產(chǎn)生變更。
當滿足以下條件之一的時候,buf中的日志會統(tǒng)一刷盤。
- buf日志數(shù)達到100刷盤一次;
- 每隔1s刷盤一次(如果buf有日志);
- 程序退出刷盤1次。

數(shù)據(jù)結(jié)構(gòu)
既然,我們要做日志系統(tǒng),那么,數(shù)據(jù)結(jié)構(gòu)得先考慮好。
1、logger結(jié)構(gòu)體
(1)f
既然是日志系統(tǒng),那么必然有一個寫文件的過程。那么logger數(shù)據(jù)結(jié)構(gòu)中,應(yīng)該有一個文件指針字段f。
(2)bufMessages
以上三個功能中,都需要一個緩沖區(qū)暫存一些日志,到達一定的條件后,就將緩沖區(qū)的日志批量刷盤。因此,在logger結(jié)構(gòu)體中,需要有一個緩沖區(qū)切片bufMessages。
(3)mesChan
這里有一個問題,bufMessages是一個切片,線程不安全。如果日志并發(fā)寫入的話,會存在問題。這里主要有兩種方案解決,一種是加一個互斥鎖,一種是用channel。這里,我用了第二種方案。因此,logger結(jié)構(gòu)體多了一個channel。
2、message結(jié)構(gòu)體
當然,我們還需要一個message結(jié)構(gòu)體,來存儲日志的詳細信息。這里,我們做得比較簡單,只有內(nèi)容和時間。
// 日志對象
type logger struct {
f *os.File // 日志文件指針
bufMessages []*message // 存儲每一次需要同步的消息,相當于緩沖區(qū),刷盤就會被清空
mesChan chan *message // 該管道接收每一條日志消息
}
// 日志消息
type message struct {
content string // 日志內(nèi)容
currentTime time.Time // 日志寫入時間
}代碼框架
基于上述數(shù)據(jù)結(jié)構(gòu),我們可以把代碼的架子逐漸地搭起來:
1、調(diào)用日志庫的第一步:通過New()初始化一個logger對象
const (
MsgChanLength = 10000 // 日志消息管道最大長度
MaxMsgBufLength = 100 // 日志消息緩沖區(qū)最大長度
FlushTimeInterval = time.Second // 日志刷盤周期
)
// 初始化一個logger對象
func New(logPath string) *logger {
// 打開一個文件,這里的模式為創(chuàng)建或者追加
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777)
if err != nil {
panic(err)
}
logger := &logger{
f: f,
bufMessages: make([]*message, 0, MaxMsgBufLength),
mesChan: make(chan *message, MsgChanLength),
}
// todo: 這里需要做一點事兒,監(jiān)聽日志刷盤情況
// ...
return logger
}2、當用戶調(diào)用Info()的時候,日志消息直接進入管道。
const (
MsgChanLength = 10000 // 日志消息管道最大長度
MaxMsgBufLength = 100 // 日志消息緩沖區(qū)最大長度
FlushTimeInterval = time.Second // 日志刷盤周期
)
// 初始化一個logger對象
func New(logPath string) *logger {
// 打開一個文件,這里的模式為創(chuàng)建或者追加
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777)
if err != nil {
panic(err)
}
logger := &logger{
f: f,
bufMessages: make([]*message, 0, MaxMsgBufLength),
mesChan: make(chan *message, MsgChanLength),
}
// todo: 這里需要做一點事兒,監(jiān)聽日志刷盤情況
// ...
return logger
}3、格式化一下日志,讓日志好看一點兒~~
// 格式化一下日志,讓日志好看一點兒
func (l *logger) formatMsg(mes *message) string {
builder := &strings.Builder{}
builder.WriteString(mes.currentTime.Format("2006-01-02 15:04:05.999999"))
builder.WriteString(" ")
builder.WriteString(mes.content)
builder.WriteString("\n")
return builder.String()
}4、批量將日志刷盤,實際上就是操作buf,然后將buf清空的過程。
// 公用方法:批量將buf內(nèi)容刷新到日志文件
// 由于該方法放在同一個select中調(diào)用,因此線程安全
func (l *logger) batchFlush() (err error) {
builder := strings.Builder{}
for _, mes := range l.bufMessages {
// 將所有的buffer內(nèi)容全部拼接起來
builder.WriteString(l.formatMsg(mes))
}
content := builder.String()
if content == "" {
return
}
// 重置bufMessages
l.bufMessages = make([]*message, 0, MaxMsgBufLength)
// 寫入日志文件
_, err = l.f.WriteString(content)
if err != nil {
fmt.Println("寫入日志文件失敗,", err)
return
}
fmt.Println("成功寫入日志文件,", time.Now().String())
return
}定時刷盤
既然是要定時刷盤,我們很容易想到用ticker來處理。
func (l *logger) listenFlush() {
// 注冊定時器
ticker := time.NewTicker(FlushTimeInterval)
// 這里我們使用select-case組合,對channel進行優(yōu)雅處理
for {
select {
case <-ticker.C:
fmt.Println("每隔1s,將日志刷盤")
l.batchFlush()
}
}
}由于這個函數(shù)會阻塞,因此,我們要將其放在一個協(xié)程中,這個協(xié)程應(yīng)該在New()中去創(chuàng)建。
// 初始化一個logger對象
func New(logPath string) *logger {
// 打開一個文件,這里的模式為創(chuàng)建或者追加
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777)
if err != nil {
panic(err)
}
logger := &logger{
f: f,
bufMessages: make([]*message, 0, MaxMsgBufLength),
mesChan: make(chan *message, MsgChanLength),
}
// 監(jiān)聽定時日志刷盤情況
go logger.listenFlush()
return logger
}超限刷盤
“超限刷盤”的實現(xiàn),其實就是接收mesChan的消息,將其塞到bufMessages中,當bufMessages達到100條,則觸發(fā)批量刷盤。由于我們不清楚啥時候需要close這個mesChan,因此,我們需要用for-select-case來接收其消息。這里,我們可以把它加到ListenFlush中。
func (l *logger) listenFlush() {
// 注冊定時器
ticker := time.NewTicker(FlushTimeInterval)
for {
select {
case mes := <-l.mesChan:
l.bufMessages = append(l.bufMessages, mes)
if len(l.bufMessages) == MaxMsgBufLength {
fmt.Println("緩沖區(qū)日志到達上限,將日志刷盤")
l.batchFlush()
}
case <-ticker.C:
fmt.Println("每隔1s,將日志刷盤")
l.batchFlush()
}
}
}退出刷盤
無論是上面的“定時刷盤”,還是“超限刷盤”,都有一個問題,那就是,當主進程退出后,如果bufMessages中還有日志,那么這部分日志就會丟失。為了解決這個問題,我們可以做一個優(yōu)化,利用golang的結(jié)束信號,讓程序優(yōu)雅退出:退出前將buffer刷盤。
這部分代碼,我們可以加到上限刷盤的代碼里面:
func (l *logger) listenFlush() {
// 這里,我們加一個結(jié)束信號,來優(yōu)雅退出
c := make(chan os.Signal)
// 監(jiān)聽信號
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
// 注冊定時器
ticker := time.NewTicker(FlushTimeInterval)
for {
select {
case mes := <-l.mesChan:
l.bufMessages = append(l.bufMessages, mes)
if len(l.bufMessages) == MaxMsgBufLength {
fmt.Println("緩沖區(qū)日志到達上限,將日志刷盤")
l.batchFlush()
}
case <-ticker.C:
fmt.Println("每隔1s,將日志刷盤")
l.batchFlush()
case <-c:
fmt.Println("收到結(jié)束信號,將日志刷盤")
l.batchFlush()
return
}
}
}這樣,我們就完成了整個日志庫。
完整代碼
package log
import (
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
const (
MsgChanLength = 10000 // 日志消息管道最大長度
MaxMsgBufLength = 100 // 日志消息緩沖區(qū)最大長度
FlushTimeInterval = time.Second // 日志刷盤周期
)
// 日志對象
type logger struct {
f *os.File // 日志文件指針
bufMessages []*message // 存儲每一次需要同步的消息,相當于緩沖區(qū),刷盤就會被清空
mesChan chan *message // 該管道接收每一條日志消息
}
// 日志消息
type message struct {
content string // 日志內(nèi)容
currentTime time.Time // 日志寫入時間
}
// 初始化一個logger對象
func New(logPath string) *logger {
// 打開一個文件,這里的模式為創(chuàng)建或者追加
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777)
if err != nil {
panic(err)
}
logger := &logger{
f: f,
bufMessages: make([]*message, 0, MaxMsgBufLength),
mesChan: make(chan *message, MsgChanLength),
}
// 監(jiān)聽日志buf刷盤情況
go logger.listenFlush()
return logger
}
// 格式化一下日志,讓日志好看一點兒
func (l *logger) formatMsg(mes *message) string {
builder := &strings.Builder{}
builder.WriteString(mes.currentTime.Format("2006-01-02 15:04:05.999999"))
builder.WriteString(" ")
builder.WriteString(mes.content)
builder.WriteString("\n")
return builder.String()
}
// 將日志入隊
func (l *logger) Info(content string) {
l.mesChan <- &message{
content: content,
currentTime: time.Now(),
}
}
// 批量將buf內(nèi)容刷新到日志文件
func (l *logger) batchFlush() (err error) {
builder := strings.Builder{}
for _, mes := range l.bufMessages {
// 將所有的buffer內(nèi)容全部拼接起來
builder.WriteString(l.formatMsg(mes))
}
content := builder.String()
if content == "" {
return
}
// 重置bufMessages
l.bufMessages = make([]*message, 0, MaxMsgBufLength)
// 寫入日志文件
_, err = l.f.WriteString(content)
if err != nil {
fmt.Println("寫入日志文件失敗,", err)
return
}
fmt.Println("成功寫入日志文件,", time.Now().String())
return
}
// 監(jiān)聽刷盤情況
func (l *logger) listenFlush() {
// 這里,我們加一個結(jié)束信號,來優(yōu)雅退出
c := make(chan os.Signal)
// 監(jiān)聽信號
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
// 注冊定時器
ticker := time.NewTicker(FlushTimeInterval)
for {
select {
case mes := <-l.mesChan:
l.bufMessages = append(l.bufMessages, mes)
if len(l.bufMessages) == MaxMsgBufLength {
fmt.Println("緩沖區(qū)日志到達上限,將日志刷盤")
l.batchFlush()
}
case <-ticker.C:
fmt.Println("每隔1s,將日志刷盤")
l.batchFlush()
case <-c:
fmt.Println("收到結(jié)束信號,將日志刷盤")
l.batchFlush()
return
}
}
}測試文件:log_test.go
package log
import (
"math/rand"
"testing"
"time"
)
// 模擬生產(chǎn)場景進行日志輸出
func TestBatchLog(t *testing.T) {
// 初始化一個logger對象
logger := New("./batch.log")
for i := 0; i < 1000; i++ {
// 開啟1000個協(xié)程,每個協(xié)程都是一個死循環(huán),不定期地往batch.log里面寫日志
go func() {
for {
n := rand.Intn(100)
logger.Info("hello" + time.Now().String())
time.Sleep(time.Duration(n) * time.Millisecond)
}
}()
}
// 阻塞程序
select {}
}小結(jié)
雖然,這個日志庫和zerolog等標準日志庫相比,還有一定的差距,但是,通過這個小巧的項目,我們可以對golang的并發(fā)處理有更加清晰的認識,例如channel、select、定時器的使用,golang程序退出的優(yōu)雅處理,切片非線程安全的應(yīng)對等等。
不僅如此,我們還可以在此基礎(chǔ)上繼續(xù)探索,一個標準日志庫的實現(xiàn),需要具備哪些要素,甚至打造出一套深受業(yè)界青睞的好庫。
以上就是一文教你打造一個簡易的Golang日志庫的詳細內(nèi)容,更多關(guān)于Golang日志庫的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
詳解go 動態(tài)數(shù)組 二維動態(tài)數(shù)組
這篇文章主要介紹了go 動態(tài)數(shù)組 二維動態(tài)數(shù)組,本文通過實例代碼給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-07-07
Go type關(guān)鍵字(類型定義與類型別名的使用差異)用法實例探究
這篇文章主要為大家介紹了Go type關(guān)鍵字(類型定義與類型別名的使用差異)用法實例探究,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2024-01-01

