前置き
この記事は リクルートエンジニアアドベントカレンダーの3日目の記事です。
Recruit Engineers Advent Calendar 2022 - Adventar
ちなみにココで書いたやつを一部抜粋させていただいております(ネタ切れにより過去投稿を利用してしまっております。。。すいません。。。)
fetch が Node v18 から試験的にサポートされた
ブラウザでは数年前から採用されていた HTTP リクエストを行う関数の fetch が global 空間に関数として作成されました。使うだけなら特に何のフラグもいりません、その代わり使うと Experimental であることを知らせる Warnings が出ます。
// fetch.mjs const response = await fetch('https://api.github.com/users/github'); const data = await response.json(); console.log(data);
$ node fetch.mjs (node:30917) ExperimentalWarning: The Fetch API is an experimental feature. This feature could change at any time (Use `node --trace-warnings ...` to show where the warning was created) { login: 'github', id: 9919, … created_at: '2008-05-11T04:37:31Z', updated_at: '2022-04-08T10:02:08Z' }
この対応により、 Node.js 内部では長らく未サポートだった fetch が追加され、 node-fetch
などのユーザーランドでの polyfill は不要になりました。一方で、 全ての fetch の仕様が実装されているわけではありません。そもそも、 browser 側の fetch には Node.js で実装しようにも Origin など、根本的な概念からして違うものもあり、100% 互換性のある実装は不可能です。そのため、試験的なサポートにまだとどまっています。
fetch の利用方法
fetch は上述した簡易的な GET リクエスト以外にもいくつかの利用方法があります。いくつか紹介します。
Agent をカスタム指定するケース
Node.js の fetch は Agent と呼ばれるクライアントを自分でカスタマイズする事が可能です。ブラウザの場合も細かくオプションに入れることで fetch の動きをカスタマイズできますが、 Node.js の場合はさらに強力に指定できます。例えばブラウザで HTTP/1.1 の場合、同じドメインに対して6接続までしか同時にアクセスできませんが、 Node.js の場合はこれを無制限にも、逆に1接続しか使わないようにもカスタマイズが可能です。他にも例えば、 HTTP 接続に対して keep-alive モードで接続することや keep alive の期間も細かく調整することが可能です。
import { Agent } from 'undici' const res = await fetch('https://example.com', { dispatcher: new Agent({ keepAliveTimeout: 10, keepAliveMaxTimeout: 10 }) }) const json = await res.json() console.log(json)
ただし、 一行目に書いてあるとおり、 fetch の dispatcher をカスタマイズするには undici
ライブラリが必要です。 undici
は後述しますが、 Node.js の HTTP クライアントの新しいライブラリです。将来的には Node.js の新しい HTTP クライアントに成り代わる予定のため、 undici
はインストールしなくても良くなる予定です。
Web Stream / Node.js Stream を相互に利用するケース
Node.js には現状2種類の Stream が存在しています。 Web Stream と呼ばれる Stream と Node Stream と呼ばれるクラシックな Stream です。どちらも利用用途は存在します。 Web Stream は Promise が基本となっているため、 async/await で処理の流れを書いているプロダクトと親和性が高いです。 Node Stream は既存のエコシステムが Node Stream ベースで記述されている場合に有効です。
Promise と for await で書くと以下のようになります。
import fs from 'node:fs/promises'; const response = await fetch('https://example.com'); const readableWebStream = response.body; // write mode で file open する。 const file = await fs.open("./index.html", "w"); // for await 文で読み出しながらファイル出力する for await (const c of readableWebStream) { await file.write(c); } // 忘れずにクローズする。 await file.close();
また、 Node Stream を使った例ではこのように書くことが可能です。
import { Readable } from 'node:stream'; import fs from 'node:fs'; const response = await fetch('https://example.com'); const readableWebStream = response.body; // Web Stream から Node Stream へ変換する。 const readableNodeStream = Readable.fromWeb(readableWebStream); // Node Streamとして書き出す。 readableNodeStream.pipe(fs.createWriteStream('./index.html'));
POST で async iterator を利用する事も可能です。
const data = { async *[Symbol.asyncIterator]() { yield 'hello' yield 'world' }, }; const res = await fetch('http://localhost:3000/', { body: data, method: "POST"});
fetch の中身である undici
について
Node.js では fetch は undici
というライブラリに依存しています。 undici
はイタリア語で 11 を示す言葉で、その名前の通り、 HTTP/1.1 のクライアントであることを示しています。逆に言うと、 HTTP/2 や HTTP/3 のクライアントとしてはまだ実装されていません。
そのため、 Node.js の fetch はブラウザの fetch とは異なり、 HTTP/2や3のネゴシエーションを行いません。こう聞くとなぜそんな中途半端な状態なのだろうと思うかもしれませんが、インターネット上に公開されている API ならまだしも、イントラネット内にあるバックエンドサーバへのリクエストでは HTTP で接続することも多く、 HTTPS が基本となる HTTP/2 や HTTP/3 は後で実装する運びになっています。
それよりもNode.js に新たな HTTP クライアントを用意し、インタフェースを他のランタイム (Denoなど) と揃えることをまずはターゲットにしたからという理由です。 ただそれ以外の部分では面白い実装になっている所も多いです。今回はそんな undici についても解説を進めます。
HTTP/1.1 クライアントの中身
HTTP クライアントがどう作られているかをそもそも知らないという方も多いと思うので、簡単に解説します。ここでは undici の内容についてということで HTTP/1.1 に限定した話をします。
HTTP クライアントは TCP と呼ばれるプロトコルからデータを受け取っています。接続方式とデータの受け渡しを決めているのが TCP で、HTTPはそのデータがどういう意味で使われているのかのセマンティクスを定義しています。特に開始行、ヘッダ、ボディ、トレーラーと言った4つの区分けされた部分を意識する必要があります。
これらを区分けして中身を JavaScript で扱いやすくするためのものが「HTTPパーサー」です。 undici は主にこの「HTTPパーサー」で最適化が施されています。新しいクライアントを作るに当たって Node.js HTTP の既存ライブラリはイベントを発火しながら操作する事が前提となっていますが、余計な処理を省いて、完全に新規のライブラリとして書き換えたものとなっています。
HTTP パーサーが WebAssembly になっている。
Node.js の HTTP パーサーはかなりユニークな仕組みになっています。このHTTPパーサーは TypeScript で書かれているのですが、コンパイルされて JavaScript になるのではなく、 C言語に変換されます。 llhttp というライブラリで公開されているので、内容を見てみたい方は下記の GitHub のリンクを見ていただけると良いでしょう。
https://github.com/nodejs/llhttp
Node.js は llhttp が出力した C 言語をそのままネイティブモジュールとして読み込んで使っています。 undici はここに一手間加えています。 C言語から wasm に変換した上で wasm として HTTP パーサーを利用しています。
また、 wasm には simd 命令と呼ばれるベクトル演算に特化したCPU命令を試験的にサポートしています。これにより、並列実行されることを可能としており、パフォーマンスを飛躍的に伸ばしています。
Connections 1
Tests | Samples | Result | Tolerance | Difference with slowest |
---|---|---|---|---|
http - no keepalive | 15 | 4.63 req/sec | ± 2.77 % | - |
http - keepalive | 10 | 4.81 req/sec | ± 2.16 % | + 3.94 % |
undici - stream | 25 | 62.22 req/sec | ± 2.67 % | + 1244.58 % |
undici - dispatch | 15 | 64.33 req/sec | ± 2.47 % | + 1290.24 % |
undici - request | 15 | 66.08 req/sec | ± 2.48 % | + 1327.88 % |
undici - pipeline | 10 | 66.13 req/sec | ± 1.39 % | + 1329.08 % |
Connections 50
Tests | Samples | Result | Tolerance | Difference with slowest |
---|---|---|---|---|
http - no keepalive | 50 | 3546.49 req/sec | ± 2.90 % | - |
http - keepalive | 15 | 5692.67 req/sec | ± 2.48 % | + 60.52 % |
undici - pipeline | 25 | 8478.71 req/sec | ± 2.62 % | + 139.07 % |
undici - request | 20 | 9766.66 req/sec | ± 2.79 % | + 175.39 % |
undici - stream | 15 | 10109.74 req/sec | ± 2.94 % | + 185.06 % |
undici - dispatch | 25 | 10949.73 req/sec | ± 2.54 % | + 208.75 % |
https://github.com/nodejs/undici#benchmarks より抜粋 (2022年10月時点)
ただし、この結果はまだ実験中の WASM SIMD 呼び出しを利用した結果になります。実際にこのベンチマーク結果を得るためには「 --experimental-wasm-simd 」というフラグを付けて利用する必要があります。
# WASM SIMD を有効化しないといけない $ node --experimental-wasm-simd fetch.js
fetch の今後
fetch は WINTER CG と呼ばれる団体でランタイムエンジンそれぞれの仕様を固めようとしています。これにより、ブラウザの fetch ではなく、 JS ランタイムエンジンの fetch として仕様が定義されることになる予定です。もう少し噛み砕いて言うと、 Node.js / Deno / Bun / Cloudflare Workers などのランタイムエンジン上で共通の仕様を定義しようとしており、ブラウザの fetch の仕様に依存しない新しい fetch の仕様に改良しようとしています。
この fetch の共通仕様ができたら、それを実装する形で安定版としてリリースされる可能性があります。