Go 1.22におけるルーティングの機能強化

Routing Enhancements for Go 1.22 by Jonathan Amsterdam, on behalf of the Go team

Go 1.22では、net/http パッケージのルーターに2つの機能強化が加わりました。メソッドマッチングとワイルドカードです。 これらの機能により、よく使われるルートをGoのコードではなくパターンとして表現できるようになります。 説明も利用も簡単な機能ですが、複数のパターンが1つのリクエストにマッチしたときにどちらを優先するかという 適切なルールを考え出すのは簡単ではありませんでした。

これらの変更は、Goを本番システム構築に適した優れた言語にし続けるための継続的な取り組みの一環です。 私たちは数多くのサードパーティ製Webフレームワークを調査し、最もよく使われていると思われる機能を抽出して、 それらを net/http に統合しました。そのうえで、GitHubのディスカッション提案イシューを通じてコミュニティと協力しながら、選んだ機能の検証と設計の改善を行いました。 これらの機能を標準ライブラリに追加することで、多くのプロジェクトにとって依存関係が1つ減ることになります。 とはいえ、現在のユーザーや高度なルーティング機能を必要とするプログラムにとっては、 サードパーティ製のWebフレームワークも依然として良い選択肢であり続けます。

機能強化

新しいルーティング機能は、net/http.ServeMux の2つのメソッド HandleHandleFunc、 およびそれに対応するトップレベル関数 http.Handlehttp.HandleFunc に渡すパターン文字列に ほぼ限定して影響します。唯一のAPIの変更は、ワイルドカードのマッチ結果を扱うために net/http.Request に追加された2つの新しいメソッドです。

ここでは、それぞれの投稿が整数のIDを持つ、架空のブログサーバーを例にして変更点を説明します。 GET /posts/234 のようなリクエストは、ID 234の投稿を取得します。Go 1.22より前では、 こうしたリクエストを処理するコードは次のような行から始まっていたでしょう。

http.HandleFunc("/posts/", handlePost)

末尾のスラッシュによって、/posts/ で始まるすべてのリクエストが handlePost 関数にルーティングされます。 この関数の中で、HTTPメソッドがGETであることを確認し、IDを取り出し、投稿を取得する必要があります。 メソッドの確認はリクエストを処理するうえで厳密には必須ではないため、それを省略してしまうのはありがちな間違いです。 そうすると、DELETE /posts/234 のようなリクエストでも投稿が取得されてしまい、少なくとも意外な挙動になります。

Go 1.22でも既存のコードは引き続き動作しますが、代わりに次のように書くこともできます。

http.HandleFunc("GET /posts/{id}", handlePost2)

このパターンは、パスが /posts/ で始まり2つのセグメントからなるGETリクエストにマッチします。 (特殊なケースとして、GETはHEADにもマッチします。それ以外のメソッドは完全に一致する必要があります。) handlePost2 関数ではもはやメソッドを確認する必要がなく、IDの文字列の取り出しは Request に追加された 新しい PathValue メソッドを使って次のように書けます。

idString := req.PathValue("id")

handlePost2 の残りの部分は handlePost と同様に動作し、文字列のIDを整数に変換して投稿を取得します。

他にマッチするパターンが登録されていない場合、DELETE /posts/234 のようなリクエストは失敗します。 HTTPの仕様に従って、net/http サーバーはこのようなリクエストに対して 405 Method Not Allowed エラーを返し、Allow ヘッダーに利用可能なメソッドの一覧を示します。

ワイルドカードは、上の例の {id} のようにセグメント全体にマッチします。また /files/{pathname...} のパターンのように ... で終わると、パスの残り全体のセグメントにマッチします。

構文についてもう一つだけ触れておきます。前述の通り、/posts/ のようにスラッシュで終わるパターンは、 その文字列で始まるすべてのパスにマッチします。末尾にスラッシュが付いたパスだけにマッチさせたい場合は、 /posts/{$} と書けます。これは /posts/ にはマッチしますが、/posts/posts/234 にはマッチしません。

そしてAPIについてももう一つだけ触れておきます。net/http.Request には SetPathValue メソッドがあり、 標準ライブラリの外にあるルーターが、自分自身で行ったパス解析の結果を Request.PathValue 経由で 公開できます。

優先順位

あらゆるHTTPルーターは、/posts/{id}/posts/latest のように重なり合うパターンを扱わなければなりません。 この2つのパターンはどちらもパス「posts/latest」にマッチしますが、リクエストを処理できるのはどちらか一方だけです。 では、どちらのパターンが優先されるべきでしょうか。

ルーターの中には重なりを許可しないものもあれば、最後に登録されたパターンを使うものもあります。 Goは以前から重なりを許容しており、登録順序に関わらず、より長いパターンを選択してきました。 登録順序に依存しないという性質を維持することは私たちにとって重要でしたし(また後方互換性のためにも必要でした)、 それでもなお「長い方が勝つ」というルールよりも優れたルールが必要でした。このルールでは /posts/{id} より /posts/latest が選ばれますが、両方より /posts/{identifier} が選ばれてしまいます。これはおかしいように思えます。 ワイルドカードの名前は関係ないはずだからです。/posts/latest は多数ではなく単一のパスにマッチするので、 この競合には常に /posts/latest が勝つべきであるように感じられます。

優れた優先順位のルールを追求する中で、私たちはパターンの持つさまざまな性質を検討しました。 例えば、リテラル(ワイルドカードでない)部分の接頭辞が最も長いパターンを優先するという方法を検討しました。 この方法では /posts/{id} より /posts/latest が選ばれます。しかし、/users/{u}/posts/latest/users/{u}/posts/{id} を区別できず、前者が優先されるべきであるように思われます。

最終的に私たちは、パターンの見た目ではなく、パターンが意味する内容に基づいたルールを選びました。 有効なパターンはすべてリクエストの集合にマッチします。例えば、/posts/latest はパスが /posts/latest である リクエストにマッチしますが、/posts/{id} は最初のセグメントが「posts」である任意の2セグメントのパスを持つ リクエストにマッチします。あるパターンが別のパターンのマッチするリクエストの真部分集合にマッチするとき、 そのパターンはもう一方よりも 具体的 であると言います。/posts/{id}/posts/latest がマッチする すべてのリクエストにマッチし、さらにそれ以上にマッチするので、/posts/latest/posts/{id} よりも具体的です。

優先順位のルールは単純です。最も具体的なパターンが勝ちます。このルールは、posts/latestposts/{id} より 優先され、/users/{u}/posts/latest/users/{u}/posts/{id} より優先されるべきだという私たちの直感と一致します。 またこのルールはメソッドについても筋が通っています。例えば、GET /posts/{id}/posts/{id} より優先されます。 前者はGETリクエストとHEADリクエストにしかマッチしませんが、後者はどのメソッドのリクエストにもマッチするからです。

「最も具体的なものが勝つ」というルールは、ワイルドカードや {$} を含まない従来のパターンのパス部分について、 元の「長い方が勝つ」というルールを一般化したものです。そのようなパターンは、一方が他方の接頭辞であるときにのみ 重なり合い、より長い方がより具体的です。

2つのパターンが重なり合っていて、どちらもより具体的とは言えない場合はどうなるでしょうか。例えば、 /posts/{id}/{resource}/latest はどちらも /posts/latest にマッチします。どちらが優先されるべきか 明確な答えがないため、私たちはこれらのパターンを互いに 競合 しているとみなします。この2つを (どちらの順序で登録しても!)両方登録すると、パニックが発生します。

優先順位のルールはメソッドとパスについては上述の通りに機能しますが、互換性を保つためにホストについては 1つだけ例外を設ける必要がありました。2つのパターンが本来なら競合してしまうときに、一方にホストが指定されていて もう一方には指定されていない場合は、ホストが指定されている方のパターンが優先されます。

計算機科学を学んだ方であれば、正規表現と正規言語の美しい理論を思い出すかもしれません。それぞれの正規表現は、 その表現にマッチする文字列の集合である正規言語を1つ選び出します。ある種の問いは、表現そのものではなく 言語について論じたほうが、提起も回答もしやすくなります。私たちの優先順位のルールは、この理論に着想を得ています。 実際、それぞれのルーティングパターンは正規表現に対応し、マッチするリクエストの集合が正規言語の役割を 果たしています。

優先順位を表現ではなく言語によって定義すると、記述も理解もしやすくなります。しかし、無限になりうる集合に 基づくルールには欠点もあります。それをどう効率的に実装すればよいかが自明ではないのです。実際には、2つの パターンをセグメントごとに走査していくことで、それらが競合するかどうかを判定できることがわかりました。 おおまかに言うと、一方のパターンが持つワイルドカードの位置に、もう一方が常にリテラルのセグメントを持っている 場合、そのパターンはより具体的です。しかし両方向でリテラルとワイルドカードが噛み合っている場合、 それらのパターンは競合します。

新しいパターンが ServeMux に登録されるたびに、それ以前に登録されたパターンとの競合がチェックされます。 しかし、すべてのパターンの組み合わせをチェックすると二次時間がかかってしまいます。そこで、新しいパターンと 競合しえないパターンをスキップするためのインデックスを利用しており、実用上はかなりうまく機能しています。 いずれにせよ、このチェックはパターンが登録されるとき、通常はサーバー起動時に行われます。Go 1.22での 受信リクエストのマッチングにかかる時間は、以前のバージョンからほとんど変わっていません。

互換性

新機能を古いバージョンのGoと互換性のあるものにするため、あらゆる努力を払いました。新しいパターンの構文は 従来の構文の上位互換であり、新しい優先順位のルールも従来のルールを一般化したものです。しかし、いくつかの エッジケースがあります。例えば、以前のバージョンのGoでは波括弧を含むパターンを受け付け、それをリテラルとして 扱っていましたが、Go 1.22では波括弧をワイルドカードとして使います。GODEBUG の設定 httpmuxgo121 によって、 以前の挙動に戻せます。

これらのルーティングの機能強化についての詳細は、net/http.ServeMux のドキュメントを参照してください。

By Jonathan Amsterdam, on behalf of the Go team