Где и зачем использовать итераторы в Go?

Итераторы - это инструмент языка, позволяющий перебирать элементы коллекции или потока данных без необходимости загружать все сразу в память. В Go их завезли еще в прошлом году, и это вызвало большой резонанс: многим эти нововведения не понравились, разработчики не понимали зачем они нужны и где их использовать, но самая частая претензия, которую я слышал - итераторы усложнили язык еще больше, а простота Go - это фундамент философии языка.
С момента релиза я, как и многие, так ни разу в реальной работе итераторы не применил: просто все мои задачи решались привычными инструментами, и нигде не возникало какой-то проблемы, которая решалась бы только с их использованием.
В этой статье я решил разобраться где я могу применить итераторы в реальной работе. Я просмотрел множество статей, отзывов разработчиков, спросил на reddit и в итоге собрал список кейсов, где итераторы действительно могут быть полезны. Часть из них я уже сумел заиспользовать в своих рабочих проектах и это оказалось и правда удобно.
Все кейсы использования логически можно разбить на несколько больших групп.
Ленивое чтение данных
Когда мы говорим про использование итераторов при работе с большим набором данных - это, прежде всего, про ленивое чтение. Например, читая из файла или из базы, где несколько миллионов строк, мы не хотим выгружать все эти строки в память, мы хотим читать их построчно и обрабатывать последовательно.
Самый часто встречающийся в работе кейс с ленивым чтением - это чтение из базы, поэтому давай рассмотрим, как к нему можно применить итератор и почему это может быть удобно.
Построчное чтение из базы с дополнительной обработкой строк
Хотим мы этого или нет, но часто в реляционных базах приходится хранить данные в json или каких либо других форматах, для чего у большинства современных баз даже есть специальные типы.
Представим, что мы работаем с postgres и решили хранить данные в формате cbor - формат, аналогичный json, но хранящийся в бинарном виде, более эффективный по размеру и скорости. Мы хотим читать данные из файла и сразу анмаршалить данные из cbor в go-структуру.
Классический способ прочитать данные из базы последовательно и без выгрузки в память всех строк - использовать метод Next()
типа sql.Rows
из стандартного пакета "database/sql"
:
rows, err = db.Query("select data from users_serialized")
if err != nil {
panic(err)
}
defer rows.Close()
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
return
}
var user User
if err := cbor.Unmarshal(raw, &user); err != nil {
return
}
fmt.Printf("User: %+v\n", user)
}
Мы можем вынести этот код в репозиторий и вызывать его там, где нужно. Но если где-то в одном месте нам потребуется дополнительно обработать структуру User (например, добавится еще одно поле, но уже в формате json, которое мы тоже захотим заанмаршалить), нужно будет или использовать этот метод, дождаться выгрузки всех пользователей в память, а затем отфильтровать их еще раз, или написать еще один метод уже с дополнительной логикой.
Альтернативно, можно сделать тоже самое с использованием итераторов:
func UsersFromRows(rows *sql.Rows) iter.Seq[User] {
return func(yield func(User) bool) {
defer rows.Close()
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
return
}
var user User
if err := cbor.Unmarshal(raw, &user); err != nil {
return
}
if !yield(user) {
return
}
}
}
}
...
rows, err := db.Query("select data from users_serialized")
if err != nil {
panic(err)
}
for user := range UsersFromRows(rows) {
fmt.Printf("User: %+v\n", user)
}
В этом случае мы можем переиспользовать итерируемый метод UsersFromRows()
, и обрабатывать каждого пользователя так, как посчитаем нужным без выгрузки всех пользователей в память.
Код выше выглядит удобным, но в нем не хватает одной важной детали - обработки ошибок. Итераторы не умеют возообновлять работу после прерывания и не умеют передавать ошибки наружу. Поэтому, нам в любом случае придется усложнять логику, чтобы добавить эту возможность. Самый очевидный способ это сделать - положить ошибку рядом с возвращаемым значением:
type User struct {
ID int
Name string
Err error
}
func UsersFromRows(rows *sql.Rows) iter.Seq[User] {
return func(yield func(User) bool) {
defer rows.Close()
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
yield(User{Err: err})
return
}
var user User
if err := cbor.Unmarshal(raw, &user); err != nil {
yield(User{Err: err})
return
}
if !yield(user) {
return
}
}
}
}
...
rows, err := db.Query("select data from users_serialized")
if err != nil {
panic(err)
}
for user := range UsersFromRows(rows) {
if user.Err != nil {
// ...
}
fmt.Printf("User: %+v\n", user)
}
Работа с API, предоставляющим пагинацию
Еще одна группа - это API с пагинацией. В случае пагинации нам не нужно использовать итераторы для экономии памяти - API и так возвращает нам фиксированное количество элементов на страницу. В этом случае итераторы - это инструмент, инкапсулирующий логику перебора страниц.
Хороший пример такого API с пагинацией привел пользователь reddit в комментариях под моим постом с вопросом, используют ли разработчики итераторы:
I have wrapped the various AWS paginated APIs with great results.
Давай рассмотрим реальный пример использования итераторов для обработки пагинации API AWS.
Постраничное чтение файлов с пагинацией из AWS S3
type Object struct {
Value types.Object
Err error
}
func S3Objects(ctx context.Context, client *s3.Client, bucket string) iter.Seq[Object] {
return func(yield func(Object) bool) {
var token *string
for {
resp, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
Bucket: &bucket,
ContinuationToken: token,
})
if err != nil {
yield(Object{Err: err})
return
}
for _, obj := range resp.Contents {
if !yield(Object{Value: obj}) {
return
}
}
if resp.IsTruncated != nil && !*resp.IsTruncated {
break
}
token = resp.NextContinuationToken
}
}
}
func main() {
ctx := context.Background()
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
panic(err)
}
client := s3.NewFromConfig(cfg)
bucket := "some-bucket-name"
for obj := range S3Objects(ctx, client, bucket) {
fmt.Printf("Object: %s (%d bytes)\n", *obj.Value.Key, *obj.Value.Size)
}
}
И это, как по мне, действительно удобно. Я сейчас работаю над проектом, у которого множество зависимостей как среди внутренних сервисов, так и среди внешних, и половина из них в своем API использует пагинацию через токены. И итераторы, при грамотном их использовании и при хорошей структуре проекта могут сильно упростить работу с такими API.
Пайплайны данных
Построение пайплайнов - еще одна очень мощная возможность использования итераторов, которую я для себя открыл.
Давай еще раз посмотрим на код который мы написали выше для ленивого чтения из базы:
type User struct {
ID int
Name string
Err error
}
func UsersFromRows(rows *sql.Rows) iter.Seq[User] {
return func(yield func(User) bool) {
defer rows.Close()
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
yield(User{Err: err})
return
}
var user User
if err := cbor.Unmarshal(raw, &user); err != nil {
yield(User{Err: err})
return
}
if !yield(user) {
return
}
}
}
}
...
rows, err := db.Query("select data from users_serialized")
if err != nil {
panic(err)
}
for user := range UsersFromRows(rows) {
if user.Err != nil {
// ...
}
fmt.Printf("User: %+v\n", user)
}
Теперь представим, что этот цикл у нас со временем стал использоваться в десятке мест в проекте, и в некоторых из них появилась дополнительная фильтрация, например, где-то нужно выводить только активных пользователей:
for user := range UsersFromRows(rows) {
if user.Err != nil {
// ...
}
if !user.IsActive {
continue
}
fmt.Printf("User: %+v\n", user)
}
А где-то мы хотим дополнительно ограничить выборку пользователей в 10 элементов:
count := 0
for user := range UsersFromRows(rows) {
if user.Err != nil {
// ...
}
if !user.IsActive {
continue
}
count++
if count >= 10 {
break
}
fmt.Printf("User: %+v\n", user)
}
Как правило, подобных фильтраций со временем будет все больше, и это приведет к тому, что в кодовой базе у нас будет десятки циклов, где часть логики дублируется, а часть - отличается, и у нас не будет возможности вынести ее и переиспользовать.
Решить это можно через пайплайны - цепочку итераторов, где потоковые данные последовательно проходят через набор функций.
Например, мы хотим выводить только активных пользователей, при этом вывести только их имена, и ограничить выборку в 10 элементов. Для фильтрации пользователей по какому-либо параметру мы можем написать дополнительный итератор:
func Filter[T any](seq iter.Seq[T], pred func(T) bool) iter.Seq[T] {
return func(yield func(T) bool) {
seq(func(item T) bool {
if pred(item) {
return yield(item)
}
return true
})
}
}
Для маппинга пользователя к его имени - итератор Map:
func Map[T any, R any](seq iter.Seq[T], transform func(T) R) iter.Seq[R] {
return func(yield func(R) bool) {
seq(func(item T) bool {
return yield(transform(item))
})
}
}
И так же напишем дополнительный итератор для ограничения выборки пользователей:
func Take[T any](seq iter.Seq[T], n int) iter.Seq[T] {
return func(yield func(T) bool) {
count := 0
seq(func(item T) bool {
if count >= n {
return false
}
count++
return yield(item)
})
}
}
И использование будет выглядеть так:
...
activeNames := Take(
Map(
Filter(UsersFromDB(db), func(u User) bool {
return u.Active
}),
func(u User) string {
return u.Name
},
),
10,
)
for name := range activeNames {
fmt.Println("active user:", name)
}
Какие тут плюсы:
- Можно комбинировать итераторы для фильтрации или изменения данных в любом порядке, сокращая дублирования кода
- Лучшее тестирование: нам достаточно покрыть тестами логику итераторов, и затем мы можем тестировать уже весь пайплайн на ожидаемые значения
Выводы
Итераторы - это мощный механизм, и на самом деле достаточно простой, если немного с ним поработать и привыкнуть к нему. Пока я писал эту статью - я протестировал множество примеров применения итераторов, и выделенные выше точно войдут в мой арсенал.
Планирую дописывать эту статью по мере открытия новых интересных кейсов, так что подписывайся :)
Так же я веду англоязычный telegram-канал с дайджестом материалов по Go:
И авторский телеграм-канал, куда пишу различные заметки, инструменты и делюсь опытом: