軽いツールにHonoを使ってみたら最高だった件

軽いツールにHonoを使ってみたら最高だった件

LINE MessagingAPIのリッチメニュー管理ツールの開発にHonoを採用してみました。ファイルサイズが小さくFEとBEどちらも実装できてとても満足しています
Clock Icon2024.09.17

こんばんは、リテールアプリ共創部のmorimorikochanです🏊‍♂️
最近Honoを使ってみたのですが、最高に良かったのでみなさんにも紹介させて下さい。

何がそんなに良かったのか?

FEとBEの実装が1ツールで完結する

Honoを使うことで、それなりのフロントエンドとバックエンドの実装を1つのツールで完結することができます。
これによって、従来のように"FEとBEで別々のパッケージ構成にしてFEとBEで共通で利用するロジックをsharedパッケージに切り出して..."
というような構成にする必要もなく、同じパッケージで自然に管理することができます。

もちろん場合によっては前述のような構成が好まれる場合もありますが、少なくともコードベースのサイズが小さい軽量なツールやローカル環境でササっと開発して使うツールの場合、同じパッケージでFEとBEの管理が簡単に可能な点は大きなアドバンテージではないでしょうか

HTML(=ビュー)の構築が型安全

Honoを利用して、JSXをレスポンスして画面表示を行う場合(=MPA)の話です。

従来のWebフレームワークのMPAでは、HTML(=ビュー)の構築ロジックはテンプレートエンジンが利用されている場合が多いです。
主要な組み合わせだと、LaravelのBrade、Djangoの独自のテンプレートエンジン、SpringBootのThymeleaf、など様々です。
ですが、これらのような組み合わせではコントローラーからビューを構築する方法で型情報が失われてしまい、開発効率が大きく下がってしまう問題がありました。
ビューを実装したけど実行時に初めてエラーが出てまた修正する...という経験は多くの方がされていると思います。
実際問題、この問題を避けるためにMPA構成からSPA構成に変更する場合も少なくはないとは思います。

TypeScriptを使ったHonoのJSXではこの問題が解決されています。
コントローラー(Honoでいうhandler)から直接JSXを呼び出せるため、型情報が失われずHTML(=ビュー)を構築することができます。

公式に記載されている以下の例で言うと、messagesに渡される値の型はチェックされていて、仮にmessages={[5,3,7,2]}というような値を与えてもコンパイルエラーが出ます。

app.get('/', (c) => {
  const messages = ['Good Morning', 'Good Evening', 'Good Night']
  return c.html(<Top messages={messages} />)
})

export default app

https://hono.dev/docs/guides/jsx#usage

このように、従来のMPAのテンプレートエンジンでは難しかった型安全を実現することができているため、フロントエンドを安全かつ高速に実装できる点が素晴らしいと感じました。

ヘルパーメソッドが充実してる

例えば画像ファイルをサーバーから返却したい場合、ストリーミングで返却したいことが多いと思います。

Honoではヘルパーメソッドとしてstream()が提供されており、この仕様を簡単に実現できます。

実装例
app.get("/image/:id", async (c) => {
  const { id } = c.req.param();
  const richMenuResponse = await lineApiClientBlob.getRichMenuImage(id);
  return stream(c, async (stream) => {
    stream.onAbort(() => {
      console.log("Aborted!");
    });
    await stream.pipe(readableToReadableStream(richMenuResponse));
  });
});

https://hono.dev/docs/helpers/streaming

また、他にもJWTやWebSocketを扱うためのヘルパーメソッドも存在しており、開発が進んでいる印象を受けました。
今回は軽いツールにHonoを採用したのですが、これからプロダクション利用がどんどん進んでいくのではないでしょうか

Honoを使ってみた例

私が実際にHonoを使ってみた例について共有します。
LINEアプリが提供しているMessagingAPIでは、トーク画面でユーザーに表示できるリッチメニューをAPI経由で管理することができます。

API経由で管理できるという点では、システムへの組み込みが行いやすく様々なシステムと連携することが可能となっています。
しかしその反面、グラフィカルな管理画面が提供されていないため、現在何件リッチメニューが存在し、各リッチメニューにどういう画像や設定値が割り当たっているか把握するのが困難です。

その問題を解決するために、開発者向けにローカルで動作するリッチメニュー管理ツールをHonoで実装してみました。

できたもの

richmenu-viewer

現在登録されているリッチメニューを全件表示し、各リッチメニューへの操作もできます。

これを実現するためのソースコードは、たった1ファイルの180行だけです。
また、主要なパッケージもhono@hono/node-server@line/bot-sdkの3つだけです。
FEとBEどちらも含んでいてこのサイズなのは本当に驚きます。

ソースコード
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { css, Style } from "hono/css";
import { stream } from "hono/streaming";
import { Readable } from "stream";

import { messagingApi, ClientConfig } from "@line/bot-sdk";
import { PropsWithChildren } from "hono/jsx";

const LINE_CHANNEL_SECRET = process.env.LINE_CHANNEL_SECRET;
if (LINE_CHANNEL_SECRET === undefined) {
  throw new Error("環境変数に`LINE_CHANNEL_SECRET`が設定されていません");
}

const clientConfig: ClientConfig = {
  channelAccessToken: LINE_CHANNEL_SECRET,
};
export const lineApiClient = new messagingApi.MessagingApiClient(clientConfig);
export const lineApiClientBlob = new messagingApi.MessagingApiBlobClient(
  clientConfig
);

const app = new Hono();

const Layout = (props: PropsWithChildren<{}>) => {
  return (
    <html>
      <head>
        <title>リッチメニュー管理ツール</title>
        <Style />
      </head>
      <body>{props.children}</body>
    </html>
  );
};

const Top = (
  props: PropsWithChildren<{
    richMenus: RichMenu[];
    defaultRichMenuId: string | null;
  }>
) => {
  const wrapper = css`
    display: flex;
    flex-wrap: wrap;
    gap: 16px;
  `;
  return (
    <Layout>
      <ul>
        <li>
          <a href="/cancel-default-richmenu">
            デフォルトのリッチメニューを解除する
          </a>
        </li>
      </ul>
      <h3>現在リッチメニューは{props.richMenus.length}件です</h3>
      <div class={wrapper}>
        {props.richMenus.map((richMenu) => {
          return (
            <RichMenuCard
              richMenu={richMenu}
              isActive={props.defaultRichMenuId === richMenu.richMenuId}
            />
          );
        })}
      </div>
    </Layout>
  );
};

type RichMenu = {
  name: string;
  richMenuId: string;
};

const RichMenuCard = ({
  richMenu,
  isActive,
}: PropsWithChildren<{ richMenu: RichMenu; isActive: boolean }>) => {
  const cardWrapper = css`
    max-width: 300px;
    overflow-wrap: anywhere;
    background-color: ${isActive ? "lightblue" : "white"};
  `;
  const image = css`
    max-width: 300px;
  `;
  return (
    <div class={cardWrapper}>
      <h2>{richMenu.name}</h2>
      <h4>{richMenu.richMenuId}</h4>
      <img class={image} src={"/image/" + richMenu.richMenuId} />
      <code>{JSON.stringify(richMenu)}</code>
      <ul>
        <li>
          <a href={"/delete-image/" + richMenu.richMenuId}>削除する</a>
        </li>
        <li>
          <a href={"/set-default/" + richMenu.richMenuId}>
            デフォルトのリッチメニューとして設定する
          </a>
        </li>
      </ul>
    </div>
  );
};

app.get("/", async (c) => {
  const richMenuResponse = await lineApiClient.getRichMenuList();
  const defaultRichMenuId = await lineApiClient
    .getDefaultRichMenuId()
    .then((a) => a.richMenuId)
    .catch((e) => {
      console.log(e);
      return null;
    });
  return c.html(
    <Top
      richMenus={richMenuResponse.richmenus}
      defaultRichMenuId={defaultRichMenuId}
    />
  );
});

export const readableToReadableStream = (readable: Readable) => {
  return new ReadableStream({
    start(controller) {
      readable.on("data", (chunk) => {
        controller.enqueue(chunk);
      });

      readable.on("end", () => {
        controller.close();
      });

      readable.on("error", (err) => {
        controller.error(err);
      });
    },
    cancel() {
      readable.destroy();
    },
  });
};
app.get("/image/:id", async (c) => {
  const { id } = c.req.param();
  const richMenuResponse = await lineApiClientBlob.getRichMenuImage(id);
  return stream(c, async (stream) => {
    stream.onAbort(() => {
      console.log("Aborted!");
    });
    await stream.pipe(readableToReadableStream(richMenuResponse));
  });
});

app.get("/delete-image/:id", async (c) => {
  const { id } = c.req.param();
  await lineApiClient.deleteRichMenu(id);
  return c.redirect("/");
});

app.get("/set-default/:id", async (c) => {
  const { id } = c.req.param();
  await lineApiClient.setDefaultRichMenu(id);
  return c.redirect("/");
});

app.get("/cancel-default-richmenu", async (c) => {
  await lineApiClient.cancelDefaultRichMenu();
  return c.redirect("/");
});

const port = 3002;
console.log(`Server is running on port ${port}`);

serve({
  fetch: app.fetch,
  port,
});

まとめ

  • 良かった点
    • FEとBEの実装が1ツールで完結する
    • HTML(=ビュー)の構築が型安全
    • ヘルパーメソッドが充実してる
  • ドキュメントでサポートされているデプロイ先も本当に多いので、ローカルで利用するだけでなく今後もしデプロイが必要になったとしても、スムーズにデプロイできそうです
  • 今後どしどしHonoを布教していきたいと思います💪

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.