Honoのルーティングの分割を思いつくままに実験

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:同じパスにマッピングできるかな?

よさそうと思いつつ気になったのは、同じパスにマッピングできるのかな?ということ。/usersapp.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;

ふむふむ。よい。↓こんなことを思ったりした。

気になり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が呼ばれている。よい。

順番が重要

ちなみに今のやつで、appuserAppを追加した後にuserApphelloAppを追加したら動かない。↓こんな順番で追加すると/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が/に対してかかっているからだな。

  1. /にuserAppのMiddlewareを登録
  2. /usersにuserAppのハンドラーを登録
  3. /にbookAppのMiddlewareを登録
  4. /booksにbookAppのハンドラーを登録

こうなっていそう。だから、/usersを叩くと(1)(2)が適用されていて、/booksを叩くと(1)(3)(4)が適用されている。なるほど。

でも順番が決まるのはいつなの?

userAppbookAppに登録した順番ではなく、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

やっぱりそうね。今回は↓こういう順番で登録されていそう。

  1. /にbookAppのMiddlewareを登録
  2. /booksにbookAppのハンドラーを登録
  3. /にuserAppのMiddlewareを登録
  4. /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で定義することになるんだろうなと思った。適当な題材で試してみるかなー。