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.Route r.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_httpmux.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 # delete

1.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.id
Next.js App Router/users/[id]params.id
Go 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.Cleanstrings.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.go

chi の特徴 - 「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.Contextecho.Context)を使う
  • 標準の http.ResponseWriter / *http.Request ではない
  • JSON バインディング、バリデーション、テンプレートエンジンが組み込み済み
  • パフォーマンスは速い(gin は radix tree、echo も同様)

弱み:

  • 標準 net/http 知識の延長で使えない(独自 API を覚える必要)
  • ミドルウェアも独自インターフェース。標準のミドルウェアは流用できない
  • 大規模になると独自 API への依存が痛い

8. 比較表

観点標準 ServeMux (1.22+)chigin / echo
依存なし軽量重め
互換性-net/http と100%独自 API
学習コスト
ミドルウェア自前で書く豊富、自作も簡単組み込み多数
サブルーターなしありあり
パフォーマンス良好(最適化済み)高速最速級
JSON / バインディング自前自前組み込み
採用 OSS 例k8s, Docker, Prometheusgo-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/42DELETE /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

練習課題

  1. 標準 ServeMux 版の PUT /todos/{id}DELETE /todos/{id} を実装
  2. /api/v1/todos/{id} のように バージョン付きパス に書き換え
  3. chi に書き換え、r.Route("/api/v1", ...) でグルーピング
  4. GET /todos?done=true でクエリパラメータによる絞り込みを実装
  5. GET /todos/{id}/comments/{commentID} のような 複数パラメータ を試す
  6. 競合する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=42

HTTP メソッドの意味が無視され、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.id
Next.js App/users/[id]params.id
Go 1.22 ServeMux/users/{id}r.PathValue("id")
chi/users/{id}chi.URLParam(r, "id")
gin/users/:idc.Param("id")

ルータライブラリ選定マトリクス

観点標準 (1.22+)chigin
net/http 互換△(独自Context)
サブルータ×
ミドルウェア部分適用自前簡単簡単
学習コスト
OSS実例k8s, Dockergo-swaggerk8s 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 のシンボリックリンク注意

詰まった時のチートシート

やりたいこと標準 ServeMuxchi
ハンドラ登録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...} ワイルドカードと {$} の使い分けを言える

さらに深掘りするなら


次のレッスン

json で JSON 入出力の罠と最適化に進む。omitempty の落とし穴、ストリーミング処理、http.MaxBytesReader との連携、jsoniter / sonic の使いどころまで掘る。

つながりの予告

  • 本章で書いた mux.HandleFunc("POST /todos", h) のハンドラ本体が、次章で JSON Decode/Encode で埋まる
  • パスパラメータ取得が 3-2_CRUDハンドラDB のキーに直結
  • chi の r.Use2-4_ミドルウェア で本格活用