Иногда в интерфейсе наших приложений СБИС возникает необходимость "сгруппировать" часть записей в некотором списке (например, служебные сообщения в чате, контакты и телефонные звонки).
Хорошо, если все эти записи приходят с одного источника, а вот если из разных сервисов, да с навигацией по курсору - алгоритм реализации становится весьма нетривиальным.
Я целенаправленно не буду здесь приводить реализацию "в коде", а опишу исключительно алгоритмический подход к решению, чтобы при необходимости вы могли его самостоятельно смасштабировать на свои задачи. Итак...
Постановка задачи
У нас есть два сервиса. Как бы может быть и больше, но, следуя предыдущей картинке, пусть для определенности это будут сервисы Звонков и Контактов.
Спасибо коллегам из CRM за интересную задачу.
Хотим в карточке организации в сквозном хронологическом порядке по дате выводить контакты и звонки, но все звонки между соседними записями контактов "схлопнуть" в единственную запись с указанием их количества.
Но при этом интерфейс должен оставаться "живым" для пользователя - то есть не должно быть длительных пауз, когда мы чего-то ждем, но не рисуем в списке.
Сразу договоримся, что сервисы работают с базой адекватно, есть все нужные индексы, и запросы строятся эффективно, примерно как я описывал в прошлых статьях:
Неудачное решение #1: "дай мне все"
Понятно, что можно вычитать сразу вообще все и смержить на бизнес-логике, но это приведет к ожиданию интерфейсом хоть какой-то реакции, пока БЛ не проделает всю работу. А ее может быть реально много - одних только звонков могут быть сотни.
Да и для сервиса задача "отдать все" совсем нелегка, если там данных за несколько лет работы. Но зачем нам "все", если пользователь дальше первой страницы листает очень редко?
Неудачное решение #2: "частый гребень"
Так, нам контакты группировать не надо?.. Давайте тогда запросим первую страницу (20 записей) с сервиса контактов, а для каждого интервала дат между "соседними" контактами спросим, что там есть в звонках - сразу и количество получим.
А теперь давайте представим, что у нас все звонки (или очень много) оказались хронологически "над" первым же контактом - что будет? А будут те же самые тормоза в интерфейсе, что и в предыдущем варианте.
Кроме того, мы или отправим на сервис много запросов (на каждый из интервалов), чем создадим избыточную нагрузку. Или отправим один запрос со списком всех интервалов, но он заведомо будет отрабатывать "долго".
Удачное решение #1: "чтение сегментами"
Набив шишек на предыдущих решениях, приходим к выводу, что нам надо запрашивать данные у обоих сервисов по курсору - сколько-то записей (пусть будет 20), начиная с какого-то индексного ключа.
Что дальше делать с двумя упорядоченными сегментами данных, достаточно очевидно - сливаем (merge ordered) и отрезаем (limit) от упорядоченного все записи после ближайшего из "крайних" ключей от каждого из сервисов.
Например, в нашем примере получилось, что ключ времени "крайнего" звонка соответствует только 15 из 20 прочитанных контактов. Про порядок оставшихся 5 контактов и других звонков мы не можем ничего сказать, потому что "других звонков" как раз нету в обозримом пространстве - поэтому нарисовать их пока не можем.
Неудачное решение #3: "One Ring to rule them all"
Как любому разработчику хочется сразу упростить себе жизнь в предыдущем варианте? Конечно же, запомнить только один ключ - последней отрисованной записи, чтобы потом итерацию для следующего сегмента делать уже от него. Но при таком варианте часть данных будет вычитываться повторно раз за разом - ровно те, которые мы отбросили в предыдущей итерации и не отобразили.
В особо клиническом случае, типа "сначала много-много звонков, потом уже начинаются контакты" один и тот же первый сегмент контактов может запрашиваться повторно много-много раз, с каждым сегментом звонков.
Неудачное решение #4: "два ключа на server-side"
Обернем предыдущую проблему себе на пользу - будем запоминать независимые ключи, отдельно для каждого из источников. Это правильный ход, но он не избавит нас от проблемы повторной вычитки тех же данных, если нам их негде сохранить.
Поскольку у нас stateless server-side БЛ, то либо мы их таки и не сохраним, или вынуждены будем городить где-то отдельное хранилище состояний. Сделать это можно, но совсем не просто:
хранение сегментов должно происходить по уникальному ключу экземпляра-выборки-на-странице
необходима политика инвалидации этих данных со временем, чтобы память не "текла"
работа с этим хранилищем подразумевает дополнительные издержки на сериализацию-пересылку-десериализацию
Удачное решение #2: "два ключа на client-side"
Собственно, а зачем нам уносить все это на сервер-сайд, если все данные нам нужны только на клиенте?.. Давайте их там и оставим.
То есть ровно те данные, которые "не отрисовали" оставить храниться на клиенте (например, прямо в памяти вкладки, даже не в localStorage), пока нам не понадобится их нарисовать.
В нашем предыдущем примере получится что-то вроде:
прочитали параллельно 20 контактов и 20 звонков
звонки "сгруппировали" в 5 записей
нарисовали 5 "групповых" звонков + 15 контактов
5 ненарисованных конктактов оставили в хранилище
до 20 чего-то не хватает? запрашиваем! (контакты и звонки по 20, параллельно от своих "крайних" ключей)
"задача сведена к предыдущей", только у нас уже сразу 25 контактов на 20 звонков есть
Edge Cases
Фактически, единственный отрицательный эффект у такого решения - последняя нарисованная "групповая" запись может "крутить счетчиком", пока мы дочитываем "вливающиеся" в нее все новые и новые записи.
К счастью, такая ситуация достаточно позитивно воспринимается в интерфейсе, поскольку мы показываем пользователю: "Эй, все хорошо, мы не умерли, не повисли, мы работаем!"