課題19: タイムアウト付きAPI呼び出し

課題概要

この課題では、contextパッケージを活用して、外部APIへのリクエストにタイムアウトとキャンセル機能を実装します。実際のWebアプリケーションで必須となる、処理時間の制御とリソース管理を学びます。

マンダトリー要件(80点)

要件1: タイムアウト付きHTTPクライアント(30点)

外部APIへのリクエストにタイムアウトを設定するクライアントを実装してください。

実装ファイル: apiclient/client.go

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "time"
)

type GitHubUser struct {
    Login     string `json:"login"`
    Name      string `json:"name"`
    Bio       string `json:"bio"`
    Followers int    `json:"followers"`
}

// FetchUser はタイムアウト付きでGitHubユーザー情報を取得します
func FetchUser(ctx context.Context, username string) (*GitHubUser, error) {
    // TODO: 実装
    // 1. コンテキスト付きのリクエストを作成
    // 2. APIを呼び出し
    // 3. レスポンスをパース
    // 4. タイムアウト時は適切なエラーを返す

    url := fmt.Sprintf("https://api.github.com/users/%s", username)

    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, err
    }

    // User-Agentヘッダーを設定(GitHubの要件)
    req.Header.Set("User-Agent", "Go-Client")

    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("API error: %s", resp.Status)
    }

    var user GitHubUser
    // TODO: JSONをデコード

    return &user, nil
}

// FetchMultipleUsers は複数ユーザーを並行取得します
func FetchMultipleUsers(ctx context.Context, usernames []string) (map[string]*GitHubUser, error) {
    // TODO: 実装
    // 1. 各ユーザーをgoroutineで並行取得
    // 2. 結果をチャネルで集約
    // 3. 親コンテキストのキャンセルを監視
}

func main() {
    // 5秒のタイムアウト
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    user, err := FetchUser(ctx, "golang")
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }

    fmt.Printf("User: %s\n", user.Login)
    fmt.Printf("Name: %s\n", user.Name)
    fmt.Printf("Followers: %d\n", user.Followers)

    // 複数ユーザーの取得
    usernames := []string{"golang", "torvalds", "gvanrossum"}
    users, err := FetchMultipleUsers(ctx, usernames)
    if err != nil {
        fmt.Printf("Error fetching multiple users: %v\n", err)
        return
    }

    fmt.Printf("\nFetched %d users\n", len(users))
}

要件2: データベースクエリのタイムアウト(25点)

コンテキストを使ったデータベース操作を実装してください。

実装ファイル: dbclient/database.go

package main

import (
    "context"
    "database/sql"
    "fmt"
    "time"

    _ "github.com/mattn/go-sqlite3"
)

type User struct {
    ID        int
    Username  string
    Email     string
    CreatedAt time.Time
}

type UserDB struct {
    db *sql.DB
}

func NewUserDB(dbPath string) (*UserDB, error) {
    db, err := sql.Open("sqlite3", dbPath)
    if err != nil {
        return nil, err
    }

    // テーブル作成
    _, err = db.Exec(`
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT NOT NULL,
            email TEXT NOT NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        )
    `)
    if err != nil {
        return nil, err
    }

    return &UserDB{db: db}, nil
}

// CreateUser はユーザーを作成します(タイムアウト付き)
func (db *UserDB) CreateUser(ctx context.Context, username, email string) (int64, error) {
    // TODO: 実装
    // コンテキストを使った INSERT 操作
}

// GetUser はユーザーを取得します(タイムアウト付き)
func (db *UserDB) GetUser(ctx context.Context, id int) (*User, error) {
    // TODO: 実装
    // コンテキストを使った SELECT 操作
}

// ListUsers はすべてのユーザーを取得します(タイムアウト付き)
func (db *UserDB) ListUsers(ctx context.Context) ([]User, error) {
    // TODO: 実装
    // コンテキストを使った SELECT 操作
}

// DeleteUser はユーザーを削除します(タイムアウト付き)
func (db *UserDB) DeleteUser(ctx context.Context, id int) error {
    // TODO: 実装
    // コンテキストを使った DELETE 操作
}

func (db *UserDB) Close() error {
    return db.db.Close()
}

func main() {
    userDB, err := NewUserDB("users.db")
    if err != nil {
        panic(err)
    }
    defer userDB.Close()

    // 3秒のタイムアウト
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    // ユーザー作成
    id, err := userDB.CreateUser(ctx, "alice", "alice@example.com")
    if err != nil {
        fmt.Printf("Create error: %v\n", err)
        return
    }
    fmt.Printf("Created user with ID: %d\n", id)

    // ユーザー取得
    user, err := userDB.GetUser(ctx, int(id))
    if err != nil {
        fmt.Printf("Get error: %v\n", err)
        return
    }
    fmt.Printf("User: %+v\n", user)

    // 全ユーザー取得
    users, err := userDB.ListUsers(ctx)
    if err != nil {
        fmt.Printf("List error: %v\n", err)
        return
    }
    fmt.Printf("Total users: %d\n", len(users))
}

要件3: 協調的キャンセルシステム(25点)

複数のgoroutineを協調的にキャンセルするシステムを実装してください。

実装ファイル: worker/worker.go

package main

import (
    "context"
    "fmt"
    "sync"
    "time"
)

type Task struct {
    ID   int
    Name string
}

type Worker struct {
    id      int
    taskCh  chan Task
    results chan Result
}

type Result struct {
    WorkerID int
    TaskID   int
    Success  bool
    Error    error
}

// Start はワーカーを起動します
func (w *Worker) Start(ctx context.Context, wg *sync.WaitGroup) {
    defer wg.Done()

    fmt.Printf("Worker %d: Started\n", w.id)

    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker %d: Stopped (%v)\n", w.id, ctx.Err())
            return

        case task, ok := <-w.taskCh:
            if !ok {
                fmt.Printf("Worker %d: Channel closed\n", w.id)
                return
            }

            // タスクを処理
            fmt.Printf("Worker %d: Processing task %d (%s)\n", w.id, task.ID, task.Name)

            // 処理をシミュレート
            time.Sleep(1 * time.Second)

            result := Result{
                WorkerID: w.id,
                TaskID:   task.ID,
                Success:  true,
            }

            select {
            case w.results <- result:
            case <-ctx.Done():
                fmt.Printf("Worker %d: Result sending cancelled\n", w.id)
                return
            }
        }
    }
}

type WorkerPool struct {
    workers []*Worker
    taskCh  chan Task
    results chan Result
}

func NewWorkerPool(numWorkers int) *WorkerPool {
    taskCh := make(chan Task, 100)
    results := make(chan Result, 100)

    workers := make([]*Worker, numWorkers)
    for i := 0; i < numWorkers; i++ {
        workers[i] = &Worker{
            id:      i + 1,
            taskCh:  taskCh,
            results: results,
        }
    }

    return &WorkerPool{
        workers: workers,
        taskCh:  taskCh,
        results: results,
    }
}

// Start はすべてのワーカーを起動します
func (p *WorkerPool) Start(ctx context.Context) *sync.WaitGroup {
    var wg sync.WaitGroup

    for _, worker := range p.workers {
        wg.Add(1)
        go worker.Start(ctx, &wg)
    }

    return &wg
}

// Submit はタスクを送信します
func (p *WorkerPool) Submit(ctx context.Context, task Task) error {
    select {
    case p.taskCh <- task:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

// Close はプールを閉じます
func (p *WorkerPool) Close() {
    close(p.taskCh)
}

func main() {
    // 10秒のタイムアウト
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    // ワーカープールを作成(3ワーカー)
    pool := NewWorkerPool(3)

    // ワーカーを起動
    wg := pool.Start(ctx)

    // 結果を収集するgoroutine
    go func() {
        for result := range pool.results {
            fmt.Printf("Result: Worker %d completed task %d (Success: %v)\n",
                result.WorkerID, result.TaskID, result.Success)
        }
    }()

    // タスクを送信
    for i := 1; i <= 10; i++ {
        task := Task{
            ID:   i,
            Name: fmt.Sprintf("Task-%d", i),
        }

        err := pool.Submit(ctx, task)
        if err != nil {
            fmt.Printf("Submit error: %v\n", err)
            break
        }
    }

    // すべてのワーカーが終了するまで待機
    pool.Close()
    wg.Wait()
    close(pool.results)

    fmt.Println("All workers stopped")
}

期待される動作

APIクライアント

$ go run client.go

User: golang
Name: The Go Programming Language
Followers: 123456

Fetched 3 users

データベースクライアント

$ go run database.go

Created user with ID: 1
User: {ID:1 Username:alice Email:alice@example.com CreatedAt:2024-01-15 10:00:00}
Total users: 1

ワーカープール

$ go run worker.go

Worker 1: Started
Worker 2: Started
Worker 3: Started
Worker 1: Processing task 1 (Task-1)
Worker 2: Processing task 2 (Task-2)
Worker 3: Processing task 3 (Task-3)
Result: Worker 1 completed task 1 (Success: true)
...
Worker 1: Stopped (context deadline exceeded)
All workers stopped

ボーナス課題(20点)

ボーナス1: リトライメカニズム(10点)

失敗したリクエストを自動的にリトライする機能を実装してください。

実装ファイル: apiclient/retry.go

package main

import (
    "context"
    "fmt"
    "time"
)

// RetryConfig はリトライ設定
type RetryConfig struct {
    MaxRetries int
    InitialBackoff time.Duration
    MaxBackoff time.Duration
}

// RetryWithBackoff は指定された関数をリトライします
func RetryWithBackoff(ctx context.Context, config RetryConfig, fn func() error) error {
    // TODO: 実装
    // 1. 指定回数までリトライ
    // 2. エクスポネンシャルバックオフを実装
    // 3. コンテキストのキャンセルを監視
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    config := RetryConfig{
        MaxRetries:     5,
        InitialBackoff: 1 * time.Second,
        MaxBackoff:     10 * time.Second,
    }

    err := RetryWithBackoff(ctx, config, func() error {
        // 不安定なAPI呼び出しをシミュレート
        return fmt.Errorf("temporary error")
    })

    if err != nil {
        fmt.Printf("Failed after retries: %v\n", err)
    }
}

ボーナス2: リクエストIDの伝播(5点)

コンテキストを使ってリクエストIDを伝播させる実装をしてください。

実装ファイル: requestid/middleware.go

package main

import (
    "context"
    "fmt"
    "math/rand"
    "net/http"
)

type contextKey int

const requestIDKey contextKey = 0

// WithRequestID はコンテキストにリクエストIDを追加します
func WithRequestID(ctx context.Context, requestID string) context.Context {
    // TODO: 実装
}

// GetRequestID はコンテキストからリクエストIDを取得します
func GetRequestID(ctx context.Context) (string, bool) {
    // TODO: 実装
}

// requestIDMiddleware はリクエストIDを生成してコンテキストに追加します
func requestIDMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // リクエストIDを生成
        requestID := fmt.Sprintf("%d", rand.Int63())

        // コンテキストに追加
        ctx := WithRequestID(r.Context(), requestID)

        // 新しいコンテキストでリクエストを実行
        next(w, r.WithContext(ctx))
    }
}

func handler(w http.ResponseWriter, r *http.Request) {
    requestID, _ := GetRequestID(r.Context())
    fmt.Fprintf(w, "Request ID: %s\n", requestID)

    // ログ出力にもリクエストIDを含める
    logWithRequestID(r.Context(), "Processing request")
}

func logWithRequestID(ctx context.Context, message string) {
    requestID, _ := GetRequestID(ctx)
    fmt.Printf("[%s] %s\n", requestID, message)
}

func main() {
    http.HandleFunc("/", requestIDMiddleware(handler))
    http.ListenAndServe(":8080", nil)
}

ボーナス3: タイムアウト階層(5点)

親子関係のあるコンテキストで、異なるタイムアウトを設定する実装をしてください。

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    // 親コンテキスト: 10秒
    parentCtx, parentCancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer parentCancel()

    // 子コンテキスト1: 3秒
    // TODO: parentCtxから派生させる

    // 子コンテキスト2: 5秒
    // TODO: parentCtxから派生させる

    // 各コンテキストで処理を実行し、どれが先にタイムアウトするか観察
}

評価基準

項目 配点 詳細
HTTPクライアント 30点 タイムアウト付きAPI呼び出しが動作する
データベース操作 25点 コンテキストを使ったクエリが実装されている
協調的キャンセル 25点 ワーカープールが正しく動作する
**ボーナス1: リトライ** 10点 エクスポネンシャルバックオフが実装されている
**ボーナス2: リクエストID** 5点 コンテキストを通じた値の伝播が動作する
**ボーナス3: 階層タイムアウト** 5点 親子コンテキストの挙動が正しい

提出方法

以下のディレクトリ構造で提出してください:

submission/
├── apiclient/
│   ├── client.go
│   └── retry.go         # ボーナス課題
├── dbclient/
│   └── database.go
├── worker/
│   └── worker.go
├── requestid/           # ボーナス課題
│   └── middleware.go
└── README.md            # 実行方法と説明

ヒント

  • http.NewRequestWithContext: HTTPリクエストにコンテキストを設定
  • db.QueryContext: データベースクエリにコンテキストを設定
  • select-case: 複数のチャネルを監視
  • defer cancel(): コンテキストのキャンセルを確実に呼び出す
  • sync.WaitGroup: goroutineの完了を待機
  • 学習リソース

  • context Package
  • Context and Cancellation
  • Timeouts and Deadlines