- Go 1.22の
net/http ServeMux 改善では、標準HTTPマルチプレクサにより強力なパターンマッチングが導入され、単純なパスマッチングのためにサードパーティルーターへ依存していた領域が減る
- 新しい mux は、パターン内で HTTPメソッド と
/task/{id}/ のようなパスワイルドカードを一緒に使えるため、同じパスでも GET、POST、DELETE ごとに別のハンドラーへ接続できる
- マッチした値は
req.PathValue("id") で読み取れ、{id}... と {$} によって後続パス全体や正確なパス終端まで制御できる
- パターン衝突は登録時点の panic として表れ、どのパスが両方にマッチするのか、そしてなぜどちらもより具体的ではないのかを知らせてくれる
- 標準 mux だけで十分なサーバーは増える可能性があるが、
gorilla/mux や Gin のようなサードパーティルーター・フレームワークは依然としてより幅広い機能とツールを提供する
Go 1.22のServeMuxの変化
- Go 1.22では、
net/http の基本HTTPサービングマルチプレクサである http.ServeMux に 強化されたパターンマッチング が入る予定
- 従来の
http.ServeMux は基本的なパスマッチング中心だったため、より複雑なルーティングにはサードパーティライブラリが多く使われていた
- 新しい mux は高度なマッチングを標準ライブラリ内に取り込み、サードパーティパッケージとの機能差を縮める
- 比較対象としては、既存の標準ライブラリ方式と
gorilla/mux を使った REST サーバー例が用いられている
新しい mux の基本的な使い方
gorilla/mux のような Go ルーターパッケージを使ったことがある開発者なら、新しい標準 mux の使い方は馴染みやすい
- 例では
http.NewServeMux() で mux を作成し、HandleFunc により豊かなパターンを登録する
mux.HandleFunc("GET /path/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "got path\n")
})
mux.HandleFunc("/task/{id}/", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "handling task with id=%v\n", id)
})
- 1つ目のハンドラーは
GET メソッド をパターンに含むため、/path/ で始まる GET リクエストにのみ動作する
- 2つ目のハンドラーは
/task/{id}/ の {id} で単一のパスコンポーネントをマッチさせ、ハンドラー内で r.PathValue("id") により値を読み取る
- Go 1.22 がまだリリースされていない時点の例なので、
gotip での実行が推奨される
- テスト結果はルーティング規則をすぐ確認させてくれる
/what/ リクエストは 404 page not found
/path/ への GET リクエストは got path
/path/ への POST リクエストは Method Not Allowed
/task/f0cd2e/ リクエストは id=f0cd2e として処理される
追加パターン機能と衝突処理
- 新しい
ServeMux は基本ワイルドカードに加えて、パスマッチングをより細かく制御できる
{id}... で 後続パス全体 をワイルドカードにマッチ
{$} で パス終端 を厳密にマッチ
- パターン間の優先順位と衝突ルールを処理
- 衝突の可能性は提案で特に重視された部分
- たとえば次の2つのパターンは
/task/0/status/ リクエストの両方にマッチする
mux.HandleFunc("/task/{id}/status/", ...)
mux.HandleFunc("/task/0/{action}/", ...)
- この場合、新しい
ServeMux は登録時点で panic を発生させる
- エラーメッセージは、両パターンがともにマッチするサンプルパスと、どちらのパターンも他方より具体的ではない理由をあわせて表示する
- 複数の場所でパターンを登録する複雑なコードでは、このような 詳細な衝突診断 が特に有用
task サーバー例の再実装
- 既存の REST Servers in Go シリーズの task/todo-list サーバーを、Go 1.22 の強化された mux で再実装している
- 全コードは stdlib-newmux 例 にある
- 登録パターンでは HTTP メソッドとパスワイルドカードを一緒に使う
mux.HandleFunc("POST /task/", server.createTaskHandler)
mux.HandleFunc("GET /task/", server.getAllTasksHandler)
mux.HandleFunc("DELETE /task/", server.deleteAllTasksHandler)
mux.HandleFunc("GET /task/{id}/", server.getTaskHandler)
mux.HandleFunc("DELETE /task/{id}/", server.deleteTaskHandler)
mux.HandleFunc("GET /tag/{tag}/", server.tagHandler)
mux.HandleFunc("GET /due/{year}/{month}/{day}/", server.dueHandler)
gorilla/mux の例のように、同じパスでも HTTPメソッド別ハンドラー に分けられる
- 以前の
http.ServeMux では、同じパスの複数メソッドが同じハンドラーに入り、ハンドラー内部でメソッドを確認する必要があった
- 新しい mux ではルーティング判断がハンドラー外でより多く処理されるため、ハンドラーの分離がしやすくなる
PathValue の利用と制限
getTaskHandler は req.PathValue("id") でパスから抽出した ID を読み取り、strconv.Atoi で整数へ変換する
- 新しい mux の
{id} パターンは整数だけにマッチする正規表現を指定できないため、strconv.Atoi のエラー処理が必要
- ID 変換に失敗した場合は
http.StatusBadRequest で応答し、ストレージで task が見つからなければ http.StatusNotFound で応答する
- 最終実装は
gorilla/mux を使った Part 2 の解法と非常によく似ている
- 必要であれば、新しい
http.ServeMux の {$} ワイルドカードで後続パスを簡単に制限できる
標準 mux とサードパーティルーターの位置づけ
- Go 初心者にとって「どのルーターパッケージを使うべきか」はよくある質問だった
- Go 1.22 以降は、多くのユーザーがサードパーティパッケージなしでも新しい標準 mux だけで十分だと感じられるかもしれない
gorilla/mux のようなルーターは、標準ライブラリより依然として多くの機能を提供する
- Gin のような軽量フレームワークは、ルーターだけでなく Web バックエンド構築のための追加ツールも提供する
- 標準ライブラリの機能が強化される変化は、サードパーティパッケージを使うユーザーにも標準ライブラリだけを使うユーザーにも前向きなものだ
1件のコメント
Hacker News のコメント
2つのルートが同時にマッチしたときに panic を起こすのは、直感に反するように思える。他のWebフレームワークでは通常、先に登録されたマッチするルートを使うので、Goだけにそうする理由があるのか気になる
「複数の場所でHTTPルートを登録できるため、重複マッチを見つけにくい」というエッジケースは、ツールで解決できそうに見える。キャリアを通じて「先にマッチしたものが勝つ」という動作をよく活用してきたし、
/foo/barは別ルート、/foo/{id}は別ルートとして置く必要がある業務要件も多かった設計提案書[1]には、より具体性の高いパターンをマッチさせれば元の ServeMux パターンの順序独立性を維持できる一方、2つのパターンのどちらがより具体的なのか、あるいはなぜ衝突するのかが一目では分かりにくい場合がある、とある。そのため、衝突するパターンを登録した際に出る panic メッセージは、例となるパスで衝突を示すようになっている
背景議論[2]でも、mux の意味論は
HandleやHandleFuncの呼び出し順序に依存しないと説明している。順序独立性のおかげで、パッケージの初期化順序が重要ではなくなり、コードのリファクタリングもしやすくなる。そのため、同率時の判定基準を登録順序にせず、重複登録は panic になり、登録順序に依存する意味論を避けることが中核的な設計目標として残っている[1]: https://github.com/golang/go/issues/61410
[2]: https://github.com/golang/go/discussions/60227
最近、個人プロジェクトで gorilla/mux を使っていて、誤って重なるルートを作ってしまうバグに遭遇したが、ルーターが実行される代わりに panic してくれていれば、URLをリファクタリングするよう促され、バグを完全に避けられただろう
init関数の順序に影響し得る/foo/barと/foo/{id}のような動作が必要なら、基本パスを受けるハンドラを作り、その中で別のハンドラ関数へ明示的に分岐すればよい。標準ライブラリにない動作を望むなら、より多機能な別ライブラリもたくさんあるGo の標準ライブラリと言語は、可能な限り曖昧さの前では常に保守的な側に傾いてきたし、そのおかげで標準ライブラリがどう動作するかについて、強く正確な直感を作りやすい。コードを書くときに数秒を節約することより、素早く正確に読んで理解できることのほうがはるかに価値がある
/foo/barと/foo/{id}は許可されそうに思う。前者のほうがより具体的なので優先順位を持つため、問題なさそうただし
/foo/{id}/deleteと/foo/bar/{action}があると、/foo/bar/deleteは両方にマッチし、どちらもより具体的とは言えないので panic になりそうだ。妥当に感じるが、先に登録されたほうが勝つ優先順位のほうがよい場合もあるかもしれない$正規表現が抜けたルートのせいで、実際にこういうことを経験したことがあるgorilla/mux プロジェクトは少し奇妙で混乱する。昨年、メンテナーたちがプロジェクトをアーカイブしたので、gin-gonic という別のマルチプレクサへ移った
ところが記事で言及されていたので改めて確認してみると、もうアーカイブ状態ではなかった。これがプロジェクト全体の安定性に疑問を投げかけるものなのか、それとも開発者が抜けても他の人たちが名乗り出てメンテナンスを続けられるという点で、オープンソースが実際には安定している証拠なのかは分からない。それでも、この機能が Go 自体で提供されるのはよいことだ
[0] https://gorilla.github.io/blog/2023-07-17-project-status-upd...
[1] https://www.reddit.com/r/golang/comments/1528e25/gorilla_web...
[2] https://news.ycombinator.com/item?id=36935541
以前コメント[1]にも書いたが、この提案は構文が間違っていると思う
ハンドラーを定義するために変なマジック文字列を作らなければならない。なぜ実際の引数にしないのか分からないし、そうすれば既存の定数ももっと簡単に使える
[1] https://github.com/golang/go/issues/61410#issuecomment-16580...
Muxインターフェースに新しい引数を追加すると既存コードが壊れるし、メソッドシグネチャを変えられないので、こうしたマジック文字列は妥当な妥協に見える。HTTPメソッドがすぐ変わるわけでもないし、登録時点や静的解析で検証するのも簡単。定数を使うことにどんな価値があるのかよく分からないし、特に後方互換性を壊したり、既存APIとごくわずかに違う新しいmux APIを永遠に並行して持たなければならないなら、なおさらだ
{parameter}のパースがある時点ですでにマジック文字列ではないかと思うRFC2616の用語でいう
Request-URIに少しパラメータの魔法が付いたものと見て、そこにリクエストラインのように見える[ Method SP ] Request-URIが加わったと考えればいい。完全に後方互換で、ドキュメントを見なくてもほぼ何をするものか明確だMethodはいくつかの事前定義された定数の一つかextension-method = tokenで、tokenは空白・スラッシュ・各種括弧を除くので、かなり奇妙なカスタムメソッドでも混同されたり誤ってパースされたりする可能性はなさそうだ.Get()や.Post()のようなメソッドが何かを解決してくれたことはない。文字列の中にメソッドを入れる単一の登録メソッドでも、おそらくまったく問題ないと思うHEADルートを自動登録することの何が問題なのか分からないこの方式は気に入らない。なぜ文字列化されたメソッド接頭辞を使うのか、理由があるのか気になる
実行時に検証されるマジック文字列より、
mux.Get、mux.Postのような動詞別メソッドの型安全性のほうが良い。エディタの自動補完やIntelliSenseも使えるfunc Get(mux, uri, handler) { mux.HandleFunc("GET " + uri, handler) }のように作ればいい簡単に見せるために型は省略している
興味深い。これでmuxがメソッドまでマッチできるようになったので、ルートは合っているがメソッドが合っていない場合にどうなるのか気になっていた。404なのか405なのかが問題だ
確認してみると、
Allowヘッダーが正しく埋められた405が返るhttps://cs.opensource.google/go/go/+/master:src/net/http/ser...
文字列ベースのインターフェースを嫌う人が多いのは分かるが、HTTPメソッド名をタイプミスするより、正しく変なものを入力する可能性のほうが高いと思う。個人的には、静的解析が間違った構文を警告してくれれば十分だ
ただし、すでに高度な要件があるなら、基本のserve muxは使わないほうがいいと思う。さまざまなユースケースにより合う選択肢は多いし、すでに動的なルート生成をしているなら、持っているデータ構造を既存のルーターに無理に合わせるより、自前のルーターを使うほうが手間が少ないかもしれない
重複するパスはpanicではなく、定義された順序どおりにマッチするほうがはるかに良さそうだ
例で
/task/0/{action}/パスをワイルドカードパスより先に定義していたなら、それが先にマッチすると期待したはずだ。そうすれば特殊ケース用のハンドラーを簡単に定義できる「親切な」形で失敗するより、自分が言ったとおりにそのまま動くほうが良いと思う。少し変な臭いがして、Goらしくない
もともとServeMuxは登録順を尊重しないように設計されていた。登録順に依存するのは危険だからだ。Goの主要な設計目標の一つは「大規模なプログラミング」を支援することで、離れた場所のコード修正によって生じる奇妙な副作用は、まさに避けたい種類のものだ
すべての登録が単一パッケージの単一関数内で行われる単純な例なら、登録順の意図は明確だ。しかし十分に大きなコードベースでは、登録が複数の場所で起こる可能性が高く、入力ファイルや生成コードの結果であることもある。生成コードの仕様を書く人が、実行順序の微妙さとそれがルーティングに与える影響を知らないかもしれない。パッケージimportの辞書順ソートが変わるだけで、ルーティングが予期せず変わることもあり得る
予想外の結果を避け、リファクタリングを容易にし、複雑なプログラムを推論しやすくしようとするのは、明らかにGoらしい方向だ。選択に常に同意する必要はないが、理由は一貫している
外部依存が一つ減るのは良い。余談だが、sqlc + Postgres + templ + htmx + Tailwind の組み合わせは開発生産性が非常に高いスタックだった。templはGo用のJSXのような感じだ
htmxも一度見てみようと思う
Svelteコンパイルを付けたbudがこの隙間を埋めてくれることを期待していたが、templのほうがより良い代替案に見える
この提案は本当に気に入らない。HTTPリクエストメソッドをURIの中に入れるなんて、しかも時々だけそうして、場合によっては
POST,PUT,PATCH /somethingみたいなこともしろというのは遠慮したいどうしても必要なら、HTTPリクエストメソッド名を受け取る専用メソッドを作ればよい
HTTPメソッド名を受け取る専用メソッドが、文字列にメソッドを含めることと比べてどんな問題を解決するのか気になる
メソッドやオプション一覧のために別の引数を用意できたはず
Goが主な道具というわけではないがGinを使ったことがあり、こんな感じだった:
router.GET("/", func(context *gin.Context) { ... })ほかの多くの言語・フレームワークと大きく違うようには見えない。
GET /path/方式を使う例がほかにもあるのか気になる公式の言語チームが列挙型の代わりに接頭辞付き文字列を使うのは、かなり奇妙な設計上の選択に見える
例えば現在の
("GET /path")より(Http.GET, "/path")のほうがよさそうに見える。変だそれでもあの方式よりは、
mux.GET、mux.POSTのような関数が複数あるほうを好む