A Tour of Go - Exercise: Web Crawler をやってみた

冬休みの間にGoの概要を知るためにA Tour of Goをやってみた。Exercise: Web Crawlerにちょっと苦戦したので記録として残しておく。

V1:とりあえずCache機能作る

Cacheを作成するとこまでを目標に実装。Mutexを扱うことを想定して、結果とMutexを同じ場所で管理できるようにstruct Crawlerを作っておいた。CrawlもCrawlerのインスタンスメソッド化。

package main

import (
    "fmt"
    // "sync"
)

type Fetcher interface {
    Fetch(url string) (body string, urls []string, err error)
}

type Crawler struct {
    // m sync.Mutex
    result map[string]string
}

func (c *Crawler) Crawl(url string, depth int, fetcher Fetcher) {

    if depth <= 0 {
        return
    }
 
    if c.result[url] != "" {
        return
    }
 
    body, urls, err := fetcher.Fetch(url)

    if err != nil {
        c.result[url] = err.Error()
        fmt.Println(err)
        return
    }

    c.result[url] = body
    fmt.Printf("found: %s %q\n", url, body)

    for _, u := range urls {
        c.Crawl(u, depth-1, fetcher)
    }
    return
}

func main() {
    c := Crawler{ result: make(map[string]string) }
    c.Crawl("https://golang.org/", 4, fetcher)
}

V2: go routine + sync.Mutex で非同期化

非同期化したものMutexだけじゃ処理が終わるのを待ってくれない。いまいちな方法だがとりあえずtime.Sleepで処理が終わるのを待たせてみる。チュートリアルの流れ的にMutex使う方法かなと思っていたけどそう単純にはいかなかった。

package main

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

type Fetcher interface {
    Fetch(url string) (body string, urls []string, err error)
}

type Crawler struct {
    m sync.Mutex
    result map[string]string
}

func (c *Crawler) Crawl(url string, depth int, fetcher Fetcher) {

    if depth <= 0 {
        return
    }
 
    if c.result[url] != "" {
        return
    }
 
    c.m.Lock()
    body, urls, err := fetcher.Fetch(url)
    c.m.Unlock()

    if err != nil {
        c.result[url] = err.Error()
        fmt.Println(err)
        return
    }

    c.result[url] = body
    fmt.Printf("found: %s %q\n", url, body)

    for _, u := range urls {
        go c.Crawl(u, depth-1, fetcher)
    }
    return
}

func main() {
    c := Crawler{ result: make(map[string]string) }
    go c.Crawl("https://golang.org/", 4, fetcher)
 
    time.Sleep(1000)
    c.m.Lock()
    fmt.Printf("Cache: %s\n", c.result)
    c.m.Unlock()
}

V3: time.Sleepを使わないように修正

別の方法としてchannelを使う、JavaScriptでいうPromise .allのようなものを探すなどがありそうか。channel使うとデータの受け渡しをする方向に修正が必要か。そう考えるとPromise .all的なものを探して使ってみた。(V2の後にfetcherをstruct Crawlerに入れる修正してた)

import (
    "fmt"
    "sync"
)

type Fetcher interface {
    Fetch(url string) (body string, urls []string, err error)
}

type Crawler struct {
    wg      *sync.WaitGroup
    result  map[string]string
    fetcher Fetcher
}

func (c *Crawler) Crawl(url string, depth int) {
    if depth <= 0 {
        return
    }

    if c.result[url] != "" {
        return
    }

    body, urls, err := c.fetcher.Fetch(url)

    if err != nil {
        c.result[url] = err.Error()
        fmt.Println(err)
        return
    }

    c.result[url] = body
    fmt.Printf("found: %s %q\n", url, body)

    c.wg.Add(len(urls))
    for _, u := range urls {
        go func(u string) {
            defer c.wg.Done()
            c.Crawl(u, depth-1)
        }(u)
    }
    return
}

func main() {
    c := Crawler{
        result:  make(map[string]string),
        wg:      &sync.WaitGroup{},
        fetcher: fetcher,
    }
    c.Crawl("https://golang.org/", 4)
    c.wg.Wait()
}

V3 おまけ

やっていく中で本題とは違うところで引っ掛かった。クロージャ + range + goroutineを使うと挙動が想定と違った。以下のように単純化してループを作成して挙動を確認してみる。

urls := [2]string{
    "https://golang.org/cmd/",
    "https://golang.org/pkg/",
}

1個目のコードでは range urls の初回の結果がバインドされ、ループ2回目も同じ値が入っている。なのでこのようなケースでは引数として値を渡す必要がある。

for _, u := range urls {
        go func() {
            fmt.Printf("url = %v\n", u)
        }()
    }
    time.Sleep(1000)
}

// url = https://golang.org/pkg/
// url = https://golang.org/pkg/
for _, u := range urls {
        go func(u string) {
            fmt.Printf("url = %v\n", u)
        }(u)
    }
    time.Sleep(1000)
}

// url = https://golang.org/cmd/
// url = https://golang.org/pkg/

goroutineを使わない場合はレキシカルな評価をしてくれる。

for _, u := range urls {
        func() {
            fmt.Printf("url = %v\n", u)
        }()
    }
}

// url = https://golang.org/cmd/
// url = https://golang.org/pkg/
for _, u := range urls {
        func(u string) {
            fmt.Printf("url = %v\n", u)
        }(u)
    }
}

// url = https://golang.org/cmd/
// url = https://golang.org/pkg/

こちらのサンプルコードが、自分の方針と同じ方針だったのは励みと参考になってよかった。

A Tour of Go の Exercise: Web Crawler を書いてみた - Qiita

syncパッケージは奥深そう。アプリケーションレベルではMap, Onceあたりが利用機会ありそうな予感がする。

sync

sync.Condはうまく使うとコードがシンプルにできるのは良さそうだな。

sync.Cond/コンディション変数についての解説

まとめ

冬休み中にA Tour of Goを完走できた!前半はサクサク進めれたのでGo簡単じゃんと思ってたけど並列処理周辺は比較すると理解に時間が必要だった。あくまで前半よりという話なので、シンプルにかつ使いやすい感じに作られているんだろうな。ちょうどいい難易度のExerciseを適宜挟んで理解を促進してくれたのも助かった。

この先は何をやろうかな。