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

Где и зачем использовать итераторы в 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:

https://t.me/digest_golang

И авторский телеграм-канал, куда пишу различные заметки, инструменты и делюсь опытом:

https://t.me/junsenior

Subscribe to vpoltora

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe