🎄

【最強】Honoフル活用事例2024年

2024/12/01に公開

Hono アドベントカレンダー 2024 初日担当の おりばー です。

本記事では、11 月にリリースした漫画プラットフォーム「comilio」の開発事例をもとに、とにかく Hono が最強だということをつらつらと書いていく記事となります。

個人的 2024 年ベストオブ優勝フレームワークは Hono 一択です。Hono が無ければ、おそらくプロダクトを今もリリースできていなかったと言っても過言ではありません。

ぜひこの記事を参考にして、0 -> 1 を立ち上げる際は Hono を積極的に採用してもらえればと思います。

また、Hono という最高のプロダクトを生み出してくれた @yusukebe さんには全身全霊を持って感謝します。

「comilio」のインフラ構成

まず、今回の実例である漫画プラットフォーム「comilio」の構成を紹介します。

comilioのインフラ構成図

「comilio」では TypeScript で書かれたサーバーはほぼ全てに Hono を利用しています。作業分担の都合や技術難易度、エコシステムの都合で一部 Go など別の言語のサーバーやバッチが混ざっているのですが、基本は Hono で完結させるように心がけています。

Hono の詳細な使い方は本記事では省略させていただきますが、実装する上で特に感動的だった点をいくつかご紹介していきます。

また、今回のサンプルコードは以下にまとめてありますので、ご参考ください。

強力な RPC により、堅牢な開発が可能となる

Hono を利用することで、強力な型支援を受けることができます。

基本的に TypeScript プロジェクト上から Hono で実装されたサーバーにアクセスする際は、 hono/client を利用するのが良いでしょう。例えば、簡易的な実装は以下となります。

server.ts
import { Hono } from "hono";
const app = new Hono().get("/hello", (c) => c.text("Hono!"));

export type AppType = typeof app;
export default app;
client.ts
import { AppType } from "./server";
import { hc } from "hono/client";

const client = hc<AppType>("http://localhost:8787/");

const res = await client.hello.$get();
if (res.ok) {
  const text = await res.text();
  console.log(text);
}

上記のコードで、例えば server.ts"/hello""/hello2" に変わった時、 client.ts 側の client.hello.$get() は存在しなくなるので、Build 時にエラーとさせることが容易となります。

server.ts
import { Hono } from "hono";
// hello2に変更
const app = new Hono().get("/hello2", (c) => c.text("Hono!"));

export type AppType = typeof app;
export default app;
client.ts
import { AppType } from "./server";
import { hc } from "hono/client";

const client = hc<AppType>("http://localhost:8787/");

// hello -> hello2に変わってるので、ここでBuildエラーになる
const res = await client.hello.$get();
if (res.ok) {
  const text = await res.text();
  console.log(text);
}

また、レスポンスデータの型も自動的に生成されるため、レスポンスデータのフィールドが変わった場合、クライアント側の Build を落とすことも容易となります。

server.ts
import { Hono } from "hono";
// user -> nameに変更したとする
const app = new Hono().get("/user", (c) => c.json( { name: "oliver" }));

export type AppType = typeof app;
export default app;
client.ts
import { AppType } from "./server";
import { hc } from "hono/client";

const client = hc<AppType>("http://localhost:8787/");

const res = await client.user.$get();
if (res.ok) {
  const json = await res.json();
  // user -> nameに変わってるので、ここでBuildエラーになる
  console.log(json.name);
}

この RPC 機能が pnpm のワークスペース との組み合わせに非常にマッチしており、サーバーとクライアント側でパッケージを分けておき、サーバー側の実装変更の影響でもしっかりとクライアント側で Build エラーとなってくれるため、安心して開発することが可能となります。

Hono + Drizzle + Zod が最強すぎる

プロジェクトが大規模になってきた際、どうしても出てくる悩みがバリデーションをどう行うかだと思います。

個人的にバリデーションの記述は TypeScript 上だけで完結させられることが理想であり、その前提だと Drizzle が公式で drizzle-zod を提供しているので、これと Hono を組み合わせることで、非常に開発体験が良くなります。

https://x.com/yusukebe/status/1779857722437238959

特にバリデーションを TypeScript 上だけで完結させられることは、0 -> 1 開発において非常に重要なポイントだと考えてます。理由としては、ホットリロードでもすぐに反映される点や、バリデーションの値を TypeScript 上に定義できるようになるからです。

例えば、ユーザー名の最大文字数を 64 文字にしたとして、フロント側の Input でもその値を利用したいとなったとき、バリデーションの値を TypeScript 上の 1 つの場所に定義できることは、値変更時などの対応が簡単となるからです。

Next.js のバックエンド API として利用できる

「comilio」を開発する上で、非常に役に立ったのがこの機能です。

「comilio」は App Router で開発されているのですが、Server Actions 上から Hono のサーバーへリクエストする方針となってます。例えば、以下のようなコードになります。

comic.ts
"use server";

import { client } from "./client";

export async function fetchComic({ comicId }:{ comicId: string; }) {
  const res = await client().public.comic[":comicId"].$get({
    param: { comicId },
  });

  if (!res.ok) {
    return notFound();
  }

  const json = await res.json();
  if (json === null) {
    return notFound();
  }

  return json;
}

「comilio」では基本的に全ての Server Actions でこのようなコードになっており、シンプルなコードになっています。このようにしている理由は、開発環境では Next.js の起動だけで開発したいが、本番環境ではバックエンドを分離したい(というケースが出てくる可能性がある)からです。

Hono のすごい点は、そのポータビリティです。「comilio」の開発環境では Next.js を立ち上げた際、バックエンドのコードは Next.js 上の API Routes にマウントされるようになっています。これにより開発環境は Next.js の起動だけで済み、本番環境ではバックエンドを別のところに配置する。ということが可能となります。

要するに、Next.js を Vercel にデプロイしたいけれど、コストの都合や、Database の IP 制限などの要件に対応するためにバックエンドだけ CloudRun など別の場所にデプロイすることがめちゃくちゃ簡単にできる。ということです。

実際のコードは以下のようなイメージとなります。

web/src/app/api/[...route]/route.ts
import { app as backend } from "@hono-advent-calendar-2024/backend";
import { Hono } from "hono";
import { handle } from "hono/vercel";

const handleDevOnly = (...args: Parameters<ReturnType<typeof handle>>) => {
  if (process.env.NODE_ENV === "development") {
    const app = new Hono().basePath("/api").route("/", backend);
    return handle(app)(...args);
  }
  return new Response(null, { status: 404 });
};

export const runtime = "nodejs";
export const GET = handleDevOnly;
export const POST = handleDevOnly;
export const PUT = handleDevOnly;
export const PATCH = handleDevOnly;
export const DELETE = handleDevOnly;
client.ts
import { hc } from "hono/client";
import type { AppType } from "@hono-advent-calendar-2024/backend";
import { getBaseURL } from "@/app/lib/baseUrl";

// getBaseURLでVercelとCloudRunを切り替える
export const client = hc<AppType>(`${getBaseURL()}/api`);

どのサービスも必要なインフラ構成は文脈によって変わってきます。その変数はローカル開発や本番環境であったり、コストやセキュリティ、パフォーマンスなど多岐にわたると思います。そして、そのインフラ構成の変更にコードを追従させなければいけなくなった際、Hono は最小の変更で済ませられるフレームワークと言えるでしょう。まさに 0->1 開発の救世主と言えます。

おわりに

まだまだ Hono について語りたいことは無限にあるのですが、また今度の機会に取っておきます。ぜひみなさんも Hono を使って最強の開発を体験しよう!

Discussion