こんにちは!ひさふるです。
先日、こんな記事を書いたところ、たくさんの方に見ていただくことができました。
今回は、"ちゃんと業務で使えるようになる"シリーズ第二弾として、honoについて解説していきたいと思います。
Honoとは?
Honoは、バックエンドを構築するためのWebアプリケーションフレームワークです。
2021年から開発が始まった比較的最近のフレームワークであり、以下のような特徴から様々な箇所で利用が拡大しています。
- 軽量:最も軽量化した場合で
14KB - 高速:RegExpRouterと呼ばれる超高速ルーター搭載
- Web標準:特殊な外部依存が無く、様々な環境で動作する
Honoは日本語の「炎」のことで、Cloudflareの「flare」にかけているそうです
まずは使ってみる
はじめに、Node.jsで簡単なバックエンドサーバーを作ってみます。
npm create hono@latest my-app
途中でどのプラットフォームで利用するか聞かれるのでnodejsを選択しましょう。
その後、出来上がったmy-appフォルダで必要なライブラリをインストールします。
cd my-app
npm i
my-app内に生成されたファイル、src/index.tsを見ると、最低限の実装はされていますね。
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello Hono!')
})
詳細は後述しますが、このコードではルートに対するGETでHello Hono!というテキストが返ってくるようですね。
サーバーを起動して確認してみます。
npm run dev
localhost:3000を叩いて、Hello Hono!が返ってくることが確認できました。
❯ curl localhost:3000
Hello Hono!
まずはチュートリアル通り進めてみましたが、ちょっとでもWeb開発をしたことがある方なら説明不要なほど直感的に理解出来そうなコードだったかと思います。
全体像
一旦、honoの流れを掴むために全体像を説明しておきます。(ここでは詳細はわからなくて大丈夫です)
honoは、大きく分けるとルーティング、ハンドラ、コンテキスト、ミドルウェアという要素から構成されています。
- ルーティング:
app.get('/todo/:id', ...のような形式で、エンドポイントを定義する記述 - ハンドラ:上記ルーティングに続く
(c) => { ...の部分で、各エンドポイントで実行する処理を記述する部分 - コンテキスト:リクエストやレスポンス用オブジェクトにアクセスするためのインスタンス(
(c)や(c, next)などのcがコンテキスト) - ミドルウェア:ハンドラ実行の前後に実行される処理
ルーティングはエンドポイントの定義ですね。
コンテキストはリクエストやレスポンスにアクセスするためのものです。これを通じてリクエストに含まれる情報(パスパラメータなど)にアクセスしたり、逆にレスポンスの作成(例えばレスポンスヘッダーの設定)を行うことができます。
実際に実行される処理を定義するのはハンドラもしくはミドルウェア部分で、各エンドポイントごとの処理はハンドラに書きます。
ミドルウェアは全てのエンドポイントで共通して実行したい処理や、バリデーションなどハンドラに入る前に実行したい処理を定義します。
ここからは、各要素を詳しく説明していきます。
ルーティング:エンドポイントを設計する
ルーティングは、どのようなURLとHTTPメソッドの組み合わせのリクエストを受け付けるか定義するものです。要はエンドポイント設計ですね。
基本構造
honoのエンドポイント定義は以下のように行います。
const app = new Hono()
app.get('/users', (c) => c.text('ユーザー一覧'))
詳細は後述しますが、new Hono()で定義したインスタンスに対して、以下のようにメソッド名、パス定義、ハンドラを順に定義します。
app.get('/users', (c) => c.text('ユーザー一覧'))
app.{メソッド名}({パス定義}, {ハンドラ})
HTTPメソッド設定の仕方
まずはHTTPメソッドの指定の仕方から。
GET、POST、PUT、DELETEそれぞれをapp.getのような形式で指定するだけです。簡単ですね。
const app = new Hono()
app.get('/users', (c) => c.text('ユーザー一覧'))
app.post('/users', (c) => c.text('ユーザー作成'))
app.put('/users/:id', (c) => c.text('ユーザー更新'))
app.delete('/users/:id', (c) => c.text('ユーザー削除'))
パス指定の仕方
上記でお気づきの方も多いと思いますが、パス指定の方法は基本的にはapp.get('/users', ...)のような形式で、メソッドの第一引数に指定するだけです。
app.get('/users', (c) => c.text('ユーザー一覧'))
app.get('/users/me', (c) => c.text('自分の情報'))
ワイルドカード指定で、複数のパターンを一気に受け付けることもできます。/users/*なら/users/aaaでも/users/bbbでもここにルーティングされます。
app.get('/users/*', (c) => c.text('全てのユーザー情報'))
パラメータの指定方法
これも既に登場していますが、:idのように:を付けることで、それをパスパラメータとして使えるようになります。
例えば/users/aaaというリクエストなら、c.req.param('id')でaaaという値が取得できます。
app.get('/users/:id', async (c) => {
const id = c.req.param('id')
// ...
})
?をつけることでオプション化することが出来ます。:id部分が含まれていなくてもリクエスト可能になりますが、c.req.param('id')はundefinedになる可能性があることに注意です。
app.get('/users/:id?', async (c) => {
// 注意)idはundefinedの可能性がある
const id = c.req.param('id')
// ...
})
パスのグループ化
例えば、ここまで説明していた例のように、/users関連だけでも様々なエンドポイントを定義したいことがあります。(GETやPOSTといったメソッド別、また/users/xxxという特定のパス指定など)
そんなとき、全ての定義にいちいち/usersを書くのはめんどくさいですよね。
honoには/usersを省略できるグルーピングという記法があります。2種類記法があるので順に説明します。
パターン1:routeで指定する
1つ目のパターンは、routeでhonoインスタンスを結合するときにパスを指定する方法です。
まず、大切な原則としてhonoインスタンスはrouteで結合することができます。
const users = new Hono()
users.get('/:id', (c) => c.text('ユーザー取得'))
const todos = new Hono()
todos.get('/:id', (c) => c.text('ToDo取得'))
const app = new Hono()
app.route('/users', users) // /users/:idになる
app.route('/todos', todos) // /todos/:idになる
上記の例では、usersとtodosで個別に定義したhonoインスタンスを、appという親インスタンスに統合しています。
その際、app.route('/users', users)のような形式で、appにusersを結合する際に'/users'というパスを指定していますよね。
このときusersで定義した/:idというエンドポイントは、実際には'/users/:id'というパスとして扱われるようになります。
このように、honoは
- エンドポイントを分割して定義できる
- 結合する際にパスを指定できる
という特徴を持っています。これにより、大規模なプロジェクトになってもファイル分割が容易になります。
パターン2:basePathで指定する
2つ目の方法はbasePathで指定する方法です。
honoインスタンスを作成するときにbasePathを指定すると、そのインスタンスは、そのベースパスをもとにしたものになります。
const users = new Hono().basePath('/users')
users.get('/:id', (c) => c.text('ユーザー取得'))
users.post('/', (c) => c.text('ユーザー作成'))
この例の場合はusersに対して作成されるエンドポイントは自動的に'/users'で始まるパスとなります。
今回の場合は、users.get('/:id' ...)部分は実際には/users/:idというパスになります。
routeとbasePathの使い分けは?
routeとbasePathの違いは単に宣言の箇所です。
routeは取り込む側が好きに決められますが、basePathは取り込まれる側で決まるため、後から変更することが難しいです。
小規模なプロジェクトではrouteだけで十分ですが、routeだと/usersとして結合して欲しかったのに/todosのパスに繋げられてしまった、ということが発生するかもしれません。
basePathで/usersを定義しておけば、その後何をしてもパスの先頭が/usersで固定されるため、思わぬミスが少なくなります。
パス実行の優先順位
最後に、honoで非常に重要な実行の優先順位についても説明しておきましょう。
honoでは、パスや後述するミドルウェアは宣言された順に優先的に実行されます。
以下の例を見てください。
// パターンA: meが優先的にマッチするので正常に動作
app.get('/users/me', (c) => c.text('自分のユーザー情報')) // '/users/me'はこっち
app.get('/users/:id', (c) => c.text('idのユーザー情報')) // '/users/aaa'などはこっち
// パターンB: '/users/me'が'/users/:id'に一致するため、'/users/me'に遷移しない
app.get('/users/:id', (c) => c.text('idのユーザー情報')) // 全部こっちに吸われて
app.get('/users/me', (c) => c.text('自分のユーザー情報')) // こっちに到達できない
まず、パターンAでは/users/meを先に宣言しています。
このとき、/users/meにアクセスすると、先に宣言しているapp.get('/users/me',に優先的にマッチし、それ以外のパス(/users/aaaなど)にアクセスするとapp.get('/users/:id',側にルーティングされます。
一方、パターンBでは/users/meも/users/aaaも、全て先に宣言しているapp.get('/users/:id',側に優先的にルーティングされてしまい、app.get('/users/me',に到達できません。
このように、パスパラメータ(:idなど)や、ワイルドカード(*)などの広範囲にマッチするパス指定を先に宣言すると、それ以降に宣言したパスに遷移しなくなる、という事象が発生します。
単純なルールですが、エンドポイント設計が大規模かつ複雑になってくると思わぬところでバグを引き起こす可能性があるので、注意しておきましょう。
参考
コンテキスト:リクエスト&レスポンスを一括管理
ルーティングの章では、どのようにエンドポイントを指定するかについて説明しました。
次は、そのエンドポイント内で実行する処理について説明していきます。
リクエストもレスポンスもcにある
(めちゃくちゃ当たり前の話ですが) Web APIは、何らかのリクエストが来て、その内容をもとに処理を行い、結果をレスポンスとして返す、というのが基本構造ですよね。
要は、リクエストとレスポンスの2軸で成り立っている、というわけです。
honoにおいては、リクエストとレスポンスは両方ともコンテキストというものに格納されています。
コンテキストはハンドラ(またはミドルウェア)の第一引数であり、慣習的にcという名前で定義されます。
以下の例を見てください。
app.get('/users/:id?', async (c) => {
// リクエスト情報はc.reqに格納されている
const id = c.req.param('id')
// idをもとに、ユーザー情報を取得
const data = getData(id)
// 取得した情報をjson形式でレスポンス化
return c.json(data)
})
パスパラメータの部分でも説明しましたが、c.reqという形式でリクエスト情報を参照できます。c.req.param()でパスパラメータにアクセスできましたね。
また、c.json({ message: "OK"})のような書き方で、送り返すレスポンスを作成することも出来ます。
リクエストの詳しい話
もちろん取得できるのはパスパラメータだけではありません。
c.reqはWeb標準のRequestオブジェクトをラップしたものに過ぎません。
そのため、以下のように一般的にHTTPリクエストに含まれる情報は何でも取ってこれます。
パスパラメータ
const id = c.req.param('id')
クエリパラメータ
const query = c.req.query('q')
リクエストヘッダー
const userAgent = c.req.header('User-Agent')
リクエストボディ
// multipart/form-data or application/x-www-form-urlencoded
const body = await c.req.parseBody()
// application/json
const body = await c.req.json()
// text/plain
const body = await c.req.text()
詳細は...
全部説明してるとキリが無いですが、少しでもHTTPリクエストに触れたことがある方ならすぐに使いこなせると思います。
以下のページに情報の取得方法は書いてありますので、参考にしてみてください。
レスポンスの詳しい話
さて、次にレスポンスについてです。
リクエストの話でなんとなく察したと思いますが、こちらも単にHTTPレスポンスを簡単に作成できるというだけです。
代表的なものだけかいつまんで説明します。
ステータス
※ 明示しない場合はデフォルトで200となります。
c.status(201)
ヘッダー
c.header('X-Message', 'My custom message')
レスポンスボディ
レスポンスボディの生成時に呼び出す.textや.jsonは、Responseインスタンスを返します。
これをreturnすることで、クライアント側にレスポンスが返ります。
上記のステータスやヘッダーはあくまで値の設定のための記述であり、Responseインスタンスをreturnしなくてはならないことに注意してください。
// Content-Type: text/plain
return c.text('hello')
// Content-Type: application/json
return c.json({ ok: true })
// Content-Type: text/html
return c.html('<h1>Hi</h1>')
リダイレクト
return c.redirect('/other')
404
return c.notFound()
詳しくは...
こちらも、詳しくはhono公式ドキュメントを参照してください。
ハンドラと内部処理
これで、基本的な構造は定義できるようになりました。先程の例をおさらいしてみましょう。
app.get('/users/:id?', async (c) => {
// リクエスト情報はc.reqに格納されている
const id = c.req.param('id')
// idをもとに、ユーザー情報を取得
const data = getData(id)
// 取得した情報をjson形式でレスポンス化
return c.json(data)
})
1行目、app.get('/users/:id?'までがメソッドとパスでしたね。
1行目のasyncから始まる関数全体がハンドラです。API内で実行される処理を定義します。
ハンドラ内の処理は自由に書けば良いのですが、最も基本的な構造としては
const id = c.req.param('id')のように、リクエスト情報を読み取り...
(ここは架空のコードですが)、const data = getData(id)のような形式でそのAPIで達成したい処理を書き...
最後にreturn c.json(data)でレスポンスとして返す、という感じですね!
ミドルウェア:前後に処理を挟み込む
次にミドルウェアです。
ミドルウェアは、ハンドラの前後に固定で実行できる処理のことです。
実例を知ったほうが分かりやすいと思いますが、要は認証・認可やログ出力を共通処理として実装できるということですね。
ミドルウェア定義の方法
まず、ミドルウェアはapp.useで定義します。use内にcとnextを引数に持つ関数を渡します。
app.use(async (c, next) => {
console.log(`${c.req.method} ${c.req.url}`)
await next()
console.log(`Status: ${c.res.status}`)
})
cは先程説明したコンテキストです。同じようにリクエスト・レスポンスにアクセスできます。
nextは、名前の通り次の処理に移行する関数です。
ざっくり説明するなら、next呼び出し前に書いた処理は前処理、next呼び出し後に書いた処理は後処理となるわけです。
特定のパスに対する適用
関数の前にパスを指定することで、特定のパスに対してのみそのミドルウェアを適用することが出来ます。
app.use('/admin/*', async (c, next) => {
...
})
特定のページにのみ認証をかけたい場合に便利ですね。
ミドルウェアの複数定義
前処理・後処理の仕組みについて更に詳しく見ていきましょう。
ミドルウェアは、複数定義することができます。今回は、ログを出力するミドルウェア①と、時間を計測するミドルウェア②を定義してみます。
const app = new Hono()
// ミドルウェア①
app.use(async (c, next) => {
// ミドルウェア① 前処理
console.log(`${c.req.method} ${c.req.url}`)
// ミドルウェア②を呼び出し
await next()
// ミドルウェア① 後処理
console.log(`Status: ${c.res.status}`)
})
// ミドルウェア②
app.use(async (c, next) => {
// ミドルウェア② 前処理
const start = Date.now()
// ハンドラを呼び出し
await next()
// ミドルウェア② 後処理
c.header('X-Time', `${Date.now() - start}ms`)
})
// ハンドラ
app.get('/todo/:id', (c) => {
const id = c.req.param('id')
const todo = getTodo(id)
return c.text(todo)
})
serve(app)
このとき、各処理の実行順は以下のようになります。ここで、ミドルウェアの実行順も定義した順に優先的に実行されます。
1. ミドルウェア① 前処理
2. ミドルウェア② 前処理
3. ハンドラ
4. ミドルウェア② 後処理
5. ミドルウェア① 後処理
honoではOnion(タマネギ)構造と表現されていますが、ハンドラを包み込むように、それぞれのミドルウェアの前処理・後処理が実行されています。
この構造もhonoを扱う上では非常に重要なので、実行順を頭に入れておきましょう。
組み込みミドルウェア
honoには、組み込みミドルウェアとしてよく使うツールが既に実装されています。
いくつか代表的なものを紹介します。
ベーシック認証
usernameとpasswordの指定だけでベーシック認証をかけられます。
import { Hono } from 'hono'
import { basicAuth } from 'hono/basic-auth'
const app = new Hono()
app.use('/auth/*', basicAuth({
username: 'hono',
password: 'acoolproject',
})
)
ロガー
リクエストとレスポンス内容をログに出力してくれます。
import { Hono } from 'hono'
import { logger } from 'hono/logger'
const app = new Hono()
app.use(logger())
app.get('/', (c) => c.text('Hello Hono!'))
タイムアウト
以下の例では、5秒のリクエストタイムアウトを設定しています。
import { Hono } from 'hono'
import { timeout } from 'hono/timeout'
const app = new Hono()
app.use('/api', timeout(5000))
app.get('/', (c) => c.text('Hello Hono!'))
補足:ミドルウェアの定義順
上記でサラッと触れましたが、ミドルウェアは定義順に実行されます。
また、ハンドラの後にミドルウェアを書いても実行されないというルールも重要です。
app.use(middleWare1())
app.use(middleWare2())
app.get('/', (c) => c.text('Hello Hono!'))
app.use(middleWare3())
上記のような定義をした場合、middleWare3()はapp.get('/'...の実行時には無視されます。
(私は最初、「後処理はハンドラの後に書けばいいのかな?」と勘違いしていましたが、そうではないことに注意してください。)
参考
Zodと組み合わせた型定義
ここまでで既にhonoを使えそうな気がしてきましたが、今回は更にもう一歩踏み込んで、実際のアプリケーションでよく行うZodを使った型定義とそのRPCについても説明しておきましょう。
Zodとは?
ZodはJavaScript / TypeScript向けのスキーマ宣言・バリデーションのためのライブラリです。
以下のようにスキーマを定義し...
import { z } from "zod";
const todoSchema = z.object({
id: z.string(),
content: z.string(),
});
.parseで、todoがtodoSchemaの型に合っているかをバリデーションできます。
todoSchema.parse(todo) //todoがtodoSchemaに則しているか検査
ここでは、todoがidとcontentを持つオブジェクトでなければZodErrorが返されます。
todoSchemaはあくまでZodのスキーマ型ですが、z.inferを使うことでTypeScriptの型に変換できます。
type Todo = z.infer<typeof todoSchema>;
詳しくは以下の方の記事が参考になると思います。
zodのバリデーションをhonoのAPIに組み込む
APIを実装する時、送られてきたリクエストボディの形式が想定するものになっているかどうか検証したいこと、ありますよね?
zodとhonoを組み合わせることで、zodのスキーマを使ってリクエストボディのバリデーションを行うことができるようになります。
import { zValidator } from '@hono/zod-validator'
const todoSchema = z.object({
id: z.string(),
content: z.string(),
});
app.post(
'/todos',
zValidator('json', todoSchema),
(c) => {
// この時点では、リクエストボディがtodoSchemaに則していることが保証されている
const { id, content } = c.req.valid('json')
...
}
)
まず、ミドルウェア記法から確認しておきましょう。
実は、ミドルウェアはapp.useで定義するほか、パス定義とハンドラの間に記述することもできます。
app.post('/todos', zValidator('json', todoSchema), (c) => { ... })
app.{メソッド名}({パス定義}, {ミドルウェア} ... , {ハンドラ})
今回の例では、'/todos'へのpost専用のミドルウェアとして、zValidatorを追加しています。
このzValidatorでは、リクエストボディのjsonがtodoSchemaに合致しているかどうかを検証しています。(正確には、必要なフィールドがあるかを検証します。余分なフィールドは許容されます。)
リクエストボディの他にも、以下のような検証を行うことができます。
| zValidatorの設定 | バリデーション対象 | Content-Type |
|---|---|---|
| json | リクエストボディ(json形式) | application/json |
| form | フォームデータ |
multipart/form-data or application/x-www-form-urlencoded
|
| query | クエリパラメータ | - |
| header | リクエストヘッダ | - |
| param | パスパラメータ | - |
| cookie | クッキー | - |
RPCで、型推論をクライアントでも利用する
これまで、honoを使ってサーバー側のAPIを構築する方法を学んできました。
せっかくなら、ここで定義した情報をクライアント側でも使えたら、便利そうですよね?
honoでは、RPCという機能を使うことによりサーバー側とクライアント側でAPIの仕様を共有することが可能になります。
例えば、zValidatorを組み込んだ以下のコードを見てください。
import { zValidator } from '@hono/zod-validator'
const todoSchema = z.object({
id: z.string(),
content: z.string(),
});
const route = app.post(
'/todos',
zValidator('json', todoSchema),
(c) => {
// この時点では、リクエストボディがtodoSchemaに則していることが保証されている
const { id, content } = c.req.valid('json')
...
return c.json({
id: id,
message: "OK"
})
}
)
export type AppType = typeof route
ここでは、zValidator部分でリクエストボディ、return部分でレスポンスボディの形式が確定します。
ここでapp.post(...)の戻り値をrouteとして受け取り、type AppType = typeof routeという記述をすると、'/todos'にpostするときはどのようなリクエストとレスポンスの形式になるのかという情報を、まるごと取り出すことができます。
クライアント側では、取り出したAppTypeとサーバーのURLをhc(hono client)に渡すことで、サーバー側のAPIを型安全に呼び出せるようになります。
import type { AppType } from './server.ts'
import { hc } from 'hono/client'
const client = hc<AppType>('http://localhost:8787/')
const res = await client.todos.$post({
json: {
id: "aaa",
content: "bbb"
}
})
今回の例では'/todos'に対するpostを定義していましたが、それをclient.todos.$postという形式で呼び出せます。
また、このとき渡されるリクエスト内容がtodoSchemaに合致しているかもTypeScriptの型レベルで検証されるため、型が合わない場合はコンパイル時にエラーとして検出されます。
この機能は、特にNext.jsのように同じプロジェクト内でサーバーとクライアントを同時に定義できるような環境だと非常に便利です。
参考
おわりに
今回はhonoについて解説しました。
正直、少しでもWeb APIに慣れている方ならコード例を見るだけで書き始められるほど、シンプルかつわかりやすい設計になっています。
一方で、zValidatorのように既存のミドルウェアが強力で簡単に機能を拡張できるのもhonoの魅力だと思っています。
私はNext.jsとセットで使うことが多いですが、シンプルが故に使う場所を選ばない汎用性も兼ね備えていると思っているので、ぜひ皆様もお使いの環境にhonoを導入してみてはいかがでしょうか。
今回も、最後までお読みいただきありがとうございました!
参考
