Kalan's Blog

Kalan 頭像照片,在淡水拍攝,淺藍背景

四零二曜日電子報上線啦!訂閱訂起來

本部落主要是關於前端、軟體開發以及我在日本的生活,也會寫一些對時事的觀察和雜感
本部落格支援 RSS feed(全文章內容),可點擊下方 RSS 連結或透過第三方服務設定。若技術文章裡有程式碼語法等特殊樣式,仍建議至原網站瀏覽以獲得最佳體驗。

目前主題 亮色

我會把一些不成文的筆記或是最近的生活雜感放在短筆記,如果有興趣的話可以來看看唷!

node.js 讀取檔案時的細節

在 node.js 當中透過 fs 模組來操作檔案是相當常見的操作,然而如果在吞吐量比較大的情況下,任何有關 IO 的操作都應該非常小心。舉例來說,這是讀取檔案時相當常見的程式碼:

const fs = require('fs')
fs.readFile('./text.txt', (err, data) => {
  if (!err) {
    console.log(data.toString())
  }
})

這個寫法在讀取小檔案時沒有什麼大問題,但如果檔案過大會造成記憶體的 footprint 負載龐大。node.js 當中會根據平台的整數指標長度來決定最大 Buffer 大小。

console.log(buffer.constants.MAX_LENGTH)
// 4294967296 = 4GB

也就是說檔案大於 4GB 時上面的寫法就有問題了,在 node.js 當中會直接噴錯。

透過 stream 操作

如果是比較有經驗的開發者,為了避免檔案大小造成的問題,通常都會用 fs.createReadStream 來操作檔案,跟 readFile 最大的差別在於透過 stream 的寫法我們可以將一大坨檔案拆成數個 chunk,每個 chunk 只有數十 KB 的大小。對於網路服務來說,透過 stream 來傳遞資料是相當自然的一件事,透過 stream 甚至能讓瀏覽器在接收到部分的 HTML 內容時就開始執行渲染,不用等到全部內容傳完。

透過 createReadStream 改寫會像下面這樣:

const fs = require('fs')
let data = ''

const stream = fs.createReadStream('./text.txt')

stream.on('data', chunk => {
  data += chunk
})

stream.on('end', () => {
  console.log(data)
})

乍看之下沒有什麼問題,程式也運作的很好,甚至可以套用在大多數的檔案處理當中。不過仔細看 data += chunk 會發現事有蹊蹺。有些開發者會將 chunk 很自然地當作字串來處理,但 stream 回傳的資料其實是 buffer,所以 data += chunk 其實背後是將 chunk.toString() 後才拼接起來的。看到這裡,應該某些開發者的警鈴大作了。

沒錯!在字串當中最重要的就是編碼問題,在預設的情況下 buffer 轉字串是用 utf-8。因此這樣 data += chunk 的寫法有可能導致不正確的結果,因為 UTF-8 可以分別用 1, 2, 3, 4 個 bytes 來表示一個字。為了方便展示,我把 highWaterMark 調整為 5 bytes。

// text.txt
這是一篇部落格PO文
const fs = require('fs')
let data = ''
const stream = fs.createReadStream('./text.txt', { highWaterMark: 5 })
stream.on('data', chunk => {
	data += chunk
})
stream.on('end', () => {
	console.log(data)
})

顯示結果為:

這��一���部落��PO��

因為一個 chunk 最多 5 bytes,因此在 chunk.toString() 有可能出現不正確的編碼,因為還沒有收到全部的資料。

正確拼接方式:Buffer.concat

如果要正確使用 Buffer 的話,最好用 node.js 提供的 API 操作再轉為字串,避免發生編碼的問題。

const fs = require('fs')
let data = []
let size = 0
const stream = fs.createReadStream('./text.txt', { highWaterMark: 5 })

stream.on('data', chunk => {
	data.push(chunk)
  size += chunk.length
})

stream.on('end', () => {
  Buffer.concat(data, size).toString()
})

這樣一來就避免了編碼的問題。不過這樣寫說真的還挺麻煩的,如果只是做簡單的分析,直接用 readFilereadFileSync 並沒有什麼不好,不過如果是要處理大檔案的分析或是高吞吐的話,這些就是不得不注意的細節了。(吐槽:不過這時候可能就是用其他語言來寫了)

結語

大檔案操作不要直接整坨進出記憶體,盡量以 stream 來操作,而在傳輸時盡量以 Buffer 來操作提高吞吐量,並避免不必要的編碼,但同時也要注意編碼時的相關操作。

上一篇

2021 雜談

下一篇

avr-libc 當中的ATOMIC_BLOCK

如果覺得這篇文章對你有幫助的話,可以考慮到下面的連結請我喝一杯 ☕️ 可以讓我平凡的一天變得閃閃發光 ✨

Buy me a coffee