handbookНаписать

patterns/behavioral/iterator/practice/README.md · практика · 4 языка · 20 тестов

база: Итератор

Практика: постраничный обход API заказов (Paginated iterator) ​

Итератор прячет пагинацию внешнего API: клиент видит один длинный поток заказов, а следующая страница запрашивается, только когда до неё дошли.

Базовый пример: Итератор. Там дерево целиком лежит в памяти, и ленивость обхода ничего не экономит. Здесь каждый шаг за границу страницы — сетевой запрос, и вопросы другие: когда именно запрашивать, что будет при раннем выходе и что делать с ошибкой на середине.

Задача ​

Сервис выгрузки забирает заказы из API маркетплейса. API отдаёт их страницами по курсору: в ответе заказы и nextCursor, который надо передать в следующий запрос; на последней странице курсора нет. Первая версия выгрузки собирала все страницы в массив и только потом обрабатывала. На большом магазине это десятки тысяч заказов в памяти, а отчёт «последние 20 заказов» ждал, пока скачаются все страницы, и тратил лимит запросов к API.

Цикл по курсору при этом повторялся в трёх местах: в выгрузке, в отчёте и в сверке остатков. В одном из них забыли проверить последнюю страницу, и цикл зациклился на пустом курсоре.

Что урезано ​

  • API — заглушка в памяти: словарь «курсор → страница» и счётчик запросов. В проекте это HTTP-клиент с авторизацией.
  • Нет повторов при сбое, пауз при лимите запросов (429) и таймаутов: ошибка страницы сразу заканчивает обход.
  • Нет контекста отмены (context.Context, AbortSignal): ранний выход — только break.
  • Заказ — два поля, без статусов, позиций и валют.

Решение ​

Контракт описывает, что умеет API: fetchPage(cursor) возвращает Page — заказы и курсор следующей страницы. Фейковый API FakeOrdersApi отдаёт три страницы (2 + 2 + 1 заказ) и считает запросы, чтобы тесты видели каждое обращение.

Коллекция PagedOrders — та же роль, что CategoryTree в базовом примере: клиент проходит её циклом и не знает, как она устроена. Внутри генератор: запросить страницу, отдать её заказы по одному, взять курсор, повторить. Запрос следующей страницы стоит в коде после последнего yield текущей, поэтому выполняется, только когда клиент попросил следующий заказ. Создание PagedOrders не делает ни одного запроса: генератор начинает работать при первом шаге цикла.

Ошибка API на середине оборачивается в PageFetchFailed (в Python и TS — PageFetchError, в Go — обёртка %w) с курсором страницы: из сообщения видно, на какой странице оборвалось. Уже отданные заказы клиент получил, и что с ними делать, решает он.

Структура ​

fetches pages

«interface»

OrdersApi

+fetchPage(cursor) : Page

Page

+orders: Order[]

+nextCursor: string

Order

+id: string

+totalCents: int

FakeOrdersApi

+requests: int

-pages: map

-failOn: string

+fetchPage(cursor) : Page

PagedOrders

-api: OrdersApi

+iterate() : Order

Контракт — всё, что видит коллекция: интерфейс API, страница, заказ и две ошибки. ApiUnavailable бросает сам API, PageFetchFailed / PageFetchError — коллекция, добавляя к причине курсор. В Go вместо классов сентинел ErrUnavailable и обёртка через %w. Первая страница запрашивается без курсора (null, в Go — пустая строка), отсутствие nextCursor значит последнюю страницу.

Фейковый API ​

Заглушка — главный инструмент тестов. Она считает каждый вызов fetchPage в requests, и тест проверяет не только какие заказы получены, но и сколько запросов на это ушло. Параметр failOn заставляет её упасть на заданном курсоре: так ошибку на середине обхода можно воспроизвести без сети и без случайности.

Как это собирается ​

Клиент API создаётся один раз в контейнере сервиса, а PagedOrders - там, где нужен обход: в обработчике страницы или в задаче по расписанию. В примере два потребителя одного API. Виджет на дашборде берёт первые три заказа и выходит из цикла: страница p3 не запрашивается, и на всё уходит 2 запроса. Ночной отчёт проходит заказы до конца и суммирует их.

Во втором случае API падает на странице p3. Отчёт ловит PageFetchFailed, но уже посчитанные четыре заказа не теряет: в результат попадает частичная сумма и текст ошибки с курсором страницы, с которой задачу можно перезапустить. В приложении клиент один, а сбой эмулирует второй фейковый API с failOn.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

Коллекция в каждом языке — генератор, но форма ошибки и ожидания разная. Синхронные PHP, Go и Python просто блокируются на запросе; в TypeScript сеть асинхронна, и коллекция становится асинхронным итератором, по которому идут for await.

ЯзыкОсобенность
PHPIteratorAggregate с генератором; ошибка — PageFetchFailed с исходной в previous
Goiter.Seq2[Order, error]: ошибка приходит вторым значением в range, после неё обход заканчивается
Python__iter__ — генератор с yield from page.orders; ошибка — PageFetchError через raise ... from e; ранний выход в тестах — itertools.islice
TypeScriptasync *[Symbol.asyncIterator] и for await; ошибка — PageFetchError с причиной в { cause }

Запуск: make test T=patterns/behavioral/iterator/practice

Что проверяют тесты ​

Тесты одинаковы во всех языках: API с заказами A-1...A-5 на трёх страницах (2 + 2 + 1), клиент берёт первые N заказов и останавливается. Таблица случаев считает запросы. Ничего не прочитали — ноль запросов: создание коллекции в сеть не ходит. Два заказа — один запрос: вторая страница не нужна, и её не просят, хотя первая кончилась. Три заказа — два запроса: третья страница не загружена. Все заказы — три запроса, ровно по одному на страницу. Отдельный тест — ошибка на второй странице: клиент успел получить A-1 и A-2, затем пришла ошибка fetch orders page p2: service unavailable с исходной причиной внутри, запросов было два.

Где граница ​

  • Повторный проход — снова в сеть. Каждый цикл по PagedOrders начинает с первой страницы. Если данные нужны дважды, их надо сохранить или поставить перед API кэширующего заместителя.
  • Сбои и лимиты. Настоящему клиенту нужны повторы с паузой, обработка 429 и таймаут. Это обёртка вокруг fetchPage (декоратор), а не забота итератора: он только решает, когда запросить следующую страницу.
  • Продолжение после сбоя. Сейчас обход после ошибки начинается заново. Для длинных выгрузок курсор последней успешной страницы сохраняют и начинают с него — тогда курсор становится частью публичного API коллекции.
  • Параллельная загрузка. Ленивый итератор грузит страницы строго по одной. Если важна скорость, а не экономия запросов, следующую страницу запрашивают заранее, пока клиент обрабатывает текущую, или переходят на потоки и каналы.
  • Избыточность. Если API отдаёт всё одним ответом или данных заведомо немного, обычный массив проще итератора.

Отличия между языками ​

  • PHP — генератор и есть ленивость: код после yield не выполнится, пока foreach не попросит следующий элемент. Готовый LimitIterator для «первых N» здесь не подходит: после последнего элемента он всё равно вызывает next() у генератора и тем самым запрашивает лишнюю страницу. Поэтому клиент в тестах выходит из foreach через break.
  • Go — у iter.Seq нет способа вернуть ошибку, поэтому для обхода, который может сломаться, используют iter.Seq2[T, error]: клиент проверяет err на каждом шаге. После ошибки итератор обязан закончить, иначе клиент получит смесь ошибок и данных.
  • Python — itertools.islice(orders, n) берёт n элементов и не просит (n+1)-й, поэтому лишняя страница не грузится. islice(..., None) берёт всё, это удобно для таблицы случаев.
  • TypeScript — fetch асинхронный, поэтому синхронный Symbol.iterator тут не подходит: нужен Symbol.asyncIterator и for await. break в for await вызывает return() у генератора, и следующий запрос не уходит.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS