2-2. ルーティング - ServeMux と Go 1.22+ の新パターン
所要時間: 40-60分(2セッション分) コミット内容:
~/learn/go/level2/day02/に標準 ServeMux 版と chi 版の TODO ルータ
このレッスンのゴール
- Go 1.22 ServeMux で
GET /todos/{id}形式のパターンを書ける -
r.PathValue("id")でパスパラメータを取得できる - パターン衝突の panic 挙動を理解している
- chi の
r.Router.Useでサブルータを組める - 「標準 ServeMux vs chi vs gin」の選定基準を言える
なぜ学ぶか
「Express の app.get('/users/:id') を Go でどう書く?」が本章の中心問題。Go 1.22 までは標準ライブラリにメソッド分岐もパスパラメータも無く、chi/gorilla/mux/gin が事実上の標準だった。1.22 以降は 標準だけで REST が書けるが、過去のコードベースは chi のままが多い。両方の作法を覚える必要 がある。
前章とのつながり
2-1_net_http で mux.HandleFunc("/", h) という単純なルーティングを書いた。本章ではそれを REST API レベル(GET/POST/PUT/DELETE × 複数リソース × パスパラメータ)に拡張する。http.Handler インターフェースの理解が前提。
これができると何が嬉しいか
- REST API の骨格 が10分で組める - 急ぎの新規プロジェクトに即対応
- 既存 OSS コード(chi 版が大半)が読める - サードパーティのルータが読める
- URL 設計の良し悪し が判断できる - レビューで指摘できる
ストーリー導入: ルータは「電話交換手」
巨大なオフィスビルに来た客(=リクエスト)を、適切な部署(=ハンドラ)に取り次ぐ電話交換手がルータ。古いビル(Go 1.21)の交換手は「住所しか聞かない」(パスのみ)。新しいビル(Go 1.22)の交換手は「御用件は何でしょう?(=メソッド)どちらまで?(=パス + パラメータ)」と一気に聞いて瞬時に振り分ける。chi の交換手は「サブビル単位で受付集約」(サブルータ)まで出来る。
大前提: ルーティングは「URL とハンドラの対応表」
Web サーバーは、来た URL(とメソッド)を見て「どのハンドラを呼ぶか」を決める。これがルーティング。地味だが、ここを甘く設計するとコードベース全体が破綻する。
歴史的に Go の標準 net/http.ServeMux は メソッド指定もパスパラメータも無いため、長らく chi や gorilla/mux が事実上の標準だった。しかし Go 1.22 (2024年2月) で大幅強化され、いまや「標準だけで本番運用」が現実的になった。
歴史: Go ルーティング進化の流れ
- 〜Go 1.21: 標準 ServeMux は「パスのプレフィックスマッチのみ」。
GET/POSTの区別なし。実務では chi、gorilla/mux、gin、echo を入れるのが常識- Go 1.22 (2024-02): ServeMux に メソッド指定 と
{path}パターン が公式導入。標準だけで REST が書けるようになった- 現在: 「シンプルな API なら標準で十分。複雑な要件 (CORS、認証、レート制限を組み込みで持ちたい) なら chi」が新基準
1.22 以前のコードベースが残っている現場も多いので、両方の作法を覚える必要がある。
セッション①: 標準 ServeMux を使い倒す(30分)
0. 録画スタート&作業ディレクトリ
mkdir -p ~/log ~/learn/go/level2/day02
cd ~/learn/go/level2/day02
script ~/log/go_level2_day02.log
# Go モジュール初期化
go mod init example.com/level2/day02
go version # 1.22+ であることを確認1. ServeMux の基本(1.22+ 文法)
// main.go
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
// メソッド + パスを同時指定
mux.HandleFunc("GET /todos", listTodos)
mux.HandleFunc("POST /todos", createTodo)
// パスパラメータ: {id} のように波括弧で囲む
mux.HandleFunc("GET /todos/{id}", getTodo)
mux.HandleFunc("PUT /todos/{id}", updateTodo)
mux.HandleFunc("DELETE /todos/{id}", deleteTodo)
http.ListenAndServe(":8080", mux)
}
func getTodo(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // ← 1.22 で導入された取得API
fmt.Fprintf(w, "todo id=%s\n", id)
}
func listTodos(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "list") }
func createTodo(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "create") }
func updateTodo(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "update") }
func deleteTodo(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "delete") }go run main.go &
curl http://localhost:8080/todos # list
curl -X POST http://localhost:8080/todos # create
curl http://localhost:8080/todos/42 # todo id=42
curl -X PUT http://localhost:8080/todos/42 # update
curl -X DELETE http://localhost:8080/todos/42 # delete1.22 ServeMux のパターン文法
パターン文字列は次の3要素を一緒に書ける:
[METHOD ] [HOST]/path例:
"GET /users"→ メソッド + パス"GET example.com/users"→ ホスト指定(vhost ライク)"/users"→ メソッドなしで全部受ける"GET /users/{id}"→ 単一パスパラメータ"GET /users/{id}/posts/{postID}"→ 複数"GET /files/{path...}"→ ワイルドカード(残り全部キャプチャ)"GET /users/{$}"→ 末尾$で「完全一致」(プレフィックスマッチを禁止)パラメータは
r.PathValue("id")で取り出す。
Express / Next.js との対比(FE向け)
フレームワーク 動的セグメント 取り出し Express /users/:idreq.params.idNext.js App Router /users/[id]params.idGo 1.22 ServeMux /users/{id}r.PathValue("id")似たような感覚で使えるが、Go は コロンも角括弧もなく波括弧。Express を長く触った人ほどタイポしやすいので注意。
2. パターンの優先順位と「衝突」
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("GET /users/me", getMe)このコードはどちらが呼ばれるか?
1.22 ServeMux のマッチング規則
「より具体的なパターン」が優先される。
/users/meはリテラル → 具体的/users/{id}はパラメータ → 一般的よって
GET /users/meは 常にgetMeが呼ばれる。{id}は他のパスにマッチする。1.21 以前の
ServeMuxは「長いパスが優先」だったが、1.22 からは 包含関係で判断するように改善された(具体性ベース)。
落とし穴: 曖昧な衝突は起動時に panic する
mux.HandleFunc("GET /a/{x}/c", h1) mux.HandleFunc("GET /a/b/{y}", h2)どちらも
/a/b/cにマッチし得るが、どちらが優先かは決められない(互いに包含関係にない)。これを登録すると 起動時に panic で落ちる。サーバー起動時にエラーで止まる方が、本番で意図しないハンドラが呼ばれるより遥かに安全。Go の典型的な「早く失敗する」設計。
アンチパターン: メソッド判定を自前で if 文
// 1.22 以前の名残でよく見るコード mux.HandleFunc("/todos", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": listTodos(w, r) case "POST": createTodo(w, r) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } })なぜNG: 1.22+ なら
mux.HandleFunc("GET /todos", listTodos)と書ける。自前メソッド判定は冗長で、許可メソッド一覧の自動Allowヘッダ送信もない。1.22 ServeMux はメソッドミスマッチ時に自動で
405 Method Not Allowed+ 適切なAllowヘッダを返す。
3. プレフィックスマッチと末尾 $
mux.HandleFunc("GET /static/", serveStatic) // 末尾 / でプレフィックスマッチ
mux.HandleFunc("GET /api/{$}", apiRoot) // {$} で完全一致のみ末尾スラッシュは「サブツリーマッチ」
"/static/"→/static/css/app.cssも/static/img/logo.pngもマッチ(プレフィックス)"/api/"→/api/anythingにマッチ(API ルートのフォールバックに使われがち)"/api/{$}"→/apiまたは/api/のみマッチ(プレフィックスマッチを禁止)「
/apiジャストにだけ反応したいんだけど!」という時に{$}を使う。
4. 静的ファイル配信
fs := http.FileServer(http.Dir("./public"))
mux.Handle("GET /static/", http.StripPrefix("/static/", fs))http.FileServer は「ディレクトリ → HTTP配信」変換器
http.Dir("./public")でローカルディレクトリをhttp.FileSystemインターフェースに変換http.FileServerがそれをハンドラ化http.StripPrefixで URL から/static/を取り除いてからファイル検索例:
GET /static/css/app.css→ ファイルシステムでは./public/css/app.cssを返す。
FileServer のセキュリティ落とし穴
http.FileServerは シンボリックリンクをそのまま辿る。/public/secret -> /etc/passwdのシンボリックリンクが置かれたら、/static/secretで/etc/passwdが読まれる。本番では:
- 静的ファイル配信は 専用ディレクトリ に限る
- Nginx などのリバースプロキシ側で配信するのが安全(Go プロセスを通さない)
path/filepath.Cleanとstrings.HasPrefixで「上位ディレクトリへ抜けないこと」を自分で確認するか、http.ServeFileFSを使う
セッション②: chi / gin / echo の比較と REST 設計(30分)
5. なぜ標準だけで満たないケースがあるか
標準 ServeMux は 1.22 で大幅進化したが、以下は依然として 無い:
- ミドルウェアのグルーピング(特定パス配下にだけ認証)
- サブルーター(
/api/v1/...をモジュール化) - 名前付きルート(リバース URL 生成)
- 詳細なマッチング(正規表現、カスタム matcher)
これらが必要になると、ルータライブラリの出番。
6. chi - 最も「標準らしい」サードパーティ
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Route("/api/v1", func(r chi.Router) {
r.Get("/todos", listTodos)
r.Post("/todos", createTodo)
r.Route("/todos/{id}", func(r chi.Router) {
r.Get("/", getTodo)
r.Put("/", updateTodo)
r.Delete("/", deleteTodo)
})
})
http.ListenAndServe(":8080", r)
}
func getTodo(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
w.Write([]byte("id=" + id))
}go get github.com/go-chi/chi/v5
go run main.gochi の特徴 - 「net/http と100%互換」
chi のハンドラは
http.Handlerインターフェースそのもの。標準ライブラリのミドルウェアもそのまま使える。強み:
- 学習コストが低い(net/http 知識がそのまま生きる)
r.Routeでサブルーターをネスト → 大規模 API でも整理できる- ミドルウェアを「特定パス配下にだけ」適用可能
- 依存が少ない(Go の標準だけで動く)
Kubernetes のいくつかのプロジェクト、Docker の Engine API、go-swagger などが採用。
7. gin / echo - フルスタック寄り
// gin の例
package main
import "github.com/gin-gonic/gin"
func main() {
r := gin.Default()
r.GET("/todos/:id", func(c *gin.Context) {
id := c.Param("id")
c.JSON(200, gin.H{"id": id})
})
r.Run(":8080")
}gin / echo の特徴 - 「使いやすさ重視」
- 独自の
Context型(gin.Context、echo.Context)を使う- 標準の
http.ResponseWriter/*http.Requestではない- JSON バインディング、バリデーション、テンプレートエンジンが組み込み済み
- パフォーマンスは速い(gin は radix tree、echo も同様)
弱み:
- 標準 net/http 知識の延長で使えない(独自 API を覚える必要)
- ミドルウェアも独自インターフェース。標準のミドルウェアは流用できない
- 大規模になると独自 API への依存が痛い
8. 比較表
| 観点 | 標準 ServeMux (1.22+) | chi | gin / echo |
|---|---|---|---|
| 依存 | なし | 軽量 | 重め |
| 互換性 | - | net/http と100% | 独自 API |
| 学習コスト | 低 | 低 | 中 |
| ミドルウェア | 自前で書く | 豊富、自作も簡単 | 組み込み多数 |
| サブルーター | なし | あり | あり |
| パフォーマンス | 良好(最適化済み) | 高速 | 最速級 |
| JSON / バインディング | 自前 | 自前 | 組み込み |
| 採用 OSS 例 | k8s, Docker, Prometheus | go-swagger 等 | Kubernetes ダッシュボード等 |
| 「フレームワーク色」 | なし | 薄い | 濃い |
選定のガイドライン
- 小規模 API / マイクロサービス: 標準 ServeMux で十分
- 中〜大規模 API: chi(標準互換性を保ちつつ機能拡張)
- 学習コストを許容して開発速度優先: gin / echo
- 「とりあえず人気のやつ」: gin(GitHub スター数最多。ただし思想は要確認)
私見だが、Go らしさを保ちたいなら標準 or chi、Rails / Express 経験者で生産性優先なら gin / echo。
9. パフォーマンス - トライ木 vs 線形検索
ルートマッチングのアルゴリズム
- 標準 ServeMux (〜1.21): 線形検索(登録順に全パターンを照合)。ルート数が多いと O(N)
- 標準 ServeMux (1.22+): 改善された決定木ベース。実用上問題なし
- chi: 圧縮トライ木(radix tree)。O(log N) 〜 O(K)(K はパス文字数)
- gin / echo: radix tree
数十〜数百ルート程度なら どれも体感差はない。10000+ ルートのような巨大 API ゲートウェイなら chi / gin / echo が有利。
ベンチマーク結果は重要だが、実アプリで律速になるのは DB クエリ・外部 API 呼び出し。ルーティングで悩むのは早すぎる最適化のことが多い。
10. REST URL 設計の原則
リソース指向の URL
良い URL:
GET /users- ユーザー一覧GET /users/42- 特定ユーザーPOST /users- 新規作成PUT /users/42- 更新(全体)PATCH /users/42- 部分更新DELETE /users/42- 削除GET /users/42/posts- ネストリソース名詞(リソース)を URL に、動詞は HTTP メソッドに。動詞を URL に入れない:
アンチパターン: 動詞を URL に入れる
POST /createUser POST /getUserById?id=42 POST /deleteUserなぜNG: HTTP メソッドの意味を無視している。GET でも POST 扱いで送られると、ブラウザのキャッシュも CDN のキャッシュも効かない。冪等性(PUT/DELETE は何度叩いても同じ結果)の概念も失われる。
修正:
GET /users/42、DELETE /users/42のようにメソッドで動詞を表現。
パス vs クエリの使い分け
- パスパラメータ
/users/42: リソースを 一意に特定 する識別子- クエリパラメータ
/users?role=admin: 絞り込み・並び替え・ページングGET /users/42 ← 単体取得 GET /users?role=admin&limit=20 ← 一覧の絞り込み GET /users/42/posts?status=draft ← ネスト + 絞り込み
11. 実装課題: TODO API を標準 ServeMux と chi の両方で
// standard.go - 標準 ServeMux 版
package main
import (
"encoding/json"
"net/http"
"strconv"
"sync"
"time"
)
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
At time.Time `json:"at"`
}
type store struct {
mu sync.Mutex
todos map[int]Todo
nextID int
}
func (s *store) list() []Todo {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Todo, 0, len(s.todos))
for _, t := range s.todos {
out = append(out, t)
}
return out
}
func (s *store) create(title string) Todo {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
t := Todo{ID: s.nextID, Title: title, At: time.Now()}
s.todos[s.nextID] = t
return t
}
func main() {
s := &store{todos: map[int]Todo{}}
mux := http.NewServeMux()
mux.HandleFunc("GET /todos", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(s.list())
})
mux.HandleFunc("POST /todos", func(w http.ResponseWriter, r *http.Request) {
var in struct{ Title string `json:"title"` }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "bad json", 400)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(s.create(in.Title))
})
mux.HandleFunc("GET /todos/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "bad id", 400)
return
}
s.mu.Lock()
t, ok := s.todos[id]
s.mu.Unlock()
if !ok {
http.NotFound(w, r)
return
}
json.NewEncoder(w).Encode(t)
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
srv.ListenAndServe()
}curl -X POST -d '{"title":"買い物"}' http://localhost:8080/todos
curl http://localhost:8080/todos
curl http://localhost:8080/todos/1練習課題
- 標準 ServeMux 版の
PUT /todos/{id}とDELETE /todos/{id}を実装 /api/v1/todos/{id}のように バージョン付きパス に書き換え- chi に書き換え、
r.Route("/api/v1", ...)でグルーピング GET /todos?done=trueでクエリパラメータによる絞り込みを実装GET /todos/{id}/comments/{commentID}のような 複数パラメータ を試す- 競合する2つのパターンを意図的に登録し、起動時 panic を確認する
締め: git で証跡を残す
cd ~/learn/go/level2/day02
git add .
git commit -m "feat(go-routing): 標準ServeMux 1.22とchiで同等のTODOルータを実装"
exitアンチパターン集 - やらかし事例
ルーティング設計のやらかし
1. 動詞を URL に入れる
POST /createUser POST /deleteUser?id=42HTTP メソッドの意味が無視され、CDN・ブラウザキャッシュ・冪等性概念が全て壊れる。
2. パスとクエリの使い分けがめちゃくちゃ
GET /users?id=42 ← 単体取得を query で GET /users/role/admin ← 絞り込みを path で単体取得は path(
/users/42)、絞り込みは query(/users?role=admin)。3. 1.22 以前のスタイルで if 文ハンドラ
mux.HandleFunc("/todos", func(w, r) { switch r.Method { case "GET": ...; case "POST": ... } })1.22 以降は
mux.HandleFunc("GET /todos", ...)で書ける。405 Method Not Allowed+Allowヘッダも自動。4. パターン衝突を放置
mux.HandleFunc("GET /a/{x}/c", h1) mux.HandleFunc("GET /a/b/{y}", h2)
/a/b/cに両方マッチして起動時 panic。早く失敗する Go の哲学。5. http.FileServer をシンボリックリンク放置で公開 公開ディレクトリ内のシンボリックリンクが
/etc/passwdを指していたら…典型的脆弱性。
対比表で違いを明確化
パスパラメータ取り出しの3スタイル
フレームワーク 定義 取得 Express /users/:idreq.params.idNext.js App /users/[id]params.idGo 1.22 ServeMux /users/{id}r.PathValue("id")chi /users/{id}chi.URLParam(r, "id")gin /users/:idc.Param("id")
ルータライブラリ選定マトリクス
観点 標準 (1.22+) chi gin net/http 互換 ◯ ◎ △(独自Context) サブルータ × ◯ ◯ ミドルウェア部分適用 自前 簡単 簡単 学習コスト 低 低 中 OSS実例 k8s, Docker go-swagger k8s Dashboard
自己評価チェックリスト
手を動かせた
- 1.22 ServeMux で
GET /todos/{id}を書いた -
r.PathValue("id")でパラメータ取得 - サブツリーマッチ
/static/を試した -
{$}で完全一致を強制 - chi に書き換え、
r.Routeでグルーピング - パターン衝突を意図的に起こして panic を確認
説明できる
- 1.22 ServeMux のパターン文法(METHOD HOST/path/{param})
- 「具体的なパターンが優先」のマッチング規則
- 「405 + Allow」を自動で返す仕組み
- 標準 vs chi vs gin の選定基準
やらかし回避
- 動詞 in URL のアンチパターンを指摘できる
- パス vs クエリの使い分けを明文化
- FileServer のシンボリックリンク注意
詰まった時のチートシート
| やりたいこと | 標準 ServeMux | chi |
|---|---|---|
| ハンドラ登録 | mux.HandleFunc("GET /x", h) | r.Get("/x", h) |
| パスパラメータ取得 | r.PathValue("id") | chi.URLParam(r, "id") |
| サブルーター | なし(接頭辞でグルーピングのみ) | r.Route("/api", func(r chi.Router) {...}) |
| 完全一致 | "GET /x/{$}" | r.Get("/x", h)(デフォルト) |
| ワイルドカード | "GET /files/{path...}" | r.Get("/files/*", h) |
| ミドルウェア | 自前で http.Handler をラップ | r.Use(middleware.X) |
「実務OK」基準
- 1.22 ServeMux で REST API を即書ける: GET/POST/PUT/DELETE + パスパラメータ
- chi の
r.Route+r.Useでモジュール分割ができる - 「これは標準で足りるか chi が必要か」を判断できる
- アンチパターン(動詞 in URL、巨大 if-else)を指摘できる
{path...}ワイルドカードと{$}の使い分けを言える
さらに深掘りするなら
- 公式 Release Notes: Go 1.22 Release Notes - ServeMux - 新パターンの仕様書
- 公式 blog: Routing Enhancements for Go 1.22 - 設計者本人の解説
- chi のソース: github.com/go-chi/chi - radix tree 実装を読むと勉強になる
- gin vs echo vs fiber 比較: 各フレームワークの README とベンチマーク
- 書籍: 『Web API: The Good Parts』(水野貴明) - REST API 設計の決定版
次のレッスン
json で JSON 入出力の罠と最適化に進む。omitempty の落とし穴、ストリーミング処理、http.MaxBytesReader との連携、jsoniter / sonic の使いどころまで掘る。
つながりの予告
- 本章で書いた
mux.HandleFunc("POST /todos", h)のハンドラ本体が、次章で JSON Decode/Encode で埋まる - パスパラメータ取得が 3-2_CRUDハンドラ で DB のキーに直結
- chi の
r.Useが 2-4_ミドルウェア で本格活用