2020/10/20にNode.js v15がリリースされました 🎉
色々新機能や破壊的変更が加わっているので、詳しくは公式のリリースノート等をご参照ください。
— Node.js v15.0.0 is here!. This blog was written by Bethany… | by Node.js | Oct, 2020 | Medium
また、Node.jsのコラボレータによる日本語のわかりやすい記事もあるのであわせてご覧ください。
まとめは以上にして本題です。本記事はv15の変更点まとめを目的とした記事ではなく、v15にて新しく追加されたQUICを用いてシンプルなHTTP/3サーバを実装してHTTP over QUIC(HTTP/3)の使用感を掴むことを目的としています。この記事を読み終えると以下のものが手に入ります。
まずQUICおよびHTTP/3について軽くおさらいし、QUICモジュールを利用する環境構築を構築、HTTP/3サーバのデモコードと簡単な説明をして、最後にcURLを用いて動作確認します。仕様の詳細にはあまり触れずに実装するために必要な情報にフォーカスします。
当記事ではNode.jsのv15.0.1を前提にコードを書いています。またQUICは登場したばかりで現在のStability indexはStability: 1 - Experimental
、しかもHTTP over QUICについてはUndocumentedです。
おそらくQUICをラップしたHTTP/3用の高レベルのAPIが今後登場するでしょうし、後方互換のない破壊的変更が予告なく加わる可能性もあります。ここで得た知識は陳腐化する前提でエッジなAPIをシュッと試したい方は読み進めてもらえればと思います。
なお、当記事ではこれらを前提に書いています。
真面目に解説するとボリュームがありすぎるので参考になったリンクを掲載します。概論をおさえておくと以後の理解がスムーズになると思います。
少なくとも注意すべきことは、**QUIC=HTTP/3ではないということです。QUICを利用した新しいHTTPの仕様がHTTP/3です。**QUICはHTTP以外のプロトコルでも使用できるよう設計されています。
また、**Node.jsのQUICにおいても同様です。**QUICを扱う=HTTP/3を扱うではありません。Node.jsで提供されたQUIC APIはQUICそのものを扱う低レイヤのAPIです。そのためQUICを用いてHTTP/3サーバを実装するというイメージを持っておいてください。ここを混同するとドキュメントの読み方やトラブルシューティング時に混乱します。
実装を始める前に、既存のHTTP/3のサイトで動作確認します。ユーザエージェントがHTTP/3に対応しているかどうかはこちらのサイトから確認できます。
現時点ではHTTP/3のブラウザの対応状況はいまひとつです。iOS Safariでフラグつきでサポートされている、Google Chromeにてサポートされているとの情報を得ましたが、私の環境ではどちらも動作しませんでした。
https://caniuse.com/?search=quic
本記事では動作確認にcURLを利用します。cURLでHTTP/3を利用するためにcURLをソースコードからビルドする必要があり、cURL公式のDockerイメージにもHTTP/3に対応したタグがないためHTTP/3対応したビルドを配布されているcurlのHTTP/3通信をDocker上で使ってみる - Qiitaを利用させてもらいます。試しに http3.is に対してリクエストを送った結果の抜粋がこちらです。
$ docker run -it --rm ymuski/curl-http3 curl -v https://http3.is --http3
* Trying 199.232.233.77:443...
* Sent QUIC client Initial, ALPN: h3-29,h3-28,h3-27
* Connected to http3.is (199.232.233.77) port 443 (#0)
* h3 [:method: GET]
* h3 [:path: /]
* h3 [:scheme: https]
* h3 [:authority: http3.is]
* h3 [user-agent: curl/7.73.0-DEV]
* h3 [accept: */*]
* Using HTTP/3 Stream ID: 0 (easy handle 0x55f88c209a20)
> GET / HTTP/3
> Host: http3.is
> user-agent: curl/7.73.0-DEV
> accept: */*
>
< HTTP/3 200
...
Your browser does not support the video tag, but it does support HTTP/3!
...
* Connection #0 to host http3.is left intact
HTTP/3に対応してる旨のHTMLが返ってきました。出力からHTTP/3で通信されているのがわかります。 次にサーバを実装して、このcurlコマンドを使って動作確認をします。
QUICはExperimentalな機能のためQUICを使用するにはフラグをつけてNodeをビルドし直す必要があります。よくあるExperimentalな機能とは違い--experimental-...
などのフラグをnodeコマンドに渡しても動作しません。また執筆時点(2020/10/22)ではQUICに対応したDockerイメージもありません。手元でビルドするのは少しハードルが高いかもしれませんが、やることは単にフラグをつけていつも通りNode.jsをビルドするだけです。
$ cd /path/to/nodejs/node
$ ./configure --experimental-quic
$ make -j4 # コア数を指定すると早くなります
$ ./node -p -e "require('net').createQuicSocket"
[Function: createQuicSocket] # <-- 表示されたらOK
$ node -p -e "require('net').createQuicSocket"
undefined # <-- グローバルなnodeだとundefinedになる
require('net').createQuicSocket
が存在していれば成功です。**以後、./node
と書いてある場合はいまビルドしたNode.jsを実行するという意味を持ちます。**グローバルにインストールされているnodeコマンドを起動しないようご注意ください。
QUICを使用するにはlocalhostであっても証明書が必須です。適当に自己証明書を作成しておきます。
$ mkdir .certs
$ cd .certs
$ openssl genrsa 2024 > server.key
$ openssl req -new -key server.key -subj "/C=JP" > server.csr
$ openssl x509 -req -days 3650 -signkey server.key < server.csr > server.crt
$ cd -
$ ls .certs
server.crt server.csr server.key
環境構築が終わったので本題です。さっそくサーバを実装します。
今回は静的なファイルを配信するサーバを実装します。このように起動できるhttp3-serve.jsを実装します。
PORT=8888 PUBLIC_ROOT=$PWD ./node http3-serve.js
パラメータは2つです。設定値はすべて環境変数で与えます。
content-length
とcontent-type
ヘッダも返すいよいよ実装です。先にコードを載せます。このjsはES Modules形式で記述しています。package.jsonに"type": "module"
フィールドが設定されている前提で読んでください。
// [数字] ...
と書いてあるところを順に触れていきます。
import fs from 'fs'
import fsPromises from 'fs/promises'
import path from 'path'
import { createQuicSocket } from 'net'
import { lookup } from 'mime-types'
const { PORT, DOCUMENT_ROOT } = process.env
const key = await fs.readFileSync('./.certs/server.key')
const cert = await fs.readFileSync('./.certs/server.crt')
// [1] QUICソケットの初期化
const server = createQuicSocket({
endpoint: { port: PORT },
server: { key, cert, alpn: 'h3-29' },
})
server.on('session', async (session) => {
// [2] session, streamイベント
session.on('stream', (stream) => {
// [3] リクエストヘッダを受け取る
stream.on('initialHeaders', (rawHeaders) => {
const headers = new Map(rawHeaders)
const url = new URL(headers.get(':path'), 'https://localhost')
const requestPath = path.join(DOCUMENT_ROOT, url.pathname)
fsPromises
.stat(requestPath)
.then((stats) => {
if (!stats.isFile()) {
// [4] レスポンスヘッダを返す
stream.submitInitialHeaders({
':status': '403',
})
stream.end()
return
}
stream.submitInitialHeaders({
':status': '200',
'content-length': stats.size,
'content-type': lookup(requestPath) || 'application/octet-stream',
})
// [5] レスポンスボディを返す
fs.createReadStream(requestPath).pipe(stream)
})
.catch((e) => {
stream.submitInitialHeaders({
':status': '404',
})
stream.end()
})
})
})
})
await server.listen()
console.log(`The socket is listening on :${PORT}`)
// [1] QUICソケットの初期化
const server = createQuicSocket({
endpoint: { port: PORT },
server: { key, cert, alpn: 'h3-29' },
})
特にserver.alpn
が重要です。単にQUICを扱うなら任意の値が指定可能ですが、HTTP/3のサーバを立てるなら値を(現バージョンにおいては)h3-29
にする必要があります。
ALPN identifiers that are known to Node.js (such as the ALPN identifier for HTTP/3) will alter how the QuicSession and QuicStream objects operate internally, but the QUIC implementation for Node.js has been designed to allow any ALPN to be specified and used.
createQuicSocketに指定可能な全てのオプションは公式ドキュメントをご確認ください。
session
, stream
イベントserver.on('session', async (session) => {
// [2] session, streamイベント
session.on('stream', (stream) => {
QUICのセッションが開始されたときにsession
イベントが呼び出されます。コールバックの引数はQuicSessionのインスタンスです。QuicSessionが確立した後にクライアントがストリームを作成した時にstream
イベントが呼び出されます。コールバックの引数はQuicStreamのインスタンスです。基本的に1リクエストにつき1回stream
イベントが呼び出されます。
QuicSessionは以下の4つの状態のいずれかをとります。このうちInitial
に相当するのがsession
イベントです。Ready
に相当するのが次に紹介するstream
イベントです。
- Initial - Entered as soon as the QuicSession is created
- Handshake - Entered as soon as the TLS 1.3 handshake between the client and server begins. The handshake is always initiated by the client
- Ready - Entered as soon as the TLS 1.3 handshake completes. Once the QuicSession enters the Ready state, it may be used to exchange application data using QuicStream instances
- Closed - Entered as soon as the QuicSession connection has been terminated
— Client and server QuicSessions | Node.js v15.0.1 Documentation
QuicStreamはstream.Duplexを継承しており、リクエストボディはstream
から読み込めます。
// [3] リクエストヘッダを受け取る
stream.on('initialHeaders', (rawHeaders) => {
QuicSessionが確立した後にクライアントがストリームを作成し、リクエストヘッダが届いた時に呼び出されます。rawHeadersにはこのような値が格納されています。
[
[ ':method', 'GET' ],
[ ':path', '/hoge' ],
[ ':scheme', 'https' ],
[ ':authority', 'host.docker.internal:8080' ],
[ 'user-agent', 'curl/7.73.0-DEV' ],
[ 'accept', '*/*' ]
]
:
からはじまるヘッダは擬似ヘッダ(Pseudo-Header Fields)と呼ぶそうです。よく利用するであろう擬似ヘッダは:method
と:path
です。名前から察する通り:method
はHTTPメソッド、:path
はリクエストされたパス(クエリ文字列含む)が格納されています。リクエストボディが不要ならこの時点でリクエストを処理できます。
他にもヘッダに関するメソッド、イベントがありますが、使い分けは以下の通りです。
- Informational Headers: Any response headers transmitted within a block of headers using a 1xx status code
- Initial Headers: HTTP request or response headers
- Trailing Headers: A block of headers that follow the body of a request or response
- Push Promise Headers: A block of headers included in a promised push stream
// [4] レスポンスヘッダを返す
stream.submitInitialHeaders({
':status': '403',
})
stream.end()
stream
イベントで受け取ったQuicStreamに対してsubmitInitialHeaders
メソッドをコールすることでレスポンスヘッダを返せます。
HTTPステータスコードは:status
という擬似ヘッダでセットします。
ボディを返さずにレスポンスを終了する場合はend
でストリームを閉じます。
// [5] レスポンスボディを返す
fs.createReadStream(requestPath).pipe(stream)
QuicStreamはストリームです。リクエストボディの読み取りとレスポンスボディの書き込み両方に対応するためにstream.Duplexを継承しています。
ファイルの内容をレスポンスするならReadableなストリームを作りパイプするだけです。ストリームではない文字列を書き込む場合はstream.write('...')
などを使用できます。この辺はストリームの基礎的な話なので詳しくは割愛します。
駆け足ですが解説は以上です。次に作ったサーバの動作確認をします。
最後に起動したサーバの動作確認をします。サーバが正常に起動すると以下のような出力になると思います。
$ PORT=8080 DOCUMENT_ROOT=$PWD ./node http3-serve.js
The socket is listening on :8080
(node:10968) ExperimentalWarning: The QUIC protocol is experimental and not yet supported for production use
(Use `node --trace-warnings ...` to show where the warning was created)
curlコマンドを利用していくつかリクエストを飛ばしてみます。host.docker.internal
はホスト側のlocalhostを参照するDockerの特殊なホスト名です。Linuxの方は適宜読み替えてください。
まずは正常系です。ファイルが存在しているので200が返されます。
$ echo 'Hello world' > hello.txt # サーバを起動したディレクトリで適当なファイルを作る
$ docker run -it --rm ymuski/curl-http3 curl -v 'https://host.docker.internal:8080/hello.txt' --http3
* Trying 192.168.65.2:8080...
* Sent QUIC client Initial, ALPN: h3-29,h3-28,h3-27
* Connected to host.docker.internal (192.168.65.2) port 8080 (#0)
* h3 [:method: GET]
* h3 [:path: /hello.txt]
* h3 [:scheme: https]
* h3 [:authority: host.docker.internal:8080]
* h3 [user-agent: curl/7.73.0-DEV]
* h3 [accept: */*]
* Using HTTP/3 Stream ID: 0 (easy handle 0x562874d14a40)
> GET /hello.txt HTTP/3
> Host: host.docker.internal:8080
> user-agent: curl/7.73.0-DEV
> accept: */*
>
< HTTP/3 200
< content-length: 12
< content-type: text/plain
<
Hello world
* Connection #0 to host host.docker.internal left intact
ファイルが存在しないパスにリクエストします。404が返されます。
$ docker run -it --rm ymuski/curl-http3 curl -v 'https://host.docker.internal:8080/xxx' --http3
* Trying 192.168.65.2:8080...
* Sent QUIC client Initial, ALPN: h3-29,h3-28,h3-27
* Connected to host.docker.internal (192.168.65.2) port 8080 (#0)
* h3 [:method: GET]
* h3 [:path: /xxx]
* h3 [:scheme: https]
* h3 [:authority: host.docker.internal:8080]
* h3 [user-agent: curl/7.73.0-DEV]
* h3 [accept: */*]
* Using HTTP/3 Stream ID: 0 (easy handle 0x56541c3d9a30)
> GET /xxx HTTP/3
> Host: host.docker.internal:8080
> user-agent: curl/7.73.0-DEV
> accept: */*
>
< HTTP/3 404
* Connection #0 to host host.docker.internal left intact
最後にリクエストしたパスがファイルではない場合のテストです。403が返されます。
$ mkdir dir # サーバを起動したディレクトリでディレクトリを作成する
$ docker run -it --rm ymuski/curl-http3 curl -v 'https://host.docker.internal:8080/dir' --http3
* Trying 192.168.65.2:8080...
* Sent QUIC client Initial, ALPN: h3-29,h3-28,h3-27
* Connected to host.docker.internal (192.168.65.2) port 8080 (#0)
* h3 [:method: GET]
* h3 [:path: /dir]
* h3 [:scheme: https]
* h3 [:authority: host.docker.internal:8080]
* h3 [user-agent: curl/7.73.0-DEV]
* h3 [accept: */*]
* Using HTTP/3 Stream ID: 0 (easy handle 0x562fe362ba30)
> GET /dir HTTP/3
> Host: host.docker.internal:8080
> user-agent: curl/7.73.0-DEV
> accept: */*
>
< HTTP/3 403
* Connection #0 to host host.docker.internal left intact
本記事では本当に最低限の処理しかしてないので、あとはドキュメントやソースコード、QUIC、HTTP/3の仕様書を読みながらいろいろ試してみてください。Node.jsの内部実装の話や、0-RTT・Server pushなどのHTTP/3の他の機能を試す記事も書けたら書こうと思います。ただ、冒頭にも書いた通り現時点ではまだUndocumentedなAPIなので深く使い込むのは時期尚早だと思います。今後QUICをラップした高レベルのAPIがおそらく登場するので、しっかり学ぶのはそれを待ってからでも遅くないと思います。
Node.jsのコミュニティはオープンで誰でも開発・議論に参加できます。例えばドキュメントの誤字脱字やAPIに対するフィードバック、仕様と実装が乖離しているなどの何かしらの問題を見つけたらチャンスと思ってコントリビュートしてもらえればと思います。興味のある方はnodejs/nodeリポジトリからぜひ参加してみてください。
これらの一次情報が参考になりました。
これらの二次情報も参考になりました。