· 4分で読了

Node.jsでファイルを読み込む際の注意点

この記事は中国語から自動翻訳されたものです。翻訳によりニュアンスが失われている場合があります。

Node.jsにおいて fs モジュールを使ってファイルを操作するのは非常に一般的な処理だが、スループットが大きい状況下では、I/Oに関連するあらゆる操作に細心の注意を払う必要がある。例えば、以下はファイルを読み込む際によく見かけるコードだ。

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

この書き方は小さなファイルを読み込む分には大きな問題はないが、ファイルが大きすぎるとメモリのフットプリントへの負荷が膨大になってしまう。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が使われる。UTF-8では1文字を表すのに1、2、3、4バイトと可変長のバイト数が使われるため、このような data += chunk という書き方は不正確な結果を招く可能性がある。わかりやすく示すために、僕が 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つのchunkが最大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()
})

これでエンコーディングの問題を回避できる。ただ、正直なところこう書くのはかなり面倒だ。単なる簡単な解析程度であれば、直接 readFilereadFileSync を使うのも決して悪くはない。しかし、大容量ファイルの解析や高スループットを処理しなければならない場合、これらは見落としてはならない細かな注意点となる。(ツッコミ:まあ、そういうケースならそもそも別の言語で書くかもしれないけれど)

まとめ

大容量ファイルを扱う際は、丸ごとメモリに載せるのではなく、極力Streamで操作すること。そして転送時には可能な限り Buffer のまま扱ってスループットを向上させ、不要なエンコードを避けること。ただし、エンコードが必要な場面ではその扱いに十分注意を払う必要がある。

関連記事

他のトピックを探索