- Go言語
Go net/httpでWebサーバー|1.22以降ルーティング完全コード
Go の net/http は、外部ライブラリなしで本番運用レベルの Web サーバーを書ける標準パッケージです。 Go 1.22 で http.ServeMux が強化され、GET /tasks/{id} のようにメソッドとパス変数を1行で書けるようになったため、REST API 程度のルーティングなら標準ライブラリだけで十分にまかなえます。
この記事では、Go 1.27.1 で実際にビルド・起動した REST サーバーの完成コード(メソッド付きパターン、PathValue、{path...}、{$}、ログ/リカバリのミドルウェア、JSON 入出力、http.Server のタイムアウト、graceful shutdown)を載せ、curl で確かめた 405 と Allow ヘッダー、404、パターンの優先順位の結果をそのまま掲載します。さらに、パターンが競合したときに起動時に panic するエラー文、Go 1.21 以前との対照表、chi・echo などの外部ルーターが必要かどうかの判断表もまとめました。
net/http ServeMux の早見表
| やりたいこと | パターンの書き方(Go 1.22 以降) | 取り出し方・挙動 |
|---|---|---|
| GET だけ受け付ける | GET /tasks |
GET と HEAD にマッチ。他メソッドは 405 |
| POST だけ受け付ける | POST /tasks |
メソッドは完全一致(大文字小文字を区別) |
| パス変数を受け取る | GET /tasks/{id} |
r.PathValue("id") → "42"(文字列) |
| 残りのパスを丸ごと受け取る | GET /files/{path...} |
/files/a/b.txt → "a/b.txt"(末尾のみ可) |
ルート / だけにマッチ |
GET /{$} |
/about にはマッチしない |
| 配下すべてにマッチ | /static/ |
末尾 / は匿名の {...} と同じ |
| ホストで分ける | api.example.com/ |
ホスト付きが同条件なら優先 |
| 固定パスを変数より優先 | GET /tasks/today と GET /tasks/{id} を両方登録 |
より具体的な方が勝つ(登録順は無関係) |
| マッチしたパターンを知る | — | r.Pattern(Go 1.23 以降) |
| 旧挙動に戻す | GODEBUG=httpmuxgo121=1 |
go.mod の go 行が 1.21 以下でも旧挙動 |
| リクエスト本文の上限 | — | http.MaxBytesReader(w, r.Body, 1<<20) |
| ヘッダー読み取りの上限時間 | http.Server{ReadHeaderTimeout: 5*time.Second} |
Slowloris 型の遅い接続への対策 |
| 安全に停止 | srv.Shutdown(ctx) |
処理中のリクエストを待ってから停止 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Go net/httpでWebサーバー|1.22以降ルーティング完全コード」
Go 1.22 で ServeMux はどう変わったか
Go 1.21 までの ServeMux はパスの前方一致だけで、メソッドの判定やパス変数の取り出しは自分で書く必要がありました。そのため gorilla/mux や chi を併用するのが一般的でした。Go 1.22 で次の機能が標準に入りました。
| 項目 | Go 1.21 以前 | Go 1.22 以降 |
|---|---|---|
| メソッド指定 | 不可(ハンドラー内で r.Method を判定) |
GET /path のように先頭に書く |
| パス変数 | 不可(strings.Split などで自前解析) |
{id} と r.PathValue("id") |
| 残りのパス | 末尾 / の前方一致のみ |
{name...} で値として取得 |
| ルートだけにマッチ | 不可(/ は全パスにマッチ) |
/{$} |
| 405 の自動応答 | なし(404 になる) | 405 と Allow ヘッダーを自動で返す |
| 優先順位 | 最長一致 | より具体的なパターン(後述) |
| 不正なパターン | ほぼ受け付ける | 登録時に panic |
| パスのエスケープ | パス全体をデコード | セグメントごとにデコード(%2F は区切りにならない) |
go.mod の go 行で挙動が切り替わる
新しいルーティングは go.mod の go ディレクティブが 1.22 以上のときに有効になります。同じ Go 1.27.1 でビルドしても、go 1.21 と書かれたモジュールでは旧挙動です。以下は /tasks/{id} と /tasks/ を登録し、GET /x パターンも登録したプログラムを、go.mod の値だけ変えて実行した結果です。
## go.mod: go 1.21(GOTOOLCHAIN=local, Go 1.27.1)
GET /tasks/42 -> 200 pattern=/tasks/ id=""
GET /x -> 404
## go.mod: go 1.22
GET /tasks/42 -> 200 pattern=/tasks/{id} id="42"
GET /x -> 200
{id} が効かない、GET /x が 404 になるときは、まず go.mod の go 行を確認してください。旧挙動では {id} は文字どおりのパス、GET /x は「GET /x」という名前のホスト指定として扱われるため、このような結果になります。環境変数 GODEBUG=httpmuxgo121=1 でも同じ旧挙動になります(起動時に1回だけ読まれます)。
標準ライブラリだけの REST サーバー完成コード
前提:Go 1.27.1、外部モジュールなし(go.mod の go 行は 1.22 以上)。 タスク(ToDo)を登録・取得・削除する API です。そのまま main.go として保存すれば動きます。
mkdir tasks && cd tasks
go mod init example.com/tasks
# main.go を保存してから
go run .
package main
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"sync"
"syscall"
"time"
)
// ---------- データとストア ----------
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
type Store struct {
mu sync.Mutex
nextID int
tasks map[int]Task
}
func NewStore() *Store { return &Store{nextID: 1, tasks: map[int]Task{}} }
// ---------- JSON ヘルパー ----------
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// ---------- ハンドラー ----------
func (s *Store) list(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Task, 0, len(s.tasks))
for i := 1; i < s.nextID; i++ {
if t, ok := s.tasks[i]; ok {
out = append(out, t)
}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Store) create(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MiB まで
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var in struct {
Title string `json:"title"`
}
if err := dec.Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if in.Title == "" {
writeError(w, http.StatusUnprocessableEntity, "title is required")
return
}
s.mu.Lock()
t := Task{ID: s.nextID, Title: in.Title}
s.tasks[t.ID] = t
s.nextID++
s.mu.Unlock()
w.Header().Set("Location", "/tasks/"+strconv.Itoa(t.ID))
writeJSON(w, http.StatusCreated, t)
}
func (s *Store) get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "id must be an integer")
return
}
s.mu.Lock()
t, ok := s.tasks[id]
s.mu.Unlock()
if !ok {
writeError(w, http.StatusNotFound, "task not found")
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Store) delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "id must be an integer")
return
}
s.mu.Lock()
_, ok := s.tasks[id]
delete(s.tasks, id)
s.mu.Unlock()
if !ok {
writeError(w, http.StatusNotFound, "task not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------- ミドルウェア ----------
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
slog.Info("access", "method", r.Method, "path", r.URL.Path,
"pattern", r.Pattern, "status", rec.status, "dur", time.Since(start).String())
})
}
func recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
if v == http.ErrAbortHandler {
panic(v)
}
slog.Error("panic recovered", "value", v, "path", r.URL.Path)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
// 外側から順に適用する小さなヘルパー
func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
// ---------- ルーティング ----------
func routes(s *Store) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"message": "top page"})
})
mux.HandleFunc("GET /tasks", s.list)
mux.HandleFunc("POST /tasks", s.create)
mux.HandleFunc("GET /tasks/{id}", s.get)
mux.HandleFunc("DELETE /tasks/{id}", s.delete)
mux.HandleFunc("GET /tasks/today", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"matched": "GET /tasks/today"})
})
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"path": r.PathValue("path")})
})
mux.HandleFunc("GET /panic", func(w http.ResponseWriter, r *http.Request) {
panic("something went wrong")
})
mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(3 * time.Second):
writeJSON(w, http.StatusOK, map[string]string{"message": "slow done"})
case <-r.Context().Done():
slog.Info("client gone", "err", r.Context().Err())
}
})
return chain(mux, recoverer, logging)
}
// ---------- 起動と graceful shutdown ----------
func main() {
addr := ":7040"
if v := os.Getenv("ADDR"); v != "" {
addr = v
}
srv := &http.Server{
Addr: addr,
Handler: routes(NewStore()),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
slog.Info("listening", "addr", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("listen failed", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
slog.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("shutdown error", "err", err)
return
}
slog.Info("server stopped")
}
go vet . と go build がエラーなしで通ることを確認しています。/panic と /slow は動作確認用のルートなので、自分の API に流用するときは削除してください。
curl で確かめた実行結果
登録・取得・削除(正常系)
curl -si -X POST localhost:7040/tasks -d '{"title":"備品の発注"}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /tasks/1
Date: Thu, 01 Oct 2026 14:33:49 GMT
Content-Length: 48
{"id":1,"title":"備品の発注","done":false}
| リクエスト | ステータス | レスポンス |
|---|---|---|
POST /tasks {"title":"在庫の確認"} |
201 | {"id":2,"title":"在庫の確認","done":false} |
GET /tasks |
200 | [{"id":1,...},{"id":2,...}] |
GET /tasks/1 |
200 | {"id":1,"title":"備品の発注","done":false} |
HEAD /tasks |
200 | 本文なし(GET パターンが HEAD も受ける) |
DELETE /tasks/1 |
204 | 本文なし |
GET / |
200 | {"message":"top page"}(GET /{$}) |
GET /files/docs/2026/report.pdf |
200 | {"path":"docs/2026/report.pdf"} |
405 と Allow ヘッダー(自動で返る)
パスは一致するのにメソッドが一致しないとき、ServeMux は 405 と Allow ヘッダーを自動で返します。
curl -si -X PUT localhost:7040/tasks/1
HTTP/1.1 405 Method Not Allowed
Allow: DELETE, GET, HEAD
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Thu, 01 Oct 2026 14:33:49 GMT
Content-Length: 19
Method Not Allowed
GET を登録しているので Allow に HEAD も自動で含まれます。
404 とエラー応答
| リクエスト | ステータス | レスポンス本文 |
|---|---|---|
GET /nothing |
404 | 404 page not found(ServeMux 既定) |
GET /tasks/(末尾スラッシュ) |
404 | GET /tasks は完全一致なので別物 |
GET /tasks/99 |
404 | {"error":"task not found"}(ハンドラーが返す) |
GET /tasks/abc |
400 | {"error":"id must be an integer"} |
POST /tasks {"title":1} |
400 | {"error":"invalid JSON: json: cannot unmarshal number into Go struct field .title of type string"} |
POST /tasks {"name":"x"} |
400 | {"error":"invalid JSON: json: unknown field \"name\""} |
POST /tasks {} |
422 | {"error":"title is required"} |
GET /panic |
500 | {"error":"internal server error"}(リカバリが応答) |
GET /files/../tasks(--path-as-is) |
307 | Location: /tasks(.. を正規化してリダイレクト) |
{id} は「何か1セグメント」にマッチするだけで、数値かどうかは見ません。 型のチェックはハンドラー内で strconv.Atoi などを使って行い、400 を返すのが基本です。
アクセスログ(ミドルウェアの出力)
2026/10/01 23:33:49 INFO access method=GET path=/tasks/1 pattern="GET /tasks/{id}" status=200 dur=34.258µs
2026/10/01 23:33:49 INFO access method=GET path=/tasks/today pattern="GET /tasks/today" status=200 dur=23.63µs
2026/10/01 23:33:49 INFO access method=PUT path=/tasks/1 pattern="" status=405 dur=57.31µs
2026/10/01 23:33:49 INFO access method=HEAD path=/tasks pattern="GET /tasks" status=200 dur=150.922µs
2026/10/01 23:33:49 INFO access method=GET path=/nothing pattern="" status=404 dur=67.269µs
2026/10/01 23:33:49 ERROR panic recovered value="something went wrong" path=/panic
r.Pattern を記録すると、どのパターンにマッチしたかがログでわかります。 405・404 のときは空文字になります。/tasks/1 と /tasks/42 が同じ GET /tasks/{id} に集計できるので、メトリクスのラベルにも向いています(パスそのものをラベルにすると種類が増えすぎるため)。
なお、このコードでは recoverer を一番外側に置いたので、panic したリクエストは access ログに出ず、ERROR ログだけが出ます。access ログにも 500 として残したい場合は、chain(mux, logging, recoverer) の順にします。
graceful shutdown の確認
3秒かかる /slow を呼んでいる最中に kill -TERM を送りました。
2026/10/01 23:33:54 INFO shutting down
2026/10/01 23:33:57 INFO access method=GET path=/slow pattern="GET /slow" status=200 dur=3.001123212s
2026/10/01 23:33:57 INFO server stopped
curl 側は {"message":"slow done"} を 200 で最後まで受け取り、SIGTERM の直後に送った新しいリクエストは接続できませんでした(curl の終了コード 7)。Shutdown は新規の受け付けを止め、処理中のリクエストが終わるまで待ってから戻ります。
ルーティングの優先順位ルール
Go 1.22 以降は「登録順」でも「最長一致」でもなく、「より具体的なパターンが勝つ」ルールです。 パターン P1 がマッチするリクエストが、P2 がマッチするリクエストの真部分集合なら、P1 の方が具体的です。
次のパターンを登録し、httptest で各リクエストがどれにマッチしたかを確認しました(Go 1.27.1)。
"/", "GET /{$}", "/tasks/{id}", "GET /tasks/today", "GET /tasks/{id}/comments",
"/images/", "/images/thumbnails/", "GET /files/{path...}", "api.example.com/", "get /lower"
METHOD PATH STATUS MATCHED
GET / 200 GET /{$}
POST / 200 /
GET /about 200 /
GET /tasks/today 200 GET /tasks/today
PUT /tasks/today 200 /tasks/{id}
GET /tasks/42 200 /tasks/{id}
GET /tasks/42/comments 200 GET /tasks/{id}/comments
POST /tasks/42/comments 200 /
GET /images/a.png 200 /images/
GET /images/thumbnails/a.png 200 /images/thumbnails/
GET /images 307 (Location=/images/)
GET /files/ 200 GET /files/{path...}
GET /files/a/b/c.txt 200 GET /files/{path...}
GET /lower 200 /
get /lower 200 get /lower
GET api.example.com/tasks/today 200 api.example.com/
結果から読み取れるルール
| ルール | 実例(上の結果) |
|---|---|
| 固定文字は変数より具体的 | GET /tasks/today は /tasks/{id} より優先 |
| メソッド付きはメソッドなしより具体的(パスが同じ範囲なら) | GET /{$} は / より優先 |
| メソッドが合わなければ次の候補へ | PUT /tasks/today は /tasks/{id} へ |
/ が登録されていると 405 にならない |
POST /tasks/42/comments は 405 ではなく / が受けた |
末尾 / は配下すべて、長い方が具体的 |
/images/thumbnails/ が /images/ より優先 |
| 配下ルートへのスラッシュなしアクセスはリダイレクト | GET /images → 307 /images/ |
{path...} は空文字にもマッチ |
GET /files/ → path="" |
| メソッドは大文字小文字を区別 | get /lower は get メソッドのときだけ |
| ホスト付きは同条件ならホストなしより優先 | api.example.com/ がすべてを受けた |
特に注意したいのは「/ を登録すると 405 が返らなくなる」点です。 静的ファイル配信などで / を使う場合は、API を /api/ 配下に分けると 405 の自動応答を活かせます。
パターンの競合で起動時に panic する例
どちらがより具体的か決められない2つのパターンを登録すると、HandleFunc の時点で panic します。 リクエストが来てから気づくのではなく、起動時に必ず検出されるので、テストでも見つけやすい仕組みです。Go 1.27.1 で再現したエラー文です(ファイルパスは省略)。
mux.HandleFunc("GET /posts/{id}", h)
mux.HandleFunc("GET /{resource}/latest", h) // ここで panic
panic: pattern "GET /{resource}/latest" (registered at .../main.go:16) conflicts with pattern "GET /posts/{id}" (registered at .../main.go:16):
GET /{resource}/latest and GET /posts/{id} both match some paths, like "/posts/latest".
But neither is more specific than the other.
GET /{resource}/latest matches "/resource/latest", but GET /posts/{id} doesn't.
GET /posts/{id} matches "/posts/id", but GET /{resource}/latest doesn't.
| 登録したパターン | 結果 | エラーの要点 |
|---|---|---|
GET /tasks/{id} と GET /tasks/{name} |
panic | matches the same requests as(変数名違いは同一) |
GET /tasks/{id} を2回 |
panic | 同上 |
GET /tasks/{id} と /tasks/today |
panic | matches more methods than ..., but has a more specific path pattern |
GET / と /index.html |
panic | 同上(メソッドは広いがパスは狭い) |
/tasks/{id} と GET /tasks/today |
OK | 後者がメソッドもパスも狭い |
GET /tasks/{id}、GET /tasks/today、GET /tasks/{id}/comments |
OK | すべて包含関係 |
GET /b_{bucket} |
panic | bad wildcard segment (must start with '{') |
GET /files/{path...}/edit |
panic | {...} wildcard not at end |
get /tasks |
OK | 小文字の get というメソッドとして登録される |
競合の直し方
| 状況 | 直し方 |
|---|---|
| 変数名だけ違う同じ形 | どちらか一方にまとめる |
| 「メソッドは広い・パスは狭い」の組み合わせ | 両方にメソッドを付ける(例:GET / → GET /index.html も GET 付きに) |
/{resource}/latest のような先頭変数 |
固定の接頭辞を付ける(/posts/latest を個別登録、または /r/{resource}/latest) |
/b_{bucket} のような部分変数 |
セグメント全体を変数にし、ハンドラー内で strings.CutPrefix |
get と小文字で書いた |
メソッドは大文字で書く(小文字は別メソッドとして扱われ、通常のクライアントからは届かない) |
ミドルウェアとルートグループの書き方
net/http のミドルウェアは func(http.Handler) http.Handler という形の関数です。上の完成コードでは chain ヘルパーで「リカバリ → ログ → ルーター」の順に包んでいます。
特定のルートだけにミドルウェアをかけたいときは、ServeMux を入れ子にして http.StripPrefix を使います。
admin := http.NewServeMux()
admin.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "admin user %s (path=%s)", r.PathValue("id"), r.URL.Path)
})
root := http.NewServeMux()
root.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "ok") })
root.Handle("/admin/", http.StripPrefix("/admin", requireToken(admin)))
/health token="" -> 200 ok
/admin/users/7 token="" -> 401 unauthorized
/admin/users/7 token="Bearer secret" -> 200 admin user 7 (path=/users/7)
StripPrefix で /admin を外すので、子の ServeMux では /users/{id} と短く書けます。 requireToken は Authorization ヘッダーを確認するだけの例です。認証の設計そのものは別途ご検討ください。
http.Server のタイムアウト設定
http.ListenAndServe(":8080", mux) は手軽ですが、タイムアウトがすべて無制限になります。公開するサーバーは http.Server を自分で組み立てて、各値を設定するのが基本です。
| フィールド | 意味(公式ドキュメントより) | 0 のとき | 目安 |
|---|---|---|---|
ReadHeaderTimeout |
リクエストヘッダーを読み終えるまでの時間 | ReadTimeout の値を使う |
5秒 |
ReadTimeout |
本文を含むリクエスト全体を読む時間 | 無制限 | 10〜30秒(アップロードがあれば長め) |
WriteTimeout |
レスポンスを書き終えるまでの時間(ヘッダー読了でリセット) | 無制限 | 最も遅いハンドラー+余裕 |
IdleTimeout |
keep-alive で次のリクエストを待つ時間 | ReadTimeout の値を使う |
60〜120秒 |
MaxHeaderBytes |
ヘッダーの最大バイト数 | 1MB(DefaultMaxHeaderBytes) |
既定のままで可 |
WriteTimeout はハンドラーの処理時間も含みます。 上のコードでは 15 秒なので、3 秒の /slow は収まります。ルートごとに時間を変えたいときは、http.TimeoutHandler(h, 2*time.Second, "timeout") でハンドラー単位に包むか、http.NewResponseController(w).SetWriteDeadline(...) を使います。
本文サイズの上限は http.MaxBytesReader で設定します。 これを超えると Decode がエラーを返すので、400 または 413 で応答できます。
chi・echo などの外部ルーターが要るかの判断基準
標準 ServeMux で足りるか、外部ルーターを選ぶかは、必要な機能で判断します。 下表は機能の有無を整理したものです(外部ライブラリはこの検証環境で取得できないため、比較は各公式ドキュメントに基づく整理で、実行はしていません)。
| 機能 | 標準 ServeMux(1.22+) | chi | echo / gin |
|---|---|---|---|
| メソッド+パス変数 | ○ | ○ | ○ |
| 405 と Allow の自動応答 | ○ | ○ | ○(設定による) |
| 残りパス(ワイルドカード) | ○ {name...} |
○ * |
○ * |
| 正規表現による変数の制約 | ×(ハンドラー内で検証) | ○ {id:[0-9]+} |
×/一部 |
| 変数がセグメントの一部 | ×(/b_{x} は不可) |
○ | 一部可 |
| ルートグループ+グループ単位のミドルウェア | △(入れ子+StripPrefix) |
○ Route、Group |
○ Group |
| ハンドラーの型 | http.Handler |
http.Handler(互換) |
独自の Context 型 |
| JSON のバインド・バリデーション | ×(encoding/json で自前) |
× | ○ |
| 依存関係 | なし | 小さい | やや多い |
| こんなとき | おすすめ |
|---|---|
| 小〜中規模の REST API、依存を増やしたくない | 標準 ServeMux |
| 正規表現の制約やグループ単位のミドルウェアを多用する | chi(http.Handler 互換なので移行しやすい) |
| バインドやバリデーションまで一式ほしい、チームが慣れている | echo / gin |
| 既存コードが gorilla/mux | 標準 ServeMux への移行を検討(パターン構文が近い) |
まず標準で書き、足りない機能が具体的に出てきた時点で chi を検討するという順番にすると、後からの移行コストも小さく済みます。
よくある質問
Q. Go 1.22 以降なら gorilla/mux や chi は不要ですか? A. メソッド指定とパス変数、405 の自動応答だけなら標準で足ります。 正規表現による変数の制約や、グループ単位のミドルウェアを多く使う場合は chi が便利です。判断表を参考にしてください。
Q. パターンの登録順で優先順位は変わりますか? A. 変わりません。 「より具体的なパターンが勝つ」ルールで決まり、どちらとも言えない組み合わせは登録時に panic します。
Q. http.HandleFunc(パッケージ関数)と http.NewServeMux() はどちらを使うべきですか?
A. パッケージ関数は http.DefaultServeMux に登録します。import したパッケージも同じ DefaultServeMux に登録できるため、ルートを自分で管理したい場合は NewServeMux() で専用の mux を作るのがおすすめです。
Q. {id} を数値だけに制限できますか?
A. パターンでは制限できません。ハンドラー内で strconv.Atoi を使い、失敗したら 400 を返します(本記事の get ハンドラーの形)。
まとめ
- Go 1.22 以降の ServeMux は
GET /tasks/{id}のようにメソッドとパス変数を書け、r.PathValueで値を取り出せます。 {path...}は残りのパス、/{$}はルートだけにマッチします。- 405 と Allow ヘッダーは自動で返りますが、
/を登録しているとそちらにマッチします。 - 優先順位は「より具体的なパターンが勝つ」。決められない組み合わせは起動時に panic するので、早く気づけます。
- 新しい挙動は go.mod の
go行が 1.22 以上で有効になります。 - 公開するサーバーは
http.ServerのタイムアウトとShutdownによる停止を組み込みます。 - 外部ルーターは、正規表現の制約やグループ機能が必要になってから検討すれば十分です。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Go net/httpでWebサーバー|1.22以降ルーティング完全コード」
株式会社RJCでは、Go の標準ライブラリを使った Web サーバーの組み立て方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。