generated at
Expressで1GB以上のAPIレスポンスを返すテクニック

以下で使っているExpressの技をまとめた
HelpfeelCosenseの間のデータ連携
scrapboxの

全部やるとメモリ使用量が超安定する
before / after
サーバーの数がCOVID-19以前に戻った


基本方針:メモリ上に大きな文字列(JSONなど)を保持しない
これがとても重要ですshokaishokaishokaishokaishokai
node.jsが確保できるメモリ量は--max-old-space-sizeで指定できるが、それも上限2.5GBまで
それ以上のデータは扱えない
オーバーするとJavaScript heap out of memoryが発生し、プロセスが強制終了してしまう
巨大な文字列ができる所
JSON.parseの引数やJSON.stringifyの返り値
streamの送信バッファ
これらを対策すると解決できる


chunkに分割して少しずつ送信する
BAD
res.json(object) はダメ
大きな1つのJSON stringが作られてしまう
GOOD
JSONの中の小さなパーツを作って送出していく
HTTPのコネクションはつなぎっぱなし
DBから1件readする毎に、 res.write(string) で少しずつレスポンス
最後に res.end() で閉じる
詳しい解説


stream/pipeline処理しやすいデータフォーマットを採用する
chunk分割はJSONでも可能だが、もっと良い方法もある
受信側にも優しい
JSONは最後までダウンロードしないとparseできない
事前にschemaが決まっていればparseできなくもないが
行指向データなら1行ダウンロードする毎にparseできる
parse前の文字列の方は破棄できる
ダウンロードとparseとその後の処理を並行して実行できる
JSON Linesならダウンロードした部分から処理を開始できる
JSONだと30分まってダウンロード完了してからじゃないと処理できない
行指向フォーマットはMapReduceとも相性が良いshokai
将来的にもっと大きなデータ量になっても対応できる余地がある


クライアント側がダウンロードをキャンセルするとstream.Writableが切断されるので、すぐにexpress handlerをcloseする
DBから1件readする毎に res.write(string) する方式は、リクエストが中断されても止まらない
streamが閉じたら res.end() する
js
router.get('/export', (req, res) => { let streamClosed = false res.once('error', () => (streamClosed = true)) res.once('close', () => (streamClosed = true)) req.once('close', () => (streamClosed = true)) // 略 if (streamClosed) return res.end()
クライアントが切断した時、 req res のcloseイベントはどちらも同時に発火する
なので片方で十分なはずだが、念の為両方を見ておく


Expressの送信バッファが詰まったらDBからのreadを一時停止する
DB→appの方がapp→インターネットよりも当然速い
最速でDBからreadして res.write(string) してると、送信バッファが巨大になっていく
メモリを逼迫し、JavaScript heap out of memoryを発生させる
送信バッファ
res.writableLength で確認できる
送信バッファが空くまで res.write を止める
js
import delay from '@notainc/delay' async function writeLine(line) { while (!streamClosed && res.writableLength > 0) { await delay(10) } res.write(line) res.write('\n') }
マイコンのレジスタみたいな考え方をしている