先日、Twitterにて素晴らしいツイートを見かけました。
うおおお。@usualoma さんの神PRをマージしたぞ。
— Yusuke Wada (@yusukebe) 2023年11月27日
これでHonoがNode.js上で3倍近く速くなった!これはFastifyよりかは少し遅いけど、他のフレームワークと比べると十分速いレベルで、Node.jsの世界においてもHonoは第一選択肢に入ってもいい存在になった。https://t.co/mcAmUsQcUZ pic.twitter.com/pZiKy913rv
このツイートはHono
製作者のYusuke Wadaさんのもので、どうやらNode.jsランタイム上でHono
のパフォーマンスがめちゃくちゃ改善されたとのこと。この対応はTaku Amanoさんという方が行われたそうで、神PRと称賛されていました。
現在、世界中で注目されているOSSのHono
に取り込まれた神PRとは、一体、どのような内容なのでしょうか。
気になってしょうがないので実際に神PRの内容を見てみたいと思います。Hono
全体のアーキテクチャやWEBフレームワークの作りに詳しいわけではないので、理解できる情報に限界はありますが、チャレンジしてみます。
対応についての全体像はPRを出されたTaku Amanoさんが解説されていました。
RequestとResponseのオブジェクトの初期化が重すぎるのを、prototypeなどの昔ながらの技術を使い置き換えたということで、身も蓋もないものではありますが、とはいえほとんどのユースケースで速くなるし問題もおきないと思います。(問題が起きないのは既存の資産のおかげ)https://t.co/XmENromyyz
— Taku Amano (@usualoma) 2023年11月27日
ふむふむ...。
このツイートを頼りにRequestとResponse周りを中心に見ていきたいと思います。
PRについて
まず最初にPRを見て驚いたのはConverstationの数です。
Node.jsランタイム上でのHono
のパフォーマンス改善の是非、方針について深く議論されていました。元のイメージでは完成したPRを出してマージされたものだと思っていたのですが、そうではありませんでした。
Taku Amanoさんが提案PRを出した後、Yusuke Wadaさんを中心に議論を重ねた後、マージされた状態のPRになっていったようです。まさにOSSの理想系のような進め方、感服しました。
コードを見てみる
全体的に目を通して、今回のNode.jsランタイム上でのパフォーマンス改善のコア部分だと判断した箇所を順に紹介したいと思います。
内容を省略したり、簡略化する部分もあるため、気になった方はぜひ自分でPRを見てみてください。
一番、目立つ変更はglobal
オブジェクトにResponse
を追加している処理です。
src/global.ts
import { Response } from './response' Object.defineProperty(global, 'Response', { value: Response, })
これで以降、global.Response
の参照が可能になります。
console.log('global.Response: ', global.Response) // global.Response: undefined Object.defineProperty(global, 'Response', { value: 'Response', }) console.log('global.Response: ', global.Response) // global.Response: Response
Responseは何者だ
global.Response
に追加されているResponse
は一体、どんな値なのでしょうか。
PRの中で新しくresponse.ts
ファイルが追加されており、そのファイルからインポートしているようです。ということでresponse.ts
を見てみます。
src/response.ts
const responseCache = Symbol('responseCache') export const cacheKey = Symbol('cache') export const globalResponse = global.Response export class Response { #body?: BodyInit | null #init?: ResponseInit; // @ts-ignore private get cache(): typeof globalResponse { delete (this as any)[cacheKey] return ((this as any)[responseCache] ||= new globalResponse(this.#body, this.#init)) } constructor(body?: BodyInit | null, init?: ResponseInit) { this.#body = body this.#init = init if (typeof body === 'string' || body instanceof ReadableStream) { let headers = (init?.headers || { 'content-type': 'text/plain;charset=UTF-8' }) as | Record<string, string> | Headers | OutgoingHttpHeaders if (headers instanceof Headers) { headers = buildOutgoingHttpHeaders(headers) } (this as any)[cacheKey] = [init?.status || 200, body, headers] } } }
Response
の正体は定義されたResponse
クラスでした。
このクラスはインスタンス作成時にbody
とinit
の2つの値を受け取って、それぞれをクラス変数に代入しています。
その後、受け取ったbody
の型に応じて、ヘッダー情報を書き換えた後、クラス変数に対してcacheKey
と「ステータス・ボディ・ヘッダー」を保持している配列をそれぞれキー・バリューにして保持しているようです。
this
に対してキー・バリューを設定できることを知らなかったです。
const cacheKey = Symbol('cache') class Sample { constructor() { this[cacheKey] = [200, 'body', 'headers']; } getCache() { return this[cacheKey]; } } const ins = new Sample(); console.log(ins.getCache()); // [200, 'body', 'headers']
クラス変数はどこで使っている?
では、このクラス変数はどこで使われているのかというとプライベート関数のcache
の内部です。
クラス内部でcach
関数を呼び出している箇所はありませんが、src/response.ts
の下部でcache
関数を呼び出している処理があります。
src/response.ts
;[ 'body', 'bodyUsed', 'headers', 'ok', 'redirected', 'status', 'statusText', 'trailers', 'type', 'url', ].forEach((k) => { Object.defineProperty(Response.prototype, k, { get() { return this.cache[k] }, }) }) ;['arrayBuffer', 'blob', 'clone', 'formData', 'json', 'text'].forEach((k) => { Object.defineProperty(Response.prototype, k, { value: function () { return this.cache[k]() }, }) }) Object.setPrototypeOf(Response, globalResponse) Object.setPrototypeOf(Response.prototype, globalResponse.prototype) Object.defineProperty(global, 'Response', { value: Response, })
Object.defineProperty
を使用してResponse
クラスのプロパティを設定しています。
プロパティに仕込むコールバックでcache
関数を使用しています。例えば.body
が呼び出された場合にはcache
関数からglobalResponse
のインスタンスが返ってきます。||=
(自己代入)によって初回呼び出し時であれば、新しくthis[responseCache] = new globalResponse(this.#body, this.#init))
が実行されて、以降はすでに代入済みの値が返ってきます。
キャッシュ機構を作る際に||=
(自己代入)は非常に便利ですね。
class Sample { cache() { return (this.hoge ||= this.setHoge()); } setHoge() { console.log('call!'); return 'hoge' } } const ins = new Sample(); console.log(ins.cache()); console.log(ins.cache()); // call! // <- 一度だけしか呼ばれていない // hoge // hoge
PRで追加されていた単体テストのコードをデバッガで止めてResponse
インスタンスのプロパティを確認してみます。
先ほど動的に定義されていたプロパティが問題なく参照することができました。
app.get('/json-blob', async () => { const resp = new Response(new Blob([JSON.stringify({ foo: 'blob' })]), { headers: { 'content-type': 'application/json' }, }) return resp; // 🔴 ここで停止 }) // resp.body // ReadableStream {Symbol(kType): 'ReadableStream', Symbol(kState): {…}} // res.headers // Headers {Symbol(headers list): HeadersList, Symbol(guard): 'response', Symbol(realm): {…}} // res.ok // true // res.type // 'default'
new Response()はどこ?
Response
クラスのインスタンスを作成している処理はhonojs/node-server
のコードを見た限りではそれらしい箇所はありませんでした。おそらくHono
の本体側でインスタンスを作成しているのかなと思いましたが、正しくはHono
側で作成した値(Response
)をhonojs/node-server
側で型変換してResponse
型に変換しているようです。
src/listener.ts
// getRequestListener関数の処理の一部 res = fetchCallback(req) as Response | Promise<Response>
PRでsrc/listener.ts
に変更があった理由がわかりました。
Hono
側に実装されたテストコードを見てみると、honojs / node-server
はHono
クラスのインスタンスを受け取りcreateAdaptorServer
関数に受け渡すことでNode.js上での実行を可能にしているようです。
const app = new Hono() app.get('/', (c) => { return c.text('Hello! Node.js!') }) : const server = createAdaptorServer(app)
hono/runtime_tests/node/index.test.ts at main · honojs/hono · GitHub
createAdaptorServer
関数はsrc/listener.ts
に定義されたgetRequestListener
関数を呼び出しています。
そして先ほど記載した型変換がされています。PRにsrc/listener.ts
ファイルに変更があったのは、既存の機構にキャッシュ処理を組み込むためだったんですね。
// responseViaCache関数 // cacheKey(Symbol型)を使ってキャッシュを取得している const [status, body, header] = (res as any)[cacheKey]
変更前のコードを見た感じだと、呼び出される度に新しくResponse(fetchAPI)
を生成していたようです。
キャッシュ処理を組み込んだおかげでResponse
の生成コストが下がりパフォーマンスが劇的に向上したようです。
これは確かに神PRですね。既存の機構に組み込むのが上手すぎる...。
v1.2.3: src/listener.ts
// getRequestListener関数の処理の一部 res = new Response(null, { status: 500 })
Request
に対しても同じようなキャッシュの処理が組み込まれていました。
最後に
神PRを...読まさせて頂きました。
変更の規模感はそれほど大きなものではないと思いますが、結果的にこれだけのパフォーマンス改善がされるとは驚きを隠せません。キャッシュの偉大さを感じましたし、PR作成者のTaku Amanoさんの実装はHono
を熟知しているこその変更だったと感じました。
また、Conversationsではキャッシュを組み込むことの是非についても議論がされており、キャッシュを組み込むことでシンプルさが失われてしまうのではないかという懸念もされていた点も「確かになぁ...」と感じました。
自分はまだ個人で触るぐらいでしかHono
を使っていないのですが、もっと触ってみたいと思います。
今回の内容については可能な限り間違いがないように書いたつもりですが、もし間違いや誤解を与えてしまう表現などがあれば、優しく指摘して頂けるとありがたいです。
少しでも「ええな〜」と思ったらはてなスター・はてなブックマーク・シェアを頂けると励みになります。
PS. Special Thanks
記事の投稿後、Hono
製作者のYusuke Wadaさんに紹介をいただきました。
すごい。PRが解説されてるw
— Yusuke Wada (@yusukebe) 2023年12月11日
HonoのNode.jsランタイムにマージされた神PRを見てみる - やわらかテック https://t.co/Usko4qksE9
結果、普段ではありえないぐらい多くの方に記事を読んで頂くことができました。本当にありがとうございます。
Xのブックマーク、はてなブックマークなどリアクションを頂けると本当に嬉しいです。