1. なぜルーティングを改善するのか: if が少ないほど楽になる
HTTP サーバーを書き始めたばかりの頃は、1 つの handler を作って、その中で「ミニディスパッチャ」のようなものを動かすのが自然に思えます。つまり、r.URL.Path を確認し、r.Method を確認し、パス文字列を分割し、strings.Split を使い、そしてスラッシュを見落としていないことを祈る、というやり方です。これは動きます……でも、ルートが 3 つを超えたあたりから、チーム(あるいは 1 週間後の自分)が「なぜ POST /tasks がタスク表示の handler に入るんだ?」と首をかしげるようになります。
Go 1.22 で標準ライブラリはとても気持ちのよい一歩を踏み出しました。net/http.ServeMux に メソッド付きパターンと wildcard が追加されたのです。つまり、GET /tasks や POST /tasks のように、handler のコード内ではなくルーティング層でルートを直接記述できます。同時に、{id} や {path...} のような wildcard セグメント(パスの一部を捕まえるもの)も使えるようになりました。Go 1.22 のリリースノートでも、net/http.ServeMux が使う patterns にメソッドと wildcard セグメントが追加されたことが、GET /task/{id}/ のような例とともに明記されています。
私たち、つまりこれから学ぶ側にとっての最大の利点は、文字列操作の「魔術」が減って、宣言的な書き方が増えることです。サーバーの可読性が上がり、テストもしやすくなり、「あ、メソッドのチェック忘れてた」みたいなミスもかなり減ります。
2. http.ServeMux をリクエストのディスパッチャとして見る
サーバーを宅配オフィスだと想像してください。リクエストは紙を持った配達員です。「メソッド: GET、パス: /api/v1/tasks/10」。ServeMux は入口の受付のようなもので、その紙を見て、どの担当者に配達員を回すべきかを判断します。受付がもたつくと、みんなが列に並んで文句を言い始めます。
ServeMux の仕事はシンプルです。mux.Handle(...) や mux.HandleFunc(...) で登録した pattern にリクエストを対応付けることです。以前の pattern は主に「パス」だけに関するもので、開発者は /tasks という 1 つの pattern を登録し、その後 handler 内で switch r.Method を書くことがよくありました。これは動くやり方でしたが、コピペと細かなミスを生みやすい方法でもありました。
今では、メソッドをルートの一部として定義できます。つまり、受付は「どこに行くか」だけでなく(/tasks)、「何をしに行くか」も見ます(GET/POST)。これこそ、ちゃんとした API に求めるものです。
イメージを固めるために、リクエストが mux を通る流れを図にするとこうなります:
flowchart TD
A[クライアントがリクエストを送る] --> B[http.Server]
B --> C[ServeMux: 適切な pattern を探す]
C -->|見つかった| D[Handler]
C -->|見つからない| E[状況に応じて 404 / 405]
D --> F[クライアントへ応答]
重要なのは、私たちは今も外部ルーターやフレームワークの「魔法」なしで、標準の net/http の世界にいるということです。ServeMux は同じ ServeMux のままですが、より賢くなりました。
3. Method-aware patterns: GET /tasks で r.Method をチェックしない
"GET /tasks" のような文字列を見ると、「フレームワークっぽい妙な構文かな?」と感じるかもしれません。でも実際にはとても素直な考え方です。ServeMux は文字列 pattern を受け取り、Go 1.22 以降はその pattern に HTTP メソッドを含められます。
最もよくある実践上の利点は、各 handler で毎回同じことを書かなくて済むようになることです:
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
その代わりに、正しい route を登録するだけで済みます。Handler はより短く、よりまっすぐになります。「1 つのメソッドに対して 1 つの動作をする」と明確になるのです。
最小例: GET /health
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok") // ok
})
_ = http.ListenAndServe(":8080", mux)
}
書き方に注目してください。メソッドとパスの間には必ずスペースが必要です。これは「スタイル」ではなく、pattern 構文の一部です。
1 つの path に 2 つの method: GET /tasks と POST /tasks
API では定番です。GET は一覧を読む、POST は作成する。
package main
import (
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("list tasks\n"))
})
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
})
_ = http.ListenAndServe(":8080", mux)
}
ここで大事なのは、1 つの path に複数の route があるということです。これは普通です。受付役(mux)は、ただ異なる「routing rule」を見ているだけです。
なぜこれが「Go らしい」のか
Go は、インフラ的なルールを 1 か所 で表したがる言語です。handler にメソッドチェックを散らすと、小さな不整合がたくさん生まれます。すると誰かが PUT を追加し、Allow を書き忘れ、どこかでは 405 ではなく 400 を返してしまい、カオスになります。
すでに「handler をアダプタで包む」考え方を見たことがあるなら、それも同じ哲学です。Go チームの古い資料には、自分の型 appHandler(関数型)を作り、ServeHTTP を実装して、エラーを一元的に処理する例があります。そこで明示されているのは、receiver は関数でもよく、それによってインフラを 1 か所に保ちやすくなる、ということです。
method-aware patterns も同じ効果があります。インフラの一部が handler 本体から route 登録の場所へ移動するのです。
4. ServeMux の wildcard: {id} と {path...}
method-aware patterns が r.Method に関する悩みを減らすなら、wildcard はパスに対する文字列操作の悩みを減らしてくれます。しかも、パスの文字列操作というのは、「慎重にやっているつもり」から始まって、突然二重スラッシュや空セグメント、予想外の末尾で壊れる類のバグです。
{id}: パスの 1 セグメント
次のような pattern:
- GET /tasks/{id}
は、/tasks/ の後に ちょうど 1 つのセグメント が来ることを意味します。たとえば /tasks/12 や /tasks/abc です。この段階で大切なのはマッチングだけです。ルートが存在し、リクエストが handler に「入る」ということです。
{id} の意味は、「ここは可変部分の path です」ということです。まだ値をどう取り出すかは扱いませんが(それは別の話です)、可変セグメントがあるだけで、手動の strings.Split は不要になります。
{path...}: 複数セグメントにわたる path の末尾
ときには 1 セグメントではなく、「その先すべて」をマッチしたいことがあります。典型例は静的ファイル配信です(API ではやや少ないですが、学習用の例としてはとても良いです)。
次のような pattern:
- GET /assets/{path...}
は、/assets/ の後に logo.png でも、css/app.css でも、images/icons/edit.svg でもよく、これらをすべて「末尾」としてまとめてマッチします。URL の「ディレクトリ」全体を 1 つの handler で扱いたいときに便利です。
実践でなぜ必要なのか
リソースを持つ API を作り始めると、ほぼ必然的に次のような構成になります。
- 一覧: GET /tasks
- 特定のエンティティの操作: GET /tasks/{id}
- 削除: DELETE /tasks/{id}
そして wildcard を使わないと、かなりの確率で「r.URL.Path を手でパースしよう」となります。これは、森の中の細い道に入り込んで、すぐに罠を踏みやすいのと同じです。
ServeMux はどうやって最適な route を選ぶのか
route が増えると、自然にこんな疑問が出ます。複数の pattern が「合っている」場合、誰が勝つのか?
乾いたルール列挙(呪文のように読まれがちなもの)ではなく、日常的なロジックで考えてみましょう。受付に次の 2 つの rule があるとします:
- 「/assets/… へのすべてのリクエストを静的ファイル担当へ送る」
- 「/assets/logo.png へのすべてのリクエストをデザイナーへ送る。彼はこのファイルが大好きだ」
この場合、より具体的な rule が勝つべきです。
ServeMux でも考え方は同じです。より specific な pattern が勝ちます。そして method-aware patterns は、さらに 1 段階 specific になります。つまり、GET /tasks は、すべてのメソッド向けの単なる /tasks よりも具体的です。なので、両方を登録した場合、GET /tasks のリクエストは「GET 側」に送るのが自然です。
初心者が覚えておくとよい別の点があります。query パラメータ(? の後ろ)は handler の選択に関与しません。つまり、/tasks?done=true も結局は /tasks としてマッチします。もし "GET /tasks?done=true" のような pattern を書こうとすると、handler に入れず、404 を見ながら「でも、全部正しいはずなのに!」となってしまいます。
5. HTTP アプリケーションの骨組み: route を登録する
今の目標は、タスク保存機能を実装することでも CRUD を完成させることでもありません(それは別の話です)。routing の骨組みを作り、どんな route があるのか、メソッド+パスがどう具体的な handler になるのかを見えるようにすることです。
最小のサーバーを作ります。以下を行います:
- GET /health に対して ok を返す
- GET /api/v1/tasks と POST /api/v1/tasks を区別できる
- /api/v1/tasks/{id} で task を id によって扱う GET と DELETE の route を持つ
- /assets/{path...} に対して「末尾 wildcard」の例を示す
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", health)
mux.HandleFunc("GET /api/v1/tasks", listTasks)
mux.HandleFunc("POST /api/v1/tasks", createTask)
mux.HandleFunc("GET /api/v1/tasks/{id}", getTask)
mux.HandleFunc("DELETE /api/v1/tasks/{id}", deleteTask)
mux.HandleFunc("GET /assets/{path...}", assets)
_ = http.ListenAndServe(":8080", mux)
}
func health(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok") // ok
}
func listTasks(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "list tasks") // タスク一覧
}
func createTask(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
fmt.Fprintln(w, "created") // 作成済み
}
func getTask(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "get task by id") // id で task を取得
}
func deleteTask(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func assets(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "static asset") // 静的アセット
}
このコードで重要なこと:
- handler 内で r.Method を 確認しない。ServeMux がすでにメソッドで handler を選んでくれているからです。
- r.URL.Path を 解析しない。route を登録しただけです。もちろん、handler getTask はまだ、どの {id} が来たのかを知りませんが、少なくとも /api/v1/tasks/{id} に対してのみ呼ばれることは保証されています。これは大きな利点です。
- mux := http.NewServeMux() を明示的に作り、それを ListenAndServe に渡しています。これによって、誤って DefaultServeMux に route を登録してしまい、別の mux でサーバーを起動してしまう、という事故を減らせます(典型的なバグ: 「なんで全部 404 なんだ?」)。
コードの感触はどう変わるか
高尚な言い方をしないなら、method-aware patterns と wildcard はサーバーをより「行ベース」ではなく、より「構造ベース」にしてくれます。
route 登録のブロックを見ると、ほとんど API の仕様書のように見えます。何をサポートしているのかを示す小さな一覧表のようなものです。そして将来、たとえば「route がない」のか「route はあるが method が合わない」のかを正しく区別したくなったとき、pattern に method があることで、その振る舞いはずっと自然になります。
加えて、純粋に心理的にも、手書きの if が減り、ミスしやすい箇所も減ります。これは特に初心者にとって重要です。なぜなら、脳はすでに := と = を混同しないことや、import を忘れないことに精一杯だからです。
6. ServeMux patterns でよくあるミス
ミス 1: method と path の間のスペースを忘れる。
pattern 文字列は「たぶん何とか解釈される」ものではなく、明確な構文です。"GET /api/v1/tasks" の代わりに "GET/api/v1/tasks" と書くと、まったくマッチしない route になり、ネットワークが壊れたのかと疑うまで 404 を見続けることになります。
ミス 2: query パラメータを pattern に入れようとする。
"/tasks?done=true" は、意味として pattern では動きません。query は path の一部ではないからです。正しい pattern は "GET /tasks" で、フィルタリングは r.URL.Query() を読む別のロジックです(そしてそれは handler の選択に影響すべきではありません)。
ミス 3: 理由もなく 2 つのスタイルを混ぜる。つまり method-aware patterns と、handler 内の switch r.Method を両方使う。
「念のため」にこうすることがありますが、その後で両方の場所を更新し忘れます。結果として、ServeMux はすでに method を保証しているのに、handler が古いチェックのせいで 405 を返すことがあります。つまり「自分で自分の敵」みたいな挙動です。特定のコード領域では 1 つのスタイルに絞りましょう。pattern に method があるなら、handler では通常 method を再チェックしません。
ミス 4: r.URL.Path を、{id} があるのに手で解析しようとする。
これは他の言語や昔の実験から来た習慣です。問題は、path の手動パースはすぐに条件分岐の塊になり、テストしにくく壊しやすいことです。ServeMux が wildcard を理解できるなら、そちらに任せた方がよいです。スラッシュ当てゲームで競う必要はありません。
ミス 5: route を DefaultServeMux に登録したのに、サーバーは別の mux で起動している。
http.HandleFunc(...) を使うと DefaultServeMux を操作しています。mux := http.NewServeMux() を作って mux.HandleFunc(...) と書くと、特定の mux を操作していることになります。この 2 つを混同すると、route が「消えた」ように見えます。だから学習プロジェクトでは、常に明示的な mux を作り、常にそれを ListenAndServe に渡す、というルールを守るのがよいです。
GO TO FULL VERSION