やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

HonoのNode.jsランタイムにマージされた神PRを見てみる

先日、Twitterにて素晴らしいツイートを見かけました。

このツイートはHono製作者のYusuke Wadaさんのもので、どうやらNode.jsランタイム上でHonoのパフォーマンスがめちゃくちゃ改善されたとのこと。この対応はTaku Amanoさんという方が行われたそうで、神PRと称賛されていました。

現在、世界中で注目されているOSSのHonoに取り込まれた神PRとは、一体、どのような内容なのでしょうか。
気になってしょうがないので実際に神PRの内容を見てみたいと思います。Hono全体のアーキテクチャやWEBフレームワークの作りに詳しいわけではないので、理解できる情報に限界はありますが、チャレンジしてみます。

対応についての全体像はPRを出されたTaku Amanoさんが解説されていました。

ふむふむ...。
このツイートを頼りにRequestとResponse周りを中心に見ていきたいと思います。

PRについて

github.com

まず最初に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クラスでした。
このクラスはインスタンス作成時にbodyinitの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-serverHonoクラスのインスタンスを受け取り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さんに紹介をいただきました。

結果、普段ではありえないぐらい多くの方に記事を読んで頂くことができました。本当にありがとうございます。
Xのブックマーク、はてなブックマークなどリアクションを頂けると本当に嬉しいです。