1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Honoについて学ぶ 〜RPCとMiddlewareから理解するHonoの強み〜

1
Posted at

◾️はじめに

未経験エンジニアが初日から案件に携わりその内容をキャッチアップしていくという高いハードルを乗り越えるための一環として、Qiitaをアウトプットの場所として活用したい

HonoについてAIやネットでの検索を見るに
軽量
高速
Web標準
マルチランタイム
型安全
Middleware
など様々な特徴が出てくる。

しかし初学者である筆者にとって、
「Web標準だとなぜランタイムを選ばないのか?」「さらっと出てきたランタイムってなんだよ・・・」
「型安全とは何から守ってくれるのか?TypeScriptと関係はあるんだろう」
「Middlewareは結局どこで何をしているのか?」
と、用語だけを聞いても実像と結びつかなかった。

そこで今回はHonoの基本的な特徴を整理した上で、特に実務でも重要となる
「Web標準とマルチランタイム」
「Hono RPCによる型安全」
「Middlewareによる共通処理」
の3点を、通常のfetchなどと比較しながら深掘りしていく。

そもそもHonoとは?

HonoはTypeScript / JavaScript向けの軽量なWebフレームワークである。
例えばGET APIなら次のように記述できる。

const app = new Hono()

app.get('/users', (c) => {
  return c.json({
    name: 'Tanaka'
  })
})

非常にシンプルである。

Honoの主な特徴

Honoについて学ぶ上で、まず以下の特徴を押さえておきたい。

軽量:フレームワーク自体が小さく、余計な依存や処理が少ない。

シンプルなAPI
(上記に書いたようなシンプルな記述)

Web標準APIをベースにしている
(Request, Response, fetch などがベース)

複数のランタイムに対応できる

TypeScriptとの親和性が高い

RPCによってクライアントとAPIの型を共有できる

Middlewareによって共通処理を切り出せる

未経験が聞きたい深掘り

1. 「Web標準だからランタイムを選ばない」とは?

Honoでは、
Request
Response
URL
fetch
など、Web標準として定義されたAPIを中心に利用する。

例えばfetchはHTTPリクエストを送信するためのWeb標準APIである。

const response = await fetch('/api/users')

Node.jsだけに存在する独自機能に強く依存する設計ではなく、Web標準をベースにしているため、
Node.js・Bun・Deno等複数のJavaScriptランタイムで動かしやすい。

つまり「ランタイムを選ばない」とは、
どこでも無条件に全く同じように動く
という意味ではなく、
特定のランタイム固有APIへの依存が少なく、複数の実行環境へ移植しやすい
という意味だと理解した。

2. Honoの型安全とは何なのか

ここからが今回特に深掘りしたかった部分である。

通常のfetchが抱える問題

まずHono RPCを使わず、通常のfetchでAPIを呼び出してみる。

const response = await fetch('/api/users')
const data = await response.json()

一見何の問題もない。
しかし、URLを書き間違えたらどうだろう。

const response = await fetch('/api/usres')

本来は /api/users なのに、/api/usres と書いてしまった。

問題は、URLが単なる文字列であることだ。
TypeScriptから見れば '/api/usres' はただの文字列として正しく、
それがAPI定義と一致しているかどうかを判断する材料を持っていない。

結果として、この間違いは次のように扱われる。

タイミング 検出できるか
コードを書いている時 ❌ エディタは何も言わない
ビルド時 ❌ 型エラーにならず通ってしまう
実行時 ⭕ 404として初めて発覚する

つまり最後の最後まで気づけない
これが型安全でない状態である。

Hono RPCを使った場合

ここで登場するのがHono RPCである。

RPCとは Remote Procedure Call の略であり、
一般的な意味はネットワーク越しに関数を呼び出す仕組みのことである。

サーバー側で定義したHonoアプリの型を、

export type AppType = typeof app

としてクライアント側へ共有する。
クライアントでは、

const client = hc<AppType>('/')
const response = await client.api.users.$get()

のように呼び出せる。

Server
app.get('/api/users', ...)
        │
        │ typeof app
        ↓
      AppType
        │
        ↓
      Client
client.api.users.$get()

重要なのは、クライアントがAPIを文字列として扱っていないことである。
client.api.users はオブジェクトのプロパティであり、
サーバー側にそのルートが存在するという事実が、型として保証されている。

冒頭の例はどうなるか

では、同じタイポをHono RPCの世界で犯してみる。

const response = await client.api.usres.$get()

今度はコードを書いたその瞬間に、エディタが赤線を引く。

Property 'usres' does not exist on type '{ users: ... }'

usres などというプロパティは AppType に存在しないため、
TypeScriptがAPI定義との不一致として検出できるのだ。
当然ビルドも通らない。

先ほどの表は、こう書き換わる。

タイミング fetch Hono RPC
コードを書いている時 ここで気づく
ビルド時
実行時 404

発覚のタイミングが、実行時から記述時まで前倒しされる。
これがHonoにおける型安全の正体である。

例えるなら

hono-fig1-finder.png

client.api.users がオブジェクトのプロパティであるとは、つまりこういうことである。
存在するものの中から選ぶのか、存在するかどうか分からないものを書き下すのか。
この違いが、エラーに気づくタイミングの違いを生んでいる。
そして型安全の恩恵はURLだけに留まらない。
HTTPメソッド、リクエストの中身、レスポンスの形。
これらすべてが同じ仕組みの上に乗っている。

では、なぜサーバー側の定義がクライアントまで届くのか。
それを支えているのが型推論である。

3. 型安全を支える「型推論」

2章で見た型安全は、なぜ成立するのか。
それを支えているのが型推論である。

例えばサーバーが、

app.get('/users', (c) => {
  return c.json({
    name: 'Tanaka',
    age: 30
  })
})

というレスポンスを返すとする。

このとき c.json() に渡したオブジェクトの形から、
{ name: string, age: number } という型が自動的に導き出される。
開発者が型を書かなくても、コードそのものが型の発生源になる。
これが型推論である。

そしてHono RPCは、この推論された型をクライアント側まで運ぶ。
だから開発者が毎回、

type User = {
  name: string
  age: number
}

と同じ型を手作業で定義し直す必要がない。

ここが重要な点だ。
もし手作業で二重に定義していれば、サーバー側を変更したときに書き換え漏れが起きる。
型とAPIの実態がズレていては、型があっても信用できない。

サーバーのAPI定義
       ↓
     型推論
       ↓
クライアントから利用

型の発生源がサーバー側の一箇所に限られているからこそ、両者はズレようがない。
型推論があるからこそ、型安全が成り立つのである。

4. 型を運ぶ「メソッドチェーン」

型推論によって、サーバー側のコードから型が生まれることは分かった。
では、その型はどうやって AppType という一つの型にまとまるのか。
ここで効いてくるのがメソッドチェーンである。

そして、ここには一つ注意点がある。
typeof app が意味を持つのは、ルートをメソッドチェーンで繋いで書いた場合だけである。

例えば次のように書いたとする。

const app = new Hono()

app.get('/api/users', ...)
app.post('/api/users', ...)

一見問題なさそうに見えるが、これでは型が育たない。
app の型は new Hono() の時点で確定してしまい、後から登録したルートの情報が AppType に含まれないのだ。

正しくはこう書く。

const app = new Hono()
  .get('/api/users', ...)
  .post('/api/users', ...)

Honoの .get().post() は、
ルート情報を1つ追加した新しいアプリの型を返す
という作りになっている。

つまりチェーンを繋ぐたびに、

new Hono()             → ルート情報なし
  .get('/api/users')   → users の GET を知っている型
  .post('/api/users')  → users の GET と POST を知っている型

というように型が積み上がっていく。
この積み上がった最終形こそが typeof app であり、AppType の正体である。

チェーンを切ってしまうと、この積み上げの結果が受け取られずに捨てられる。
厄介なのは、実行時にはルートが正しく登録されるためサーバー自体は普通に動いてしまうことだ。
壊れるのは型の世界だけなので、クライアント側の補完が効かなくなって初めて気づくことになる。

型推論が型を生み、メソッドチェーンがそれを一箇所へ集める。
この2つが揃って初めて、2章で見た型安全が成立する。

5. Middlewareとは何なのか

次にHonoを理解する上で欠かせないMiddlewareについて考える。

Middlewareとは簡単に言えば、
Handlerの前後に共通処理を挟む仕組み
である。

例えば、

app.use('*', async (c, next) => {
  console.log('リクエストが来た')
  await next()
  console.log('処理が終わった')
})

app.get('/users', (c) => {
  return c.json({ name: 'Tanaka' })
})

この場合、

async (c, next) => {
  // ...
}

がMiddleware本体であり、
app.use()
によってHonoへMiddlewareを登録している。

6. Middlewareの中身は何をしているのか

Middlewareは例えば、

  • 認証
  • 認可
  • ログ
  • CORS
  • リクエスト検証
  • エラーハンドリング
  • 処理時間計測

などを担当できる。

重要なのは、これらを各Handlerへ毎回書かなくてよいことである。

仮に、

app.get('/users', ...)
app.get('/posts', ...)
app.post('/posts', ...)
app.delete('/posts/:id', ...)

というAPIが存在するとする。
それぞれに同じ認証処理を書くのではなく、

             認証Middleware
                    ↓
        ┌───────────┼───────────┐
        ↓           ↓           ↓
     /users      /posts     /comments

と共通化できる。

7. Middlewareも型情報を運んでいる

ここで2章から4章の話が戻ってくる。
Middlewareもまた、メソッドチェーンと同じ仕組みで型に乗るのである。

Honoでは .get() の引数を
「パス → ミドルウェア → ハンドラ」
の順に並べて書ける。

const app = new Hono()
  .get(
    '/api/users/:id',              // ① パス
    zValidator('param', schema),   // ② ミドルウェア(バリデータ)
    (c) => { ... }                 // ③ ハンドラ(本来やりたい処理)
  )

②の zValidator(...) がミドルウェアである。
リクエストは③のハンドラに届く前に必ず②を通り、
:id の中身がスキーマ通りかチェックされる。
(今回はURLの一部なので必ず文字列 = string を求め、それ以外は弾く)
形式が違えばその時点で弾かれ、ハンドラは実行されない。

そして重要なのは、この②が
「このルートはこういう入力を受け取る」
という情報を型にも追加していることだ。
そのためクライアント側では、渡す引数の形までチェックされるようになる。

client.api.users[':id'].$get({ param: { id: '1' } })
//                            └─ ②のスキーマから型が決まる ─┘

例えると

hono-fig2-gate.png

APIルートという交通整理員と、Middlewareという門番。
この両者が持っている型情報が、そのままクライアントまで届く。

ここを押さえておいてほしい。

8. await next()と玉ねぎ構造

今回のプロジェクトについてあまり深掘りする必要はなかったのだが、いずれ必要になるであろう点も深掘りしておく。

HonoのMiddlewareを理解すると、

await next()

というコードが登場する。
最初は、
「awaitなのに次へ行くとはどういうことだ?」
と混乱した。

これを分解すると分かりやすい。

next()
  ↓
次のMiddleware / Handlerへ進む

await
  ↓
その処理が完了するまで待つ

つまり
「次の処理を実行して、その処理が終わったらここへ戻ってきて」
という意味になる。

例えばMiddlewareが2つ存在すると、

app.use('*', async (c, next) => {
  console.log('A 前')
  await next()
  console.log('A 後')
})

app.use('*', async (c, next) => {
  console.log('B 前')
  await next()
  console.log('B 後')
})

app.get('/users', (c) => {
  console.log('Handler')
  return c.json({ name: 'Tanaka' })
})

図に示すと

Middleware A
┌──────────────────────────┐
│                          │
│   Middleware B           │
│   ┌──────────────────┐   │
│   │                  │   │
│   │     Handler      │   │
│   │                  │   │
│   └──────────────────┘   │
│                          │
└──────────────────────────┘

HandlerをMiddlewareが包み込むような構造になる。
これが**玉ねぎ構造(Onion Model)**である。

実務でのHonoとmiddleware

ここまででHonoとMiddlewareの仕組みは理解できた。
では実務では何が嬉しいのだろうか。

長所① 共通処理をHandlerから分離できる

例えば認証処理をHandlerへ直接書いてしまうと、

app.get('/users', async (c) => {
  // 認証
  // users取得
})

app.get('/posts', async (c) => {
  // また認証
  // posts取得
})

と同じ処理が散らばってしまう。

Middlewareへ分離すれば、

app.use('/api/*', authMiddleware)

として共通化できる。

Handlerは、
「そのAPIが本来やるべき仕事」
に集中できる。

長所② Handlerまで届く前にリクエストを弾ける

認証Middlewareを考える。

app.use('/admin/*', async (c, next) => {
  const user = /* ユーザーを確認 */

  if (!user) {
    return c.json(
      { error: 'Unauthorized' },
      401
    )
  }

  await next()
})

認証失敗時にはnext()を呼ばない。
すると、

Request
   ↓
認証 Middleware
   ↓
認証 OK?
   │
   ├── NO ──→ 401 Unauthorized
   │              ↓
   │             終了
   │
   └── YES ─→ await next()
                  ↓
               Handler

となる。

つまり不正なリクエストを、
本来のHandlerまで到達する前に弾くことができる。
認証に失敗しているのにDBアクセスなどの本処理まで実行してしまうことを防げる。

長所③ Handlerの前後をまたいだ処理ができる

例えば処理時間を計測したい。

app.use('*', async (c, next) => {
  const start = Date.now()
  await next()
  const end = Date.now()
  console.log(`${end - start}ms`)
})

await next()によってHandlerが終了した後にMiddlewareへ戻ってくるため、

時間計測開始
    ↓
 Handler
    ↓
時間計測終了

という処理が簡単に実装できる。

長所④ Contextに情報を埋め込んで共通化できる

HonoではMiddlewareからContextへ値を設定できる。
例えば認証Middlewareでユーザー情報を取得したとする。

app.use('/api/*', async (c, next) => {
  const user = {
    id: 1,
    name: 'Tanaka'
  }

  c.set('user', user)

  await next()
})

Handler側では、

app.get('/api/profile', (c) => {
  const user = c.get('user')
  return c.json(user)
})

と利用できる。

認証Middleware
   ↓
ユーザー情報取得
   ↓
Contextへ保存
   ↓
c.set('user')
   ↓
Handler
   ↓
c.get('user')

と情報を受け渡せる。

認証済みユーザーの取得などを各Handlerで毎回行う必要がなくなり、
「共通処理だけでなく、その共通処理によって得られた情報までHandlerへ共有できる」
というのがMiddleware + Contextの強みだと理解した。

まとめ

具体例を用い長くなってしまったが押さえるべきはここ

  • Honoは軽量でシンプルなWebフレームワーク
  • Web標準APIをベースにしており、特定ランタイムへの依存が少ない
  • Hono RPCでサーバー側のAPI定義をクライアント側へ型として共有できる
  • Middlewareで認証・ログ・計測などの共通処理を分離できる

Honoの強みを一言で表現するなら、

Web標準をベースにしたシンプルなAPIサーバーを構築でき、TypeScriptの型とMiddlewareによってクライアントとの通信とサーバー内部の共通処理を安全かつ整理された形で実装しやすい

Honoについて学習を始めた当初は、「軽量」「高速」「型安全」といった特徴を聞いてもそのまま受け取っていたため上長との受け答えにも自信が持てなかった。

そこで実際にfetchとHono RPCを比較したり、Middlewareのawait next()がどのように動いているのかを追ったりすることで、それぞれの特徴が実際のコード上で何を解決しているのかが少しずつ見えるようになった。

今後は実際のプロジェクトコードを読みながら、Hono RPCによる型推論やMiddlewareによる責務分離がどのように利用されているのか、さらに理解を深めていきたい。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?