課題13: 並行ダウンローダーの実装

課題概要

この課題では、複数のURLから並行してファイルをダウンロードする実用的なツールを実装します。goroutine、sync.WaitGroup、Mutexを使った並行処理の基礎を習得し、レースコンディションの回避方法を学びます。

マンダトリー要件

要件1: ダウンロード統計の実装(20点)

並行安全な統計情報を管理する構造体を実装してください。

ファイル: stats.go

package downloader

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

// DownloadStats はダウンロード統計を管理
type DownloadStats struct {
    mu            sync.Mutex
    TotalFiles    int
    SuccessCount  int
    FailureCount  int
    TotalBytes    int64
    StartTime     time.Time
    CompletedURLs []string
    FailedURLs    []string
}

// NewDownloadStats は新しい統計を作成
func NewDownloadStats() *DownloadStats {
    return &DownloadStats{
        StartTime:     time.Now(),
        CompletedURLs: make([]string, 0),
        FailedURLs:    make([]string, 0),
    }
}

// IncrementTotal は総ファイル数を増加
func (s *DownloadStats) IncrementTotal() {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.TotalFiles++
}

// RecordSuccess は成功を記録
func (s *DownloadStats) RecordSuccess(url string, bytes int64) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.SuccessCount++
    s.TotalBytes += bytes
    s.CompletedURLs = append(s.CompletedURLs, url)
}

// RecordFailure は失敗を記録
func (s *DownloadStats) RecordFailure(url string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.FailureCount++
    s.FailedURLs = append(s.FailedURLs, url)
}

// GetStats は統計のスナップショットを取得
func (s *DownloadStats) GetStats() (int, int, int, int64, time.Duration) {
    s.mu.Lock()
    defer s.mu.Unlock()

    elapsed := time.Since(s.StartTime)
    return s.TotalFiles, s.SuccessCount, s.FailureCount, s.TotalBytes, elapsed
}

// Report はレポートを生成
func (s *DownloadStats) Report() string {
    s.mu.Lock()
    defer s.mu.Unlock()

    elapsed := time.Since(s.StartTime)

    report := "\n=== ダウンロードレポート ===\n"
    report += fmt.Sprintf("総ファイル数: %d\n", s.TotalFiles)
    report += fmt.Sprintf("成功: %d\n", s.SuccessCount)
    report += fmt.Sprintf("失敗: %d\n", s.FailureCount)
    report += fmt.Sprintf("総ダウンロードサイズ: %d bytes (%.2f MB)\n",
        s.TotalBytes, float64(s.TotalBytes)/(1024*1024))
    report += fmt.Sprintf("所要時間: %v\n", elapsed)

    if s.SuccessCount > 0 {
        avgSpeed := float64(s.TotalBytes) / elapsed.Seconds()
        report += fmt.Sprintf("平均速度: %.2f KB/s\n", avgSpeed/1024)
    }

    if len(s.FailedURLs) > 0 {
        report += "\n失敗したURL:\n"
        for _, url := range s.FailedURLs {
            report += fmt.Sprintf("  - %s\n", url)
        }
    }

    return report
}

実装すべき内容

  • IncrementTotal: 総ファイル数を増加
  • RecordSuccess: 成功を記録(URL、バイト数)
  • RecordFailure: 失敗を記録
  • GetStats: 統計情報を取得
  • Report: レポートを生成
  • すべてのメソッドでsync.Mutexを使った同期

要件2: ダウンローダーの実装(30点)

並行ダウンロードを実行するワーカーを実装してください。

ファイル: downloader.go

package downloader

import (
    "fmt"
    "io"
    "net/http"
    "os"
    "path/filepath"
    "sync"
    "time"
)

// DownloadTask はダウンロードタスク
type DownloadTask struct {
    URL      string
    FileName string
}

// DownloadResult はダウンロード結果
type DownloadResult struct {
    Task     DownloadTask
    Success  bool
    ByteSize int64
    Error    error
    Duration time.Duration
}

// Downloader は並行ダウンローダー
type Downloader struct {
    OutputDir   string
    MaxWorkers  int
    Timeout     time.Duration
    Stats       *DownloadStats
}

// NewDownloader は新しいダウンローダーを作成
func NewDownloader(outputDir string, maxWorkers int, timeout time.Duration) *Downloader {
    return &Downloader{
        OutputDir:  outputDir,
        MaxWorkers: maxWorkers,
        Timeout:    timeout,
        Stats:      NewDownloadStats(),
    }
}

// DownloadFile は単一ファイルをダウンロード
func (d *Downloader) DownloadFile(task DownloadTask) DownloadResult {
    start := time.Now()

    result := DownloadResult{
        Task:    task,
        Success: false,
    }

    // HTTPクライアントの作成
    client := &http.Client{
        Timeout: d.Timeout,
    }

    // HTTPリクエスト
    resp, err := client.Get(task.URL)
    if err != nil {
        result.Error = fmt.Errorf("HTTPリクエスト失敗: %w", err)
        result.Duration = time.Since(start)
        return result
    }
    defer resp.Body.Close()

    // ステータスコードチェック
    if resp.StatusCode != http.StatusOK {
        result.Error = fmt.Errorf("HTTPステータスエラー: %d", resp.StatusCode)
        result.Duration = time.Since(start)
        return result
    }

    // 出力ファイルの作成
    outputPath := filepath.Join(d.OutputDir, task.FileName)
    outFile, err := os.Create(outputPath)
    if err != nil {
        result.Error = fmt.Errorf("ファイル作成失敗: %w", err)
        result.Duration = time.Since(start)
        return result
    }
    defer outFile.Close()

    // ダウンロード
    bytesWritten, err := io.Copy(outFile, resp.Body)
    if err != nil {
        result.Error = fmt.Errorf("ファイル書き込み失敗: %w", err)
        result.Duration = time.Since(start)
        return result
    }

    result.Success = true
    result.ByteSize = bytesWritten
    result.Duration = time.Since(start)

    return result
}

// worker はダウンロードワーカー
func (d *Downloader) worker(id int, tasks <-chan DownloadTask, results chan<- DownloadResult, wg *sync.WaitGroup) {
    defer wg.Done()

    for task := range tasks {
        fmt.Printf("Worker %d: %s をダウンロード中...\n", id, task.FileName)

        result := d.DownloadFile(task)

        if result.Success {
            fmt.Printf("Worker %d: %s 完了 (%d bytes, %v)\n",
                id, task.FileName, result.ByteSize, result.Duration)
            d.Stats.RecordSuccess(task.URL, result.ByteSize)
        } else {
            fmt.Printf("Worker %d: %s 失敗 - %v\n",
                id, task.FileName, result.Error)
            d.Stats.RecordFailure(task.URL)
        }

        results <- result
    }
}

// DownloadAll はすべてのタスクを並行ダウンロード
func (d *Downloader) DownloadAll(tasks []DownloadTask) []DownloadResult {
    // 出力ディレクトリの作成
    if err := os.MkdirAll(d.OutputDir, 0755); err != nil {
        fmt.Printf("出力ディレクトリ作成失敗: %v\n", err)
        return nil
    }

    // チャネルの作成
    taskChannel := make(chan DownloadTask, len(tasks))
    resultChannel := make(chan DownloadResult, len(tasks))

    var wg sync.WaitGroup

    // ワーカーの起動
    for i := 1; i <= d.MaxWorkers; i++ {
        wg.Add(1)
        go d.worker(i, taskChannel, resultChannel, &wg)
    }

    // タスクの送信
    for _, task := range tasks {
        d.Stats.IncrementTotal()
        taskChannel <- task
    }
    close(taskChannel)

    // 結果収集用goroutine
    go func() {
        wg.Wait()
        close(resultChannel)
    }()

    // 結果の収集
    results := make([]DownloadResult, 0, len(tasks))
    for result := range resultChannel {
        results = append(results, result)
    }

    return results
}

実装すべき内容

  • DownloadFile: 単一ファイルのダウンロード
  • worker: ワーカーgoroutine
  • DownloadAll: 並行ダウンロードの調整
  • エラー処理
  • 統計情報の更新

要件3: プログレス表示(20点)

ダウンロードの進行状況を表示する機能を実装してください。

ファイル: progress.go

package downloader

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

// ProgressMonitor は進行状況を監視
type ProgressMonitor struct {
    stats    *DownloadStats
    interval time.Duration
    stop     chan struct{}
    wg       sync.WaitGroup
}

// NewProgressMonitor は新しいモニターを作成
func NewProgressMonitor(stats *DownloadStats, interval time.Duration) *ProgressMonitor {
    return &ProgressMonitor{
        stats:    stats,
        interval: interval,
        stop:     make(chan struct{}),
    }
}

// Start は監視を開始
func (pm *ProgressMonitor) Start() {
    pm.wg.Add(1)
    go pm.monitor()
}

// Stop は監視を停止
func (pm *ProgressMonitor) Stop() {
    close(pm.stop)
    pm.wg.Wait()
}

// monitor は定期的に進行状況を表示
func (pm *ProgressMonitor) monitor() {
    defer pm.wg.Done()

    ticker := time.NewTicker(pm.interval)
    defer ticker.Stop()

    for {
        select {
        case <-ticker.C:
            pm.displayProgress()
        case <-pm.stop:
            return
        }
    }
}

// displayProgress は進行状況を表示
func (pm *ProgressMonitor) displayProgress() {
    total, success, failure, bytes, elapsed := pm.stats.GetStats()

    if total == 0 {
        return
    }

    completed := success + failure
    percentage := float64(completed) / float64(total) * 100

    fmt.Printf("\r進行状況: %d/%d (%.1f%%) | 成功: %d | 失敗: %d | %.2f MB | %v",
        completed, total, percentage, success, failure,
        float64(bytes)/(1024*1024), elapsed)
}

実装すべき内容

  • Start: 監視開始
  • Stop: 監視停止
  • monitor: 定期的な進行状況表示
  • displayProgress: 進行状況のフォーマット

要件4: メインプログラム(10点)

実装した機能を統合するメインプログラムを作成してください。

ファイル: main.go

package main

import (
    "fmt"
    "time"

    "yourmodule/downloader"
)

func main() {
    // ダウンロードタスクのリスト
    tasks := []downloader.DownloadTask{
        {URL: "https://golang.org/doc/gopher/frontpage.png", FileName: "gopher1.png"},
        {URL: "https://golang.org/doc/gopher/doc.png", FileName: "gopher2.png"},
        {URL: "https://golang.org/doc/gopher/pkg.png", FileName: "gopher3.png"},
        {URL: "https://golang.org/doc/gopher/project.png", FileName: "gopher4.png"},
        {URL: "https://golang.org/doc/gopher/ref.png", FileName: "gopher5.png"},
    }

    // ダウンローダーの作成
    dl := downloader.NewDownloader(
        "./downloads",  // 出力ディレクトリ
        3,              // 並行ワーカー数
        30*time.Second, // タイムアウト
    )

    // プログレスモニターの起動
    monitor := downloader.NewProgressMonitor(dl.Stats, 500*time.Millisecond)
    monitor.Start()

    // ダウンロード実行
    fmt.Println("=== ダウンロード開始 ===")
    results := dl.DownloadAll(tasks)

    // モニター停止
    monitor.Stop()

    // 結果の表示
    fmt.Println("\n\n=== 結果 ===")
    for _, result := range results {
        if result.Success {
            fmt.Printf("✓ %s - %d bytes (%v)\n",
                result.Task.FileName, result.ByteSize, result.Duration)
        } else {
            fmt.Printf("✗ %s - %v\n",
                result.Task.FileName, result.Error)
        }
    }

    // 統計レポート
    fmt.Println(dl.Stats.Report())
}

実装すべき内容

  • ダウンロードタスクの定義
  • ダウンローダーの作成と設定
  • プログレスモニターの起動/停止
  • 結果の表示
  • 統計レポートの出力
  • 期待される出力

    === ダウンロード開始 ===
    Worker 1: gopher1.png をダウンロード中...
    Worker 2: gopher2.png をダウンロード中...
    Worker 3: gopher3.png をダウンロード中...
    進行状況: 3/5 (60.0%) | 成功: 3 | 失敗: 0 | 0.25 MB | 1.2s
    Worker 1: gopher1.png 完了 (85234 bytes, 850ms)
    Worker 1: gopher4.png をダウンロード中...
    Worker 2: gopher2.png 完了 (72156 bytes, 920ms)
    Worker 2: gopher5.png をダウンロード中...
    進行状況: 5/5 (100.0%) | 成功: 5 | 失敗: 0 | 0.45 MB | 2.1s
    
    === 結果 ===
    ✓ gopher1.png - 85234 bytes (850ms)
    ✓ gopher2.png - 72156 bytes (920ms)
    ✓ gopher3.png - 91024 bytes (1.1s)
    ✓ gopher4.png - 88765 bytes (780ms)
    ✓ gopher5.png - 95432 bytes (890ms)
    
    === ダウンロードレポート ===
    総ファイル数: 5
    成功: 5
    失敗: 0
    総ダウンロードサイズ: 432611 bytes (0.41 MB)
    所要時間: 2.134s
    平均速度: 197.83 KB/s
    

    ボーナス課題

    > ボーナス: これらはオプションです。マンダトリー部分が完了してから取り組んでください。

    ボーナス1: リトライ機能(10点)

    失敗したダウンロードを自動的にリトライする機能を追加してください。

    type RetryConfig struct {
        MaxRetries int
        Delay      time.Duration
    }
    
    func (d *Downloader) DownloadWithRetry(task DownloadTask, config RetryConfig) DownloadResult {
        var result DownloadResult
    
        for attempt := 1; attempt <= config.MaxRetries; attempt++ {
            result = d.DownloadFile(task)
    
            if result.Success {
                return result
            }
    
            fmt.Printf("リトライ %d/%d: %s\n", attempt, config.MaxRetries, task.FileName)
            time.Sleep(config.Delay)
        }
    
        return result
    }
    

    ボーナス2: レート制限(5点)

    ダウンロード速度を制限する機能を実装してください。

    type RateLimiter struct {
        rate     int64 // bytes per second
        interval time.Duration
        tokens   int64
        mu       sync.Mutex
    }
    
    func NewRateLimiter(bytesPerSecond int64) *RateLimiter
    func (rl *RateLimiter) Wait(bytes int64)
    

    ボーナス3: レジューム機能(5点)

    中断されたダウンロードを再開する機能を追加してください。

    func (d *Downloader) ResumeDownload(task DownloadTask) DownloadResult {
        // 既存ファイルのサイズを取得
        outputPath := filepath.Join(d.OutputDir, task.FileName)
        fileInfo, err := os.Stat(outputPath)
        var offset int64 = 0
    
        if err == nil {
            offset = fileInfo.Size()
            fmt.Printf("%s: %d bytesから再開\n", task.FileName, offset)
        }
    
        // Range ヘッダーを使ってリクエスト
        req, _ := http.NewRequest("GET", task.URL, nil)
        if offset > 0 {
            req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
        }
    
        // ダウンロード続行...
    }
    

    評価基準

    項目 配点 詳細
    統計管理 20点 並行安全な統計情報の管理ができている
    ダウンローダー 30点 並行ダウンロードが正しく動作する
    プログレス表示 20点 リアルタイムで進行状況を表示できている
    メインプログラム 10点 全機能を統合して動作している
    コード品質 20点 エラー処理、同期、リソース管理が適切
    **ボーナス1** 10点 リトライ機能が正しく動作する
    **ボーナス2** 5点 レート制限が実装されている
    **ボーナス3** 5点 レジューム機能が動作する

    提出方法

    以下のファイルを提出してください:

    submission/
    ├── go.mod
    ├── stats.go           # 統計管理
    ├── downloader.go      # ダウンローダー
    ├── progress.go        # プログレス表示
    ├── main.go            # メインプログラム
    └── bonus/            # ボーナス課題(オプション)
        ├── retry.go      # リトライ機能
        ├── ratelimit.go  # レート制限
        └── resume.go     # レジューム機能
    

    ヒント

  • Mutex: 共有データへのアクセスは必ずロック
  • defer: mu.Unlock()wg.Done()は必ずdefer
  • チャネル: バッファサイズを適切に設定
  • エラー処理: ネットワークエラーは頻繁に発生する
  • テスト: go run -raceでレースコンディションを検出
  • 学習リソース

  • Go Concurrency Patterns
  • Effective Go - Concurrency
  • sync package documentation
  • net/http package documentation