撮合引擎開發:日誌輸出

歡迎關注「Keegan小鋼」公衆號獲取更多文章緩存


撮合引擎開發:開篇數據結構

撮合引擎開發:MVP版本異步

撮合引擎開發:數據結構設計函數

撮合引擎開發:對接黑箱post

撮合引擎開發:解密黑箱流程性能

撮合引擎開發:流程的代碼實現測試

撮合引擎開發:緩存和MQui


日誌需求

咱們都知道日誌在一個程序中有着重要的做用,撮合引擎也一樣須要一個完善的日誌輸出功能,以方便調試和查詢數據。spa

對一個撮合引擎來講,須要輸出的日誌主要有如下幾類:設計

  1. 程序啓動的日誌,包括鏈接 Redis 成功的日誌、Web 服務啓動成功的日誌;
  2. 接口請求和響應數據的日誌;
  3. 啓動了某引擎的日誌;
  4. 關閉了某引擎的日誌;
  5. 訂單被添加到 orderBook 的日誌;
  6. 成交記錄的日誌;
  7. 撤單結果的日誌。

另外,撮合引擎產生的日誌會很是多,因此還應該作日誌分割,按日期分割是最經常使用的日誌分割方式,因此咱們也一樣將不一樣日期的日誌分割到不一樣日誌文件保存。

實現思路

首先,咱們都知道日誌是有分級別的,多的好比 log4j 定義了 8 種級別的日誌。不過,最經常使用的就 4 種級別,優先級從低到高分別爲:DEBUG、INFO、WARN、ERROR。通常,不一樣環境會設置不一樣的日誌級別,如 DEBUG 級別通常只在開發和測試環境才設置,生產環境則會設置爲 INFO 或更高級別。當設置爲高級別時,低級別的日誌消息是不會打印出來的。那爲了打印不一樣級別的日誌消息,能夠提供不一樣級別的打印函數,好比提供 log.Debug()、log.Info() 等函數。

其次,日誌須要輸出到文件保存,所以,就須要指定文件保存的目錄、文件名和文件對象。通常,保存的文件目錄和運行程序應該放在一塊兒,因此,指定的文件目錄最好是相對路徑。

另外,文件還要根據日期作分割,即不一樣日期的日誌消息要保存到不一樣的日誌文件,那麼,天然要記錄下當前日誌的日期。以及須要定時監控,當檢測到最新日期跟當前日誌的日期相比已經跨日了,說明須要進行日誌分割了,那就將當前的日誌文件進行備份,並建立新文件用來保存新日期的日誌消息。

最後,日誌消息寫入文件的話,那就少不了耗時的 I/O 操做,若是用同步方式寫日誌,無疑會減低撮合性能,所以,最好選用異步方式寫日誌,能夠用帶緩衝的通道實現。

代碼實現

我從新自定義了一個 log 包,並建立了 log.go 文件,全部代碼都寫在該文件中。

第一步,先定義幾種日誌等級,直接定義成枚舉類型,以下:

type LEVEL byte

const (
	DEBUG LEVEL = iota
	INFO
	WARN
	ERROR
)
複製代碼

第二步,定義日誌的結構體,其包含的字段比較多,以下:

type FileLogger struct {
	fileDir        string         // 日誌文件保存的目錄
	fileName       string         // 日誌文件名(無需包含日期和擴展名)
	prefix         string         // 日誌消息的前綴
	logLevel       LEVEL          // 日誌等級
	logFile        *os.File       // 日誌文件
	date           *time.Time     // 日誌當前日期
	lg             *log.Logger    // 系統日誌對象
	mu             *sync.RWMutex  // 讀寫鎖,在進行日誌分割和日誌寫入時須要鎖住
	logChan        chan string    // 日誌消息通道,以實現異步寫日誌
	stopTickerChan chan bool      // 中止定時器的通道
}
複製代碼

第三步,爲了能將日誌應用到程序中任何地方,就須要定義一個全局的日誌對象,並要對該日誌對象進行初始化。初始化操做有一點複雜,咱們先來看代碼:

const DATE_FORMAT = "2006-01-02"

var fileLog *FileLogger

func Init(fileDir, fileName, prefix, level string) error {
	CloseLogger()

	f := &FileLogger{
		fileDir:       fileDir,
		fileName:      fileName,
		prefix:        prefix,
		mu:            new(sync.RWMutex),
		logChan:       make(chan string, 5000),
		stopTikerChan: make(chan bool, 1),
	}

	switch strings.ToUpper(level) {
	case "DEBUG":
		f.logLevel = DEBUG
	case "WARN":
		f.logLevel = WARN
	case "ERROR":
		f.logLevel = ERROR
	default:
		f.logLevel = INFO
	}

	t, _ := time.Parse(DATE_FORMAT, time.Now().Format(DATE_FORMAT))
	f.date = &t

	f.isExistOrCreateFileDir()

	fullFileName := filepath.Join(f.fileDir, f.fileName+".log")
	file, err := os.OpenFile(fullFileName, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0666)
	if err != nil {
		return err
	}
	f.logFile = file

	f.lg = log.New(f.logFile, prefix, log.LstdFlags|log.Lmicroseconds)

	go f.logWriter()
	go f.fileMonitor()

	fileLogger = f

	return nil
}
複製代碼

這個初始化的邏輯有點多,我來進行拆分講解。首先,第一步,調用了 CloseLogger() 函數,該函數主要是關閉文件、關閉通道等操做。爲了中止一個不斷循環的 goroutine,關閉通道是一個經常使用的方案,這在以前的文章也有說過。那麼,因爲初始化函數能夠會被調用屢次,以實現配置的變動,那若是不先結束舊的 goroutine ,那一樣功能的 goroutine 將不止一個在同時運行,這無疑將會出問題。所以,須要先關閉 Logger,關閉 Logger 的代碼以下:

func CloseLogger() {
	if fileLogger != nil {
		fileLogger.stopTikerChan <- true
		close(fileLogger.stopTikerChan)
		close(fileLogger.logChan)
		fileLogger.lg = nil
		fileLogger.logFile.Close()
	}
}
複製代碼

關閉 Logger 以後,就是對一些字段的初始化賦值了,其中,f.date 設置爲了當前日期,後面判斷是否須要分割就以這個日期爲條件。f.isExistOrCreateFileDir() 則會判斷日誌目錄是否存在,若是不存在則會建立該目錄。接着,將目錄、設置的文件名和添加的 .log 文件擴展名拼接在一塊兒,拼接出文件的完整名字並打開文件。以後就是用該文件來初始化系統日誌對象 f.lg 了,將日誌消息寫入文件時其實就是調用該對象的 Output() 函數。後面啓動了兩個 goroutine:一個用來監聽 logChan,實現將日誌消息寫入文件;一個用來定時監聽文件是否須要分割,須要分割時則實現分割。

接着,咱們就來看看這兩個 goroutine 的實現:

func (f *FileLogger) logWriter() {
	defer func() { recover() }()

	for {
		str, ok := <-f.logChan
		if !ok {
			return
		}

		f.mu.RLock()
		f.lg.Output(2, str)
		f.mu.RUnlock()
	}
}

func (f *FileLogger) fileMonitor() {
	defer func() { recover() }()
	ticker := time.NewTicker(30 * time.Second)
	defer ticker.Stop()
	for {
		select {
		case <-ticker.C:
			if f.isMustSplit() {
				if err := f.split(); err != nil {
					Error("Log split error: %v\n", err)
				}
			}
		case <-f.stopTikerChan:
			return
		}
	}
}
複製代碼

能夠看到 logWriter() 循環從 logChan 通道讀取日誌消息,當通道被關閉則退出,不然就調用 f.lg.Output() 將日誌輸出。fileMonitor() 裏則建立了一個每隔 30 秒發送一次的 ticker,當從 ticker.C 接收到數據以後,就判斷是否須要分割,若是須要則調用分割函數 f.split()。而從 f.stopTikerChan 收到數據時,說明該定時器也要結束了。

接着,再來看看 isMustSplit()split() 函數了。isMustSplit() 很是簡單,就兩行代碼,以下:

func (f *FileLogger) isMustSplit() bool {
	t, _ := time.Parse(DATE_FORMAT, time.Now().Format(DATE_FORMAT))
	return t.After(f.date)
}
複製代碼

split() 則複雜些,首先對日誌要先加寫鎖,避免分割時依然有日誌寫入,接着對當前的日誌文件進行重命名備份,而後生成新文件用來記錄新的日誌消息,並將當前的全局日誌對象指向新文件、新日期和新的系統日誌對象。實現代碼以下:

func (f *FileLogger) split() error {
	f.mu.Lock()
	defer f.mu.Unlock()

	logFile := filepath.Join(f.fileDir, f.fileName)
	logFileBak := logFile + "-" + f.date.Format(DATE_FORMAT) + ".log"

	if f.logFile != nil {
		f.logFile.Close()
	}

	err := os.Rename(logFile, logFileBak)
	if err != nil {
		return err
	}

	t, _ := time.Parse(DATE_FORMAT, time.Now().Format(DATE_FORMAT))
	f.date = &t

	f.logFile, err = os.OpenFile(logFile, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0666)
	if err != nil {
		return err
	}

	f.lg = log.New(f.logFile, f.prefix, log.LstdFlags|log.Lmicroseconds)

	return nil
}
複製代碼

最後,就剩下定義一些接收日誌消息的函數了,實現都很簡單,以 Info() 爲例:

func Info(format string, v ...interface{}) {
	_, file, line, _ := runtime.Caller(1)
	if fileLogger.logLevel <= INFO {
		fileLogger.logChan <- fmt.Sprintf("[%v:%v]", filepath.Base(file), line) + fmt.Sprintf("[INFO]"+format, v...)
	}
}
複製代碼

Debug()、Warn()、Error() 等函數都相似的,照貓畫虎便可。

至此,咱們這個可以實現按日期分割日誌文件的日誌包就完成了,剩下的,就在對應須要添加日誌輸出的地方調用響應的日誌等級函數便可。

小結

本小結的核心實際上是增長了一個通用的日誌包,該日誌包不只能夠用在咱們的撮合引擎,也能用於其餘項目。若是再將其擴展,還能夠改成按其餘條件分割,好比按小時分割,或按文件大小分割。有興趣的小夥伴能夠本身去嘗試一下。

今日的思考題:要實現接口的請求和響應數據進行統一的日誌輸出,有哪些方案?

做者的我的博客

掃描如下二維碼便可關注公衆號(公衆號名稱:Keegan小鋼)

相關文章
相關標籤/搜索