Go標準ServeMuxのルーティング—メソッド指定とワイルドカードの優先順位

Go標準ServeMuxのルーティング—メソッド指定とワイルドカードの優先順位 | mohablog

Goでルーティングを書くとき、最初にchiginを入れていませんか。Go 1.22以降、標準のnet/http.ServeMuxだけでメソッド指定とパスパラメータが書けます。現行のGo 1.26.4でも挙動は変わらず、サードパーティ製ルーターを外せる場面が増えました。

目次

旧ServeMuxのルーティングはどこが手狭だったか

Go 1.21までのServeMuxは、パターンに書けるのがパスの前方一致だけ。メソッドの振り分けもパスパラメータの取り出しも、ハンドラの中で手作業でした。

メソッドはハンドラ内のif文で振り分けていた

mux := http.NewServeMux()
mux.HandleFunc("/items/", func(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    id := strings.TrimPrefix(r.URL.Path, "/items/")
    fmt.Fprintf(w, "GET item id=%s\n", id)
})

動かすと、目的のリクエストは通ります。ただしパスの扱いが緩い。

$ curl -s localhost:8098/items/42
GET item id=42

$ curl -s localhost:8098/items/42/reviews
GET item id=42/reviews        # 前方一致なので /reviews まで id に吸い込まれる

$ curl -si -X POST localhost:8098/items/42 | head -2
HTTP/1.1 405 Method Not Allowed   # Allow ヘッダは付かない

パスパラメータは自前で切り出していた

/items/42/reviewsid=42/reviews になっているとおり、前方一致のパターンは後続セグメントを区別しません。id だけを取りたければ strings.Split でさらに分解する。405を返してもAllowヘッダは自分で付ける必要がある。ルートが増えるほど、メソッド分岐とパス分解のコードがハンドラに積み上がります。

メソッドとパスを1行で宣言するパターン構文

Go 1.22でServeMuxのパターンが拡張されました。公式ブログ “Routing Enhancements for Go 1.22” の “Enhancements” 節にあるとおり、パターンは [METHOD ][HOST]/[PATH] の形を取れます。メソッドとワイルドカードを1行に畳める。

“GET /items/{id}” を構成する3要素

mux := http.NewServeMux()

mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "GET item id=%s\n", id)
})
mux.HandleFunc("POST /items", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "created")
})
mux.HandleFunc("GET /items/latest", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "latest item")
})

http.ListenAndServe(":8099", mux)

パターンの先頭にメソッド名、続いてパス。{id} がワイルドカードで、1セグメントにマッチします。値は Request.PathValue で名前指定で受け取る。strings.TrimPrefix は要りません。

$ curl -s localhost:8099/items/42
GET item id=42

メソッド不一致は404ではなく405になる

メソッド付きでパターンを登録すると、パスは合うがメソッドが違うリクエストには405 Method Not Allowedが返ります。しかもAllowヘッダに許可メソッドが入る。手書きしていた分岐が標準で付いてきます。

$ curl -si -X DELETE localhost:8099/items/42 | head -2
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD

$ curl -si localhost:8099/unknown | head -1
HTTP/1.1 404 Not Found

登録したのはGETだけですが、HEADも自動で許可されます。パスが存在しなければ従来どおり404。405と404が分かれるので、クライアントはレスポンスだけで「パスが無い」のか「メソッドが違う」のかを判別できます。

ホスト名でも振り分けられる

パターンの[HOST]部分にホスト名を書くと、そのHostヘッダのリクエストだけにマッチします。ホスト付きはホスト無しより具体的なので、両方登録すれば前者が優先される。

mux.HandleFunc("api.localhost/health", apiHealth)  // ホスト指定あり
mux.HandleFunc("/health", defaultHealth)           // ホスト指定なし
$ curl -s -H "Host: api.localhost" localhost:8097/health
api: ok

$ curl -s -H "Host: www.localhost" localhost:8097/health
default: ok

サブドメインでAPIと管理画面を分けるような構成を、リバースプロキシ無しでハンドラ登録だけ書ける。小規模なら標準muxで足ります。

ワイルドカードは{name}・{name…}・{$}の3種類

セグメント単位の{id}以外に、残り全部を受けるものと、完全一致に絞るものがあります。

記法マッチ対象例: /files/... に対して
{name}1セグメント/files/a はマッチ、/files/a/b は不一致
{name...}末尾の全セグメント/files/a/b/c.txt 全体をマッチ
{$}末尾スラッシュの完全一致のみ/files/ だけマッチ、/files/a は不一致

GET /files/{path...} を登録すると、path にスラッシュ込みの残りが入ります。

$ curl -s localhost:8099/files/a/b/c.txt
path=a/b/c.txt

{$}は前方一致との切り分けに使います。/posts/のような末尾スラッシュのパターンは、従来どおり配下すべてにマッチする前方一致(サブツリー)。これを「/posts/ちょうどだけ」に絞りたいときに/posts/{$}と書きます。前方一致と完全一致は別物なので、混同しないこと。

「最も具体的なパターンが勝つ」優先順位

複数のパターンが同じリクエストにマッチしたとき、どれが選ばれるか。ここはフレームワーク製ルーターと挙動が分かれます。公式ブログ “Precedence” 節は次のように定義しています。

The most specific pattern wins. […] one pattern is more specific than another if it matches a strict subset of requests.

strict subsetで具体性を測る

あるパターンが別のパターンのマッチ集合の「真部分集合」なら、前者がより具体的。判定はパターンの見た目ではなく、マッチするリクエストの集合で決まります。だから登録順は関係ありません。後から登録しても、より具体的なほうが勝つ。

/items/latestが/items/{id}より優先される

先ほどのコードにはGET /items/{id}GET /items/latestを両方登録していました。/items/latest{id}のマッチ集合に含まれ、かつlatest1点に絞られる。具体的なほうが選ばれます。

$ curl -s localhost:8099/items/latest
latest item              # /items/{id} ではなく /items/latest が呼ばれる

$ curl -s localhost:8099/items/42
GET item id=42           # latest 以外は {id} に流れる

メソッドも具体性に数えます。公式の例ではGET /posts/{id}/posts/{id}に優先します。前者はGETHEADに絞られ、後者は全メソッドにマッチするから。「リテラルはワイルドカードより強い」「メソッド指定は無指定より強い」と覚えるより、マッチ集合が狭いほうが勝つと理解しておくと、自分が書いたルートの動きを予測できます。

重複するパターンは登録時にpanicする

互いに重なるのに、どちらも相手の真部分集合でないパターンがある。優先順位を決められないので、Goはこれを衝突として扱い、登録時にpanicします。/posts/{id}/{resource}/latestはどちらも/posts/latestにマッチしますが、一方が他方より具体的とは言えません。

mux.HandleFunc("/posts/{id}", handler)
mux.HandleFunc("/{resource}/latest", handler) // ここで panic
panic: pattern "/{resource}/latest" conflicts with pattern "/posts/{id}":
    /{resource}/latest and /posts/{id} both match some paths, like "/posts/latest".
    But neither is more specific than the other.

panicメッセージが衝突するパス例と理由まで出してくれます。実行時の謎の404に悩むより、起動時に落ちて原因が分かるほうが調査は速い。登録はmain初期化で走るので、起動直後に必ず表面化します。

フレームワークから標準muxへ移すときの判断

標準ServeMuxが担うのはルーティングだけ。バリデーションやレンダリングの補助は付きません。chiginとは守備範囲が違います。

観点net/http.ServeMuxchi / gin
依存標準ライブラリのみ外部モジュール
メソッド・パスパラメータ対応(1.22以降)対応
ミドルウェア合成自前でhttp.Handlerを包む専用APIあり
グループ化・名前付きルートなしあり
ルート優先順位最も具体的なパターン登録順など実装依存

互換が必要ならGODEBUGで止める

古いコードで{を含むパスをリテラルとして登録していた場合、1.22の構文変更で挙動が変わります。すぐに直せないときは環境変数で旧動作に固定できます。

GODEBUG=httpmuxgo121=1 go run .

これで{id}がワイルドカードではなくリテラルの{id}として扱われ、Go 1.21までの挙動に戻ります。あくまで移行の猶予用なので、恒久対応にはしないこと。

chi/ginを残す基準

ミドルウェアのグループ適用、ルートのネスト、URL生成といった機能に依存しているなら、フレームワークを外す利点は薄い。逆に、ルーティングとハンドラ登録しか使っていないプロジェクトなら、標準muxへ寄せて依存を1つ減らせます。判断材料は「フレームワーク固有APIをどれだけ呼んでいるか」です。

まとめ

  • Go 1.22以降のServeMux"GET /items/{id}"の形でメソッドとパスパラメータを1行宣言できる。現行のGo 1.26.4でも同じ
  • 値はRequest.PathValueで受け取る。メソッド不一致は405とAllowヘッダ、パス不在は404を標準で返す
  • ワイルドカードは{name}(1セグメント)、{name...}(残り全部)、{$}(完全一致)の3種類
  • 優先順位は登録順ではなく「マッチ集合が狭いほうが勝つ」。重なって決められないパターンは登録時にpanic
  • 移行の猶予にはGODEBUG=httpmuxgo121=1。フレームワーク固有APIへの依存が薄いほど標準muxへ寄せやすい
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次