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()
})
これでエンコーディングの問題を回避できる。ただ、正直なところこう書くのはかなり面倒だ。単なる簡単な解析程度であれば、直接 readFile や readFileSync を使うのも決して悪くはない。しかし、大容量ファイルの解析や高スループットを処理しなければならない場合、これらは見落としてはならない細かな注意点となる。(ツッコミ:まあ、そういうケースならそもそも別の言語で書くかもしれないけれど)
まとめ
大容量ファイルを扱う際は、丸ごとメモリに載せるのではなく、極力Streamで操作すること。そして転送時には可能な限り Buffer のまま扱ってスループットを向上させ、不要なエンコードを避けること。ただし、エンコードが必要な場面ではその扱いに十分注意を払う必要がある。
関連記事
- Three.js で僕の部屋を表現する 僕が React Three Fiber を使って実際の自分の部屋をブラウザ上に再現し、実世界のオブジェクトを目録に見立て、空間の記憶を通してここ数年の生活と仕事について語った話。
- フロントエンドで画像を扱う際に注意すべきこと Jake Archibaldの記事を起点に、現代のレスポンシブ画像の書き方を整理する。なぜwidth/heightを付ける必要があるのか、CSSのaspect-ratioはいつ使うべきか、AVIFとWebPの選び方、そしてpicture/source/srcsetを使ったモバイル向け画像の切り替えについて。
- CSS field-sizing — たった1行のCSSでフォーム要素を自動リサイズする かつてtextareaの自動高さ調整は、JavaScriptでscrollHeightを監視するしかなかった。しかしCSSのfield-sizing: contentなら、わずか1行で代替でき、textarea、input、selectに対応している。本記事では従来のやり方のペインポイントと、field-sizingの使い方をまとめる。
- リンクの下線をもっと見栄え良くする:text-underline-offset デフォルトでは下線と文字が近すぎて、このスタイルを好まないデザイナーもいるし、僕自身もあまり綺麗ではないと感じていた。