mcpField
G
go-web-expert

go-web-expert

github.com/existential-birds開発8585 回インストール11 週間前に更新
リポジトリ

go-web-expert について

Go の Web 開発者向けに、本番品質を意識した五つのルールをまとめたガイドです。依存性注入、入力検証、エラー処理、HTTP テスト、ドキュメントを扱います。

主な機能

  1. 01可変依存関係をサーバー構造体に注入
  2. 02エラーの明示的な処理とラップ
  3. 03リクエスト境界での JSON 検証
  4. 04httptest によるテーブル駆動テスト
  5. 05エクスポートされた識別子のドキュメント化
  6. 06定められたゲートに沿ったハンドラー変更の確認

活用シーン

  1. 01新しい HTTP ハンドラーの実装
  2. 02リクエスト検証とエラーレスポンスの確認
  3. 03ハンドラーやミドルウェアのテスト作成
  4. 04Go Web コードの規約適合性レビュー

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/existential-birds/beagle --skill go-web-expert

インストール後、Claude Code で/go-web-expertを入力して読み込まれたか確認します。

同梱ファイルreferences/testing-handlers.mdreferences/validation.md

詳細

Go Web エキスパートシステム

本番品質の Go Web アプリケーションを実現するための、譲れない5つのルールです。すべてのハンドラー、すべてのサービス、すべてのコード行が、5つすべてを満たす必要があります。

クイックリファレンス

トピックリファレンス
バリデーションタグ、カスタムバリデーター、ネストした構造体、エラーのフォーマットreferences/validation.md
httptest のパターン、ミドルウェアのテスト、統合テスト、フィクスチャreferences/testing-handlers.md

遵守すべきルール

#ルール要約
1グローバル状態ゼロすべてのハンドラーを構造体のメソッドにし、変更可能な状態のパッケージレベル var は使用しません
2明示的なエラー処理すべてのエラーを確認し、fmt.Errorf("doing X: %w", err) でラップします
3バリデーションを最初に行う受信するすべての JSON を境界で go-playground/validator を使って検証します
4テスト可能性すべてのハンドラーに、テーブル駆動テストで httptest を使う _test.go を用意します
5ドキュメントエクスポートされるすべてのシンボルに、その名前で始まる Go ドキュメントコメントを付けます
厳守事項(新しい HTTP ハンドラー)

順番どおりに適用します。現在のステップの合格条件が満たされるまでは、次のステップが完了したものとして扱わないでください(「頭の中で確認した」ではなく、ディスク上またはテスト出力で客観的に確認できる証拠が必要です)。

  1. 依存関係(ルール 1) — 合格条件: ハンドラーが、変更可能な依存関係(db、ロガー、HTTP クライアント、キャッシュ)をすべて保持する構造体のメソッドであること。新しいパッケージレベル var は、パッケージレベルで許可されるものの許可リストにあるものに限ります。証拠: コンストラクターで依存関係が設定されており、そのリストにある禁止グローバルが新たに追加されていないこと。

  2. 境界(ルール 3) — サービス/ドメインコードの呼び出し前に合格すること:合格条件: リクエストがタグ付き構造体にデコードされ、validate.Struct(または同等の処理)が実行されること。無効な JSON とバリデーション失敗に対して、HTTP ステータスと本文が定義されていること(例:400/422)。証拠: ハンドラー内にデコードと validate.Struct があり、テストまたは手動実行で不正な入力に対する 422/400 が確認できること。

  3. エラー(ルール 2) — 合格条件: ハンドラーの処理経路で _ による破棄がなく、json.NewEncoder(w).Encode のエラーが処理され、上位に渡すかログに記録するエラーが、このスキルの規定に従ってラップ(%w)されているか、AppError にマッピングされていること。証拠: 差分を確認し、無視されたエラーや、ラップが必要な箇所でコンテキストのない return err がないこと。

  4. テスト(ルール 4) — 合格条件: ハンドラーのパッケージに _test.go があり、httptest を使って ServeHTTP を呼び出し、少なくとも成功ケースと非 2xx ケース(バリデーション、Not Found、またはドメインエラー)を含むこと。証拠: テストファイルのパスが存在し、そのパッケージに対する go test が成功すること。

  5. ドキュメント(ルール 5) — 合格条件: 変更に含まれる新規または変更されたエクスポート識別子すべてに、最初の行がその識別子の名前で始まるドキュメントコメントがあること。証拠: go doc <pkg> または IDE/ドキュメントのプレビューで、新しいエクスポートの概要が表示されること。


ルール 1:グローバル状態ゼロ

すべてのハンドラーをサーバー構造体のメソッドにする必要があります。データベース、ロガー、クライアント、その他の変更可能な状態を保持するパッケージレベル var は使用しません。

// FORBIDDEN
var db *sql.DB
var logger *slog.Logger

func handleGetUser(w http.ResponseWriter, r *http.Request) {
    user, err := db.QueryRow(...)  // global state -- untestable, unsafe
}

// REQUIRED
type Server struct {
    db     *sql.DB
    logger *slog.Logger
    router *http.ServeMux
}

func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
    user, err := s.db.QueryRow(...)  // explicit dependency
}
パッケージレベルで許可されるもの
  • 定数 -- const maxPageSize = 100
  • 純粋関数 -- 副作用がなく、引数のみに依存する関数
  • センチネルエラー -- var ErrNotFound = errors.New("not found")
  • バリデーターのインスタンス -- var validate = validator.New()(初期化後はステートレス)
パッケージレベルで禁止されるもの
  • データベース接続(*sql.DB、*pgxpool.Pool)
  • ロガー(*slog.Logger)
  • タイムアウトまたはトランスポートを設定した HTTP クライアント
  • 環境変数から読み込む設定構造体
  • キャッシュ、レートリミッター、その他の変更可能な共有リソース
コンストラクターパターン
func NewServer(db *sql.DB, logger *slog.Logger) *Server {
    s := &Server{
        db:     db,
        logger: logger,
        router: http.NewServeMux(),
    }
    s.routes()
    return s
}

func (s *Server) routes() {
    s.router.HandleFunc("GET /api/users/{id}", s.handleGetUser)
    s.router.HandleFunc("POST /api/users", s.handleCreateUser)
}

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    s.router.ServeHTTP(w, r)
}

ルール 2:明示的なエラー処理

エラーを決して無視しないでください。エラーが発生したときに何を実行しようとしていたかを説明するコンテキストを付けて、すべてのエラーをラップする必要があります。

// FORBIDDEN
result, _ := doSomething()
json.NewEncoder(w).Encode(data)  // error ignored

// REQUIRED
result, err := doSomething()
if err != nil {
    return fmt.Errorf("doing something for user %s: %w", userID, err)
}

if err := json.NewEncoder(w).Encode(data); err != nil {
    s.logger.Error("encoding response", "err", err, "request_id", reqID)
}
エラーのラップ規約

形式:"<verb>ing <noun>: %w" -- 小文字で、ピリオドを付けず、呼び出しチェーンのコンテキストを示します。

// Good wrapping -- each layer adds context
return fmt.Errorf("creating user: %w", err)
return fmt.Errorf("inserting user into database: %w", err)
return fmt.Errorf("hashing password for user %s: %w", email, err)

// Bad wrapping
return fmt.Errorf("error: %w", err)           // no context
return fmt.Errorf("Failed to create user: %w", err) // uppercase, verbose
return err                                      // no wrapping at all
HTTP API 用の構造化エラー型
type AppError struct {
    Code    int    `json:"-"`
    Message string `json:"error"`
    Detail  string `json:"detail,omitempty"`
}

func (e *AppError) Error() string {
    return fmt.Sprintf("%d: %s", e.Code, e.Message)
}

// Map domain errors to HTTP errors in one place
func handleError(w http.ResponseWriter, r *http.Request, err error) {
    var appErr *AppError
    if errors.As(err, &appErr) {
        writeJSON(w, appErr.Code, appErr)
        return
    }

    slog.Error("unhandled error",
        "err", err,
        "path", r.URL.Path,
    )
    writeJSON(w, 500, map[string]string{"error": "internal server error"})
}
よくある間違い
// MISTAKE: not checking Close errors on writers
defer f.Close()  // at minimum, log Close errors for writable resources

// BETTER for writable resources:
defer func() {
    if err := f.Close(); err != nil {
        s.logger.Error("closing file", "err", err)
    }
}()

// OK for read-only resources where Close rarely fails:
defer resp.Body.Close()

ルール 3:まずバリデーション

受信するすべての JSON に go-playground/validator を使用します。境界でバリデーションし、内部データは信頼します。

import "github.com/go-playground/validator/v10"

var validate = validator.New()

type CreateUserRequest struct {
    Name  string `json:"name"  validate:"required,min=1,max=100"`
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age"   validate:"omitempty,gte=0,lte=150"`
}

func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) error {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        return &AppError{Code: 400, Message: "invalid JSON", Detail: err.Error()}
    }

    if err := validate.Struct(req); err != nil {
        return &AppError{Code: 422, Message: "validation failed", Detail: formatValidationErrors(err)}
    }

    // From here, req is trusted
    user, err := s.userService.Create(r.Context(), req.Name, req.Email)
    if err != nil {
        return fmt.Errorf("creating user: %w", err)
    }

    writeJSON(w, http.StatusCreated, user)
    return nil
}
バリデーションエラーのフォーマット
func formatValidationErrors(err error) string {
    var msgs []string
    for _, e := range err.(validator.ValidationErrors) {
        msgs = append(msgs, fmt.Sprintf("field '%s' failed on '%s'", e.Field(), e.Tag()))
    }
    return strings.Join(msgs, "; ")
}
バリデーション境界のルール
  • 境界でバリデーションする -- HTTP ハンドラー、メッセージコンシューマー、CLI 入力
  • 内部データは信頼する -- サービスレイヤーは、すでにバリデーション済みの型を受け取ります
  • 二度バリデーションしない -- ハンドラーでバリデーション済みの場合、サービスで同じフィールドを再バリデーションしません

カスタムバリデーター、ネストされた構造体のバリデーション、スライスのバリデーション、フィールド間のバリデーションについては、references/validation.md を参照してください。


ルール 4:テスト容易性

すべてのハンドラーには、httptest を使用する対応する _test.go ファイルが必要です。ハンドラーメソッドを直接呼び出すのではなく、HTTP レイヤーを通してテストします。

func TestServer_handleGetUser(t *testing.T) {
    mockStore := &MockUserStore{
        GetUserFunc: func(ctx context.Context, id string) (*User, error) {
            if id == "123" {
                return &User{ID: "123", Name: "Alice"}, nil
            }
            return nil, ErrNotFound
        },
    }
    srv := NewServer(mockStore, slog.Default())

    tests := []struct {
        name       string
        path       string
        wantStatus int
        wantBody   string
    }{
        {
            name:       "existing user",
            path:       "/api/users/123",
            wantStatus: http.StatusOK,
            wantBody:   `"name":"Alice"`,
        },
        {
            name:       "not found",
            path:       "/api/users/999",
            wantStatus: http.StatusNotFound,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest("GET", tt.path, nil)
            w := httptest.NewRecorder()

            srv.ServeHTTP(w, req)

            if w.Code != tt.wantStatus {
                t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
            }
            if tt.wantBody != "" && !strings.Contains(w.Body.String(), tt.wantBody) {
                t.Errorf("body = %q, want to contain %q", w.Body.String(), tt.wantBody)
            }
        })
    }
}
主なテスト原則
  • HTTP を通してテストする -- httptest.NewRequest と httptest.NewRecorder を使用し、srv.ServeHTTP を呼び出します
  • インターフェースベースのモック -- コンシューマー側で小さなインターフェースを定義し、テスト用のモック実装を作成します
  • テーブル駆動テスト -- テストケースを格納した単一の []struct と、単一の t.Run ループを使用します
  • エラーパスも重要です -- 200 だけでなく、400、404、422、500 もテストします
  • グローバルなテスト状態を使わない -- 各テストで、それぞれのモックを持つ独自のサーバーを作成します

ミドルウェアのテスト、実データベースを使った統合テスト、ファイルアップロードのテスト、ストリーミングレスポンスのテストについては、references/testing-handlers.md を参照してください。


ルール 5:ドキュメント

エクスポートされた関数、型、メソッド、定数にはすべて、標準規約に従った Go doc コメントが必要です。

// CreateUser creates a new user with the given name and email.
// It returns ErrDuplicateEmail if a user with the same email already exists.
func (s *UserService) CreateUser(ctx context.Context, name, email string) (*User, error) {
    // ...
}

// Server handles HTTP requests for the user API.
type Server struct {
    // ...
}

// NewServer creates a Server with the given dependencies.
// The logger must not be nil.
func NewServer(store UserStore, logger *slog.Logger) *Server {
    // ...
}

// ErrNotFound is returned when a requested resource does not exist.
var ErrNotFound = errors.New("not found")
doc コメントの規約
  • 名前から始める -- // CreateUser creates... のように記述し、// This function creates... とはしません
  • 最初の文が要約です -- go doc の一覧や IDE のツールチップに表示されます
  • 重要なエラーの戻り値に言及する -- 呼び出し側がどのエラーを確認すべきか把握できるようにします
  • 自明なことは説明しない -- // SetName sets the name には価値がありません
  • 何をするかではなく、なぜそうするかを説明する -- 動作が自明でない場合は、その理由を説明します
パッケージのドキュメント
// Package user provides user management for the application.
// It handles creation, retrieval, and deletion of user accounts,
// with email uniqueness enforced at the database level.
package user

横断的な関心事

5 つのルールは互いに補強し合います。それぞれがどのように関係するかを説明します。

グローバル状態をなくすとテスト容易性が向上する

すべての依存関係が構造体に含まれるため、テストでモックを注入できます。

// Production
srv := NewServer(realDB, prodLogger)

// Test
srv := NewServer(mockStore, slog.Default())

db がグローバルな var だった場合、テストではパッケージ状態を変更する必要があり、並列テストで競合状態が発生します。

先にバリデーションするとエラー処理が簡潔になる

ハンドラーが境界でバリデーションすることで、サービスレイヤーは入力が有効だと想定できます。つまり、サービスレイヤーのエラーは常に予期しないもの(データベースの障害、ネットワークの問題)となり、エラー処理が簡潔になります。

func (s *UserService) Create(ctx context.Context, name, email string) (*User, error) {
    // No need to check if name is empty -- handler already validated
    user := &User{Name: name, Email: email}
    if err := s.store.Insert(ctx, user); err != nil {
        return nil, fmt.Errorf("inserting user: %w", err)
    }
    return user, nil
}
ドキュメントによってエラー処理を把握しやすくなる

エラーの戻り値に言及する doc コメントは、呼び出し側が処理すべき内容を示します。

// Delete removes a user by ID.
// It returns ErrNotFound if the user does not exist.
// It returns ErrHasActiveOrders if the user has unfinished orders.
func (s *UserService) Delete(ctx context.Context, id string) error {

セルフレビュー用チェックリスト

ハンドラーまたはサービスを完成とみなす前に、5 つのルールすべてを確認してください。

グローバル状態をなくす
  • 変更可能な状態(db、ロガー、クライアント)にパッケージレベルの var を使用していない
  • すべてのハンドラーが構造体のメソッドになっている
  • コンストラクターを通して依存関係を注入している
明示的なエラー処理
  • 戻り値のエラーを無視するために _ を使用していない
  • すべてのエラーを fmt.Errorf("doing X: %w", err) でラップしている
  • json.NewEncoder(w).Encode(...) のエラーを確認するか、ログに記録している
  • HTTP エラーレスポンスに構造化された AppError を使用している
まずバリデーション
  • すべてのリクエスト構造体に validate タグがある
  • ビジネスロジックを実行する前に validate.Struct(req) を呼び出している
  • バリデーションエラーは、フィールドごとの詳細を含む 422 を返す
  • サービスレイヤーで、ハンドラーがバリデーション済みのデータを再バリデーションしていない
テスト容易性
  • 各ハンドラーファイルに対応する_test.goファイルがある
  • テストではhttptest.NewRequestとhttptest.NewRecorderを使用している
  • テーブル駆動テストで正常系とエラー系を網羅している
  • モックは具象型ではなく、限定的なインターフェースを実装している
ドキュメント
  • エクスポートされるすべての関数に、関数名で始まるドキュメントコメントがある
  • エラーの戻り値について説明されている
  • パッケージにドキュメントコメントがある

参照資料を読み込むタイミング

次の場合はvalidation.mdを読み込みます。

  • バリデーションタグを持つ新しいリクエスト型を追加するとき
  • カスタムバリデーターを作成するとき
  • ネストされた構造体、スライス、またはマップを検証するとき
  • API レスポンス用にバリデーションエラーを整形するとき

次の場合はtesting-handlers.mdを読み込みます。

  • プロジェクトで初めてハンドラーテストを書くとき
  • ミドルウェアチェーンや認証をテストするとき
  • 実際のデータベースを使った統合テストを設定するとき
  • ファイルのアップロードやストリーミングレスポンスをテストするとき

コメント