廣瀬製紙株式会社

Employees' Blog 社員ブログ

そのルーティング、順番で決まっていませんか?
HTTP ルーターの「先勝ち」と「バックトラックしない」という設計

公開日: 2026.08.06 更新日: 2026.08.06
「順番が、すべてを決める」という見出しと 400 の文字を添えて、手紙が意図した窓口ではない別の窓口に横から引き取られてしまう様子を描いた挿絵

正しいはずのリクエストが 400 で返ってくる

Web API サーバーを書いていると、ある日こんな現象に出会うことがあります。

たとえば、ユーザーを扱う API に2つのエンドポイントを用意したとします。1つは特定のユーザーを ID で引く GET /users/:id(id は数値の想定)、もう1つは全ユーザーを一覧する GET /users/all です。/users/all のハンドラーは「該当データが無ければ空配列を 200 で返す」だけの単純な処理で、単体テストも全部通っています。

ところが、ブラウザから /users/all を叩くと、返ってくるのは 400 Bad Request。エラーの中身には「id は数値である必要があります」と書かれています——。

/users/all というリクエストのどこにも数値の id など送っていないのに、なぜ「id が数値でない」と怒られるのでしょうか。

犯人は、ハンドラーのロジックでもバリデーションの実装ミスでもありません。そのリクエストが、あなたの意図した route ではなく、別の route に「先に捕まえられていた」のです。この記事では、HTTP フレームワークが「どの route がリクエストを処理するか」をどう決めているのか、その仕組みと落とし穴を、一次情報をたどりながら初心者の方にも届くように解説します。

具体的な再現は、Node.js のミドルウェア型フレームワーク Express を例に進めます。ただし、ここで扱う「複数マッチしたときにどう選ぶか」「途中で失敗したらどうなるか」という原則は、多くの HTTP ルーターに共通する話です。記事の後半では、Express とは対照的な選び方をする別系統のルーターにも触れて、両者を並べて理解できるようにします。

この記事で扱うのは、次の3点です。

  • ルーターがリクエストと route を突き合わせる2つの主要な方式(登録順マッチと特異度優先マッチ)
  • 「一度マッチした route は、途中で失敗しても次の候補に戻らない」というバックトラックしない設計
  • route をファイル分割したときに、この落とし穴がなぜ再発するのか

まず全体像:ルーティングとは何をしているのか

深掘りの前に、用語を最小限そろえておきます。

ルーティングとは、ざっくり言うと「届いた HTTP リクエストを、それを処理すべき関数に振り分ける」仕組みです。受付係が来客を担当部署に案内するようなものです。

「受付」の看板と「振り分ける」の文字を添えて、受付係が届いた配達物を奥に並ぶ複数の担当窓口のひとつへ案内している様子を描いた挿絵
図 1: ルーティングは「受付係が担当部署へ案内する」仕事

そのとき使う「担当表の1行」が route(ルート) です。route は「どんなリクエストを」「どの関数で処理するか」の対応づけで、たとえば「GET /users/all が来たら 一覧を返す関数 を呼ぶ」という1組を指します。

route の「どんなリクエストを」の部分に書くパスには、2種類あります。

  • リテラル(静的)セグメント … /users/all の all のように、文字が固定された部分。その綴りにぴったり一致したときだけマッチします。
  • パラメトリック(動的)セグメント … /users/:id の :id のように、任意の1セグメントを受け取る部分。/users/42 でも /users/all でも、その位置の中身を id という名前で受け取ってマッチします。

ここで早くも不穏な点に気づきます。/users/all というリクエストは、/users/all(リテラル)にも /users/:id(パラメトリック)にも両方マッチしうるのです。両方の route が登録されていたら、ルーターはどちらを選ぶのでしょうか。この「複数マッチしたときにどちらを選ぶか」こそが、今回の主題です。

1通の配達物から道が2本に分かれ、「文字が固定」と添えた /users/all の投函口と「任意の値」と添えた /users/:id の投函口のどちらにも当てはまることを、「どちらにも当てはまる」の文字で示した挿絵
図 2: 1つの URL が複数の route にマッチしうる

ルーターの「地図」:マッチ方式には2つの系統がある

「複数マッチしたときどちらを選ぶか」は、フレームワークによって答えが違います。まず主な選択肢を地図として並べておきます。これを押さえておくと、「自分が使っているルーターはどちらの系統で、だから何に気をつけるべきか」が判断できるようになります。

系統選び方順序の依存代表的な実装落とし穴
登録順マッチ 登録した順に上から突き合わせ、最初にマッチした route が勝つ 登録順に強く依存する ミドルウェア型フレームワーク(Express など) 順番を間違えるとパラメトリックがリテラルを飲み込む
特異度優先マッチ 登録順に関係なく、最も具体的な route が勝つ 登録順に依存しない 基数木(radix tree)ルーター(find-my-way / それを使う Fastify など) 依存が少ない一方、優先順位の規則を知らないと予想外の route が選ばれる

なお、これら2系統の呼び名「登録順マッチ」「特異度優先マッチ」は、本記事が説明の便宜上つけた区別で、広く標準化された正式名称ではありません。実装ごとの用語(find-my-way の README では「Match order」など)とは切り分けて読んでください。

ざっくり2系統の違いはこうです。

  • 登録順マッチは、route を「登録した順に上から順番に」試し、最初にマッチした route に処理を任せる方式です。シンプルですが、順番がすべてを決めます。
  • 特異度優先マッチは、内部に基数木(radix tree、圧縮された接頭辞木)という木構造を持ち、URL を木でたどって最も具体的にマッチする routeを選ぶ方式です。少なくとも本記事で扱う「静的 route 対パラメトリック route」の競合では、登録した順番は結果に影響しません。

たとえるなら、登録順マッチは「先に手を挙げた人が担当」、特異度優先マッチは「その案件に一番詳しい人が担当」という違いです。

「先に手を挙げた人」と「一番詳しい人」の2つの見出しで、案件を受け取る人の決め方の違いを左右に対比した挿絵
図 3: 「先に手を挙げた人が担当」と「一番詳しい人が担当」

この記事が主に深掘りするのは、登録順マッチの系統に特有の落とし穴です。理由は、この系統では「順序が唯一の防御線」になり、しかもその防御線が思わぬ形で崩れるからです。特異度優先マッチの系統については、比較のために要点だけ触れます。

深掘り 1:リテラルとパラメトリック、どちらが勝つか

登録順マッチの系統では、ルーターは route を登録された順に上から突き合わせ、最初にマッチした route にまず制御を渡します。その route がレスポンスを返すか、あるいはエラー処理へ進めば、通常は後続の route には進みません。これが「先勝ち(first match wins)」です。ここでの「先勝ち」は「先にマッチした route が処理権を得る」という意味で、後続へ進めるには next() や next('route') による明示的な委譲が要ります(この出口は深掘り 2 で扱います)。

Express の公式ドキュメントも、route のパス指定とパラメータの仕組みを説明しており、パラメトリックな /:id は該当セグメントの中身を req.params に取り込む、と定めています。裏を返すと、/:id は「その位置の任意の1セグメントにマッチする」ため、綴りの決まったリテラル route を後ろに置くと、永遠に呼ばれなくなります。

// 悪い順序:パラメトリックが先
app.get('/users/:id', getUserById);   // /users/all もここにマッチしてしまう
app.get('/users/all', getAllUsers);   // 通常は到達しない

// 良い順序:リテラルが先
app.get('/users/all', getAllUsers);   // /users/all はここで確定
app.get('/users/:id', getUserById);   // /users/42 などはこちら

上の「悪い順序」では、GET /users/all は1行目の /users/:id にマッチし、id = "all" として getUserById に渡ります。getAllUsers は登録されているのに一度も呼ばれません。この「all が id として渡る」ことが、次の深掘り 2 で見る 400 の火種になります。

ここで大事な原則です。

登録順マッチの系統では、リテラル route を、同じセグメント数のパラメトリック route より「先に」登録する。順序が唯一の防御線になる。

一方、特異度優先マッチの系統では話が違います。find-my-way(Fastify が内部で使うルーター)は、ドキュメントで静的(リテラル)route を常にパラメトリックより優先し、ワイルドカードを最も低い優先度とすると明記しています。優先順位はおおむね次の順です。

  1. 静的(リテラル)route
  2. 静的な末尾を持つパラメトリック route
  3. 正規表現つき・複数パラメトリック route
  4. 一般的なパラメトリック route
  5. ワイルドカード route

この系統では /users/all と /users/:id をどの順で登録しても、より具体的な /users/all が選ばれます。順序に悩まなくてよい代わりに、「なぜこの route が選ばれたのか」を追うには上記の優先順位規則を理解している必要があります。

深掘り 2:一度マッチしたら、失敗しても次の候補に戻らない

ここからが、冒頭の「数値の id なんて送っていないのに 400」という謎の核心です。そして、多くの人が直感に反して間違えるところです。

嫌らしいのは、セグメント数が同じで、片方だけがバリデーションを持つときです。ここでは深掘り 1 の /users/all × /users/:id を、もう一度そのまま使います。ちがいは1つだけ——/users/:id の id に「数値であること」というバリデーションが付いている、と考えてください(ユーザー ID は数値、という前提です)。

  • route Y … /users/:id(id は数値であることを要求する。パラメトリック)
  • route X … /users/all(全ユーザーを返す。リテラル)

深掘り 1 で見たとおり、登録順が「Y が先」だと GET /users/all は route Y にマッチし、id = "all" としてバリデーションに回されます。id は数値のはずなのに "all" が来たので、バリデーションは失敗し 400 を返します。送った URL に数値の id などどこにも無いのに、「id は数値である必要があります」と怒られる——冒頭の謎はこれです。

リクエスト:      GET /users/all
選ばれた route:  /users/:id
抽出した params: { id: "all" }
バリデーション:  id は数値のはずが "all" → 失敗
結果:            400 Bad Request

ここで多くの人がこう考えます。「Y のバリデーションで弾かれたのだから、ルーターは次の候補、route X を試してくれるだろう」。これは成立しません。

一方通行の扉を通って /users/:id の窓口に入った配達物が「検証失敗」で弾かれ 400 の出口へ送られる一方、隣の /users/all の窓口へ戻る道が「戻り道はない」として塞がれていることを示した挿絵
図 4: いったん通された先に、戻り道はない

なぜでしょうか。登録順マッチの系統では、いったんある route にマッチしてそのミドルウェア(バリデーションなど)に入った後、そのミドルウェアがエラーで失敗しても、ルーターは「別の綴りの route が拾ってくれるかも」と自動では後戻りしません。失敗のさばき方は実装によって2通りあります。バリデーションがその場で 400 を返して終わるか、あるいは next(err) を呼んでエラー処理ミドルウェアへ遷移するかです。どちらの場合も、その失敗を理由に別の route が自動的に選び直されることはなく、その時点でレスポンスが確定します。

これはバックトラック(後戻り探索)をしないという設計です。ここでいう「バックトラックしない」とは、パスマッチングの内部アルゴリズムそのものの話ではなく、いったん選ばれた route のバリデーションやハンドラーが失敗しても、その失敗を理由に別の route を自動的に選び直さない、という意味に限定して読んでください。正規表現エンジンなら、ある選択肢で行き詰まると別の選択肢へ後戻りして探索をやり直しますが、route の選択と、選ばれた route 内の入力検証は別の段階です。後段の入力検証に失敗しても、前段の route 選択は自動的にはやり直されません。

「次の候補へ回す」明示的な出口はある

ただし、Express には意図的に次の route へ回すための出口が用意されています。route のコールバックの中で next('route') を呼ぶと、その route の残りの処理を飛ばして次の route の探索に移れます。公式ドキュメントは、複数のコールバックについて「これらのコールバックは next('route') を呼んで、その route の残りのコールバックを飛ばせる」と説明しています。

app.get('/users/:id', (req, res, next) => {
  if (req.params.id === 'all') {
    return next('route');   // この route を諦め、次の route を試す
  }
  res.send(`ユーザー ${req.params.id}`);
});

app.get('/users/all', (req, res) => {
  res.send('全ユーザー一覧');   // 上が next('route') したときだけ届く
});

もっとも、all のような特定の値を個別に弾くより、通常はリテラル route(/users/all)を先に登録するほうが単純で保守しやすい設計です。上の例は、あくまで next('route') の挙動を示すためのものと考えてください。

ここで区別すべきは、「エラーで失敗する」ことと「next('route') で明示的に譲る」ことは別物だという点です。前者はエラー処理へ直行して終わり、後者だけが次の候補へ回ります。つまり「バリデーションで弾かれたら別の route が拾う」という自動的なフォールバックは存在しない、というのが原則です。フォールバックさせたいなら、そう書かなければなりません。

深掘り 3:ファイル分割で「順序の規則」が崩れる

深掘り 1 の結論は「リテラルをパラメトリックより先に登録する」でした。小さなアプリなら、同じファイルの中で route を上から順に並べるだけで守れます。

ところが、アプリが育つと route を機能ごとに別ファイル(サブルーター)へ切り出します。ここに盲点があります。「同じファイル内での並び順」を守っていても、別ファイルに切り出したリテラル route は、その並びの外に出てしまうのです。

// usersAllRouter.js … リテラル寄りの具体的な route
router.get('/users/all', getAllUsers);

// usersByIdRouter.js … パラメトリックな route
router.get('/users/:id', getUserById);

// app.js … サブルーターを合成する。ここの順序が新たな防御線になる
app.use(usersByIdRouter);   // ← 先に載せると /users/:id が all を飲み込む
app.use(usersAllRouter);

それぞれのファイルの中では順序ルールを守っているつもりでも、合成する場所(app.use の順番)が新しい「唯一の防御線」になっています。ここでパラメトリック側を先に載せると、深掘り 2 のシャドーイングがそのまま再発します。

対策は系統によって異なります。

  • 登録順マッチの系統なら、合成の順序を管理する。リテラル寄りのサブルーターを、同じセグメント数のパラメトリックなサブルーターより先にマウントする。
  • 特異度優先マッチの系統なら、そもそも順序に依存しないので、この問題は起きにくい。ただし優先順位の規則は理解しておく。

テストの教訓:バグが「結合点」にしかないなら、テストも結合点に置く

この落とし穴には、テスト設計上の重要な含意があります。

サブルーターを単独で組んだ単体テストは、いくら書いてもこのバグを検知できません。シャドーイングは「複数のサブルーターを同じアプリに合成した瞬間」にしか発生しないからです。単独のルーターだけを載せてテストすれば、当然それ単体では正しく動き、テストは緑になります。

バグが「単体の内側」ではなく「単体どうしの結合点」にしか存在しないなら、テストも結合点に置く。

「単体では正しい」と添えた2つの仕分けレーンが、「継ぎ目で壊れる」と示された接合部だけで食い違っている様子を描いた挿絵
図 5: 単体では正しく、合わせたときだけ壊れる

具体的には、実際のマウント順で複数のサブルーターを載せた「結合テスト」を書きます。このとき、ステータスコードだけを見ていると、別の route が偶然 200 を返してもテストが通ってしまいます。「どのハンドラーが選ばれたか」まで——レスポンス本文の中身を確かめる、あるいはハンドラーを spy にして呼ばれたかどうかを検証する——確かめると堅牢です。

日常の回帰テストで最も重要なのは、本番のマウント構成で GET /users/all が getAllUsers(全ユーザー一覧)へ正しく届くことです。加えて、原因を記録・説明する目的なら、わざと順序を崩した構成もテストしておくと、テスト自身が「順序が効いている」ことを語ってくれます(こちらは回帰テストとして必須ではなく、再現・説明用と割り切ってよいものです)。

  • 本番のマウント順 → GET /users/all が 200+全ユーザー一覧を返す
  • わざと崩した順 → シャドーイングで 400(/users/:id に飲み込まれ、id が数値でないと弾かれる)

前者が、将来うっかり順序を入れ替えたときに赤くなって気づかせてくれます。

判断基準とまとめ

最後に、実装で迷ったときの判断材料を整理します。

まず自分のルーターがどちらの系統か確かめる

  • 「route の登録順で挙動が変わる」なら登録順マッチ。順序が防御線です。
  • 「登録順に関係なく具体的な route が勝つ」なら特異度優先マッチ。優先順位の規則を確認します。

登録順マッチの系統で守ること

  1. 同じセグメント数なら、リテラル route をパラメトリック route より先に登録する。
  2. route をファイル分割したら、合成(マウント)の順序も同じ規則で管理する。ファイル内の順序だけでは足りません。
  3. 「バリデーションで弾かれたら別の route が拾う」という自動フォールバックはない。譲りたいなら明示的な出口(next('route') など)を使う。

テストで守ること

  1. route の衝突は結合点にしか出ない。実マウント順での結合テストを書く。
  2. 正しい順序だけでなく、崩した順序も併せてアサートして「順序が効いている」ことを固定する。

ルーティングは「フレームワークがよしなにやってくれる部分」に見えて、実はマッチ方式・順序・バックトラックの有無という設計上の選択が挙動を決めています。冒頭の「送ってもいない id が数値でないと怒られる 400」は、その選択を知らないと最後まで謎のままです。逆に、自分のルーターが「先勝ちで、失敗しても後戻りしない」と分かっていれば、原因は一直線にたどれます。

参考にした一次情報

  • Express 公式ガイド「Routing」(route のパス指定、パラメータ req.params、next('route') が現在の route の残りのコールバックを飛ばして次の route へ制御を渡す挙動)
  • Express 公式ガイド「Error handling」(next(err) は残りの通常 route/ミドルウェアを飛ばしてエラー処理ミドルウェアへ遷移し、通常の route へは自動フォールバックしない)
  • find-my-way リポジトリ(基数木ルーターの優先順位:静的 > 静的末尾つきパラメトリック > 正規表現/複数パラメトリック > 一般パラメトリック > ワイルドカード、登録順に依存しない解決)
  • Fastify 公式ドキュメント「Routes」(find-my-way を採用したルーティング)

この記事を書いた人

情報企画チーム 松村 晶(まつむら あき)

2024年11月廣瀬製紙株式会社入社。

書店・福祉・飲食業などを経験したのち、システム開発畑に転向。

転職をきっかけに生まれの地である高知市に移住し、現在は社内SEとして、社内のDBシステム開発やDX関連のシステム開発を担当している。

この著者の記事を見る →