シリーズ構成
- Go の基本文法
- CLI ツールを作る
- HTTP サーバーと REST API(本記事)
- データベース連携
- 実務的な周辺技術
- ポートフォリオを作る
- 実践知識を深める
この回の目標は 「標準ライブラリ net/http の仕組みを理解した上で REST API を作り、テストも書けるようになる」 ことです。
Go のバックエンドエンジニアは、フレームワークを使う場合でも net/http の理解が土台になります。Gin や Echo は net/http の薄いラッパーであり、http.Handler インターフェースを中心に設計されているからです。
目次
- HTTP の基礎を 5 分で
- 最小のサーバーと http.Handler
- リクエストの読み方
- レスポンスの書き方
- ルーティング(Go 1.22+)
- JSON API を作る
- 入力バリデーション
- エラーレスポンスの設計
- ミドルウェア
- context とリクエストスコープの値
- テスト:httptest
- HTTP クライアント
- サーバー設定と Graceful Shutdown
- フレームワーク比較と chi
- 静的ファイルとテンプレート
- よくある落とし穴
- 演習問題
1. HTTP の基礎を 5 分で
HTTP はテキストベースのリクエスト/レスポンス型プロトコルです。
GET /todos/1 HTTP/1.1
Host: localhost:8080
Accept: application/json
Authorization: Bearer xxx
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 45
{"id":1,"title":"Go を学ぶ","done":false}
REST API で使う主要メソッドと意味:
| メソッド | 用途 | 冪等性 |
|---|---|---|
| GET | 取得 | あり |
| POST | 作成・操作 | なし |
| PUT | 全体置換 | あり |
| PATCH | 部分更新 | 通常なし |
| DELETE | 削除 | あり |
主要ステータスコード:
| コード | 意味 | 使う場面 |
|---|---|---|
| 200 | OK | 成功 |
| 201 | Created | 作成成功 |
| 204 | No Content | 削除成功など、返す本文がない |
| 400 | Bad Request | 入力不正 |
| 401 | Unauthorized | 未認証 |
| 403 | Forbidden | 権限なし |
| 404 | Not Found | リソースがない |
| 409 | Conflict | 重複など |
| 422 | Unprocessable Entity | バリデーションエラー |
| 500 | Internal Server Error | サーバー側のバグ |
2. 最小のサーバーと http.Handler
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, World")
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
http.Handler が全ての中心
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
net/http の世界では すべてがこのインターフェース です。サーバーはリクエストが来るたびに 新しい goroutine で ServeHTTP を呼びます。これが Go のサーバーが高い並行性を持つ理由です。
http.HandlerFunc は「関数を Handler に変換するアダプタ」です。
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
つまり http.HandleFunc(pattern, fn) は http.Handle(pattern, http.HandlerFunc(fn)) の糖衣構文です。
構造体を Handler にする
依存(DB やロガー)を持たせたい場合は構造体にメソッドを生やします。
type HelloHandler struct {
greeting string
}
func (h *HelloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, h.greeting)
}
http.Handle("/hello", &HelloHandler{greeting: "Hi"})
ServeMux
http.ListenAndServe(":8080", nil) の nil は「デフォルトの ServeMux を使う」の意味です。実務では必ず自分で作ります(グローバル状態を避けるため、テストしやすくするため)。
mux := http.NewServeMux()
mux.HandleFunc("/", handler)
http.ListenAndServe(":8080", mux)
3. リクエストの読み方
*http.Request から取り出せる主な情報です。
func handler(w http.ResponseWriter, r *http.Request) {
r.Method // "GET"
r.URL.Path // "/todos/1"
r.URL.Query().Get("page") // クエリパラメータ ?page=2
r.Header.Get("Content-Type") // ヘッダ
r.Header.Get("Authorization")
r.PathValue("id") // パスパラメータ(Go 1.22+)
r.Context() // リクエストの context
r.RemoteAddr // クライアントアドレス
r.Body // io.ReadCloser。1 回しか読めない
// フォーム(application/x-www-form-urlencoded)
r.ParseForm()
r.FormValue("name")
// Cookie
c, err := r.Cookie("session")
}
クエリパラメータの型変換
func parseIntQuery(r *http.Request, key string, def int) (int, error) {
s := r.URL.Query().Get(key)
if s == "" {
return def, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("query %q: must be an integer", key)
}
return n, nil
}
page, err := parseIntQuery(r, "page", 1)
ボディの読み取り
// JSON
var in CreateTodoRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
// 生データ
body, err := io.ReadAll(r.Body)
サイズ制限を必ず設ける(巨大なボディでメモリを枯渇させる攻撃を防ぐ):
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB
ボディは一度しか読めない
r.Body はストリームです。ミドルウェアで読んだ後にハンドラでも読みたい場合は、読んだ内容で差し替えます。
body, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(body))
4. レスポンスの書き方
type ResponseWriter interface {
Header() Header // ヘッダ(WriteHeader の前に設定)
Write([]byte) (int, error)
WriteHeader(statusCode int)
}
順序が重要
w.Header().Set("Content-Type", "application/json") // 1. ヘッダ
w.WriteHeader(http.StatusCreated) // 2. ステータス
w.Write(data) // 3. ボディ
WriteHeaderを呼ばずにWriteすると自動で 200 になるWriteの後にHeader().Setしても 無視される(送信済み)WriteHeaderを 2 回呼ぶと警告ログが出る
ヘルパーを作る
毎回書くのは冗長なので、プロジェクトで 1 つヘルパーを定義します。
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if v == nil {
return
}
if err := json.NewEncoder(w).Encode(v); err != nil {
// ここではもう status を変えられない。ログだけ出す
log.Printf("write json: %v", err)
}
}
便利な標準関数
http.Error(w, "message", http.StatusBadRequest) // text/plain でエラー
http.Redirect(w, r, "/login", http.StatusFound)
http.NotFound(w, r)
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
MaxAge: 3600,
})
5. ルーティング(Go 1.22+)
Go 1.22 で ServeMux が メソッド指定とパスパラメータ に対応しました。
mux := http.NewServeMux()
mux.HandleFunc("GET /todos", listTodos)
mux.HandleFunc("POST /todos", createTodo)
mux.HandleFunc("GET /todos/{id}", getTodo)
mux.HandleFunc("PUT /todos/{id}", updateTodo)
mux.HandleFunc("DELETE /todos/{id}", deleteTodo)
mux.HandleFunc("GET /files/{path...}", serveFile) // 残り全部をキャプチャ
mux.HandleFunc("GET /{$}", index) // 完全一致の "/"
func getTodo(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// ...
}
マッチングのルール
- より 具体的 なパターンが優先される(
/todos/{id}より/todos/latest) - メソッドが一致しないと 405 Method Not Allowed が自動で返る
- 末尾
/のパターンはプレフィックス一致(/static/は/static/css/a.cssにマッチ) - 同じ優先度のパターンが衝突すると 起動時に panic(曖昧さを許さない)
ハンドラのグループ化
api := http.NewServeMux()
api.HandleFunc("GET /todos", listTodos)
mux := http.NewServeMux()
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api))
6. JSON API を作る
Todo API をメモリ上のストアで実装します。リクエストごとに goroutine が起動するので、共有状態は必ずロックで守ります。
ストア
// store.go
package main
import (
"errors"
"sync"
"time"
)
type Todo struct {
ID int64 `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}
var ErrNotFound = errors.New("todo not found")
type MemoryStore struct {
mu sync.RWMutex
todos map[int64]*Todo
nextID int64
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{todos: make(map[int64]*Todo), nextID: 1}
}
func (s *MemoryStore) List() []Todo {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Todo, 0, len(s.todos))
for _, t := range s.todos {
out = append(out, *t)
}
return out
}
func (s *MemoryStore) Create(title string) Todo {
s.mu.Lock()
defer s.mu.Unlock()
t := &Todo{ID: s.nextID, Title: title, CreatedAt: time.Now()}
s.todos[t.ID] = t
s.nextID++
return *t
}
func (s *MemoryStore) Get(id int64) (Todo, error) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.todos[id]
if !ok {
return Todo{}, ErrNotFound
}
return *t, nil
}
func (s *MemoryStore) Update(id int64, title string, done bool) (Todo, error) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.todos[id]
if !ok {
return Todo{}, ErrNotFound
}
t.Title, t.Done = title, done
return *t, nil
}
func (s *MemoryStore) Delete(id int64) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.todos[id]; !ok {
return ErrNotFound
}
delete(s.todos, id)
return nil
}
RWMutex は読み取りが多い場合に有利です。複数の読み取りは同時に行え、書き込みだけが排他になります。
ハンドラ
// handler.go
package main
import (
"encoding/json"
"errors"
"net/http"
"strconv"
)
type TodoHandler struct {
store *MemoryStore
}
func NewTodoHandler(store *MemoryStore) *TodoHandler {
return &TodoHandler{store: store}
}
// ルート登録をハンドラ側に持たせると main が短くなる
func (h *TodoHandler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /todos", h.list)
mux.HandleFunc("POST /todos", h.create)
mux.HandleFunc("GET /todos/{id}", h.get)
mux.HandleFunc("PUT /todos/{id}", h.update)
mux.HandleFunc("DELETE /todos/{id}", h.delete)
}
type createTodoRequest struct {
Title string `json:"title"`
}
type updateTodoRequest struct {
Title string `json:"title"`
Done bool `json:"done"`
}
func (h *TodoHandler) list(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.store.List())
}
func (h *TodoHandler) create(w http.ResponseWriter, r *http.Request) {
var in createTodoRequest
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if in.Title == "" {
writeError(w, http.StatusUnprocessableEntity, "title is required")
return
}
t := h.store.Create(in.Title)
w.Header().Set("Location", "/todos/"+strconv.FormatInt(t.ID, 10))
writeJSON(w, http.StatusCreated, t)
}
func (h *TodoHandler) get(w http.ResponseWriter, r *http.Request) {
id, ok := parseIDParam(w, r)
if !ok {
return
}
t, err := h.store.Get(id)
if err != nil {
handleStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
func (h *TodoHandler) update(w http.ResponseWriter, r *http.Request) {
id, ok := parseIDParam(w, r)
if !ok {
return
}
var in updateTodoRequest
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if in.Title == "" {
writeError(w, http.StatusUnprocessableEntity, "title is required")
return
}
t, err := h.store.Update(id, in.Title, in.Done)
if err != nil {
handleStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
func (h *TodoHandler) delete(w http.ResponseWriter, r *http.Request) {
id, ok := parseIDParam(w, r)
if !ok {
return
}
if err := h.store.Delete(id); err != nil {
handleStoreError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- ヘルパー ---
func parseIDParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid id")
return 0, false
}
return id, true
}
func handleStoreError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrNotFound):
writeError(w, http.StatusNotFound, "todo not found")
default:
writeError(w, http.StatusInternalServerError, "internal error")
}
}
JSON デコードを厳格にする
func decodeJSON(r *http.Request, v any) error {
r.Body = http.MaxBytesReader(nil, r.Body, 1<<20)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // 未知のフィールドを拒否
if err := dec.Decode(v); err != nil {
return fmt.Errorf("invalid json body: %w", err)
}
// 2 つ目の値があればエラー("{}{}" のような入力)
if dec.More() {
return errors.New("body must contain a single json object")
}
return nil
}
main
func main() {
store := NewMemoryStore()
mux := http.NewServeMux()
NewTodoHandler(store).Register(mux)
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
curl -i -X POST localhost:8080/todos -d '{"title":"Go を学ぶ"}'
curl -s localhost:8080/todos | jq
curl -s localhost:8080/todos/1 | jq
curl -X PUT localhost:8080/todos/1 -d '{"title":"Go を学ぶ","done":true}'
curl -i -X DELETE localhost:8080/todos/1
curl -i localhost:8080/todos/999 # 404
curl -i -X PATCH localhost:8080/todos # 405
7. 入力バリデーション
小規模なら手書きで十分ですが、フィールドが増えると go-playground/validator が便利です。
go get github.com/go-playground/validator/v10
import "github.com/go-playground/validator/v10"
var validate = validator.New()
type createTodoRequest struct {
Title string `json:"title" validate:"required,min=1,max=200"`
Priority int `json:"priority" validate:"gte=1,lte=5"`
Tags []string `json:"tags" validate:"max=10,dive,min=1,max=20"`
DueDate string `json:"due_date" validate:"omitempty,datetime=2006-01-02"`
}
if err := validate.Struct(in); err != nil {
var verrs validator.ValidationErrors
if errors.As(err, &verrs) {
fields := make(map[string]string)
for _, fe := range verrs {
fields[fe.Field()] = fe.Tag() // {"Title":"required"}
}
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": "validation failed",
"fields": fields,
})
return
}
}
バリデーションの層:
- 構文(JSON として正しいか)→ 400
- 形式(型・必須・長さ)→ 422
- ビジネスルール(重複、権限)→ 409 / 403 など
8. エラーレスポンスの設計
API 全体で 統一されたエラー形式 を決めます。
type ErrorResponse struct {
Error string `json:"error"`
Code string `json:"code,omitempty"`
Details map[string]string `json:"details,omitempty"`
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, ErrorResponse{Error: msg})
}
{
"error": "validation failed",
"code": "VALIDATION_ERROR",
"details": { "title": "required" }
}
500 エラーで内部情報を漏らさない
// ✗ DB のエラー文字列をそのまま返す
writeError(w, 500, err.Error())
// ✓ ログには詳細、クライアントには汎用メッセージ
slog.Error("get todo", "err", err, "id", id)
writeError(w, 500, "internal server error")
ハンドラがエラーを返せるようにする
func(w, r) はエラーを返せないため、毎回 writeError + return を書く必要があります。エラーを返す型に変換するアダプタ を作ると簡潔になります。
type HTTPError struct {
Status int
Message string
}
func (e *HTTPError) Error() string { return e.Message }
func NewHTTPError(status int, msg string) *HTTPError {
return &HTTPError{Status: status, Message: msg}
}
type HandlerFunc func(w http.ResponseWriter, r *http.Request) error
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
err := f(w, r)
if err == nil {
return
}
var he *HTTPError
if errors.As(err, &he) {
writeError(w, he.Status, he.Message)
return
}
slog.Error("unhandled error", "err", err, "path", r.URL.Path)
writeError(w, http.StatusInternalServerError, "internal server error")
}
// ハンドラ側がすっきりする
func (h *TodoHandler) get(w http.ResponseWriter, r *http.Request) error {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
return NewHTTPError(http.StatusBadRequest, "invalid id")
}
t, err := h.store.Get(id)
if errors.Is(err, ErrNotFound) {
return NewHTTPError(http.StatusNotFound, "todo not found")
}
if err != nil {
return err // 500 になる
}
writeJSON(w, http.StatusOK, t)
return nil
}
mux.Handle("GET /todos/{id}", HandlerFunc(h.get))
9. ミドルウェア
ミドルウェアは Handler を受け取り Handler を返す関数 です。
type Middleware func(http.Handler) http.Handler
ロギング
ステータスコードを記録するには ResponseWriter をラップします。
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK
}
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"duration", time.Since(start),
)
})
}
リカバリ
1 リクエストの panic でサーバー全体が落ちるのを防ぎます。
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic recovered",
"err", rec,
"stack", string(debug.Stack()),
)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
リクエスト ID
type ctxKey int
const requestIDKey ctxKey = iota
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.NewString()
}
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), requestIDKey, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func RequestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey).(string)
return id
}
CORS
ブラウザからの呼び出しに必要です。
func CORS(allowedOrigin string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
タイムアウト
mux.Handle("/slow", http.TimeoutHandler(slowHandler, 5*time.Second, "timeout"))
チェーンする
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
handler := Chain(mux,
Recover, // 最外側:最後に実行され、最初に受ける
RequestID,
Logging,
CORS("http://localhost:3000"),
)
http.ListenAndServe(":8080", handler)
順序:外側ほど先に実行され、後に終了します。Recover は必ず最外側に置きます。
10. context とリクエストスコープの値
r.Context() は以下のときにキャンセルされます。
- クライアントが接続を切った
http.ServerのShutdownが呼ばれたTimeoutHandlerの期限が来た
DB クエリや外部 API 呼び出しには 必ずこの context を渡します。クライアントが去った後も処理を続けるのはリソースの無駄です。
func (h *TodoHandler) list(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
todos, err := h.repo.List(ctx) // 第4回で DB に渡す
// ...
}
context.WithValue の使い方
リクエスト ID、認証済みユーザーなど リクエストスコープのメタデータだけ を載せます。関数の引数として渡すべきもの(ID や設定)を context に入れるのはアンチパターンです。
キーは 非公開の独自型 にして、他パッケージとの衝突を防ぎます。
type ctxKey struct{} // 空構造体をキーの型に使う
ctx = context.WithValue(ctx, ctxKey{}, user)
11. テスト:httptest
ハンドラ単体テスト
httptest.NewRecorder はサーバーを立てずに ResponseWriter を模倣します。
// handler_test.go
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func newTestServer() *http.ServeMux {
mux := http.NewServeMux()
NewTodoHandler(NewMemoryStore()).Register(mux)
return mux
}
func TestCreateTodo(t *testing.T) {
mux := newTestServer()
req := httptest.NewRequest(http.MethodPost, "/todos",
strings.NewReader(`{"title":"test"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req) // ルーティングも含めてテストできる
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body = %s", rec.Code, rec.Body)
}
var got Todo
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.Title != "test" || got.ID != 1 {
t.Errorf("got %+v", got)
}
if loc := rec.Header().Get("Location"); loc != "/todos/1" {
t.Errorf("Location = %q", loc)
}
}
func TestCreateTodo_Validation(t *testing.T) {
tests := []struct {
name string
body string
want int
}{
{"empty title", `{"title":""}`, http.StatusUnprocessableEntity},
{"missing title", `{}`, http.StatusUnprocessableEntity},
{"invalid json", `{title}`, http.StatusBadRequest},
{"unknown field", `{"title":"a","foo":1}`, http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mux := newTestServer()
req := httptest.NewRequest(http.MethodPost, "/todos", strings.NewReader(tt.body))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != tt.want {
t.Errorf("status = %d, want %d", rec.Code, tt.want)
}
})
}
}
func TestGetTodo_NotFound(t *testing.T) {
mux := newTestServer()
req := httptest.NewRequest(http.MethodGet, "/todos/999", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d", rec.Code)
}
}
func TestMethodNotAllowed(t *testing.T) {
mux := newTestServer()
req := httptest.NewRequest(http.MethodPatch, "/todos", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d", rec.Code)
}
}
実サーバーでの結合テスト
httptest.NewServer は実際にポートを開きます。HTTP クライアントも含めてテストしたいときに使います。
func TestTodoAPI_EndToEnd(t *testing.T) {
srv := httptest.NewServer(newTestServer())
defer srv.Close()
// 作成
resp, err := http.Post(srv.URL+"/todos", "application/json",
strings.NewReader(`{"title":"e2e"}`))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %d", resp.StatusCode)
}
// 取得
resp, err = http.Get(srv.URL + "/todos/1")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var got Todo
json.NewDecoder(resp.Body).Decode(&got)
if got.Title != "e2e" {
t.Errorf("got %+v", got)
}
}
ミドルウェアのテスト
func TestRecover(t *testing.T) {
panicking := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("boom")
})
h := Recover(panicking)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d", rec.Code)
}
}
12. HTTP クライアント
バックエンドは外部 API を呼ぶ側にもなります。
基本
resp, err := http.Get("https://api.example.com/users/1")
if err != nil {
return err
}
defer resp.Body.Close() // 必ず閉じる(コネクションリーク防止)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %s", resp.Status)
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return err
}
本番用クライアント
http.DefaultClient は タイムアウトがありません。必ず自分で設定します。
type APIClient struct {
baseURL string
http *http.Client
}
func NewAPIClient(baseURL string) *APIClient {
return &APIClient{
baseURL: baseURL,
http: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
},
}
}
func (c *APIClient) GetUser(ctx context.Context, id int64) (*User, error) {
url := fmt.Sprintf("%s/users/%d", c.baseURL, id)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, ErrNotFound
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("get user %d: status %d: %s", id, resp.StatusCode, body)
}
var u User
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
return nil, fmt.Errorf("decode user: %w", err)
}
return &u, nil
}
POST with JSON
body, _ := json.Marshal(payload)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
クライアントのテスト
httptest.NewServer で偽のサーバーを立てます。
func TestAPIClient_GetUser(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/1" {
t.Errorf("path = %s", r.URL.Path)
}
fmt.Fprint(w, `{"id":1,"name":"alice"}`)
}))
defer srv.Close()
c := NewAPIClient(srv.URL)
u, err := c.GetUser(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if u.Name != "alice" {
t.Errorf("got %+v", u)
}
}
13. サーバー設定と Graceful Shutdown
http.ListenAndServe は タイムアウト設定がなく、本番では使いません。http.Server を明示的に作ります。
func main() {
store := NewMemoryStore()
mux := http.NewServeMux()
NewTodoHandler(store).Register(mux)
srv := &http.Server{
Addr: ":8080",
Handler: Chain(mux, Recover, RequestID, Logging),
ReadHeaderTimeout: 5 * time.Second, // Slowloris 攻撃対策
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
}
// サーバーを別 goroutine で起動
go func() {
slog.Info("listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server error", "err", err)
os.Exit(1)
}
}()
// 終了シグナルを待つ
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
// 処理中のリクエストを最大 10 秒待って終了
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)
}
slog.Info("stopped")
}
Kubernetes などでは SIGTERM の後、猶予期間を経て SIGKILL が来ます。Graceful Shutdown により、デプロイ中にリクエストが失敗しなくなります。
14. フレームワーク比較と chi
標準ライブラリでここまでできますが、実務では以下の理由でフレームワーク(ルーター)を使うことが多いです。
- ルートのグループ化とグループ単位のミドルウェア
- 便利なミドルウェア群が同梱
- パスパラメータの型変換ヘルパー
| ライブラリ | 特徴 |
|---|---|
| chi | net/http 完全互換。ミドルウェア署名も標準と同じ。学習コスト最小 |
| Echo | 独自 echo.Context。バインディング・バリデーション統合が便利 |
| Gin | 最大のユーザー数。高速。独自 gin.Context |
| Fiber | Express 風。net/http 非互換(fasthttp ベース)で注意 |
おすすめは chi。標準と互換なので、ここまで学んだことがそのまま活きます。
go get github.com/go-chi/chi/v5
package main
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
func newRouter(h *TodoHandler) http.Handler {
r := chi.NewRouter()
// グローバルミドルウェア
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:3000"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
}))
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// グループ化
r.Route("/api/v1", func(r chi.Router) {
r.Route("/todos", func(r chi.Router) {
r.Get("/", h.list)
r.Post("/", h.create)
r.Route("/{id}", func(r chi.Router) {
r.Get("/", h.get)
r.Put("/", h.update)
r.Delete("/", h.delete)
})
})
})
return r
}
chi では chi.URLParam(r, "id") でパラメータを取ります(Go 1.22+ の r.PathValue も動作します)。
15. 静的ファイルとテンプレート
API だけでなく HTML を返す場面もあります。
静的ファイル
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
// embed で同梱
//go:embed static/*
var staticFS embed.FS
mux.Handle("GET /static/", http.FileServerFS(staticFS))
html/template
自動エスケープで XSS を防ぎます。
//go:embed templates/*.html
var tmplFS embed.FS
var tmpl = template.Must(template.ParseFS(tmplFS, "templates/*.html"))
func index(w http.ResponseWriter, r *http.Request) {
data := struct {
Title string
Todos []Todo
}{"My Todos", store.List()}
if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil {
http.Error(w, err.Error(), 500)
}
}
<!-- templates/index.html -->
<h1>{{.Title}}</h1>
<ul>
{{range .Todos}}
<li class="{{if .Done}}done{{end}}">{{.Title}}</li>
{{else}}
<li>No todos</li>
{{end}}
</ul>
htmx と組み合わせると、JavaScript をほぼ書かずに動的な画面が作れます。Go 界隈で人気の構成です。
16. よくある落とし穴
resp.Body.Close() 忘れ
コネクションが再利用されず枯渇します。defer resp.Body.Close() を必ず書きます。
http.DefaultClient にタイムアウトがない
外部 API が固まると goroutine が永遠に待ちます。
ハンドラ内で return を忘れるhttp.Error(w, ...) の後に return しないと、正常系の処理が続いて二重書き込みになります。
ハンドラで log.Fatal / os.Exit
サーバー全体が落ちます。エラーを返して 500 にします。
マップやスライスを goroutine 間で共有
ハンドラは並行実行されます。ロックか channel で守ります。go test -race で検出できます。
WriteHeader 後にヘッダを設定
無視されます。ヘッダ → ステータス → ボディの順を守ります。
エラーメッセージに内部情報を含める
DB のテーブル名やスタックトレースは攻撃者へのヒントになります。
ボディサイズ無制限http.MaxBytesReader で制限します。
context.Background() をハンドラ内で使うr.Context() を使わないと、クライアント切断後も処理が続きます。
17. 演習問題
基礎
- PATCH エンドポイント:
PATCH /todos/{id}でdoneだけを更新できるようにする。ボディは{"done": true}。存在しない ID は 404 - フィルタとページネーション:
GET /todos?done=true&page=2&limit=10に対応する。不正な値は 400。レスポンスに{"items": [...], "total": N, "page": 2}を含める - ヘルスチェック:
GET /healthzで{"status":"ok","uptime":"1h2m3s"}を返す
ミドルウェア
- レートリミット:IP ごとに 1 秒 10 リクエストに制限するミドルウェアを
golang.org/x/time/rateで実装する。超過時は 429 を返す - Basic 認証:
Authorization: Basic xxxを検証するミドルウェア。r.BasicAuth()を使う。失敗時はWWW-Authenticateヘッダをつけて 401 - リクエストログの拡張:リクエスト ID・ステータス・所要時間・ユーザーエージェントを構造化ログで出す。テストで出力を検証する
テスト
- テーブル駆動 API テスト:全エンドポイントに対し、正常系・404・400・405 をテーブル駆動で網羅する
- E2E テスト:
httptest.NewServerで作成 → 取得 → 更新 → 削除 → 404 のシナリオを 1 つのテストで確認する
応用
- 外部 API プロキシ:
GET /weather?city=Tokyoが外部の天気 API を呼んで結果を整形して返す。クライアントはhttptest.NewServerでモックしてテストする。タイムアウト時は 504 を返す - htmx 画面:
html/templateと htmx で、Todo の追加・完了・削除がページ遷移なしでできる画面を作る。API と画面で同じストアを共有する
次回予告
第4回では、メモリ上のストアを PostgreSQL に置き換えます。database/sql、トランザクション、マイグレーション、sqlc によるコード生成、DB を含めたテスト手法を扱います。
Analyzegear