2-7. REST API 統合 - 実務OKレベルの TODO API を組む
所要時間: 60-90分(3セッション分、Level 2 の総決算) コミット内容:
~/learn/go/level2/day07/に完成形の TODO API
このレッスンのゴール
-
go-playground/validatorでフィールド検証を組める - RFC 7807 風(Problem+JSON)のエラーレスポンスを返せる
- HTTP ステータスコード(200/201/204/400/404/409/422/500)を意味通り使い分ける
- ページネーション(上限付き)を実装
-
slogで構造化ロギング、リクエスト ID 串刺し - SIGTERM での Graceful Shutdown を実装
なぜ学ぶか - Level 2 の総決算として
「本番投入できる API」と「動くだけの API」を分けるのは、ここで扱う6項目: バリデーション・エラー形式・ステータスコード・ページネーション・ロギング・シャットダウン。これらが揃って初めて デプロイで落ちない、障害時に追跡できる、過負荷で死なない API になる。Level 1~2 の全要素が1つのアプリに統合される集大成。
前章とのつながり(間隔反復)
- 2-1_net_http の
http.Serverタイムアウト → 本章で graceful shutdown と組合せ - 2-2_ルーティング の
mux.HandleFunc("GET /todos/{id}", h)→ Handler.Routes で集約 - 2-3_JSON の
decodeJSON+DisallowUnknownFields→ 本章で必須化 - 2-4_ミドルウェア の RequestID/Recover/AccessLog → main.go で組み立て
- 2-5_エラー処理 の AppError → RFC 7807 互換に拡張
- 2-6_パッケージ分割 の handler/service/repository → 完成形
Level 2 全7章を1つのプロジェクトに統合 する。
これができると何が嬉しいか
- 「本番投入チェックリスト」が脳内で読み上げられる - レビューで漏れを指摘できる
- CRUD API を2-3時間で書ける - 新規プロジェクトの立ち上げが速い
- Kubernetes での terminationGracePeriodSeconds と整合する shutdown が書ける
- 422 vs 400 の使い分け で REST 設計レビューに耐える
ストーリー導入: 「動く」と「実務OK」の谷
「curl で動いた、よし完成」と思った3週間後、メモリリーク / Slowloris で停止 / panic でクラッシュ / 422返さずクライアント死亡 / SIGTERM で進行中リクエスト切断 などの本番障害ラッシュ。本章は その谷を埋める章。動くコード → 実務OK のコードへの 6つの追加要素 が学べる。
大前提: 「実務OK」とは何か
ここまでで個々のテーマは押さえた。最後にそれらを統合し、「本番に出して恥ずかしくない」レベルの API を組む。
「実務OK」の基準:
- 入力バリデーションが網羅されている(不正リクエストで落ちない)
- エラーレスポンスの形式が統一されている(クライアントが処理しやすい)
- 構造化ログで全リクエストの記録が残る(障害時に追跡可能)
- タイムアウトと graceful shutdown が設定されている(無限待ちなし、デプロイで落ちない)
- 適切な HTTP ステータスコードが返る(201、204、400、404、422、500 の使い分け)
- ページネーションが実装されている(10万件返してメモリ吹き飛ばないこと)
これが揃って初めて「本番投入できる API」と言える。
セッション①: バリデーションとエラーレスポンス(30分)
0. 録画スタート&作業ディレクトリ
mkdir -p ~/log ~/learn/go/level2/day07
cd ~/learn/go/level2/day07
script ~/log/go_level2_day07.log
# Go モジュール初期化(章本文で github.com/yourname/todo-api を想定)
go mod init github.com/yourname/todo-api
go get github.com/go-playground/validator/v10
go get github.com/google/uuidディレクトリ構造:
todo-api/
├── cmd/server/main.go
├── internal/
│ ├── httperr/ ← エラーレスポンス整形
│ ├── httpmw/ ← ミドルウェア群
│ └── todo/ ← ハンドラ・サービス・リポジトリ
└── go.mod
1. リクエストバリデーション
// internal/todo/request.go
package todo
import (
"github.com/go-playground/validator/v10"
)
var validate = validator.New()
type CreateRequest struct {
Title string `json:"title" validate:"required,min=1,max=200"`
Priority int `json:"priority" validate:"min=0,max=5"`
DueAt string `json:"due_at,omitempty" validate:"omitempty,datetime=2006-01-02"`
}
type UpdateRequest struct {
Title *string `json:"title,omitempty" validate:"omitempty,min=1,max=200"`
Done *bool `json:"done,omitempty"`
Priority *int `json:"priority,omitempty" validate:"omitempty,min=0,max=5"`
}
// バリデーション実行
func (r CreateRequest) Validate() error {
return validate.Struct(r)
}
func (r UpdateRequest) Validate() error {
return validate.Struct(r)
}go-playground/validator のタグ仕様
タグ 意味 required必須(ゼロ値だとエラー) min=N文字列なら最小文字数、数値なら最小値 max=N同上の上限 メールアドレス形式 urlURL 形式 oneof=a b c列挙値 gte=N/lte=N数値の以上 / 以下 omitemptyゼロ値なら検証しない(PATCH 用) datetime=YYYY-MM-DD指定フォーマットの日時 業界標準ライブラリ。gin / echo / chi のほぼ全てが内部で使っている。
PATCH リクエストにはポインタフィールド + omitempty
UpdateRequestでTitle stringにしてしまうと、「titleを空文字に更新」と「titleは更新しない」の区別ができない。修正:
*string型 +omitemptyバリデーションタグ。これで「nil = 未指定」「非 nil = この値で更新」を区別できる(2-3 JSON で学んだ通り)。
2. エラーレスポンス - RFC 7807 簡略版
// internal/httperr/httperr.go
package httperr
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/go-playground/validator/v10"
)
// Problem は RFC 7807 application/problem+json に近い構造
type Problem struct {
Type string `json:"type,omitempty"` // エラーの種類 URI
Title string `json:"title"` // 短いタイトル
Status int `json:"status"` // HTTP ステータスコード
Detail string `json:"detail,omitempty"` // 詳細メッセージ
Instance string `json:"instance,omitempty"` // 発生したリクエストパス
Errors map[string]string `json:"errors,omitempty"` // フィールド別エラー(バリデーション用)
}
// AppError - HTTP に変換可能なエラー
type AppError struct {
Status int
Title string
Detail string
Cause error
}
func (e *AppError) Error() string { return e.Title + ": " + e.Detail }
func (e *AppError) Unwrap() error { return e.Cause }
func NotFound(detail string) *AppError {
return &AppError{Status: 404, Title: "Not Found", Detail: detail}
}
func BadRequest(detail string, cause error) *AppError {
return &AppError{Status: 400, Title: "Bad Request", Detail: detail, Cause: cause}
}
func Conflict(detail string) *AppError {
return &AppError{Status: 409, Title: "Conflict", Detail: detail}
}
func Internal(cause error) *AppError {
return &AppError{Status: 500, Title: "Internal Server Error", Detail: "an internal error occurred", Cause: cause}
}
// Write は AppError / バリデーションエラー / その他を統一形式で返す
func Write(w http.ResponseWriter, r *http.Request, logger *slog.Logger, err error) {
w.Header().Set("Content-Type", "application/problem+json")
// バリデーションエラー
var vErrs validator.ValidationErrors
if errors.As(err, &vErrs) {
fields := map[string]string{}
for _, fe := range vErrs {
fields[fe.Field()] = fe.Tag()
}
writeJSON(w, http.StatusUnprocessableEntity, Problem{
Title: "Validation Failed",
Status: 422,
Detail: "request body has validation errors",
Instance: r.URL.Path,
Errors: fields,
})
return
}
// AppError
var appErr *AppError
if errors.As(err, &appErr) {
if appErr.Status >= 500 {
logger.Error("server error",
"err", err,
"path", r.URL.Path,
"cause", appErr.Cause,
)
}
writeJSON(w, appErr.Status, Problem{
Title: appErr.Title,
Status: appErr.Status,
Detail: appErr.Detail,
Instance: r.URL.Path,
})
return
}
// その他(想定外)
logger.Error("unhandled error", "err", err, "path", r.URL.Path)
writeJSON(w, 500, Problem{
Title: "Internal Server Error",
Status: 500,
Detail: "an unexpected error occurred",
Instance: r.URL.Path,
})
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.WriteHeader(status)
json.NewEncoder(w).Encode(body)
}RFC 7807 (problem+json) とは
エラーレスポンスの 標準フォーマット。
Content-Type: application/problem+jsonで返す。採用例:
- GitHub API のエラー(一部)
- Microsoft Graph API
- Spring Boot のデフォルト
良さ:
- クライアントが共通の構造でエラーをハンドリングできる
- フィールド別エラー、エラーカタログ URL を一貫した方法で提供
ただし完全準拠は仰々しいので、実務では「Title, Status, Detail + フィールドエラー」程度の簡略版が普及。
3. HTTP ステータスコード使い分け
よく使うステータスコード
コード 意味 用途 200 OK 成功 GET、PUT(更新成功)、DELETE(削除成功でボディあり) 201 Created 作成成功 POST で新リソース作成 204 No Content 成功、ボディなし DELETE、PUT(ボディ返さない時) 400 Bad Request 構文エラー JSON パース失敗、Content-Type 不一致 401 Unauthorized 認証必要 トークン無し、無効 403 Forbidden 権限なし 認証はOKだが操作不許可 404 Not Found リソースなし ID が存在しない 409 Conflict 競合 重複登録、楽観ロック失敗 422 Unprocessable Entity 意味的エラー バリデーション失敗(構文は正しいが内容がダメ) 429 Too Many Requests レート制限 スロットリング 500 Internal Server Error サーバー側バグ 想定外の panic、DB 障害 502 / 503 / 504 上流障害 バックエンド到達不可、過負荷、タイムアウト
アンチパターン: 全部 200 を返す
// NG: エラーでも 200 で返す w.WriteHeader(200) json.NewEncoder(w).Encode(map[string]any{ "success": false, "error": "user not found", })なぜNG:
- クライアントが「成功 / 失敗」を ボディの中身まで読まないと判断できない
- HTTP の意味論を無視している → CDN、ロードバランサ、監視ツールが正常扱いしてしまう
- エラーレートメトリクスが取れない(全部 2xx)
修正: HTTP ステータスは HTTP の意味通りに使う。「正常レスポンスかエラーか」はステータスコードで表現。
4. 400 vs 422 の使い分け
// 400 Bad Request: 構文がそもそもおかしい
// → JSON パース失敗、必須フィールド欠落(型レベル)
// 422 Unprocessable Entity: 構文は正しいが意味がおかしい
// → バリデーション失敗(title が長すぎる、priority が負)細かい違いだが意外と重要
- 400: 「リクエストとして読めない」レベル。JSON 壊れている、Content-Type 違う
- 422: 「読めたけどルール違反」。フィールド検証エラー、ビジネスルール違反
GitHub API、Stripe API などはこの区別を採用。
go-playground/validatorのエラーは 422 で返すのが綺麗。
セッション②: 構造化ロギングと TODO API 完成(30分)
5. slog によるリクエストログ
// internal/httpmw/middleware.go
package httpmw
import (
"context"
"log/slog"
"net/http"
"runtime/debug"
"time"
"github.com/google/uuid"
)
type ctxKey string
const (
requestIDKey ctxKey = "request_id"
loggerKey ctxKey = "logger"
)
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 InjectLogger(base *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rid, _ := r.Context().Value(requestIDKey).(string)
logger := base.With("request_id", rid)
ctx := context.WithValue(r.Context(), loggerKey, logger)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func LoggerFromCtx(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
return l
}
return slog.Default()
}
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (s *statusRecorder) WriteHeader(c int) { s.status = c; s.ResponseWriter.WriteHeader(c) }
func (s *statusRecorder) Write(b []byte) (int, error) {
if s.status == 0 { s.status = 200 }
n, err := s.ResponseWriter.Write(b)
s.bytes += n
return n, err
}
func AccessLog(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)
logger := LoggerFromCtx(r.Context())
logger.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"duration_ms", time.Since(start).Milliseconds(),
"remote", r.RemoteAddr,
"ua", r.UserAgent(),
)
})
}
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger := LoggerFromCtx(r.Context())
logger.Error("panic recovered",
"err", rec,
"stack", string(debug.Stack()),
"path", r.URL.Path,
)
http.Error(w, `{"title":"Internal Server Error","status":500}`, 500)
}
}()
next.ServeHTTP(w, r)
})
}log/slog (Go 1.21+) は標準の構造化ロガー
slogは Go 1.21 で標準入りした構造化ロギング。それまではlogruszapzerologが群雄割拠だった。標準の強み:
- 外部依存なし
- JSON / TextHandler が標準提供
With(...)でコンテキスト付きロガーを派生できる出力例(JSON ハンドラ):
{"time":"2026-05-14T10:00:00Z","level":"INFO","msg":"http","request_id":"abc-123","method":"GET","path":"/todos","status":200,"duration_ms":15}本番ではこれを Datadog / Cloud Logging / Loki に流して
request_idで串刺し検索する。
6. ページネーション設計
// internal/todo/handler.go の list の改良
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit, _ := strconv.Atoi(q.Get("limit"))
offset, _ := strconv.Atoi(q.Get("offset"))
// デフォルトと上限
if limit <= 0 || limit > 100 {
limit = 20
}
if offset < 0 {
offset = 0
}
items, total, err := h.svc.List(r.Context(), limit, offset)
if err != nil {
httperr.Write(w, r, h.logger, err)
return
}
// レスポンスにメタ情報を含める
resp := map[string]any{
"items": items,
"total": total,
"limit": limit,
"offset": offset,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}offset / limit vs cursor の使い分け
方式 利点 欠点 offset/limit 実装が単純、ページ番号でジャンプ可能 深いページで遅い(OFFSET 100000 は全件スキャン) cursor 高速、データ追加に強い 「3ページ目に飛ぶ」ができない 一般 API(管理画面、社内ツール)は offset/limit、Twitter / Stripe のような大規模 API は cursor。
cursor の例:
GET /todos?after=eyJpZCI6MTAwfQ==&limit=20 → {"items": [...], "next_cursor": "eyJpZCI6MTIwfQ=="}
afterは base64 でエンコードした最後の ID 等。
上限がないと事故る
// NG: 何件でも返せるエンドポイント SELECT * FROM todosクライアントが
?limit=999999を送ると DB の全行をメモリに乗せる → OOM → サーバー停止。鉄則: 必ず上限を設ける(コード上で
if limit > 100 { limit = 100 })。
7. Content-Type と Accept
// リクエスト Content-Type 検証
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
ct := r.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "application/json") {
return httperr.BadRequest("Content-Type must be application/json", nil)
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return httperr.BadRequest("invalid json", err)
}
return nil
}Accept ヘッダーへの対応
高度な API は
Acceptヘッダーで返す形式を変える(content negotiation):Accept: application/json → JSON で返す Accept: application/xml → XML で返す(必要なら) Accept: text/csv → CSV で返すJSON だけサポートする API でも、
Accept: text/htmlのような変な値が来た時に 406 Not Acceptable を返す選択肢もある。
セッション③: Graceful shutdown と統合(30分)
8. Graceful shutdown - サーバーを「綺麗に止める」
// cmd/server/main.go
package main
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/yourname/todo-api/internal/httpmw"
"github.com/yourname/todo-api/internal/todo"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
repo := todo.NewMemoryRepo()
svc := todo.NewService(repo)
h := todo.NewHandler(svc, logger)
mux := http.NewServeMux()
h.Routes(mux)
// ミドルウェアチェーン(外側→内側)
handler := chain(
mux,
httpmw.AccessLog,
httpmw.Recover,
httpmw.InjectLogger(logger),
httpmw.RequestID,
)
srv := &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
BaseContext: func(_ net.Listener) context.Context {
return context.Background()
},
}
// シグナル待ち goroutine
idleConnsClosed := make(chan struct{})
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
logger.Info("shutdown signal received")
// 30 秒以内に既存リクエストを完了させる
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed", "err", err)
}
close(idleConnsClosed)
}()
logger.Info("server starting", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server failed", "err", err)
os.Exit(1)
}
<-idleConnsClosed
logger.Info("server stopped cleanly")
}
func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
// 引数の右側から包む(=外側に重ねる)
for _, mw := range mws {
h = mw(h)
}
return h
}Graceful shutdown はなぜ必要か
Kubernetes / Docker などのデプロイで、コンテナを停止する時:
- SIGTERM がプロセスに送られる
- そのまま
os.Exitすると、進行中のリクエストが途中で切断される(クライアントは 502 を見る)- ロードバランサが新規リクエストを送らないようにする猶予が必要
Graceful shutdown:
- SIGTERM を受け取ったら 新規接続を拒否
- 既存リクエストは最後まで処理
- タイムアウト後、強制終了
これでデプロイ時のユーザー影響をゼロに近づける。Kubernetes の
terminationGracePeriodSecondsと合わせて設計する。
http.Server.Shutdown の挙動
- 新規接続の受付を停止
- 既存接続が完了するまで待つ
- 指定 context のタイムアウトで打ち切り
- 完了後
nil、タイムアウトならcontext.DeadlineExceededを返す注意: WebSocket や長時間ハンドラは「自分で止まる」必要がある。Shutdown は接続待ちを終わらせるだけで、ハンドラ内のループは止まらない。ハンドラは
r.Context()を見て自分で抜ける必要がある。
9. 完成形の handler(要点抜粋)
// internal/todo/handler.go
package todo
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/yourname/todo-api/internal/httperr"
)
type Handler struct {
svc *Service
logger *slog.Logger
}
func NewHandler(s *Service, l *slog.Logger) *Handler {
return &Handler{svc: s, logger: l}
}
func (h *Handler) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /todos", h.list)
mux.HandleFunc("POST /todos", h.create)
mux.HandleFunc("GET /todos/{id}", h.get)
mux.HandleFunc("PATCH /todos/{id}", h.update)
mux.HandleFunc("DELETE /todos/{id}", h.delete)
mux.HandleFunc("GET /healthz", h.health)
}
func (h *Handler) health(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
var in CreateRequest
if err := decodeJSON(w, r, &in); err != nil {
httperr.Write(w, r, h.logger, err)
return
}
if err := in.Validate(); err != nil {
httperr.Write(w, r, h.logger, err)
return
}
t, err := h.svc.Create(r.Context(), in.Title, in.Priority)
if err != nil {
httperr.Write(w, r, h.logger, err)
return
}
w.Header().Set("Location", "/todos/"+strconv.Itoa(t.ID))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(t)
}
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
httperr.Write(w, r, h.logger, httperr.BadRequest("invalid id", err))
return
}
t, err := h.svc.Get(r.Context(), id)
if errors.Is(err, ErrNotFound) {
httperr.Write(w, r, h.logger, httperr.NotFound("todo not found"))
return
}
if err != nil {
httperr.Write(w, r, h.logger, httperr.Internal(err))
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(t)
}
// ... update, delete も同様のパターン10. セキュリティチェックリスト(リリース前)
本番リリース前の最終チェック
入力検証
- 全 POST/PUT/PATCH で
MaxBytesReader設定- 全 POST/PUT/PATCH で
DisallowUnknownFields設定- バリデータでフィールド長 / 型チェック
- Content-Type 検証
エラー処理
- エラーメッセージに内部情報(SQL、ファイルパス、スタック)を含めない
- 全 panic を recover で捕捉、500 を返す
- HTTP ステータスコードを意味通りに使う
タイムアウト
ReadHeaderTimeout設定(Slowloris 対策)ReadTimeout/WriteTimeout設定IdleTimeout設定可観測性
- 全リクエストにリクエスト ID 付与、レスポンスヘッダにも返す
- 構造化ログ(JSON)で時刻・メソッド・パス・ステータス・所要時間を記録
- /healthz エンドポイント実装
運用
- SIGTERM での graceful shutdown 実装
- ページネーション上限あり(最大100件など)
- レート制限ミドルウェア(次のレベルで導入)
- TLS 必須(リバプロで対応 or
ListenAndServeTLS)- 認証ミドルウェア(次のレベルで)
11. 動作確認
go run ./cmd/server/main.go &
SERVER_PID=$!
# 作成
curl -i -X POST http://localhost:8080/todos \
-H "Content-Type: application/json" \
-d '{"title":"買い物","priority":3}'
# → 201 Created, Location: /todos/1
# バリデーションエラー
curl -i -X POST http://localhost:8080/todos \
-H "Content-Type: application/json" \
-d '{"title":"","priority":99}'
# → 422 Unprocessable Entity
# → {"title":"Validation Failed","status":422,"errors":{"Title":"min","Priority":"max"}}
# 一覧(ページネーション)
curl http://localhost:8080/todos?limit=10
# → {"items":[...], "total":1, "limit":10, "offset":0}
# 取得
curl http://localhost:8080/todos/1
# → 200 + JSON
# 存在しない
curl -i http://localhost:8080/todos/999
# → 404 Not Found
# 削除
curl -i -X DELETE http://localhost:8080/todos/1
# → 204 No Content
# graceful shutdown 確認(別ターミナルで遅いハンドラを叩きつつ SIGTERM)
kill -TERM $SERVER_PID
# → ログ: "shutdown signal received" → "server stopped cleanly"アンチパターン: 認証なしの「内部 API」
// 「内部だから」「テスト中だから」と認証を省略した API r.Use(/* なし */) r.Post("/admin/delete-all", deleteAllUsers)なぜNG: 「内部」が「外部」になる瞬間は突然来る(VPN 障害、Pod がパブリックに露出、開発者ミス)。全エンドポイントで認証 + 認可 を前提とすべき。
修正: 最低限の Bearer Token、本格的には OIDC / mTLS / API Key + IP 制限を Layer する。
練習課題
- 上記コードを完成させ、curl で全 CRUD を動作確認
PATCH /todos/{id}でポインタフィールドによる部分更新を実装- cursor ベースのページネーションを実装(base64 でカーソルをエンコード)
- レート制限ミドルウェア(
golang.org/x/time/rate)を追加して、1分あたり60リクエストに制限 - Bearer Token 検証ミドルウェアを追加し、
/healthz以外を保護 - graceful shutdown のタイムアウトを5秒にし、
/slow(10秒スリープ)を叩きながら kill して挙動を観察 - ボーナス: chi に書き換え、サブルーター + Use の組合せでミドルウェア構造を整理
締め: git で証跡を残す
cd ~/learn/go/level2/day07
git add .
git commit -m "feat(go-rest): 実務OKレベルのTODO REST API (バリデーション/構造化ログ/graceful shutdown)"
exitアンチパターン集 - やらかし事例
REST API 統合フェーズのやらかし
1. エラーでも常に 200 を返す
w.WriteHeader(200) json.Encode(map[string]any{"success": false, "error": "..."})HTTP の意味論を壊す。CDN/LB/監視ツールが全て正常扱い。メトリクス取れず障害見えない。
2. ページネーション上限なし
?limit=999999で DB を OOM 直行便。if limit > 100 { limit = 100 }は必須。3. graceful shutdown 無しのデプロイ Kubernetes が SIGTERM 送って即 kill。進行中リクエストが 502 になる。
4. リクエスト ID 振らない 障害時に「このユーザーの2025-01-15 14:23 のリクエストのログ追って」が 不可能。
5. SQL/パス情報をエラーレスポンスに含める 攻撃者に内部構造を見せる典型的脆弱性。ログには詳細、レスポンスには汎用。
6. 422 と 400 を区別しない JSON 壊れている(400)と バリデーション失敗(422)を全部 400 で返す → クライアントが対処方針を立てられない。
対比表で違いを明確化
HTTP ステータスコード使い分け早見表
場面 コード GET 成功 200 POST 作成成功 201 + Location ヘッダ DELETE 成功(ボディなし) 204 JSON 構文壊れ 400 未認証 401 認証OKだが権限なし 403 リソースなし 404 重複登録・楽観ロック失敗 409 バリデーション失敗 422 レート制限 429 サーバー側バグ 500 上流障害 502/503/504
ページネーション方式の選び分け
方式 利点 欠点 用途 offset/limit 単純、ページ番号でジャンプ 深いページ遅い 管理画面 cursor 高速、追加に強い 任意ページに飛べない Twitter/Stripe
自己評価チェックリスト
手を動かせた
-
validatorでバリデーション組んだ - 422 で フィールド別エラーを返した
- AppError で HTTP ステータスと内部詳細を分離
-
slogでリクエスト ID 付き構造化ログ - ページネーション + 上限を実装
- SIGTERM での graceful shutdown を実装
- curl で全 CRUD + エラーケースを動作確認
説明できる
- 「実務OK」の6基準を列挙
- RFC 7807 の Problem+JSON 構造
- 400 vs 422 の使い分け
-
srv.Shutdown(ctx)が何を待ち何を待たないか - 「本番投入前チェックリスト」を脳内で読み上げる
やらかし回避
- エラーでも 200 で返さない
- ページネーション上限を必ず設ける
- graceful shutdown 無しでデプロイしない
- エラーレスポンスに内部情報を含めない
- リクエスト ID を必ず振る
詰まった時のチートシート
| やりたいこと | コード |
|---|---|
| バリデーション | validator.New().Struct(req) |
| 422 エラー | validator.ValidationErrors を検知して errors フィールドで返す |
| エラー統一 | httperr.AppError + httperr.Write |
| graceful shutdown | signal.Notify → srv.Shutdown(ctx) |
| リクエスト ID | uuid + ミドルウェアで context に積む |
| 構造化ログ | slog.New(slog.NewJSONHandler(os.Stdout, nil)) |
| ページ上限 | if limit > 100 { limit = 100 } |
| ロケーションヘッダ | w.Header().Set("Location", "/todos/"+id) |
「実務OK」基準
- TODO API のような CRUD を、エラーハンドリング・バリデーション・ログ・shutdown 込みで2-3時間で書ける
- 「本番投入前チェックリスト」を脳内で読み上げられる
go-playground/validatorのタグでフィールド検証を組めるhttp.Server.Shutdownが「何を待っていて、何を待っていないか」を語れる- 422 / 400 / 404 / 409 を適切に使い分けられる
さらに深掘りするなら
- 公式: Go 1.21 slog ドキュメント - 構造化ロギングの標準
- 公式: Go の Graceful shutdown 解説
- RFC 7807: Problem Details for HTTP APIs
- go-playground/validator ドキュメント: github.com/go-playground/validator - バリデーションタグ網羅
- 書籍: 『API デザインの極意』(マーク・マセ) - 本格的 REST API 設計
- 書籍: 『Designing Web APIs』(Brenda Jin 他) - REST / GraphQL / gRPC の選定
- OSS 実例: GitHub API のエラー仕様、Stripe API のページネーション仕様
Level 2 まとめ
ここまでで:
- 2-1 標準 net/http の設計思想と本番タイムアウト
- 2-2 Go 1.22 ServeMux と chi のルーティング
- 2-3 JSON の罠と安全な復号
- 2-4 ミドルウェアの合成と context 連携
- 2-5 エラー設計(値 / ラップ / panic 境界)
- 2-6 パッケージ分割とレイヤード設計
- 2-7 統合 - 実務OKレベルの REST API
をやりきった。
次の Level 3 では データベース(database/sql、コネクションプール、トランザクション)、並行処理(goroutine / channel / sync)、テスト戦略、Observability(OpenTelemetry) に進む。Level 2 で組んだ API に魂を入れる作業。
つながりの予告
- 本章の
memoryRepoを 3-1_database_sql で PostgresRepo に差し替え Serviceのロジックを 3-3_テスト基礎 / 3-4_テーブル駆動テスト でテスト- graceful shutdown は 3-7_本番準備 で OpenTelemetry / メトリクスと統合