流行りでHonoに移行した。速くなったかは分からないが、別のものが手に入った

流行りでHonoに移行した。速くなったかは分からないが、別のものが手に入った

Expressって遅いってよく聞くけど、実際どうなの?

流行ってるフレームワークに乗り換えるの、ちょっと怖いんだよね…

筋トレ記録アプリ Trecord のAPIを、ExpressからHonoに移し替えました。
理由を正直に書くと、「流行っていたから」 です。

軽くて書きやすくて、たぶん速くなる。そう思って踏み切りました。
ただ、速くなったかどうかは分かりません。移行前の数字を測っていないからです。

そのかわり、まったく想定していなかったものが出てきました。
1年前からアプリに埋まっていた、誰も気づいていなかったバグです。

この記事でわかること
  • 「課題があったから移行した」わけではない、実際の乗り換えの順序
  • Express から Hono + zod-openapi へ移すと、何がどう書き換わるのか
  • .optional() と .nullish() の違いで、既存アプリが400を返すようになった話
  • 型検証を厳しくすることが、なぜ「過去の掃除」になるのか

Expressに不満があったわけじゃない

正直に書くと、「Expressが遅くて困っていた」わけではありません。順序はむしろ逆でした。

きっかけはYouTubeです。動画の中で「Hono」という言葉を耳にしました。
聞き慣れない名前だったので調べてみると、軽量で、エッジでも動いて、TypeScriptとの相性がいい……といった説明が出てきます。

読んでいるうちに、ふと思いました。

「じゃあ、自分が使っているExpressってどうなんだ?」

それまで一度も疑ったことがありませんでした。
Node.jsでAPIを書くならExpress、というくらいの感覚で、選んだ記憶すらないまま使っていたからです。

調べてみると、Expressは登場が古く、ベンチマークでも他のフレームワークに差をつけられていることが分かりました。

らるじゅらるじゅ

ただし「遅い」と分かったのは、自分のアプリを測ったからではありません。
調べて知った、が正確なところです。

この「測っていない」が、後でそのまま効いてきます。

移行でやったこと

TrecordのAPIは、AWS Lambdaの上で動いています。
Expressを @vendia/serverless-express でラップしてLambdaに載せる、よくある構成でした。

これを Hono + @hono/zod-openapi に置き換えます。実質61ファイル、1,696行の追加と1,554行の削除になりました。

手書きのバリデーションが全部消えた

いちばん変わったのはここです。Express時代のコントローラは、こう書いていました。

async getTagByUserId(req: Request, res: Response): Promise<void> {
  const userId = req.params.userId;
  const locale = req.params.locale as ShortLanguageCode;
  if (!userId || !locale) {
    res.status(400).json(responseUtil.createErrorResponse400(
      `リクエストのいずれかが存在しません: userId: ${userId}, locale: ${locale}`));
    return;
  }
  // ...
}

if (!userId || !locale) のような手書きのチェックが、14個のコントローラすべてに散らばっている状態です。

Honoに移すと、この部分がスキーマ側に集まります。

// schemas/tag.ts
export const getTagByUserIdRoute = createRoute({
  method: "get",
  path: "/{userId}/{locale}",
  request: { params: z.object({ userId: z.string(), locale: z.string() }) },
  responses: { 200: {/* ... */}, 400: {/* ... */}, 500: {/* ... */} },
});

// routes/v1/tag.ts
tagRoutes.openapi(getTagByUserIdRoute, tagController.getTagByUserId);

コントローラからバリデーションのコードが消えて、「何を受け取るか」がスキーマに一本化されました。

OpenAPIの定義を手で書かなくてよくなった

副産物として大きかったのがこちらです。

それまで 1,776行のOpenAPI.yamlを手で書いていました。実装を直したらyamlも直す、という二重管理です。当然ズレます。

@hono/zod-openapi を使うと、zodスキーマからOpenAPIの定義が生成されます。手書きのyamlは丸ごと削除しました。

項目BeforeAfter
ランタイムExpress + @vendia/serverless-expressHono + hono/aws-lambda
バリデーション各コントローラに手書きzodスキーマ(14ファイル)
OpenAPI手書き1,776行zodから自動生成
レート制限express-rate-limithono-rate-limiter

ここまでは、想定どおりの移行です。問題はこの後でした。

マージした翌日、記録が保存できなくなった

PRを出したのが5月25日。ところがdevelopにマージされたのは6月28日でした。
1ヶ月以上、ブランチの上で寝ていたことになります。

マージした翌日、いつもどおり確認の打鍵をしていて手が止まりました。

トレーニング記録が保存できない。 何度やっても400が返ってきます。

真っ先に疑ったのはHonoでした。
その回の変更のメインがフレームワークの載せ替えだったからです。他に疑いようがありません。

そして実際、400を返していたのは移行で入れたzodのバリデーションでした。

……ただ、壊したのはHonoではありませんでした。

犯人は .optional() だった

原因はスキーマの1文字違い、と言っていいレベルの話でした。

leftOrRight: z.string().optional(),
assistWeight: z.number().optional(),

zodの .optional() は、undefined は許可しますが null は拒否します。
「値が無い」の表現として undefined だけを認める、という意味です。

一方、アプリ側のコードはこうでした。

const newSet = {
  setNumber: (fields?.length ?? 0) + 1,
  volume: null,
  leftOrRight: null,
  assistType: null,
  weight: null,
  rep: null,
  // ...
};

新しいセットを追加するとき、空の項目を null で初期化して送っていたわけです。

.optional() は null を受け取れない。アプリは null を送る。だから400。
再現性100%でした。

修正は該当する9フィールドを .nullish() に変えるだけです。

-    leftOrRight: z.string().optional(),
-    assistWeight: z.number().optional(),
+    leftOrRight: z.string().nullish(),
+    assistWeight: z.number().nullish(),

.nullish() は null と undefined の両方を許可します。
原因の特定からマージまで、7分でした。

zodの「値が無い」の3つの書き方
  • .optional() … undefined のみ許可(null は弾く)
  • .nullable() … null のみ許可(undefined は弾く)
  • .nullish() … null と undefined の両方を許可

フロントから来るJSONは null が入りがちなので、APIのリクエストスキーマでは .nullish() が正解になる場面が多いです。

そのnullは、最初のコミットから入っていた

ここからが本題です。

気になって、この null 初期化がいつ入ったのかを調べました。

git log -S "leftOrRight: null"

結果は、リポジトリのいちばん最初のコミットでした。
つまりこのコードは、アプリが生まれたときからずっと null を送り続けていたことになります。

ではなぜ、それまで何も起きなかったのか。

Express時代のバリデーションを思い出してください。

if (!userId || !locale) {

!null は true、!undefined も true です。
必須項目のチェックとしては動きますが、任意項目についてはそもそも何も見ていませんでした。

null が来ようが undefined が来ようが、素通りです。
だから1年間、誰も困らなかった。

壊れてなかったんじゃなくて、誰も見ていなかったんだね…

型を厳しくすると、過去の手抜きが表に出る

原因が分かったとき、正直なところ3つ全部を同時に思いました。

  • Honoのせいじゃなくてよかった(移行の判断が間違っていなかった)
  • 1年も気づかずに動いてたのか(ゾッとした)
  • zodを入れてなかったら、今も気づいていない

3つ目がいちばん大きい発見でした。

Honoはバグを作っていません。 作ったのは1年前の自分です。
Honoが——正確にはzodの型検証が——やったのは、それを表に出しただけでした。

手書きの if (!x) は、書いた人が意識した条件しか見ません。
スキーマは、書いた人が意識していなかった条件まで見ます。だから、それまで通っていたものが通らなくなる。

移行で400が出るのは、多くの場合そのフレームワークのせいではなく、それまで見逃されていた契約違反が出てきただけです。
「移行したら壊れた」ではなく「移行したら見つかった」が実態に近い。

らるじゅらるじゅ

速くなったかは分かりません。
でも、測らずに移行して、測れないものが手に入ったという結果になりました。

他にも同じ地雷がないか全部見た

1箇所見つかったということは、他にもある可能性があります。
修正のついでに、リクエスト側の任意項目を全部洗いました。

残っていたのは memo 系、totalVolume、ユーザー情報、退会理由のテキストなど数箇所です。
いずれもアプリ側は undefined を送っていて、null を送る経路がないことを確認できたので、変更不要と判断しました。

こういう横展開の確認は、バグを直した直後にやるのがいちばん安いです。
頭の中に「どこを見ればいいか」が残っているうちに済ませてしまう。

なお、この400はdevelop環境で見つかったので、本番のユーザーには一度も届いていません。
Hono移行と修正は同じリリースにまとまって、初めから直った状態で本番に出ています。

テストは、まだ書けていない

最後に、格好のつかない話を書いておきます。

このAPIには自動テストがありません。package.json の test スクリプトは、今もこうなっています。

"test": "echo \"Error: no test specified\" && exit 1"

今回の400を捕まえたのは、Sentryでもテストでもなく、手動の打鍵確認でした。
リリース前に触る、という作業を毎回やっているので、そこで引っかかった形です。

書かなければいけないのは分かっています。
ただ、後回しにして機能追加や改善のほうに手が向いてしまっている、というのが正直なところです。土台固めが大事なのも分かってはいるのですが。

そのうえで、今回のことで一つ見方が変わりました。

スキーマは、テストの代わりにはなりません。 ただ、テストが無い状態でも契約違反だけは黙って拾ってくれる網にはなります。
.optional() を .nullish() に直した9行は、テストコード0行で1年分の見落としを掘り起こしたわけです。

テストを書く体力がないうちは、入口を型で固めるほうが費用対効果が高いかもしれない。
そう思えただけでも、この移行はやった価値がありました。

この移行で分かったこと
  • 「遅いから移行した」ではなく「移行してから、速いかどうかを測っていないことに気づいた」
  • フレームワーク移行で出るエラーは、移行が壊したものより、元からあったものが表に出たケースが多い
  • 手書きのバリデーションは、書いた人が意識した条件しか見ない
  • リクエストスキーマの任意項目は、.optional() より .nullish() が安全なことが多い

速くなったかは、いまも分かりません。
いつか測ろうとは思っていますが、少なくとも測らずに移行したことを後悔はしていません。