🔥

Honoマイベストプラクティス

2024/12/02に公開

はじめに

この記事はHono Advent Calendar 2024の2日目の記事です。
自分のやっているプロジェクトではレストランの予約システムをHonoで構築しています。
アプリ、ウェブ、管理画面(ウェブ)があり、それらのAPIを一つのHonoサーバーで捌いています。
モノレポで構成されており、ウェブのフロントはNext.jsで作られています。
自分なりのHonoのベストプラクティスや設定を紹介いたしますので、よければ最後までお付き合いください!
(うちのインフラの話はあまり面白い部分がないので省きますが、逆に面白い話があれば記事にするかXなどで話しかけてください!)

ディレクトリ構成

とりあえずこんな感じに落ち着いています。

src/
├── domain/
│   └── reservation/ # 予約ドメイン
├── lib/
├── routes/
│   ├── admin/   # 管理画面向けAPI
│   ├── web/     # Web向けAPI
│   └── sp/      # スマートフォン向けAPI
├── middleware/
├── schema/
│   ├── admin/   # 管理画面用zodスキーマ
│   ├── web/     # Web用zodスキーマ
│   └── sp/      # スマートフォン用zodスキーマ
├── test/
├── utils/
└── index.ts

domain

クライアントが三つあるので、それぞれに横断するドメインロジックを置きます。
たとえば、ウェブからでもアプリからでも予約のロジックは同じものを使いたいのでDomainに置いています。
DDDや、クリーンアーキテクチャにおける厳密なモデルやサービスが定義されているわけではなく、ドメインロジックを表現する関数、定数がたくさん置かれてます。クラスは一切使われていません。

lib

たとえば、mailClientやprismaのClientなど、各種SDK、ライブラリの薄いラッパーを定義しておいています。
文字通りライブラリですね。

middleware

loggerや既存のbearerAuthをカスタムしたものなんかを置いてます。

routes

いわゆるルーターです。ここに処理を書きます。

export const adminRouter = new Hono()
  .use(
    '*',
    cors({
      origin: process.env.ADMIN_URL,
      allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
    }),
  )
  .basePath('/v1')
  .route('/auth', auth)
  .route('/restaurants', restaurants)

schema

zodによるバリデーションスキーマをおきます。
リクエストだけでなく、レスポンススキーマも置いています。
/admin/reservationというように、ざっくりリソースごとに切っています。

test

vitestを用いたテストを置いています。

utils

たとえばgenerateRandomStringなど非常に汎用的、かつドメインロジックに絡まない共通関数を置いています。

index.ts

リクエストが最初に到達するところです。
ポイントはAdminAppTypeなどをexportしている部分。
モノレポ構成なので、HonoRPCの力を借りて各種APIの型をNext.jsに渡しています。

const app = new OpenAPIHono()
  .route('/sp', spRouter)
  .route('/web', webRouter)
  .route('/admin', adminRouter)
  .doc('/specification', {
    openapi: '3.0.0',
    info: {
      title: 'API',
      version: '1.0.0',
    },
  })

app.onError(async (e, c: Context) => {
  // エラーロギング,Sentry通知などをしている
})

export type AdminAppType = typeof adminRouter
export type webRouter = typeof webRouter
export type spRouter = typeof spRouter

以下、Next.js側

import { hc } from 'hono/client'
import { AdminAppType } from 'backend/src'

// typesafeなAPIClientとして使える。
const client = hc<AdminAppType>(baseUrl, {
    headers,
  })

もっとよくできる点

  • testはロジックと同じディレクトリにおきたい。

  • 若干技術駆動パッケージングみがある。
    フロントはfeatureディレクトリを採用してるので、合わせてもいいかも?と思いつつ、それはTypeScriptバックエンドでどうやるねんという問題にぶち当たっているので、とりあえず/domainの中に適切にドメインを切って、ロジックを表現する関数や定数をおき、/routesで使うというコスパの良さそうな方法をとっています。テストはこのドメインに対して書いています。
    うちはhandlerすらないので、それくらいは切っても良いかもと思ったりしてます。

  • ドメインが外部ライブラリにゴリゴリに依存している
    これは割り切っています。
    day.jsじゃなくてJavaScriptの標準のDateオブジェクトを使おうとかは最早危険ですね、、

DI

これは色々あって恥ずかしながらまだできてないんですが、HonoではTypeScriptの部分的構造型のしくみを利用したDIができます。

type Repository = {
  getUser: (id: number) => { id: number , name: string }
}
declare module 'hono' {
  interface ContextVariableMap {
    repository: Repository
  }
}

const repository = // 実体

export const restaurants = new Hono()
  .use(async (c, next) => {
    c.set('repository', repository)
    await next()
  })
  .get('/', async (c) => {
    const repository = c.get('repository')
    const user = repository.getUser()
  })

ここについては自分のベストプラクティスはないので、こちらの記事などを参考にされると良いと思います。
https://zenn.dev/sui_water/articles/36d0f9bc0f17c0

各種ミドルウェアの設定について

ミドルウェアをTypeSafeに扱うコツ

ミドルウェアをTypeSafeに扱うには少しコツが必要です。
https://hono.dev/docs/api/context#contextvariablemap

Hono公式には以下のように書かれています。
が、これはHonoの型定義を拡張するもので、ミドルウェアが適用されていない場合でも推論されてしまいます。

declare module 'hono' {
  interface ContextVariableMap {
    result: string
  }
}
const mw = createMiddleware(async (c, next) => {
  c.set('result', 'some values') // result is a string
  await next()
})
const app = new Hono()
  .use(mw)
  .get('/', (c) => {
    const val = c.get('result') // val is a string
    // ...
    return c.json({ result: val })
  })
// なんのミドルウェアもuseしてない
new Hono().get('/', (c) => {
  // stringとして推論されてしまう
  const val = c.get('result')
})

ので、

type ResultEnv = {
  interface ContextVariableMap {
    result: string
  }
}
const mw = createMiddleware(async (c, next) => {
  c.set('result', 'some values') // result is a string
  await next()
})
new Hono().get('/', (c) => {
  // type error!
  const val = c.get('result')
})

とするとよりTypeSafeに使えます!

余談:
ちなみに、これはHonoOpenAPIだと推論されなくなってしまいます。(useの戻り値がHonoOpenAPIではなくHonoのため、そのあとメソッドチェーンできなくなる。RPCはチェーンできないと型がもらえないので厳しい。)
https://github.com/honojs/middleware/issues/637#issuecomment-2239397990

が、めっちゃ最近createRoute側で対応が入ったので、多少面倒だけど都度createRouteにミドルウェアを当てると推論できるようになりました!素晴らしい!
https://x.com/engineerYodaka/status/1859546279287673269

BearerAuth

Bearerトークン認証のミドルウェアです。
https://hono.dev/docs/middleware/builtin/bearer-auth

こんな感じで雑にカスタムしてます。

type AuthenticatedEnv = {
  Variables: {
    userId: string
  }
}

export const customBearerAuth = createMiddleware<AuthenticatedEnv>(async (c, next) => {
  const bearer = bearerAuth({
    verifyToken: async (token, c) => {
      const redis = getRedisClient()
      const matchToken = await redis.get(token)
      if (matchToken == null) {
        return false
      }
      const user = JSON.parse(matchToken)
      c.set('userId', user.id)
      return true
    },
  })
  return bearer(c, next)
})

redisClientはコンテキストに詰めてDIした方がいいと思うのですがまだできてないです。
そもそも、他のMVCフレームワークなどのように、ここにログイン中のUserIDを持つレコードをDBから引っ張ってきて、JSONにしてぶち込んでおくとめっちゃ便利そうとかは思っています。
あと、普通にJSON.parseのエラーハンドリングとかしてないのがまずいですね。。
zodなど各種スキーマライブラリを使いましょう。

その他使っているもの

  • Zod OpenAPI
    スマホアプリの開発の方でOpenAPIからAPIクライアントを自動生成してもらっているので、これを使ってOpenAPIを吐き出しています。実装とドキュメントが一致するという夢のような開発体験です。
    が、前述したミドルウェアのところなどHono本体のインスタンスとの振る舞いの違いなどにクセがあったりもするので、OpenAPIが必要なところだけ導入するなどした方が良いかと思います。
    https://hono.dev/examples/zod-openapi

  • HonoRPC
    Webアプリケーション開発における銀の弾丸です。(銀の弾丸です)
    Next.js側ではServerActionなどガン無視でSWRと組み合わせて使っていて、僕が書いた非常に汎用的な美しいコードがあるんですが、これだけでまた一本の記事になりそうなんで、別で書くかも。
    https://hono.dev/docs/guides/rpc#rpc

その他

これは本当に恥ずかしいコードなのですが、Hono使ううえで重要な設定なのでご紹介…
HonoはJSONでレスポンスする時に、JSON.stringify()が使われます。
しかし、JavaScriptはBigIntをJSONに変換できません。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#解説

なので、index.tsにこれを追加します。

declare global {
  interface BigInt {
    toJSON(): number
  }
}
// 九千兆超えたら壊れることを受け入れてnumberにする
BigInt.prototype.toJSON = function (): number {
  return Number(this)
}

Prismaとか使ってると全然BigIntが返ってきたりして、これがめっちゃエラーになるのでnumberに変換して返します。これが正直一番現実的な気がしています。
他に良い方法があればコメントにぜひ!

最後に

最後までお読みいただきありがとうございます。
Honoを一年ほど前?くらいから使い始め、とにかくその魅力に取り憑かれています。
特にHonoRPCはWebバックエンドサーバーとして、自分のやりたかった「OpenAPIが介在しない型安全なWebフロントエンド開発」を実現させてくれました。
これによって非常に高速かつ安全な開発が行えています。
Honoは豊富なミドルウェア、軽量さ、コントリビューターの方のissueへの反応の良さ、プラットフォームの差分を吸収してくれるところなど、良いところがたくさんあります。
自分もそろそろ何かコントリビュートしたい。。
このあともアドベントカレンダーでHonoを盛り上げてくれる素晴らしい記事が続くと思います!
みなさまぜひHonoを使って、このように自分なりのベストプラクティスを共有していっていただければと思います!
少し早いですが、良いお年を!

Discussion