1 ポイント 投稿者 GN⁺ 2023-10-17 | 1件のコメント | WhatsAppで共有
  • Go 1.22の net/http ServeMux 改善では、標準HTTPマルチプレクサにより強力なパターンマッチングが導入され、単純なパスマッチングのためにサードパーティルーターへ依存していた領域が減る
  • 新しい mux は、パターン内で HTTPメソッド/task/{id}/ のようなパスワイルドカードを一緒に使えるため、同じパスでも GETPOSTDELETE ごとに別のハンドラーへ接続できる
  • マッチした値は 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 の利用と制限

  • getTaskHandlerreq.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件のコメント

 
GN⁺ 2023-10-17
Hacker News のコメント
  • 2つのルートが同時にマッチしたときに panic を起こすのは、直感に反するように思える。他のWebフレームワークでは通常、先に登録されたマッチするルートを使うので、Goだけにそうする理由があるのか気になる
    「複数の場所でHTTPルートを登録できるため、重複マッチを見つけにくい」というエッジケースは、ツールで解決できそうに見える。キャリアを通じて「先にマッチしたものが勝つ」という動作をよく活用してきたし、/foo/bar は別ルート、/foo/{id} は別ルートとして置く必要がある業務要件も多かった

    • 登録順序が常に同じだと保証できないので、事実上 未定義動作 になる。もともと ServeMux はそのように設計・実装されており、その動作を引き続きサポートすることが有用だと考えられたもの
      設計提案書[1]には、より具体性の高いパターンをマッチさせれば元の ServeMux パターンの順序独立性を維持できる一方、2つのパターンのどちらがより具体的なのか、あるいはなぜ衝突するのかが一目では分かりにくい場合がある、とある。そのため、衝突するパターンを登録した際に出る panic メッセージは、例となるパスで衝突を示すようになっている
      背景議論[2]でも、mux の意味論は HandleHandleFunc の呼び出し順序に依存しないと説明している。順序独立性のおかげで、パッケージの初期化順序が重要ではなくなり、コードのリファクタリングもしやすくなる。そのため、同率時の判定基準を登録順序にせず、重複登録は panic になり、登録順序に依存する意味論を避けることが中核的な設計目標として残っている
      [1]: https://github.com/golang/go/issues/61410
      [2]: https://github.com/golang/go/discussions/60227
    • Go のような コンパイル型・静的型付け言語 を使う理由は、バグをより左側に寄せるため。実サービスで隅のケースとして爆発するバグより、アプリケーション起動時に捕まるバグのほうがよく、起動時に捕まるバグよりコンパイル時に捕まるバグのほうがさらによい
      最近、個人プロジェクトで gorilla/mux を使っていて、誤って重なるルートを作ってしまうバグに遭遇したが、ルーターが実行される代わりに panic してくれていれば、URLをリファクタリングするよう促され、バグを完全に避けられただろう
    • panic そのものがツール である。曖昧で推論しにくいコードを書けないようにし、初期化順序のように任意に変わり得る要素へ依存しないようにしてくれる。たとえば、ファイル名を変えるだけでも Go のファイル単位の init 関数の順序に影響し得る
      /foo/bar/foo/{id} のような動作が必要なら、基本パスを受けるハンドラを作り、その中で別のハンドラ関数へ明示的に分岐すればよい。標準ライブラリにない動作を望むなら、より多機能な別ライブラリもたくさんある
      Go の標準ライブラリと言語は、可能な限り曖昧さの前では常に保守的な側に傾いてきたし、そのおかげで標準ライブラリがどう動作するかについて、強く正確な直感を作りやすい。コードを書くときに数秒を節約することより、素早く正確に読んで理解できることのほうがはるかに価値がある
    • /foo/bar/foo/{id} は許可されそうに思う。前者のほうがより具体的なので優先順位を持つため、問題なさそう
      ただし /foo/{id}/delete/foo/bar/{action} があると、/foo/bar/delete は両方にマッチし、どちらもより具体的とは言えないので panic になりそうだ。妥当に感じるが、先に登録されたほうが勝つ優先順位のほうがよい場合もあるかもしれない
    • 実際に実行されたルートが別のルートで、誤ったデータを受け取るよりは、エラーを受け取るほう がデバッグにはるかによい。Djangoで末尾の $ 正規表現が抜けたルートのせいで、実際にこういうことを経験したことがある
  • gorilla/mux プロジェクトは少し奇妙で混乱する。昨年、メンテナーたちがプロジェクトをアーカイブしたので、gin-gonic という別のマルチプレクサへ移った
    ところが記事で言及されていたので改めて確認してみると、もうアーカイブ状態ではなかった。これがプロジェクト全体の安定性に疑問を投げかけるものなのか、それとも開発者が抜けても他の人たちが名乗り出てメンテナンスを続けられるという点で、オープンソースが実際には安定している証拠なのかは分からない。それでも、この機能が Go 自体で提供されるのはよいことだ

  • 以前コメント[1]にも書いたが、この提案は構文が間違っていると思う
    ハンドラーを定義するために変なマジック文字列を作らなければならない。なぜ実際の引数にしないのか分からないし、そうすれば既存の定数ももっと簡単に使える
    [1] https://github.com/golang/go/issues/61410#issuecomment-16580...

    • それはGoの後方互換性保証のせいではないかと思う
      Muxインターフェースに新しい引数を追加すると既存コードが壊れるし、メソッドシグネチャを変えられないので、こうしたマジック文字列は妥当な妥協に見える。HTTPメソッドがすぐ変わるわけでもないし、登録時点や静的解析で検証するのも簡単。定数を使うことにどんな価値があるのかよく分からないし、特に後方互換性を壊したり、既存APIとごくわずかに違う新しいmux APIを永遠に並行して持たなければならないなら、なおさらだ
    • {parameter} のパースがある時点ですでにマジック文字列ではないかと思う
      RFC2616の用語でいう Request-URI に少しパラメータの魔法が付いたものと見て、そこにリクエストラインのように見える [ Method SP ] Request-URI が加わったと考えればいい。完全に後方互換で、ドキュメントを見なくてもほぼ何をするものか明確だ
      Method はいくつかの事前定義された定数の一つか extension-method = token で、token は空白・スラッシュ・各種括弧を除くので、かなり奇妙なカスタムメソッドでも混同されたり誤ってパースされたりする可能性はなさそうだ
    • 少し変だという点には同意するが、理由は明らかだ。既存の公開インターフェースを壊したり変えたりしたくないのだ
    • 最初は同じ感覚だったが、正直Ginを使っていて .Get().Post() のようなメソッドが何かを解決してくれたことはない。文字列の中にメソッドを入れる単一の登録メソッドでも、おそらくまったく問題ないと思う
    • HEAD ルートを自動登録することの何が問題なのか分からない
  • この方式は気に入らない。なぜ文字列化されたメソッド接頭辞を使うのか、理由があるのか気になる
    実行時に検証されるマジック文字列より、mux.Getmux.Post のような動詞別メソッドの型安全性のほうが良い。エディタの自動補完やIntelliSenseも使える

    • この程度ならごく簡単に追加できる。本当にそこまで気になるなら、func Get(mux, uri, handler) { mux.HandleFunc("GET " + uri, handler) } のように作ればいい
      簡単に見せるために型は省略している
    • 自分もあまり好きではない。ルーティングが実行時ではなくコンパイル時に動くと確信したい
    • 型安全性が欲しいなら、型安全な言語を選べばいい
    • 文字列の使用を好むわけではないが、公平に見るとリクエストが来るときHTTPメソッドも結局は文字列だ。HTTPパケットの最初の行の接頭辞と一致するという美しさはある
    • この内容は提案書で幅広く扱われている
  • 興味深い。これでmuxがメソッドまでマッチできるようになったので、ルートは合っているがメソッドが合っていない場合にどうなるのか気になっていた。404なのか405なのかが問題だ
    確認してみると、Allow ヘッダーが正しく埋められた405が返る
    https://cs.opensource.google/go/go/+/master:src/net/http/ser...
    文字列ベースのインターフェースを嫌う人が多いのは分かるが、HTTPメソッド名をタイプミスするより、正しく変なものを入力する可能性のほうが高いと思う。個人的には、静的解析が間違った構文を警告してくれれば十分だ
    ただし、すでに高度な要件があるなら、基本のserve muxは使わないほうがいいと思う。さまざまなユースケースにより合う選択肢は多いし、すでに動的なルート生成をしているなら、持っているデータ構造を既存のルーターに無理に合わせるより、自前のルーターを使うほうが手間が少ないかもしれない

  • 重複するパスはpanicではなく、定義された順序どおりにマッチするほうがはるかに良さそうだ
    例で /task/0/{action}/ パスをワイルドカードパスより先に定義していたなら、それが先にマッチすると期待したはずだ。そうすれば特殊ケース用のハンドラーを簡単に定義できる
    「親切な」形で失敗するより、自分が言ったとおりにそのまま動くほうが良いと思う。少し変な臭いがして、Goらしくない

    • 言語の意味論は重要で、これは実際かなりGoらしい選択
      もともとServeMuxは登録順を尊重しないように設計されていた。登録順に依存するのは危険だからだ。Goの主要な設計目標の一つは「大規模なプログラミング」を支援することで、離れた場所のコード修正によって生じる奇妙な副作用は、まさに避けたい種類のものだ
      すべての登録が単一パッケージの単一関数内で行われる単純な例なら、登録順の意図は明確だ。しかし十分に大きなコードベースでは、登録が複数の場所で起こる可能性が高く、入力ファイルや生成コードの結果であることもある。生成コードの仕様を書く人が、実行順序の微妙さとそれがルーティングに与える影響を知らないかもしれない。パッケージimportの辞書順ソートが変わるだけで、ルーティングが予期せず変わることもあり得る
      予想外の結果を避け、リファクタリングを容易にし、複雑なプログラムを推論しやすくしようとするのは、明らかにGoらしい方向だ。選択に常に同意する必要はないが、理由は一貫している
  • 外部依存が一つ減るのは良い。余談だが、sqlc + Postgres + templ + htmx + Tailwind の組み合わせは開発生産性が非常に高いスタックだった。templはGo用のJSXのような感じだ

    • これまでsqlcを使っていて、かなり良い経験をしている
      htmxも一度見てみようと思う
    • ここでTailwindはどう扱っているのか気になる。Tailwindを追加するアイデアは本当に良いが、npmを使わなければならないなら、むしろ自分でCSSを書きたい
    • 最初はtemplateのタイプミスかと思ったが、実際にはtemplという標準ライブラリとは別のパッケージだった。ドキュメントを少し見たところ、かなりすっきりして見える
      Svelteコンパイルを付けたbudがこの隙間を埋めてくれることを期待していたが、templのほうがより良い代替案に見える
    • templにIntelliJサポートがあれば本当にうれしい。アイデアは気に入っている
  • この提案は本当に気に入らない。HTTPリクエストメソッドをURIの中に入れるなんて、しかも時々だけそうして、場合によっては POST,PUT,PATCH /something みたいなこともしろというのは遠慮したい
    どうしても必要なら、HTTPリクエストメソッド名を受け取る専用メソッドを作ればよい

    • なぜなのか分からない。実際には大きく変わるわけでもない。単なるスタイル上の問題ひとつで、提案全体を本当に嫌うことはできないと思う
      HTTPメソッド名を受け取る専用メソッドが、文字列にメソッドを含めることと比べてどんな問題を解決するのか気になる
    • 同意する。あのように設計するのはあまり筋が通らない
      メソッドやオプション一覧のために別の引数を用意できたはず
  • Goが主な道具というわけではないがGinを使ったことがあり、こんな感じだった:
    router.GET("/", func(context *gin.Context) { ... })
    ほかの多くの言語・フレームワークと大きく違うようには見えない。GET /path/ 方式を使う例がほかにもあるのか気になる

    • 後方互換性以外にこうする理由はないので、おそらくほとんどないと思う
    • 実際には見たことがない。ただ悪いやり方だ
  • 公式の言語チームが列挙型の代わりに接頭辞付き文字列を使うのは、かなり奇妙な設計上の選択に見える
    例えば現在の ("GET /path") より (Http.GET, "/path") のほうがよさそうに見える。変だ

    • Go 1互換性の約束を守る方法だ
    • Go開発者としては、こういう場合には後方互換性のために関数オーバーロードがあってほしい。互換性の約束は素晴らしいが、それによって生じる奇妙さはあまりよくない
    • 既存コードを壊すか新しい関数を導入しない限り直せないミスだ
      それでもあの方式よりは、mux.GETmux.POST のような関数が複数あるほうを好む