Grouping
HonoにはGroupingという機能がある。僕はルーティングを複数のファイルに分けて書きたいタイプなので、たぶんこれを使うだろうなと思っている。
実験環境
Node.jsが好きなのでNode.jsで動かしている。
hono: 4.6.16
@hono/node-server: 1.13.7
node.js: 23.6.0
分割に対するHono的おすすめはappまるごと
ルーティングを分割して書こうと思うと、まず最初に思いつくのはhandler関数を別のファイルに定義することかなと思う。で、それをメインのルーターのところでパスにマッピングする。
でも、Hono的なおすすめはそうじゃない。handler関数ではなくappインスタンスを別のファイルに定義する。それによって、型を気にしなくてもHonoがいい感じに推論してくれる。
appインスタンスを別のファイルに定義する?
たとえば、こんなパスを定義したいとき
/users
/users/:id
/books
/books/:id
こんな風にUser用とBook用を分割できる
import { Hono } from 'hono'; const userApp = new Hono(); userApp.get('/', (c) => { return c.text('users'); }); userApp.get('/:id', (c) => { const id = c.req.param('id'); return c.text('Get User: ' + id); }); export { userApp };
import { Hono } from 'hono'; const bookApp = new Hono(); bookApp.get('/', (c) => { return c.text('books'); }); bookApp.get('/:id', (c) => { const id = c.req.param('id'); return c.text('Get Book: ' + id); }); export { bookApp };
そして、それらをメインappの.route
メソッドで登録する。
import { Hono } from 'hono'; import { bookApp } from './bookApp.js'; import { userApp } from './userApp.js'; const app = new Hono(); app.route('/users', userApp); app.route('/books', bookApp); export default app;
起動して動作確認
❯ curl localhost:3000/users users ❯ curl localhost:3000/users/1 Get User: 1 ❯ curl localhost:3000/books books ❯ curl localhost:3000/books/2 Get Book: 2
よさそう。
HonoRPCを使うときは、メソッドチェーンで定義するほうがいいらしいけど、今のところ僕はRPCじゃなくてOpenAPIを使うつもりなのでチェーンしなくていいかな。
気になり1:同じパスにマッピングできるかな?
よさそうと思いつつ気になったのは、同じパスにマッピングできるのかな?ということ。/users
をapp.ts
側じゃなくてuserApp.ts
側で持てるのかな?と。先に書いておくと、持てる。
import { Hono } from 'hono'; // basePathで指定 const userApp = new Hono().basePath('/users'); userApp.get('/', (c) => { return c.text('users'); }); userApp.get('/:id', (c) => { const id = c.req.param('id'); return c.text('Get User: ' + id); }); export { userApp };
bookApp.ts
も同じ感じなので省略。app.ts
はこうなる。
import { Hono } from 'hono'; import { bookApp } from './bookApp.js'; import { userApp } from './userApp.js'; const app = new Hono(); // '/'にマッピング app.route('/', userApp); // '/'にマッピング app.route('/', bookApp); export default app;
ふむふむ。よい。↓こんなことを思ったりした。
Honoの.routeのpathはオプショナル(指定してなかったら'/')でもいいかなって気はする?
— SHIIBA Mitsuyuki (@bufferings) January 13, 2025
気になり2:basePathを使わずに書いても動く?
次に気になったのはbasePathを使わずに書いても動くのかな?ということ。試してみたら動いた。
import { Hono } from 'hono'; // basePathを使わない const userApp = new Hono(); // 各ハンドラーでパスを指定 userApp.get('/users', (c) => { return c.text('users'); }); userApp.get('/users/:id', (c) => { const id = c.req.param('id'); return c.text('Get User: ' + id); }); export { userApp };
なるほど。
気になり3:Middlewareってどうなる?
次に気になったのが、各appでMiddlewareを指定したらどうなるんだろう?ということ。実験だから1ファイルに書いて試してしまおう。
import { Hono } from 'hono'; const userApp = new Hono().basePath('/users'); userApp.use(async (c, next) => { console.log('userApp before'); await next(); console.log('userApp after'); }); userApp.get('/', (c) => { return c.text('users'); }); const bookApp = new Hono().basePath('/books'); bookApp.use(async (c, next) => { console.log('bookApp before'); await next(); console.log('bookApp after'); }); bookApp.get('/', (c) => { return c.text('books'); }); const app = new Hono(); app.route('/', userApp); app.route('/', bookApp); export default app;
usersを叩いてみる
❯ curl localhost:3000/users users
userApp before userApp after
booksを叩いてみる
❯ curl localhost:3000/books books
bookApp before bookApp after
ふむふむ。
気になり4:親子関係があるときのMiddlewareってどうなる?
やってみよう。適当に/users/hello
みたいなパスをuserApp
の下にくっつけてみよう。
import { Hono } from 'hono'; // userAppとbookAppはさっきと同じなので省略 // これを追加 const helloApp = new Hono().basePath('/hello'); helloApp.use(async (c, next) => { console.log('helloApp before'); await next(); console.log('helloApp after'); }); helloApp.get('/', (c) => { return c.text('hello!!!'); }); // userAppにhelloAppをつけてみる userApp.route('/', helloApp); const app = new Hono(); app.route('/', userApp); app.route('/', bookApp); export default app;
/books
を叩いてみる
bookApp before bookApp after
bookAppのMiddlewareだけが呼ばれている。よい。次は/users
。
userApp before userApp after
userAppのMiddlewareのみ。よい。最後に/users/hello
。
userApp before helloApp before helloApp after userApp after
まずuserAppのMiddlewareが呼ばれて、次にhelloAppのMiddlewareが呼ばれている。よい。
順番が重要
ちなみに今のやつで、app
にuserApp
を追加した後にuserApp
にhelloApp
を追加したら動かない。↓こんな順番で追加すると/users/hello
は404になってしまう。
const app = new Hono(); app.route('/', userApp); app.route('/', bookApp); userApp.route('/', helloApp);
app.route()
でuserApp
を登録した時点の情報を使ってるってことか。Honoは順番が重要だね。
って書きながら、ふと気になって試してみたんだけど、Middlewareとhandlerの順番も重要みたいだな。
const helloApp = new Hono().basePath('/hello'); // Middlewareを定義する前にハンドラーを定義 helloApp.get('/1', (c) => { return c.text('hello!!!'); }); // Middlewareを定義 helloApp.use(async (c, next) => { console.log('helloApp before'); await next(); console.log('helloApp after'); }); // Middlewareを定義したあとのハンドラー helloApp.get('/2', (c) => { return c.text('hello2!!!'); });
この場合、/users/hello/1
にはMiddlewareがかかってない。/users/hello/2
にはかかってる。へー。Honoは順番が重要だね。Middlewareをかけたつもりでかかってないってならないように注意しておきたいな。
気になり5:basePathを使わなかったときのMiddlewareってどうなる?
気になり3ではbasePathを使ってたけど、使わなかったらMiddlewareがかかる場所はどうなる?と思ったので試してみた。
import { Hono } from 'hono'; // basePathをはずした const userApp = new Hono(); userApp.use(async (c, next) => { console.log('userApp before'); await next(); console.log('userApp after'); }); // ハンドラーの方でパスを指定 userApp.get('/users', (c) => { return c.text('users'); }); // 同様 const bookApp = new Hono(); bookApp.use(async (c, next) => { console.log('bookApp before'); await next(); console.log('bookApp after'); }); bookApp.get('/books', (c) => { return c.text('books'); }); const app = new Hono(); app.route('/', userApp); app.route('/', bookApp); export default app;
/users
から
userApp before userApp after
次に/books
userApp before bookApp before bookApp after userApp after
ふむ。やっぱりそうか。
どういうこと?
bookAppに定義されているルートである/books
を叩いたのに、bookAppに定義されたMiddlwareだけじゃなくて、userAppに定義されたMiddlewareも呼び出されている。
これは、両方のMiddlewareが/
に対してかかっているからだな。
/
にuserAppのMiddlewareを登録/users
にuserAppのハンドラーを登録/
にbookAppのMiddlewareを登録/books
にbookAppのハンドラーを登録
こうなっていそう。だから、/users
を叩くと(1)(2)が適用されていて、/books
を叩くと(1)(3)(4)が適用されている。なるほど。
でも順番が決まるのはいつなの?
userApp
やbookApp
に登録した順番ではなく、rootのルーターに登録した順番で決まるのではないか?という気がしたので
const app = new Hono(); app.route('/', userApp); app.route('/', bookApp);
↑このapp.route()
に登録する順番を逆にしてみた↓
const app = new Hono(); app.route('/', bookApp); app.route('/', userApp);
ら、こうなった。
/users
を叩いたとき
bookApp before userApp before userApp after bookApp after
/books
を叩いたとき
bookApp before bookApp after
やっぱりそうね。今回は↓こういう順番で登録されていそう。
/
にbookAppのMiddlewareを登録/books
にbookAppのハンドラーを登録/
にuserAppのMiddlewareを登録/users
にuserAppのハンドラーを登録
ところで404
ところでちょこちょこ叩いて遊んでて気付いたんだけど、404のときはどちらのMiddlewareも通過する様子。
❯ curl localhost:3000/aaa 404 Not Found
↓こんなログがでてる
bookApp before userApp before userApp after bookApp after
つまりHonoは、マッチするパスがあるかどうかを先に確認してから処理を始めるわけじゃなくて、Middlewareを適用しながらマッチするパスを探し回ってるってことか。マッチするパスがあったらそこで処理をして終わるけど、なかったら次に進んで最後まで何も見つからなかったら404になる。って動きなのかな。
おもしろかった!!!
こうかな
- Middlewareの定義順が重要なのは意識していたけど、MiddlewareとHandlerの定義の順番も重要。Middlewareを先に登録しておこうと思った
- Groupingを使う場合、異なるパスにマッピングするか、basePathを指定するほうがいい。そのほうがMiddlewareの挙動が分かりやすい
- basePathを指定せずに複数のappを同じパスにマッピングする場合、それぞれのappのrootに対してMiddlewareを定義してしまうと他のappにも影響があるから、そういう定義は避けたい。basePathを指定しない場合は、Middlewareの定義はroot以外のパスを指定するか、各Handlerに対して指定するようにする。
- 404になる場合でもMiddlewareは実行されることに注意しておく
今回こういう確認をしたのは、僕が1ファイル1handlerで定義するのが好きだから。Honoの場合は1ファイル1appで定義することになるんだろうなと思った。適当な題材で試してみるかなー。