【第3回】HTTP サーバーと REST API ── net/http を深く理解する

Go
B!

シリーズ構成

  1. Go の基本文法
  2. CLI ツールを作る
  3. HTTP サーバーと REST API(本記事)
  4. データベース連携
  5. 実務的な周辺技術
  6. ポートフォリオを作る
  7. 実践知識を深める

この回の目標は 「標準ライブラリ net/http の仕組みを理解した上で REST API を作り、テストも書けるようになる」 ことです。

Go のバックエンドエンジニアは、フレームワークを使う場合でも net/http の理解が土台になります。Gin や Echo は net/http の薄いラッパーであり、http.Handler インターフェースを中心に設計されているからです。


目次

  1. HTTP の基礎を 5 分で
  2. 最小のサーバーと http.Handler
  3. リクエストの読み方
  4. レスポンスの書き方
  5. ルーティング(Go 1.22+)
  6. JSON API を作る
  7. 入力バリデーション
  8. エラーレスポンスの設計
  9. ミドルウェア
  10. context とリクエストスコープの値
  11. テスト:httptest
  12. HTTP クライアント
  13. サーバー設定と Graceful Shutdown
  14. フレームワーク比較と chi
  15. 静的ファイルとテンプレート
  16. よくある落とし穴
  17. 演習問題

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削除あり

主要ステータスコード:

コード意味使う場面
200OK成功
201Created作成成功
204No Content削除成功など、返す本文がない
400Bad Request入力不正
401Unauthorized未認証
403Forbidden権限なし
404Not Foundリソースがない
409Conflict重複など
422Unprocessable Entityバリデーションエラー
500Internal 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
    }
}

バリデーションの層:

  1. 構文(JSON として正しいか)→ 400
  2. 形式(型・必須・長さ)→ 422
  3. ビジネスルール(重複、権限)→ 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

標準ライブラリでここまでできますが、実務では以下の理由でフレームワーク(ルーター)を使うことが多いです。

  • ルートのグループ化とグループ単位のミドルウェア
  • 便利なミドルウェア群が同梱
  • パスパラメータの型変換ヘルパー
ライブラリ特徴
chinet/http 完全互換。ミドルウェア署名も標準と同じ。学習コスト最小
Echo独自 echo.Context。バインディング・バリデーション統合が便利
Gin最大のユーザー数。高速。独自 gin.Context
FiberExpress 風。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. 演習問題

基礎

  1. PATCH エンドポイント:PATCH /todos/{id} で done だけを更新できるようにする。ボディは {"done": true}。存在しない ID は 404
  2. フィルタとページネーション:GET /todos?done=true&page=2&limit=10 に対応する。不正な値は 400。レスポンスに {"items": [...], "total": N, "page": 2} を含める
  3. ヘルスチェック:GET /healthz で {"status":"ok","uptime":"1h2m3s"} を返す

ミドルウェア

  1. レートリミット:IP ごとに 1 秒 10 リクエストに制限するミドルウェアを golang.org/x/time/rate で実装する。超過時は 429 を返す
  2. Basic 認証:Authorization: Basic xxx を検証するミドルウェア。r.BasicAuth() を使う。失敗時は WWW-Authenticate ヘッダをつけて 401
  3. リクエストログの拡張:リクエスト ID・ステータス・所要時間・ユーザーエージェントを構造化ログで出す。テストで出力を検証する

テスト

  1. テーブル駆動 API テスト:全エンドポイントに対し、正常系・404・400・405 をテーブル駆動で網羅する
  2. E2E テスト:httptest.NewServer で作成 → 取得 → 更新 → 削除 → 404 のシナリオを 1 つのテストで確認する

応用

  1. 外部 API プロキシ:GET /weather?city=Tokyo が外部の天気 API を呼んで結果を整形して返す。クライアントは httptest.NewServer でモックしてテストする。タイムアウト時は 504 を返す
  2. htmx 画面:html/template と htmx で、Todo の追加・完了・削除がページ遷移なしでできる画面を作る。API と画面で同じストアを共有する

次回予告

第4回では、メモリ上のストアを PostgreSQL に置き換えます。database/sql、トランザクション、マイグレーション、sqlc によるコード生成、DB を含めたテスト手法を扱います。

B!
← 一覧へ戻る