[Паттерны API] Списки и организация доступа к ним

bda8fd4a9528b73e41a4d7878a08e215

Это глава 20 моей книги «API». v2 будет содержать три новых раздела: «Паттерны API», «HTTP API и REST», «SDK и UI‑библиотеки». Если эта работа была для вас полезна, пожалуйста, оцените книгу на GitHub, Amazon или GoodReads. English version on Substack.

В предыдущей главе мы пришли вот к такому интерфейсу, позволяющему минимизировать коллизии при создании заказов:

const pendingOrders = await api
  .getOngoingOrders(); 
→
{ orders: [{
  order_id: <идентификатор задания>,
  status: "new"
}, …]}

Внимательный читатель может подметить, что этот интерфейс нарушает нашу же рекомендацию, данную в главе «Описание конечных интерфейсов»: количество возвращаемых данных в любом ответе должно быть ограничено, но в нашем интерфейсе отсутствуют какие-либо лимиты. Эта проблема существовала и в предыдущих версиях этого эндпойнта, но отказ от синхронного создания заказа её усугубил: операция создания задания должна работать максимально быстро, и, следовательно, почти все проверки лимитов мы должны проводить асинхронно —, а значит, клиент потенциально может создать очень много заданий, что может многократно увеличить размер ответа функции getOngoingOrders.

NB: конечно, не иметь вообще никакого ограничения на создание заданий — не самое мудрое решение; какие-то легковесные проверки лимитов должны быть в API. Тем не менее, в рамках этой главы мы фокусируемся именно на проблеме размера ответа сервера.

Исправить эту проблему достаточно просто — можно ввести лимит записей и параметры фильтрации и сортировки, например так:

api.getOngoingOrders({
  // необязательное, но имеющее
  // значение по умолчанию
  "limit": 100,
  "parameters": {
    "order_by": [{
      "field": "created_iso_time",
      "direction": "desc"
    }]
  }
})

Однако введение лимита ставит другой вопрос: если всё же количество записей, которые нужно выбрать, превышает лимит, каким образом клиент должен получить к ним доступ?

Стандартный подход к этой проблеме — введение параметра offset или номера страницы данных:

api.getOngoingOrders({
  // необязательное, но имеющее
  // значение по умолчанию
  "limit": 100,
  // По умолчанию — 0
  "offset": 100
  "parameters"
});

Однако, как нетрудно заметить, в нашем случае этот подход приведёт к новым проблемам. Пусть для простоты в системе от имени пользователя выполняется три заказа:

[{
  "id": 3,
  "created_iso_time": "2022-12-22T15:35",
  "status": "new"
}, {
  "id": 2,
  "created_iso_time": "2022-12-22T15:34",
  "status": "new"
}, {
  "id": 1,
  "created_iso_time": "2022-12-22T15:33",
  "status": "new"
}]

Приложение партнёра запросило первую страницу списка заказов:

api.getOrders({
  "limit": 2,
  "parameters": {
    "order_by": [{
      "field": "created_iso_time",
      "direction": "desc"
    }]
  }
})
→
{
  "orders": [{
    "id": 3, …
  }, {
    "id": 2, …
  }]
}

Теперь приложение запрашивает вторую страницу "limit": 2, "offset": 2 и ожидает получить заказ "id": 1. Предположим, однако, что за время, прошедшее с момента первого запроса, в системе появился новый заказ с "id": 4.

[{
  "id": 4,
  "created_iso_time": "2022-12-22T15:36",
  "status": "new"
}, {
  "id": 3,
  "created_iso_time": "2022-12-22T15:35",
  "status": "new"
}, {
  "id": 2,
  "created_iso_time": "2022-12-22T15:34",
  "status": "ready"
}, {
  "id": 1,
  "created_iso_time": "2022-12-22T15:33",
  "status": "new"
}]

Тогда, запросив вторую страницу заказов, вместо одного заказа "id": 1, приложение партнёра получит повторно заказ "id": 2:

api.getOrders({
  "limit": 2,
  "offset": 2
  "parameters"
})
→
{
  "orders": [{
    "id": 2, …
  }, {
    "id": 1, …
  }]
}

Такие перестановки крайне неудобны и для пользовательских интерфейсов — если, допустим, предположить, что заказы запрашивает бухгалтер партнёра, чтобы рассчитать выплаты, то и он легко может просто не заметить, что какой-то заказ посчитан дважды. Однако в случае программной интеграции ситуация становится намного сложнее: разработчику приложения нужно написать достаточно неочевидный код (сохраняющий состояние уже полученных страниц данных), чтобы провести такой перебор корректно.

Отметим теперь, что ситуацию легко можно сделать гораздо более запутанной. Например, если мы добавим сортировку не только по дате создания, но и по статусу заказа:

api.getOrders({
  "limit": 2,
  "parameters": {
    "order_by": [{
      "field": "status",
      "direction": "desc"
    }, {
      "field": "created_iso_time",
      "direction": "desc"
    }]
  }
})
→
{
  "orders": [{
    "id": 3,
    "status": "new"
  }, {
    "id": 2,
    "status": "new"
  }]
}

Предположим, что в интервале между запросами первой и второй страницы заказ "id": 1 изменил свой статус, и, соответственно, свое положение в списке, став самым первым. Тогда, запросив вторую страницу, приложение партнёра получит (повторно) только заказ с "id": 2, а заказ "id": 1 попросту вообще пропустит, и вновь не будет располагать вообще никаким способом узнать об этом пропуске.

Повторимся, такой подход плохо работает для визуальных интерфейсов, но в программных интерфейсах он практически гарантированно приведёт к ошибкам. API должно предоставлять способы перебора больших списков, которые гарантируют клиенту получение полного и целостного набора данных.

Если не вдаваться в детали имплементации, то можно выделить три основных паттерна организации такого перебора — в зависимости от того, как сами по себе организованы данные.

Иммутабельные списки

Проще всего организовать доступ, конечно, если список в принципе не может измениться, т.е. все данные в нём фиксированы. Тогда даже схема с limit/offset прекрасно работает и не требует дополнительных ухищрений. К сожалению, в реальных предметных областях встречается редко.

Пополняемые списки, иммутабельные данные

Более распространённый случай — когда не меняются данные в списке, но появляются новые элементы. Чаще всего речь идёт об очередях событий — например, новых сообщений или уведомлений. Представим, что в нашем кофейном API есть эндпойнт для партнёра для получения истории предложений:

GET /v1/partners/{id}/offers/history⮠
  limit=<лимит>
→
{
  "offer_history": [{
    // Идентификатор элемента
    // списка
    "id",
    // Идентификатор пользователя,
    // получившего оффер
    "user_id",
    // Время и дата поиска
    "occurred_at",
    // Установленные пользователем
    // параметры поиска предложений
    "search_parameters",
    // Офферы, которые пользователь
    // увидел
    "offers"
  }]
}

Данные в списке по своей природе неизменны — они отражают уже случившийся факт: пользователь искал предложения, и увидел вот такой их список. Но новые элементы списка постоянно возникают, причём вполне могут возникать большими сериями, если пользователь сделал несколько поисков подряд.

Партнёр может использовать эти данные, например, для реализации двух сценариев:

  1. Анализ поведения пользователей в реальном времени (скажем, партнёр может отправить пользователю пуш-уведомление с предложением скидки тем пользователям, которые искали).

  2. Построение статистического отчёта (скажем, подсчёт конверсии по часам).

Для этих сценариев нам необходимо предоставить партнёру две операции со списками:

  1. Для первой задачи, получение в реальном всех новых элементов с момента последнего запроса.

  2. Для второй задачи, перебор списка, т.е. получение всех запросов за указанный временной интервал.

Оба сценария покрываются limit/offset-схемой, но требуют значительных усилий при написании кода, так как партнёру в обоих случаях нужно как-то ориентироваться, на сколько элементов очередь событий сдвинулась с момента последнего запроса. Отдельно отметим, что использование limit/offset-подхода приводит к невозможности кэширования ответов — повторные запросы с той же парой limit/offset могут возвращать совершенно разные результаты.

Решить эту проблему мы можем, если будем ориентироваться не на позицию элемента в списке (которая может меняться), а на какие-то другие признаки. Нам важно здесь следующее условие: по этому признаку мы можем однозначно определить, какие элементы списка «более новые» по отношению к нему (т.е. имеют меньшие индексы), а какие «более старые».

Если хранилище данных, в котором находятся элементы списка, позволяет использовать монотонно растущие идентификаторы (что на практике означает два условия: (1) база данных поддерживает автоинкрементные колонки, (2) вставка данных осуществляется блокирующим образом), то идентификатор элемента в списке является максимально удобным способом организовать перебор:

// Получить записи новее,
// чем запись с указанным id
GET /v1/partners/{id}/offers/history⮠
  newer_than=&limit=
// Получить записи более старые,
// чем запись с указанным id
GET /v1/partners/{id}/offers/history⮠
  older_than=&limit=

Первый формат запроса позволяет решить задачу (1), т.е. получить все элементы списка, появившиеся позднее последнего известного; второй формат — задачу (2), т.е. перебрать нужно количество записей в истории запросов. Важно, что первый запрос при этом ещё и кэшируемый.

NB: отметим, что в главе «Описание конечных интерфейсов» мы давали рекомендацию не давать доступ во внешнем API к инкрементальным id. Однако, схема этого и не требует: внешние идентификаторы могут быть произвольными (не обязательно монотонными) — достаточно, чтобы они однозначно конвертировались во внутренние монотонные идентификаторы.

Другим способом организации такого перебора может быть дата создания записи, но этот способ чуть сложнее в имплементации:

  • дата создания двух записей может полностью совпадать, особенно если записи могут массово генерироваться программно; в худшем случае может получиться так, что в один момент времени было создано больше записей, чем максимальный лимит их извлечения, и тогда часть записей вообще нельзя будет перебрать;

  • если хранилище данных поддерживает распределённую запись, то может оказаться, что более новая запись имеет чуть меньшую дату создания, нежели предыдущая известная (поскольку часы на разных виртуальных машинах могут идти чуть по-разному, и добиться хотя бы микросекундной точности крайне сложно[1]), т.е. нарушится требование монотонности по признаку даты; если использование такого хранилища не имеет альтернативы, необходимо выбрать одно из двух зол:

    • внести рукотворные задержки, т.е. возвращать в API только элементы, созданные более чем N секунд назад — так, чтобы N было заведомо больше неравномерности хода часов (эта техника может использоваться и в тех случаях, когда список формируется асинхронно) — однако надо иметь в виду, что это решение вероятностное и всегда есть шанс отдачи неверных данных в случае проблем с синхронизацией на бэкенде;

    • описать нестабильность порядка новых элементов списка в документации и переложить решение этой проблемы на партнёров.

Часто подобные интерфейсы перебора данных (путём указания граничного значения) обобщают через введение понятия курсор:

// Инициализируем поиск
POST /v1/partners/{id}/offers/history⮠
  search
{
  "order_by": [{
    "field": "created",
    "direction": "desc"
  }]
}
→
{
  "cursor": "TmluZSBQcmluY2VzIGluIEFtYmVy"
}
// Получение порции данных
GET /v1/partners/{id}/offers/history⮠
  ?cursor=TmluZSBQcmluY2VzIGluIEFtYmVy⮠
  &limit=100
→
{
  "items": […],
  // Указатель на следующую
  // страницу данных
  "cursor": "R3VucyBvZiBBdmFsb24"
}

Курсором в данной ситуации может представлять собой просто идентификатор последней записи, а может содержать зашифрованное представление всех параметров поиска. Одним из преимуществ использования абстрактного курсора вместо конкретных монотонных полей является возможность сменить нижележащую технологию (например, перейти от использования последнего известного идентификатора к использованию даты последней известной записи) без слома обратной совместимости. (Поэтому курсоры часто представляют собой «непрозрачные» строки: предоставление читаемых курсоров будет означать, что вы теперь обязаны поддерживать формат курсора, даже если никогда его не документировали. Лучше возвращать курсоры зашифрованными или хотя бы в таком виде, который не вызывал бы желания его раскодировать и поэкспериментировать с параметрами.)

В подходе с курсорами вы сможете без нарушения обратной совместимости добавлять новые фильтры и виды сортировки — при условии, конечно, что вы сможете организовать хранение данных таким образом, чтобы перебор с курсором работал однозначно.

// Инициализируем поиск
POST /v1/partners/{id}/offers/history⮠
  search
{
  // Добавим фильтр по виду кофе
  "filter": {
    "recipe": "americano"
  },
  // добавим новую сортировку
  // по удалённости от указанной
  // географической точки
  "order_by": [{
    "mode": "distance",
    "location": [-86.2, 39.8]
  }]
}
→
{
  "items": […],
  "cursor": 
    "Q29mZmVlIGFuZCBDb250ZW1wbGF0aW9u"
}

Небольшое примечание: признаком окончания перебора часто выступает отсутствие курсора на последней странице с данными; мы бы рекомендовали так не делать (т.е. всё же возвращать курсор, указывающий на пустой список), поскольку это позволит добавить функциональность динамической вставки данных в конец списка.

NB: в некоторых источниках перебор через идентификаторы / даты создания / курсор, напротив, не рекомендуется по следующей причине: пользователю невозможно показать список страниц и дать возможность выбрать произвольную. Здесь следует отметить, что:

  • подобный кейс — список страниц и выбор страниц — существует только для пользовательских интерфейсов; представить себе API, в котором действительно требуется доступ к случайным страницам данных мы можем с очень большим трудом;

  • если же мы всё-таки говорим об API приложения, которое содержит элемент управления с постраничной навигацией, то наиболее правильный подход — подготавливать данные для этого элемента управления на стороне сервера, в т.ч. генерировать ссылки на страницы;

  • подход с курсором не означает, что limit/offset использовать нельзя — ничто не мешает сделать двойной интерфейс, который будет отвечать и на запросы вида GET /items?cursor=…, и на запросы вида GET /items?offset=…&limit=…;

  • наконец, если возникает необходимость предоставлять доступ к произвольной странице в пользовательском интерфейсе, то следует задать себе вопрос, какая проблема тем самым решается; вероятнее всего с помощью этой функциональности пользователь что-то ищет: определенный элемент списка или может быть позицию, на которой он закончил работу со списком в прошлый раз; возможно, для этих задач следует предоставить более удобные элементы управления, нежели перебор страниц.

Общий сценарий

Увы, далеко не всегда данные организованы таким образом, чтобы из них можно было составить иммутабельные списки. Например, в указанном выше примере поиска текущих заказов мы никак не можем представить постраничный список заказов, находящихся сейчас в статусе «исполняется» — просто потому, что заказы переходят в другие статусы и в реальном времени пропадают из списка. Для таких сложных случаев нам нужно в первую очередь ориентироваться на сценарии использования данных.

Бывает так, что задачу можно свести к иммутабельному списку, если по запросу создавать какой-то слепок запрошенных данных. Во многих случаях работа с таким срезом данных по состоянию на определённую дату более удобна и для партнёров, поскольку снимает необходимость учитывать текущие изменения. Часто такой подход работает с «холодными» хранилищами, которые по запросу выгружают какой-то подмассив данных в «горячее» хранилище.

POST /v1/orders/archive/retrieve
{
  "created_iso_date": {
    "from": "1980-01-01",
    "to": "1990-01-01"
  }
}
→
{
  "task_id": <идентификатор
    задания на выгрузку данных>
}

Недостаток такого подхода понятен — он требует дополнительных (и зачастую немалых) затрат на создание и хранение слепка, а потому требует и отдельной тарификации. Кроме того, проблема-то сама по себе никуда не делась: мы перенесли её из публичного API на уровень реализации нашего бэкенда, но нам всё ещё нужно каким-то образом перебрать массив данных и сформировать консистентный слепок.

Обратный подход к организации такого перебора — это принципиально не предоставлять больше одной страницы данных. Т.е. партнёр может запросить только «последние» в каком-то смысле записи. Такой подход обычно применяется в одном из трёх случаев:

  • если эндпойнт представляет собой поисковый алгоритм, который выбирает наиболее релевантные данные — как мы все отлично знаем, вторая страница поисковой выдачи уже никому не нужна;

  • если эндпойнт нужен для того, чтобы изменить данные — например, сервис партнёра достаёт все заказы в статусе "new" и переводит в статус «принято к исполнению»; тогда пагинация на самом деле и не нужна, поскольку каждым своим действием партнёр удаляет часть элементов из списка;

  • наконец, если через эндпойнт предоставляются только «горячие» необработанные данные, а к обработанным данным доступ предоставляется уже через стандартные интерфейсы.

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

// Получить все события создания
// заказа, более старые,
// чем запись с указанным id
GET /v1/orders/created-history⮠
  older_than=&limit=
→
{
  "orders_created_events": [{
    "id": <идентификатор события>,
    "occured_at",
    // Идентификатор заказа
    "order_id"
  }, …]
}

События иммутабельны, и их список только пополняется, следовательно, организовать перебор этого списка вполне возможно. Да, событие — это не то же самое, что и сам заказ: к моменту прочтения партнёром события, заказ уже давно может изменить статус. Но, тем не менее, мы предоставили возможность перебрать все новые заказы, пусть и не самым оптимальным образом.

NB: в вышеприведённых фрагментах кода мы опустили метаданные ответа — такие как общее число элементов в списке, флаг типа has_more_items для индикации необходимости продолжить перебор и т.д. Хотя эти метаданные необязательны (клиент узнает размер списка, когда переберёт его полностью), их наличие повышает удобство работы с API для разработчиков, и мы рекомендуем их добавлять.

© Habrahabr.ru