Expressで1GB以上のAPIレスポンスを返すテクニック
scrapboxの
全部やるとメモリ使用量が超安定する
before / after
https://gyazo.com/5eff4d7dca17b4a63439dfd3a7779f86https://nota.gyazo.com/6ecb627aa37143bbe8d84c3ae2fce5d4
基本方針:メモリ上に大きな文字列(JSONなど)を保持しない
これがとても重要ですshokai.icon*5
それ以上のデータは扱えない
巨大な文字列ができる所
JSON.parseの引数やJSON.stringifyの返り値
streamの送信バッファ
これらを対策すると解決できる
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分まってダウンロード完了してからじゃないと処理できない
将来的にもっと大きなデータ量になっても対応できる余地がある
DBから1件readする毎にres.write(string)する方式は、リクエストが中断されても止まらない
streamが閉じたらres.end()する
code: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)してると、送信バッファが巨大になっていく
送信バッファ
res.writableLengthで確認できる
送信バッファが空くまでres.writeを止める
code:js
import delay from '@notainc/delay'
async function writeLine(line) {
while (!streamClosed && res.writableLength > 0) {
await delay(10)
}
res.write(line)
res.write('\n')
}