用Node.js實現檔案迴圈覆寫

genetalks_大資料發表於2019-04-04

這次編寫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.loga.log.1。 其中a.log.1有20行資料,實際大小1kb,a.log只有1行資料。 雖然確實控制了檔案大小,但是會帶來兩個問題:

  1. 額外產生一個備份檔案,總佔用磁碟空間會超過檔案限制。
  2. 日誌檔案內容的大小是變動的,查詢日誌的時候很可能需要聯合備份檔案進行查詢(比如上面的情況日誌檔案只有1行資料)。

推測log4js的實現邏輯可能是下面這樣:

  1. 檢查日誌檔案是否達到限制大小,如果達到則刪除備份檔案,否則繼續寫入日誌檔案。
  2. 重新命名日誌檔案為備份檔案。

這顯然不能完全滿足需求。

字串替換?

如果要在記憶體中完成迴圈覆寫操作就比較簡單了,使用字串或Buffer的即可完成。

  1. 新增字串/Buffer長度,如果超過大小則擷取。
  2. 寫入並覆蓋日誌檔案。

但是有一個很大的問題:佔用記憶體。

比如限制檔案大小為1GB,有10個日誌檔案同時寫入,那麼至少佔用10GB記憶體空間!

記憶體可是比磁碟空間更寶貴的,如此明顯的效能問題,顯然也不是最優解決方式。

file roll

按照需求可以把實現步驟拆成兩步:

  • 追加最新的資料到檔案末尾。(Node.js的fs模組有相應函式)
  • 刪除檔案開頭超出限制部分。(Node.js沒有響應函式)

這兩步不分先後順序,但是Node.js沒有提供API來刪除檔案開頭部分,只提供了修改檔案指定位置的函式。

既然無法刪除檔案開頭部分內容,那麼我們就換個思路,只保留檔案末尾部分內容(不超出大小限制)。

什麼?這不是一個意思麼?

略有區別~

刪除是在原有檔案上進行的操作,而保留內容可以藉助臨時檔案來進行操作。

所以思路變成:

  1. 建立一個臨時檔案,臨時檔案的內容來自於日誌檔案。
  2. 往臨時檔案中增加資料。
  3. 將臨時檔案中符合檔案大小限制的內容,從後往前(採取偏移量的形式)進行讀取並複製到日誌檔案進行覆蓋。
  4. 為了不佔用額外的磁碟空間,寫操作完成後刪除臨時檔案。

這樣就不會出現像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);
    }
  }
}
複製程式碼

總結

完成這個功能有兩點感悟:

  1. 量變引起質變。當資料量變大時,很多簡單的處理方式就不可以用了,比如寫檔案,如果直接使用writeFile會佔用大量記憶體甚至有可能記憶體都不夠用。所以要通過合適的方式進行拆分,拆分過程中又會碰到各種問題,比如本文中擷取檔案內容的要求。
  2. 學會借力。君子性非異也善假於物也~當無法在單個點完成操作的時候可以藉助外部條件來實現,比如在本文中使用臨時檔案來儲存資料內容。

原文連結:tech.gtxlab.com/file-roll.h…


作者資訊:朱德龍,人和未來高階前端工程師。

相關文章