node.jsでは、ファイル操作によくfs
モジュールを使用しますが、高いスループットが必要な場合、IO関連の操作は非常に注意が必要です。例えば、次のコードはファイルを読み込む際によく使われます:
const fs = require('fs')
fs.readFile('./text.txt', (err, data) => {
if (!err) {
console.log(data.toString())
}
})
この方法は小さなファイルを読み込む場合には問題ありませんが、ファイルが大きすぎるとメモリのフットプリントが大きくなります。node.js
では、プラットフォームの整数ポインタの長さに基づいて最大バッファサイズが決まります。
console.log(buffer.constants.MAX_LENGTH)
// 4294967296 = 4GB
つまり、ファイルが4GBを超える場合、上記の方法は問題があり、node.js
はエラーをスローします。
ストリームを使用した操作
経験豊富な開発者の場合、ファイルサイズの問題を回避するために通常はfs.createReadStream
を使用してファイルを操作します。readFile
との最大の違いは、ストリームを使用することでファイルを複数のチャンクに分割し、各チャンクのサイズを数十KBにすることができる点です。ネットワークサービスの場合、ストリームを使用してデータを送信することは非常に自然なことであり、ストリームを使用することでブラウザが一部の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
を文字列として自然に処理するかもしれませんが、ストリームから返されるデータは実際にはbuffer
です。つまり、data += chunk
は実際にはchunk.toString()
を経て連結されているのです。ここまでくると、いくつかの開発者は警鐘が鳴るでしょう。
その通りです!文字列操作において最も重要なのはエンコーディングの問題です。デフォルトの設定では、バッファを文字列に変換する際にはUTF-8が使用されます。したがって、data += chunk
の方法では正しくない結果が生じる可能性があります。なぜなら、UTF-8では1バイト、2バイト、3バイト、4バイトのいずれかで1文字を表現することができるからです。表示のために、highWaterMark
を5バイトに設定しました。
// 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��
1つのチャンクは最大で5バイトなので、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()
})
これにより、エンコーディングの問題が回避されます。ただし、これは書くのが少し面倒です。単純な解析を行う場合は、readFile
やreadFileSync
を直接使用しても問題ありませんが、大きなファイルの解析や高スループットを必要とする場合は、これらの細部に注意する必要があります。(愚痴: ただし、この場合は他の言語を使うかもしれません)
結論
大きなファイルの操作では、メモリに直接一括で読み込みや書き込みを行わず、ストリームを使用することをおすすめします。また、転送時にはスループットを向上させるためにBuffer
を使用し、不要なエンコーディングを避けることも重要ですが、エンコーディング時の関連操作にも注意が必要です。