Контракт пагинации API
Каталог из четырёх курсов помещается в один небольшой ответ, но полезен для объяснения страниц. Когда данных станет больше, клиенту понадобится читать их частями. Разделение списка меняет договор: нужно знать, в каком порядке идут записи и что означает переход к продолжению, особенно если между запросами каталог редактируется.
В этом уроке выберем курсорную пагинацию с ограниченным снимком. Результат — две страницы содержат весь исходный каталог без пропусков и повторов при объявленных условиях. Сервер не реализован, а ответы являются моделью. Мы не обещаем, что само наличие строки cursor автоматически обеспечивает устойчивость любого API.
Порядок до разделения
Начальный снимок s1 содержит c001 с 40 уроками, c002 с 32, c003 с 24 и c004 с 12. Выбираем порядок lessons:desc,id:asc: сначала большее количество, при равенстве — возрастающий id. Второй ключ делает порядок однозначным, когда два курса имеют одинаковое количество уроков.
Полный логический ряд сейчас выглядит так:
c001 (40), c002 (32), c003 (24), c004 (12)
Эта строка показывает порядок предметных записей. Если разделить ряд по две, первый ответ содержит c001 и c002, второй — c003 и c004. Без заранее определённой сортировки понятие «следующие два» неоднозначно: база не обязана возвращать одинаковый порядок без явного правила.
Размер страницы задаёт limit. В нашем API допускается целое от одного до 50, по умолчанию два. Это верхняя граница ответа, а не обещание вернуть ровно столько элементов при любых данных. Последняя страница способна быть короче; пустая выборка вообще не содержит элементов. Метаданные должны сообщать завершение независимо от догадок по длине массива.
Первый ответ и продолжение
Начинаем чтение коллекции без cursor. Полный смысловой запрос:
GET /api/courses?limit=2 HTTP/1.1
Host: catalog.example
Accept: application/json
Ожидаемое тело первой страницы имеет оболочку с предметными элементами и сведениями о продолжении. Здесь JSON приведён полностью:
{
"items":[
{"id":"c001","title":"Современный JavaScript","topic":"frontend","lessons":40},
{"id":"c002","title":"Современный HTML и CSS","topic":"frontend","lessons":32}
],
"total":4,
"nextCursor":"s1-next-c002",
"snapshot":"s1"
}
Значение nextCursor в примере читаемо ради объяснения. Для клиента оно непрозрачно: нельзя извлекать c002 и самостоятельно придумывать продолжение. Реальный сервис может хранить состояние или использовать защищённый токен. Важно, чтобы курсор связывал позицию, снимок, фильтр, порядок и размер страницы, а сервер проверял согласованность обращения.
Клиент сохраняет настройки первого запроса и передаёт полученный cursor. Значения, которые были по умолчанию, по-прежнему имеют те же значения; изменение limit под старым курсором является ошибкой контекста.
GET /api/courses?limit=2&cursor=s1-next-c002 HTTP/1.1
Host: catalog.example
Accept: application/json
Вторая страница содержит c003 и c004. total остаётся четыре, snapshot остаётся s1, nextCursor становится null. Это явный признак завершения. Клиент не отправляет строку "null" как курсор и не пытается бесконечно запрашивать следующую страницу.
Завершение не вычисляется из номера страницы
Вот полное тело второй страницы нашего исходного обхода. Оно сохраняет total и snapshot первого ответа, но явно завершает продолжение:
{
"items":[
{"id":"c003","title":"Производительность веб-сайта","topic":"quality","lessons":24},
{"id":"c004","title":"Статический сайт из Markdown","topic":"tools","lessons":12}
],
"total":4,
"nextCursor":null,
"snapshot":"s1"
}
Длина массива равна limit даже на последней странице. Если клиент решит, что полная страница обязательно означает продолжение, он отправит лишнее обращение. Если ориентируется на nextCursor, завершение известно сразу. При другом размере последний массив может быть короче, но явный признак всё равно остаётся источником решения.
Число total удобно для подписи и понимания набора, но не выдаёт произвольный курсор к третьей странице. Пользовательский интерфейс с прямым переходом на страницу номер сто требует дополнительной модели адресации или последовательного получения границ. Курсорный обход обычно естественнее для действия «показать ещё». Нельзя обещать свободные скачки только на основании общего количества элементов.
Наш снимок особенно полезен для последовательного чтения полного набора. Но каждая неизменная версия требует хранения или воспроизводимого запроса к истории. Если система имеет миллионы записей и множество одновременных обходов, стоимость такой гарантии нужно оценивать отдельно. Ограниченный срок делает модель конечной, не доказывая пригодность конкретного способа хранения для любой нагрузки.
Не следует добавлять в response случайный pageNumber, а затем рассчитывать на него как на независимый адрес. В принятом договоре позицию переносит cursor, а настройки привязаны к снимку. Если продукту понадобится другая навигация, сначала определите её гарантии при изменении каталога, затем меняйте оболочку ответа.
При будущем ручном опыте сравнивайте не только число показанных карточек, но и идентификаторы по порядку. Четыре карточки способны содержать повтор c002 и пропуск c003, поэтому совпавшее количество ещё не подтверждает корректный обход. В нашей модели проверяемый ожидаемый ряд задан явно до разделения.
Почему одной позиции недостаточно
Для сравнения представим другую, пока не принятую нашим API схему с числовым смещением offset. Клиент получает первые две записи, затем между чтениями появляется новый курс с 50 уроками. При смещении два в новом ряду второй ответ начнётся с прежнего c002: запись повторится, а граница списка переместится.
Курсор по последнему ключу уменьшает такую проблему при подходящем порядке, но не гарантирует неизменность изменяемого набора. Если редактор увеличит lessons у ещё не прочитанного курса, тот переместится выше позиции курсора и может быть пропущен. Если сортировочный ключ меняется, одной границы «после последнего» недостаточно для полного устойчивого обхода.
Поэтому мы выбираем снимок s1. Страницы одного обхода видят фиксированные записи и значения, даже когда текущий каталог уже изменился. В нашем учебном сценарии курс с новым количеством появляется только в новом обходе без старого cursor. Это сознательное требование к хранению или версии запроса, а не бесплатное свойство query-параметра.
Снимок также фиксирует total после фильтра. Число не пересчитывается по текущей базе для каждой страницы, иначе заголовок «четыре курса» мог бы противоречить наборам одного обхода. Если система не может обеспечивать точный total, ей лучше объявить другую форму ответа. Мы используем точный total, поскольку он соответствует выбранному ограниченному снимку.
Срок и ошибочное продолжение
Состояние обхода доступно десять минут с создания первого курсора. Срок не продлевается бесконечно каждой страницей. Это ограничивает стоимость хранения снимков, но влияет на интерфейс: после долгого перерыва клиент должен начать новый обход, а не получить незаметно смешанные версии.
Истёкший snapshot возвращает 410 с нашей предметной причиной:
{"type":"https://catalog.example/problems/snapshot-expired","title":"Снимок каталога истёк","status":410,"detail":"Начните чтение списка без прежнего курсора."}
Статус выбран для этого прикладного договора о недоступном продолжении. Он не означает удаления всех курсов коллекции. Клиент может сохранить ранее показанные карточки, объяснить обновление и по явному действию получить новый список. Автоматическое добавление новых страниц к старым без очистки снова нарушило бы обещанный снимок.
Если cursor создан для topic frontend, его нельзя применить к topic quality. Если limit или sort изменились, сервер отвечает 400 с причиной cursor-context-mismatch. Повреждённая или поддельная строка также не превращается в начало списка. Ошибка должна быть заметной, иначе клиент будет показывать дубликаты, считая, что продолжение принято.
Курсор не является правом доступа. Даже если снимок хранит защищённые данные в будущей версии API, разрешение потребителя нужно проверять при каждом обращении. В нашем начальном каталоге чтение публичное; это уменьшает переменные, но не превращает подход в универсальную модель выдачи секретных отчётов.
Идея страницы на клиенте уже разбиралась в уроке JavaScript. Здесь мы определяем серверный договор порядка и снимка, от которого такой интерфейс зависит. Теперь у обхода есть начало, продолжение и завершение. Следующий урок добавит фильтрацию и объяснит, почему она выполняется до деления списка, а не над уже полученной страницей.