Node.js fetch の内部の話

前置き

この記事は リクルートエンジニアアドベントカレンダーの3日目の記事です。

Recruit Engineers Advent Calendar 2022 - Adventar

ちなみにココで書いたやつを一部抜粋させていただいております(ネタ切れにより過去投稿を利用してしまっております。。。すいません。。。)

www.codegrid.net

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つの区分けされた部分を意識する必要があります。

HTTP/1.1 プロトコル形式
HTTP/1.1 プロトコル形式

これらを区分けして中身を JavaScript で扱いやすくするためのものが「HTTPパーサー」です。 undici は主にこの「HTTPパーサー」で最適化が施されています。新しいクライアントを作るに当たって Node.js HTTP の既存ライブラリはイベントを発火しながら操作する事が前提となっていますが、余計な処理を省いて、完全に新規のライブラリとして書き換えたものとなっています。

HTTPクライアントのデータの流れ
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 パーサーを利用しています。

Node.jsとundiciのライブラリの中身の違い
Node.jsとundiciのライブラリの中身の違い

また、 wasm には simd 命令と呼ばれるベクトル演算に特化したCPU命令を試験的にサポートしています。これにより、並列実行されることを可能としており、パフォーマンスを飛躍的に伸ばしています。

ja.wikipedia.org

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 の仕様に改良しようとしています。

WinterCG
WinterCG

この fetch の共通仕様ができたら、それを実装する形で安定版としてリリースされる可能性があります。