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.mod 1.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してください。

golangnet/httprest-apigo-1.22httptest