本文へスキップ
キャスターアプリケーションズ

Goの並列処理で複数の画像APIから画像候補を集めた話

はじめに

英単語をコピーするだけでAnkiカードを自動生成するデスクトップアプリを個人で作っていました。

開発中に「単一の画像APIだけだと、暗記にしっくりくる画像がなかなか取れない」「複数APIを直列で叩くと体感が遅い」という2つの問題にぶつかり、Goの並列処理で解決しました。技術的には特別なことはしていませんが、流れがきれいだったので残しておきます。

alt text

問題

画像取得は最初Pixabayだけで実装していました。が、抽象的な単語ほどヒットが弱く暗記の手がかりになる画像が出てこない。

そこでPexelsも追加することにしました。素朴に直列で書くとこうなります。

for _, kw := range keywords {
    pixabayURLs, _ := pixabay.Search(kw, 5)
    pexelsURLs, _ := pexels.Search(kw, 5)
    allURLs = append(allURLs, pixabayURLs...)
    allURLs = append(allURLs, pexelsURLs...)
}

キーワード3個 × API 2個 = 6回のHTTPリクエストを順番に待つ。1回200msとしても1.2秒。外部APIはレスポンスがブレるので、実際にはもっと待たされることもあります。コピーしてからカードが出るまでの体感としては明らかに遅い。

sequenceDiagram
    participant App
    participant Pixabay
    participant Pexels

    App->>Pixabay: kw1
    Pixabay-->>App: urls
    App->>Pexels: kw1
    Pexels-->>App: urls
    App->>Pixabay: kw2
    Pixabay-->>App: urls
    App->>Pexels: kw2
    Pexels-->>App: urls
    App->>Pixabay: kw3
    Pixabay-->>App: urls
    App->>Pexels: kw3
    Pexels-->>App: urls
    Note over App: 合計 ≒ 6リクエスト分の時間

解決:N × M を全部並列に投げる

「キーワード × API」の全組み合わせを一斉に goroutine で発火して、buffered channel に結果を流し込む構造にしました。

中核部分はこれだけです。

totalJobs := len(validKeywords) * len(a.images)
ch := make(chan result, totalJobs)

// N × M を全部 goroutine で発火
for _, kw := range validKeywords {
    for _, provider := range a.images {
        go func(p imageSearcher, keywords string) {
            urls, err := p.Search(keywords, 5)
            ch <- result{urls, err}
        }(provider, kw)
    }
}

// totalJobs 回だけ受信すれば全結果が揃う
var allURLs []string
for i := 0; i < totalJobs; i++ {
    r := <-ch
    if r.err != nil {
        log.Printf("warning: image search failed: %v", r.err)
        continue
    }
    allURLs = append(allURLs, r.urls...)
}

ポイントは2つ。

1. キーワードはAIに複数生成させる

単語そのもので検索しても良い画像が出ないので、AIに「視覚的に検索しやすいキーワード」を別途生成させています。プロンプトで個数を固定。

image_keywords: MUST be a JSON array of exactly 2 strings,
each 2-3 concrete visual English words from different angles
for photo search.
Example for "integrity": ["handshake agreement", "shield honor"]

word 本体 + AI生成2個 = キーワード3個になります。

2. チャネルのバッファサイズ = ジョブ数

make(chan result, totalJobs) でバッファを totalJobs ぴったりに取り、各 goroutine が送信するのは1回だけ。これでバッファに必ず空きがあるので、送信側はブロックしません。受信側は totalJobs<-ch するだけで全結果を集められる。WaitGroupもMutexも要らないので、コードがフラットに収まります。

flowchart LR
    App[App] -->|go func| G1[kw1 × Pixabay]
    App -->|go func| G2[kw1 × Pexels]
    App -->|go func| G3[kw2 × Pixabay]
    App -->|go func| G4[kw2 × Pexels]
    App -->|go func| G5[kw3 × Pixabay]
    App -->|go func| G6[kw3 × Pexels]

    G1 --> CH[(buffered channel)]
    G2 --> CH
    G3 --> CH
    G4 --> CH
    G5 --> CH
    G6 --> CH

    CH --> Collect[受信して集約]

結果

今の運用値だと、

  • キーワード 3個(word + AI生成2個)
  • プロバイダ 2個(Pixabay + Pexels)
  • 並列 goroutine 数 6

6個のHTTPリクエストを実質1リクエスト分のレイテンシで捌けます。

「直列で書くと遅い問題に対して、Goでは自然にこう書ける」という事例として残しておきます。