REST APIを書く - Go 1.22の新しいroutingを使ったnet/http
1. 概要
Go 1.22より前では、http.ServeMuxはパスのマッチングのみに対応していました。GETとPOSTを区別できず、/orders/{id}のようなpath parameterも読み取ることができませんでした。そのため、多くのプロジェクトはgorilla/muxやchiのような追加のrouterが必要でした。
Go 1.22以降、ServeMuxはmethod、wildcard、明確な優先順位ルールのマッチングに対応しています。このチュートリアルでは、標準ライブラリのみを使ってOrder管理REST APIを構築する方法を説明します。httptestを使ってテストを書き、新しい構文による一般的なエラーも紹介します。
2. プロジェクトの準備
Go 1.22以上が必要です。go.modのgo行も1.22以上である必要があります:
mkdir order-api && cd order-api
go mod init example.com/order-api
作成後のgo.mod:
module example.com/order-api
go 1.22
注意: 新しいroutingの動作は
go.modのgo行に依存します。インストールされているtoolchainのバージョンに依存しません。go.modにgo 1.21と書かれている場合、ServeMuxは従来の動作で実行されます。その場合、"GET /orders/{id}"パターンはスペースを含むパスとして解釈され、リクエストは404を受け取ります。エラーメッセージは表示されません。
このチュートリアルは外部の依存関係が不要なため、go getコマンドは使いません。
3. 新しいパターンの構文
3.1. method と path parameter のマッチング
main.goでの最小限の例:
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
w.Write([]byte("こんにちは、" + name + "\n"))
})
log.Fatal(http.ListenAndServe(":8080", mux))
}
このコードは以下のことを行います:
"GET /hello/{name}": スペースの前の部分がmethod、後の部分がpath。{name}はwildcardで、ちょうど1つのsegmentにマッチします。r.PathValue("name")はマッチした値を返し、常にstring型です。wildcardの名前が存在しない場合、関数は空の文字列を返します。
試しに実行:
go run .
curl -i localhost:8080/hello/Lan
curl -i -X POST localhost:8080/hello/Lan
HTTP/1.1 200 OK
...
こんにちは、Lan
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
...
Method Not Allowed
pathがマッチしてもmethodが一致しない場合、ServeMuxは自動的にAllowheaderを含む405を返します。GETパターンは自動的にHEADもマッチします。
3.2. 複数segmentのwildcardと正確なマッチング
他の2つの特別な構文:
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ファイル: " + r.PathValue("path") + "\n"))
})
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ホームページ\n"))
})
{path...}はパターンの末尾に置く必要があります。パスの残りの部分全体にマッチします(/を含む)。/files/docs/2024/report.pdfの場合、値はdocs/2024/report.pdfです。{$}はパスの末尾が/で終わる場合のみマッチします。これがないと、パターン"/"はすべてのパスにマッチします。これは見知らぬrouteが404を返さない一般的な原因です。
3.3. 優先順位ルール
複数のパターンが同じrequestにマッチする場合、より具体的なパターンが優先されます:
mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /orders/latest", getLatestOrder) // /orders/latestに対して優先
/orders/latestは/orders/{id}より具体的です。2番目のパターンにマッチするすべてのrequestは最初のパターンにもマッチしますが、その逆ではありません。登録の順序は影響を与えません。
2つのパターンが互いに競合していて、どちらもより具体的でない場合、Goは登録時にpanicを起こします:
mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("GET /{resource}/latest", getLatest) // panic: conflicts with ...
エラーはプログラム起動時に発生し、requestを処理している間ではありません。そのため、routingエラーを早期に検出できます。
4. Order APIの構築
4.1. ModelとメモリStore
store.goファイルを作成:
package main
import (
"sort"
"sync"
)
type Order struct {
ID int `json:"id"`
Customer string `json:"customer"`
Total float64 `json:"total"`
}
type OrderStore struct {
mu sync.RWMutex
orders map[int]Order
nextID int
}
func NewOrderStore() *OrderStore {
return &OrderStore{orders: make(map[int]Order), nextID: 1}
}
func (s *OrderStore) Create(o Order) Order {
s.mu.Lock()
defer s.mu.Unlock()
o.ID = s.nextID
s.nextID++
s.orders[o.ID] = o
return o
}
func (s *OrderStore) Get(id int) (Order, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
o, ok := s.orders[id]
return o, ok
}
func (s *OrderStore) List() []Order {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]Order, 0, len(s.orders))
for _, o := range s.orders {
result = append(result, o)
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
func (s *OrderStore) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.orders[id]; !ok {
return false
}
delete(s.orders, id)
return true
}
各requestは独立したgoroutineで処理されます。したがって、mapはsync.RWMutexで保護する必要があります。mutexがない場合、go test -raceでdata raceが報告されます。ListはIDでソートされています。これはGoにおけるmap反復の順序がランダムだからです。
4.2. JSON用のヘルパー
json.goファイルを作成:
package main
import (
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
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})
}
呼び出し順序は非常に重要です。Header().SetをWriteHeaderの前に呼ぶ必要があります。WriteHeaderまたはWriteを呼んだ後は、headerへの変更はすべて無視されます。
4.3. Handler
handler.goファイルを作成:
package main
import (
"encoding/json"
"net/http"
"strconv"
)
type OrderHandler struct {
store *OrderStore
}
func (h *OrderHandler) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /orders", h.list)
mux.HandleFunc("POST /orders", h.create)
mux.HandleFunc("GET /orders/{id}", h.get)
mux.HandleFunc("DELETE /orders/{id}", h.delete)
}
func (h *OrderHandler) list(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.store.List())
}
func (h *OrderHandler) create(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 最大1 MB
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var in Order
if err := dec.Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "無効なbody: "+err.Error())
return
}
if in.Customer == "" || in.Total <= 0 {
writeError(w, http.StatusUnprocessableEntity, "customerとtotalは必須です")
return
}
created := h.store.Create(in)
w.Header().Set("Location", "/orders/"+strconv.Itoa(created.ID))
writeJSON(w, http.StatusCreated, created)
}
func (h *OrderHandler) get(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
order, found := h.store.Get(id)
if !found {
writeError(w, http.StatusNotFound, "orderが見つかりません")
return
}
writeJSON(w, http.StatusOK, order)
}
func (h *OrderHandler) delete(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
if !h.store.Delete(id) {
writeError(w, http.StatusNotFound, "orderが見つかりません")
return
}
w.WriteHeader(http.StatusNoContent)
}
func parseID(w http.ResponseWriter, r *http.Request) (int, bool) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "idは正の整数である必要があります")
return 0, false
}
return id, true
}
注目すべきいくつかの点:
Routesはすべてのroute宣言を1か所にまとめます。一度目を通すだけでAPIにどんなendpointがあるかわかります。GET /ordersとGET /orders/{id}は競合しません。segment数が異なるからです。ServeMuxはデータ型をチェックしません。{id}はabcにもマッチするため、strconv.Atoiで自分でvalidateする必要があります。MaxBytesReaderは大きすぎるbodyをブロックします。DisallowUnknownFieldsは"totl"のような入力ミスのunknown fieldを拒否します。
4.4. MiddlewareとmainhanksuĮ関数
net/http内のmiddlewareは、http.Handlerを受け取って返す関数です。main.goを更新:
package main
import (
"log"
"net/http"
"time"
)
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s (%s)", r.Method, r.URL.Path, time.Since(start))
})
}
func newServer() http.Handler {
mux := http.NewServeMux()
h := &OrderHandler{store: NewOrderStore()}
h.Routes(mux)
return logging(mux)
}
func main() {
srv := &http.Server{
Addr: ":8080",
Handler: newServer(),
ReadHeaderTimeout: 5 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
newServer関数はhandlerの構築部分をmainから分離します。これにより、testで実際に実行されているのと同じ構成を再利用できます。http.Serverには、クライアントがheaderを送るのが遅い場合に対抗するためにReadHeaderTimeoutがあります。http.ListenAndServeはデフォルトではこのtimeoutを設定しません。
Go 1.23以降、http.RequestにはPatternfieldが追加されています。r.Pattern(例えばGET /orders/{id})をログに記録することで、path自体ではなくrouteに基づいてmetricをグループ化できます。
試しに実行:
go run .
curl -s -X POST localhost:8080/orders -d '{"customer":"Minh","total":250000}'
curl -s localhost:8080/orders/1
curl -s localhost:8080/orders/abc
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost:8080/orders/1
{"id":1,"customer":"Minh","total":250000}
{"id":1,"customer":"Minh","total":250000}
{"error":"idは正の整数である必要があります"}
204
5. httptestでテストを書く
Goにはnet/http/httptestが組み込まれているため、実際のportを開く必要がありません。handler_test.goファイルを作成:
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateAndGetOrder(t *testing.T) {
srv := newServer()
body := strings.NewReader(`{"customer":"Lan","total":120000}`)
req := httptest.NewRequest(http.MethodPost, "/orders", body)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("POST status = %d, want 201", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/orders/1" {
t.Errorf("Location = %q, want /orders/1", loc)
}
req = httptest.NewRequest(http.MethodGet, "/orders/1", nil)
rec = httptest.NewRecorder()
srv.ServeHTTP(rec, req)
var got Order
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.Customer != "Lan" || got.Total != 120000 {
t.Errorf("got %+v", got)
}
}
func TestRouting(t *testing.T) {
srv := newServer()
tests := []struct {
name, method, path string
want int
}{
{"idが数値でない", http.MethodGet, "/orders/abc", http.StatusBadRequest},
{"存在しない", http.MethodGet, "/orders/99", http.StatusNotFound},
{"methodが間違っている", http.MethodPut, "/orders/1", http.StatusMethodNotAllowed},
{"不明なroute", http.MethodGet, "/customers", http.StatusNotFound},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
if rec.Code != tc.want {
t.Errorf("status = %d, want %d", rec.Code, tc.want)
}
})
}
}
最初のテストは、orderの作成と読み取りのフローを検証します。2番目のテストはtable-driven testで、routing動作を検証します。ServeMuxが自動生成する405を含めます。race detectorを使って実行:
go test -race -v ./...
=== RUN TestCreateAndGetOrder
--- PASS: TestCreateAndGetOrder (0.00s)
=== RUN TestRouting
=== RUN TestRouting/idが数値でない
...
--- PASS: TestRouting (0.00s)
PASS
6. 一般的なエラーと使用すべき場合でない場合
一般的なエラー:
go.modが古いバージョンを指定している。 新しいrouteは404を返します。go.modのgo行を最初に確認してください。- パターン構文を誤る。 methodとpath間には正確に1つのスペースのみが必要です。methodは大文字で記述する必要があります(例:
GETではなくget)。 - 意図せずに
"/"をfallbackとして使う。 パターン"/"はその他のrouteにマッチしないすべてのrequestをキャッチします。ホームページだけが必要な場合は"/{$}"を使ってください。 - wildcardが1つのsegmentを超える。 パターン
/orders/{id}.jsonは無効です。wildcardは同じsegment内の他の文字と共存できません。 - 405がtext形式で返される。
ServeMuxは404と405に対してplain textボディを返します。JSONではありません。クライアントがJSONを統一している必要がある場合は、追加の処理層が必要です。
注意: 古いプロジェクトを
go.mod1.22に更新した後に壊れる場合は、GODEBUG=httpmuxgo121=1で従来の動作に一時的に戻すことができます。これはあくまで移行ソリューションです。パターンの修正が必要です。
純粋なnet/httpは以下の場合では不足しているかもしれません:
- type constraints、例えば数値のみのマッチング、またはregexマッチング。
- groupごとに異なるmiddlewareを持つroute grouping。複数の
ServeMuxをネストすることで実装できますが、コードが冗長になります。 - CORS、rate limiting、recoveryなどの構築済みmiddlewareエコシステム。
これらの場合、chiは軽く、完全にhttp.Handlerと互換性があります。これらの場合を除いて、標準ライブラリは実用的なREST APIの大部分に十分です。
7. 結論
Go 1.22により、http.ServeMuxは実用的なREST APIに十分な機能を備えています。methodでマッチングでき、r.PathValueでpath parameterを読み取り、405の処理と優先順位ルールはGoが自動的に行います。middleware、input validation、httptestを使ったテストを備えた完全なOrder APIを構築しました。外部依存関係は不要です。
最も重要な点:go.modにgo 1.22以上を設定し、正確なマッチング用に{$}を使用し、wildcardの値を使用する前に自分でvalidateしてください。