😄

なぜ非同期処理? Node.jsの実装から読み解く Fetch API の response.json()

2024/09/08に公開

皆さんはなぜ Fetch API の res.json() が非同期処理なのか、不思議に思ったことはありませんか?

const response = await fetch("https://jsonplaceholder.typicode.com/todos/");
console.log(response.headers.get("content-type")); // application/json; charset=utf-8
const jsonData = await response.json(); // なぜ非同期処理なのか?

私は最初、JSON パースの処理を Web API にまかせているから非同期になっているのだと思い込んでいました。しかし、実際にはそれが理由ではありません。

この記事では、Node.js の実装を紐解きながら、response.json() が非同期である理由とその内部処理について理解を深めていきます。

なぜ response.json() は非同期処理なのか?

API から返ってくる JSON や HTML は、一度に受信しているように見えますが、実際には小さな断片(チャンク)に分けて送られています。レスポンスボディは ReadableStream として送られてきて、これを扱うために非同期処理が必要となります。

例えば、fetch() の最初の段階(①)では、HTTP リクエストがサーバーに送信され、レスポンスのヘッダーが受信されています。この時点で、response.headers からヘッダー情報は取得可能ですが、レスポンスのボディはまだ完全に受信されていません。ボディは小さなチャンクに分けて送られてくるため、全てのデータが揃うまで待つ必要があります。

const response = await fetch("https://jsonplaceholder.typicode.com/todos/"); // ①
console.log(response.headers.get("content-type")); // application/json; charset=utf-8
const jsonData = await response.json();

そのため、response.json() は、全てのチャンクを受け取ってから JSON パースを行うため、非同期処理が必要になります。これは response.text() など他のボディ処理でも同様です。

Node.js での resopnse.json() の実装を紐解く

では、response.json() がどのように実装されているのか、Node.js を例にして見ていきましょう。

response.json() に関する処理は bodyMixinMethods に記述されています。
https://github.com/nodejs/node/blob/main/deps/undici/src/lib/web/fetch/body.js#L348-L352

consumeBody では、受け取ったデータが正しい形式かどうか確認したのちに fullyReadBody を呼び出しています。
https://github.com/nodejs/node/blob/main/deps/undici/src/lib/web/fetch/body.js#L465-L471

fullyReadBody では、ボディのストリームのリーダを取得し、readAllBytes を呼び出しています。
https://github.com/nodejs/node/blob/main/deps/undici/src/lib/web/fetch/util.js#L1032-L1062

肝になるのが readAllBytes です。readAllBytes では、ストリームからデータをチャンクごとに非同期で読み込み、すべてのバイトを連結して返しています。
https://github.com/nodejs/node/blob/main/deps/undici/src/lib/web/fetch/util.js#L1107-L1131

具体的には、reader.read() によってデータを一度に全てではなく、小さなデータの塊(チャンク)ごとに受け取ります。このチャンクが終わる(doneがtrueになる)まで繰り返し、すべてのチャンクが収集されると、Buffer.concat() を使って結合し、最終的なデータが完成します。

このように、response.json() は、ReadableStream として送られてくる response.body を全て読み込み、テキストとしてデコードして、JSON パースしているため、非同期処理になるわけです。

まとめ

このように、response.json() が非同期処理である理由は、データがサーバーから一度に届くのではなく、ReadableStream としてチャンク(小さな断片)ごとに送られてくるためです。Fetch API はこの ReadableStream からすべてのチャンクを受け取り終わるまで待機し、その後に JSON パースを行います。この仕組みによって、ストリーム処理を活用し、大規模なデータや継続的に送信されるデータを効率的に扱うことが可能になっています。

※ 補足
deno で簡単な response.json() の自作もしてみました!
https://github.com/ryomaejii/build-your-own-response-json

Discussion