🤖

昔コードのりファクターに大活躍のGoのIteratorの話

2024/09/23に公開

はじめに

最近、昔作ったGoプログラムを整理していました。その中に、Go言語を初めて触ったときに作成したSNSスクレイピングツールがありました。Issueを確認すると、以下のような記述がありました:

スクレイピングした投稿データを一括でJSONファイルに保存するのではなく、一件ずつJSONファイルに追加するようにする。

かなり前に書いたものだったので、このIssueの存在自体を忘れていました。最近リリースされたGo 1.23に追加されたIteratorを使えば、この機能を簡単に実装できるのではないかと考え、実際に実装してみました。本記事では、その実装内容について紹介させていただきます。

Issueの背景

そもそも、上記のIssueを記載した理由は、すべての対象投稿をスクレイピングし終わるまでJSONファイルへの保存が行われないことにありました。途中で何らかの理由でプログラムが終了してしまった場合、それまでにスクレイピングした投稿データがすべて失われてしまう可能性がありました。

また、スクレイピングされたデータ量が増えるほど、メモリの消費量も比例して増加してしまうという問題もありました。これら2つの問題を回避するために、投稿をスクレイピングした直後にファイルに保存することを目的としていました。

実装

改善前

まずは、改善前のソースコードをご紹介します。

func main() {
    posts, err := ScrapePosts()
    if err != nil {
        log.Fatalf(err)
    }
    if ExportToJSON(path, posts); err != nil {
        log.Fatalf(err)
    }
}

// ScrapePosts ユーザー投稿をスクレイピングする
func ScrapePosts() ([]Post, error) {
    var posts []Post
    // ...
    // スクレイピングした投稿データをPost配列に追加
    posts = append(posts, post)
    // ...
    return posts, nil
}

// ExportToJSON スクレイピングしたユーザー投稿をJSONファイルに書き込む
func ExportToJSON(path string, posts []Post) error {
    postjson, err := json.Marshal(posts)
    if err != nil {
        return err
    }
    return os.WriteFile(path, postjson, 0644)
}

改善後

GoのIteratorを活用して、先ほどのソースコードを以下のように書き換えました。また、投稿データの保存形式をJSONファイルからJSONLファイルに変更しました。

func main() {
    if ExportToJSON(path, ScrapePosts()); err != nil {
        log.Fatalf(err)
    }
}

// ScrapePosts ユーザー投稿をスクレイピングする
func ScrapePosts() iter.Seq2[Post, error] {
    return func(yield func(Post, error) bool) {
        // ...
        // スクレイピングした投稿データをyieldのパラメーターに渡す
        if !yield(post, nil) {
          return
        }
        // ...
    }
}

// ExportToJSONL スクレイピングしたユーザー投稿をJSONLファイルに書き込む
func ExportToJSONL(path string, posts iter.Seq2[Post, error]) error {
    f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
    if err != nil {
    	  return err
    }
    defer f.Close()
    enc := json.NewEncoder(f)
    // スクレイピング処理が続く限り
    // 投稿データはどんどんJSONLファイルに追加する
    for post, err := range posts {
        if err != nil {
            return err
        }
    	  if err := enc.Encode(post); err != nil {
    		    return err
    	  }
    }
    return nil
}

JSONLとは

ChatGPTによると、JSON Lines(またはJSONL、newline-delimited JSON、NDJSONとも呼ばれる)は、各行に1つの有効なJSONオブジェクトを含む形式であり、構造化データをエンコードするためのフォーマットです。

{"id": 1, "title": "title1", "msg": "msg1"}
{"id": 2, "title": "title2", "msg": "msg2"}
{"id": 3, "title": "title3", "msg": "msg3"}

改善前後のメモリ費用

Goのベンチマークツールを使用して、改善前後のヒープアロケーションを比較しました。その結果、改善後にヒープアロケーションが大幅に減少したことが確認できました。なお、ヒープアロケーションは主に投稿データの取得とJSON形式への変換処理で発生しています。

BenchmarkNoIter-8         421894              2468 ns/op            1752 B/op          7 allocs/op
BenchmarkWithIter-8       340784              3402 ns/op             656 B/op         15 allocs/op

メモリプロファイルを使用して、改善前後の詳細な比較を行いました。

改善前

改善前のヒープアロケーションは1GBを超えていました。

改善後

一方、改善後は約200MBにまで抑えることができました。

まとめ

Iteratorが Go に導入されると発表されたとき、批判的な意見も多く見られました。しかし、個人的にはIteratorの導入は良い決断だったと考えています。

皆さんはどのようにお感じでしょうか?

また、実際にどのような場面で Go の Iterator を使用されていますか?ご経験やご意見をお聞かせいただければ幸いです。

Discussion