GoのHTTPルーターを強化する提案
原文は Ben Hoyt により に公開されました。 このブログを購読する
Goの標準ライブラリには、以前から堅牢で本番利用にも耐えるHTTPサーバーが含まれています。しかし、標準で付属するリクエストルーターであるhttp.ServeMuxは非常にミニマルで、自分でルーティングのコードを書かなければならないことがよくあります。
特に、HTTPメソッドによるマッチング(たとえばGETとPOSTを区別する処理)や、/users/{user}/settingsのようなワイルドカードパスに対応していません。しかし、どちらの機能もREST風のAPIサーバーにはほぼ必須です。
もちろん、これらは自分で実装することもできます。以前、GoにおけるHTTPルーティングのさまざまなアプローチについて書いたことがありますが、高度なルーティングを行う優れたサードパーティ製パッケージがいくつか存在しますし、サードパーティ製ライブラリを一切使わなくても30行程度のコードで同様の機能を追加することも可能です。
しかし、そうした回避策やサードパーティ製パッケージも、そう長くは必要なくなるかもしれません。進行中の提案では、リファレンス実装も含めて、ServeMuxを拡張してHTTPメソッドやワイルドカードパスでのマッチングに対応しようとしています。
この提案とそれに先立つディスカッションを主導しているのは、GoogleのGoチームのJonathan Amsterdam氏です。Jonathan氏は、標準ライブラリへの構造化ロギングを追加する提案を成功させた人物でもあります。彼のlog/slogパッケージはGo 1.21(2023年8月リリース予定)に含まれる予定です。
見た目はこんな感じ
現状、/users/{user}/settingsへのGETリクエストにマッチさせたい場合、(実際にはサードパーティ製ライブラリを使うことが多いとはいえ)次のような大量のボイラープレートを書く必要があります。
mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
remainder := r.URL.Path[len("/users/"):]
userId, subPath, _ := strings.Cut(remainder, "/")
switch subPath {
case "settings":
fmt.Fprintf(w, "user %s", userId)
// cases for other sub-paths could go here
default:
http.NotFound(w, r)
}
})提案が採用されれば、次のように書けるようになります。
mux.HandleFunc("GET /users/{user}/settings", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "user %s", r.PathValue("user"))
})ずっとすっきりしますね!
これは他の人気のあるルーターが使っている構文ともよく似ています。
// github.com/go-chi/chi
router.Get("/users/{user}/settings", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "user %s", chi.URLParam(r, "slug"))
})
// github.com/gorilla/mux
router.HandleFunc("/users/{user}/settings", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "user %s", mux.Vars(r)["user"])
}).Methods("GET")
// github.com/bmizerany/pat
router.Get("/users/:user/settings", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "user %s", r.URL.Query().Get(":user"))
}))
// github.com/gin-gonic/gin
router.GET("/users/:user/settings", func(c *gin.Context) {
fmt.Fprintf(w, "user %s", c.Param("user"))
})この提案で興味深い判断のひとつは、ServeMuxに新しいメソッドを追加するのではなく、既存のHandleとHandleFuncメソッドを拡張して、メソッドのプレフィックスや{wildcard}形式のパスセグメントを許容するようにしている点です。
新しいメソッドを追加したくないという気持ちは理解できますが、この判断については懐疑的です。残念ながら、古いバージョンのServeMuxでもHandle("GET /foo", h)のようなパターンは受け入れてしまいます。つまり、拡張されたServeMux向けに書かれたコードは古いバージョンのGoでもコンパイルでき、一見正常に動作しているように見えてしまうのに、実際にはどのルートにもマッチしないという事態が起こり得るのです。ややエラーが起こりやすいと言えます。私なら代わりにHandleMatch / HandleMatchFuncやRoute / RouteFuncのような新しいメソッドを追加したでしょう。
提案には、2つのパターンが重なった場合の優先順位について lengthy な説明もありますが、要するにシンプルなルールに集約されます。「2つのパターンが重なる(共通するリクエストを持つ)場合、より具体的なパターンが優先される」というものです。
たとえば、/users/(これは/users/*にマッチします)というパターンと、/users/{user}というパターンの両方を登録した場合、/users/benへのリクエストが来ると、より具体的な後者のパターンにマッチします。これは、既存のServeMuxでホスト名付きのパターンがホスト名なしのパターンより優先される仕組みと似ています。
URL末尾にマッチするワイルドカード
提案では、URLの末尾にのみマッチする「特殊なワイルドカード」として{$}も追加されます。これは主に、ホームページだけにマッチさせたいルートで役立ちます。現在これを正しく行うのは意外と面倒です。というのも、/で終わるパターンは/配下のすべてにマッチしてしまい、これはパターンが単独の/である場合にも当てはまるからです。
そのため現状では、ホームページだけにマッチさせるには次のようにする必要があります。
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { // ensure path is exactly "/"
http.NotFound(w, r)
return
}
serveHomepage(w, r)
})
mux.HandleFunc("/users", serveUsers)これは面倒ですし、もしパスのチェックを忘れると、見つからない場合に404ページを返す代わりに、すべてのURLでホームページが表示されてしまいます。なぜなら、すべてが/配下にあるからです。
新しい提案では、これはずっとシンプルになります。
mux.HandleFunc("/{$}", serveHomepage)
mux.HandleFunc("/users", serveUsers)リファレンス実装
Jonathan氏は、拡張されたServeMuxのサンプル実装をgithub.com/jba/muxpatternsパッケージとして公開しています。唯一の違いは、別パッケージにあるためhttp.Request型を変更できないことで、そのためパス値はrequest.PathValue("name")ではなくmux.PathValue(request, "name")で取得します。
私は自分のgo-routingリポジトリに、ウィジェットAPIのmuxpatternsを使ったバージョンを追加するPRを追加しました。chi版とよく似ており、シンプルで読みやすい内容です。
r.HandleFunc("GET /{$}", home)
r.HandleFunc("GET /contact", contact)
r.HandleFunc("GET /api/widgets", apiGetWidgets)
r.HandleFunc("POST /api/widgets", apiCreateWidget)
r.HandleFunc("POST /api/widgets/{slug}", apiUpdateWidget)
r.HandleFunc("POST /api/widgets/{slug}/parts", apiCreateWidgetPart)
r.HandleFunc("POST /api/widgets/{slug}/parts/{id}/update", apiUpdateWidgetPart)
r.HandleFunc("POST /api/widgets/{slug}/parts/{id}/delete", apiDeleteWidgetPart)
r.HandleFunc("GET /{slug}", widgetGet)
r.HandleFunc("GET /{slug}/admin", widgetAdmin)
r.HandleFunc("POST /{slug}/image", widgetImage)実は最初にテストした際、リファレンス実装に2つほど軽微なバグを見つけましたが、すでに修正されています。
おわりに
既存のHandleとHandleFuncメソッドを拡張する点については留保がありますが、この提案が検討されていることをとても嬉しく思います。Jonathan氏が提案に注いだ丁寧な作り込みや、log/slogでの実績、そしてコミュニティからの肯定的な反応を考えると、提案が採択される可能性は高そうです。
これが標準ライブラリに入るのは素晴らしいことでしょう。これまで開発してきたウェブサイトやREST風APIのほぼすべてで、この機能が必要でした。Goの標準ライブラリだけでもすでに多くのことができますが、これによってサードパーティ製ルーターの必要性はほぼ完全になくなるでしょう。
Go 1.22(2024年2月リリース予定)で実現してもおかしくないと思います。どうなるか見守りましょう!
記事をランダムに読む
コメント
ログインしてコメントする