這次編寫Node.js專案的時候用到了日誌模組,其中碰到了一個小問題。 這是一個定時執行可配置自動化任務的專案,所以輸出資訊會不斷增加,也就意味著日誌檔案會隨時間不斷增大。 如果對日誌檔案大小不加以控制,那麼伺服器的磁碟遲早會被撐滿。所以限制檔案大小是有必要的。 最理想的控制方式就是當檔案大小超過限制時,清除最先記錄的資料。類似一個FIFO的佇列。
# 刪除前面的資料
- 1 xxx
......
100 abc
# 檔案末尾追加資料
+ 101 xxxx
複製程式碼
log4js的file rolling
一提到記錄日誌很多Node.js開發者肯定會找到log4js,先來看看log4js是怎麼處理這個問題的。
log4js分為很多appenders(可以理解為記錄日誌的媒介),file rolling功能可以通過函式來進行配置。
file rolling功能有兩種方式:日期和檔案大小。 要控制檔案大小,當然選擇後者。 為了測試這個功能是否滿足我們要求,寫一段迴圈程式碼來寫日誌。
const log4js = require('log4js')
// 配置log4js
log4js.configure({
appenders: {
everything: {
type: 'file',
filename: 'a.log',
maxLogSize: 1000,
backups: 0
},
},
categories: {
default: {
appenders: ['everything'],
level: 'debug'
}
}
});
const log = log4js.getLogger();
for (let i = 0; i < 41; i++) {
const str = i.toString().padStart(6, '000000');
log.debug(str);
}
複製程式碼
執行之後生成兩個檔案a.log
和a.log.1
。
其中a.log.1
有20行資料,實際大小1kb,a.log
只有1行資料。
雖然確實控制了檔案大小,但是會帶來兩個問題:
- 額外產生一個備份檔案,總佔用磁碟空間會超過檔案限制。
- 日誌檔案內容的大小是變動的,查詢日誌的時候很可能需要聯合備份檔案進行查詢(比如上面的情況日誌檔案只有1行資料)。
推測log4js的實現邏輯可能是下面這樣:
- 檢查日誌檔案是否達到限制大小,如果達到則刪除備份檔案,否則繼續寫入日誌檔案。
- 重新命名日誌檔案為備份檔案。
這顯然不能完全滿足需求。
字串替換?
如果要在記憶體中完成迴圈覆寫操作就比較簡單了,使用字串或Buffer的即可完成。
- 新增字串/Buffer長度,如果超過大小則擷取。
- 寫入並覆蓋日誌檔案。
但是有一個很大的問題:佔用記憶體。
比如限制檔案大小為1GB,有10個日誌檔案同時寫入,那麼至少佔用10GB記憶體空間!
記憶體可是比磁碟空間更寶貴的,如此明顯的效能問題,顯然也不是最優解決方式。
file roll
按照需求可以把實現步驟拆成兩步:
- 追加最新的資料到檔案末尾。(Node.js的fs模組有相應函式)
- 刪除檔案開頭超出限制部分。(Node.js沒有響應函式)
這兩步不分先後順序,但是Node.js沒有提供API來刪除檔案開頭部分,只提供了修改檔案指定位置的函式。
既然無法刪除檔案開頭部分內容,那麼我們就換個思路,只保留檔案末尾部分內容(不超出大小限制)。
什麼?這不是一個意思麼?
略有區別~
刪除是在原有檔案上進行的操作,而保留內容可以藉助臨時檔案來進行操作。
所以思路變成:
- 建立一個臨時檔案,臨時檔案的內容來自於日誌檔案。
- 往臨時檔案中增加資料。
- 將臨時檔案中符合檔案大小限制的內容,從後往前(採取偏移量的形式)進行讀取並複製到日誌檔案進行覆蓋。
- 為了不佔用額外的磁碟空間,寫操作完成後刪除臨時檔案。
這樣就不會出現像log4js一樣日誌檔案內容不全的現象,也不會保留額外的臨時檔案。但是對IO的操作會增加~
對於寫操作可以採取tail
命令來實現,最終實現程式碼如下:
private write(name: string, buf?: Buffer | string) {
// append buf to tmp file
const tmpName = name.replace(/(.*\/)(.*$)/, '$1_\.$2\.tmp');
if (!existsSync(tmpName)) {
copyFileSync(name, tmpName);
}
buf && appendFileSync(tmpName, buf);
// if busy, wait
if (this.stream && this.stream.readable) {
this.needUpdateLogFile[name] = true;
} else {
try {
execSync(`tail -c ${limit} ${tmpName} > ${name}`);
try {
if (this.needUpdateLogFile[name]) {
this.needUpdateLogFile[name] = false;
this.write(name);
} else {
existsSync(tmpName) && unlinkSync(tmpName);
}
} catch (e) {
console.error(e);
}
} catch (e) {
console.error(e);
}
}
}
複製程式碼
總結
完成這個功能有兩點感悟:
- 量變引起質變。當資料量變大時,很多簡單的處理方式就不可以用了,比如寫檔案,如果直接使用
writeFile
會佔用大量記憶體甚至有可能記憶體都不夠用。所以要通過合適的方式進行拆分,拆分過程中又會碰到各種問題,比如本文中擷取檔案內容的要求。 - 學會借力。君子性非異也善假於物也~當無法在單個點完成操作的時候可以藉助外部條件來實現,比如在本文中使用臨時檔案來儲存資料內容。
原文連結:tech.gtxlab.com/file-roll.h…
作者資訊:朱德龍,人和未來高階前端工程師。