🌀

【Go】range over funcをバッチ処理で使ってみた

2024/09/18に公開

はじめに

先日Go1.23がリリースされ、range over funcが正式に導入されましたね!
解説系の記事が色々あって参考にはなりますが、一般的なWebアプリ開発での活かし方がいまいちピンとこず、、
やっとこの間いい感じのユースケースを見つけたので記事にしておきます。
range over funcの基礎知識は前提とします。

要点

range over funcを使うことで、ループの対象とループ内の処理を分離できる。
https://x.com/yagi_eng/status/1834171847287439413

結論

先にコードだけ載せて、細かい背景や解説は後に書きます。
汎用的に使えるようにジェネリクスも使ってます。

func getByPagination[T any](limit int, f func(limit, offset int) ([]T, error)) func(yield func(T, error) bool) {
	return func(yield func(T, error) bool) {
		offset := 0
		for {
			slice, err := f(limit, offset)
			if err != nil {
				var t T
				if !yield(t, err) {
					return
				}
			}
			if len(slice) == 0 {
				return
			}
			for _, v := range slice {
				if !yield(v, nil) {
					return
				}
			}
			offset += limit
		}
	}
}

// findUsers DBからユーザを取得する関数
func findUsers(limit, offset int) ([]*user, error) {
	return nil, nil
}

func main() {
	// 全ユーザを1000件ずつ取得する
	users := getByPagination(1000, findUsers)

	for u, err := range users {
		if err != nil {
			return err
		}
		// userに対してなんかする
		fmt.Println(u)
	}
	return nil
}

背景

DBからユーザを大量に取得して何かするバッチ処理、ってたまにあると思います。
その時に、レコード数が65,535件を超えると以下のようなエラーが出ます。
(環境によるかも。とりあえずGorm/MySQL環境ではでた)

Error 1390 (HY000): Prepared statement contains too many placeholders

【参考】 Import of 50K+ Records in MySQL Gives General error: 1390 Prepared statement contains too many placeholders

なので大量に取得したい時は、この数値を超えないように取得する必要があります。
(そもそも大量取得はメモリ使用量やクエリパフォーマンス的にも良くないですが)

そのため、range over func以前は以下のように記述していました。

func findUsers(limit, offset int) ([]*user, error) {
	// DBから全ユーザを取得する
	return nil, nil
}

func main() {
	limit := 1000
	offset := 0
	for {
		users, err := findUsers(limit, offset)
		if err != nil {
			return err
		}
		if len(users) == 0 {
			break
		}
		for _, u := range users {
			// userに対してなんかする
			fmt.Println(u)
		}
		offset += limit
	}
	return nil
}

特段問題ないコードではありますが、for文の中でループ対象の取得とその対象への処理を書いていて、若干気持ち悪いコードになってました。
(実はrange over funcまではそんなに気持ち悪いとも思ってなかったけど。。)

しかし、range over funcが上述のように解消してくれました!すごい!便利!

解説

getByPaginationのシグネチャがごちゃっとしているので、ジェネリクスなしで書くと以下の通りになります。
よりループの対象とループ内の処理が分離されてる感ありますね。

func main() {
	users := func(yield func(*user, error) bool) {
		limit := 1000
		offset := 0
		for {
			users, err := findUsers(limit, offset)
			if err != nil {
				if !yield(0, err) {
					return
				}
			}
			if len(users) == 0 {
				return
			}
			for _, u := range users {
				if !yield(u, nil) {
					return
				}
			}
			offset += limit
		}
	}

	for u, err := range users {
		if err != nil {
			return err
		}
		// userに対してなんかする
		fmt.Println(u)
	}
}

車輪の再発明だった

記事書いてる途中に、Gormで一括取得用のメソッド用意されてるんじゃね?って思ったら、やっぱり用意されてた、、
https://gorm.io/docs/advanced_query.html#FindInBatches

// Processing records in batches of 100
result := db.Where("processed = ?", false).FindInBatches(&results, 100, func(tx *gorm.DB, batch int) error {
  for _, result := range results {
    // Operations on each record in the batch
  }

  // Save changes to the records in the current batch
  tx.Save(&results)

  // tx.RowsAffected provides the count of records in the current batch
  // The variable 'batch' indicates the current batch number

  // Returning an error will stop further batch processing
  return nil
})

でもこの感じだと、レイヤードアーキテクチャ的な構成をとっている場合、repository層に(func(tx *gorm.DB, batch int) error内に)、usecase層的なロジックが流出することになるかも?

、、とにかく、range over funcを実践的に使える経験ができたのでよし!

参考

https://zenn.dev/foxtail88/articles/range-over-func-beginner
https://future-architect.github.io/articles/20240718a/

さいごに

Xの方でも、モダンな技術習得やサービス開発の様子を発信したりしているので良かったらチェックしてみてください!

https://x.com/yagi_eng/status/1834171847287439413

Discussion