Всем привет ! Я работаю full-stack разработчиком в одной организации в атомной отрасли. Некоторое время назад потребовалось добавить в одно из наших приложений диаграмму Ганта. В сфере деятельности нашей организации много специфики, и для реализации самой удобной диаграммы именно для наших пользователей не хватило бы библиотечных решений. Поэтому была поставлена задача: реализовать диаграмму Ганта с нуля.
В этой статье будут рассмотрены следующие проблемы:
Наши пользователи хотят отображать на диаграмме 1, 3, 5, а то и 10 тысяч работ.
На диаграмме работы должны быть отсортированы, при этом у некоторых имеются вложенные работы, т.е. у работы с номером '1.' может быть работа с номером '1.1.'. Работа с номером '1.1.' должна идти перед работой с номером '2.', и в то же время работа с номером '11.' должна идти после работы '2'. Лексикографическая сортировка здесь не подходит. Данные в БД изначально не отсортированы (удовлетворяют 1NF).
Пользователи хотят сворачивать вложенные работы. Если есть работа с номером '1.' с 1e3 вложенных работ, и пользователь её скрывает, он должен незамедлительно увидеть сразу после неё работу '2'.
Далее максимум внимания уделен архитектурным и алгоритмическим решениям сначала на фронт-энде, затем на бэк-энде. Мы используем популярное сочетание React + Nest + PostgreSQL, поэтому далее будут небольшие пояснения, как именно они ложатся на этот стэк.
Принцип сортировки работ описан в разделе про бэк-энд.
Также отмечу, что работы ("job") и задачи ("task") в статье используются синонимично.
Фронт-энд
Основные выдержки из описанного далее кода фронт-энда можно увидеть тут.
Чтобы не запрашивать разом тысячи работ пользователя, мы используем пагинацию. В рамках пагинации мы оперируем не отдельными работами, а небольшими подмножествами последовательно идущих работ, называемыми страницами. При каждом пролистывании пользователя одна страница работ загружается в память, а другая её покидает.
Важно учесть: пользователи редко просто листают работы подряд и чаще возвращаются к просмотренным ранее работам. Просто запрашивать при каждом пролистывании нужную в конкретный момент времени страницу работ может быть слишком медленно (особенно при неидеальном сетевом подключении). Поэтому в архитектуру был добавлен важный элемент: кэш работ "TasksCache
".
Также заметим тот факт, что пользователь взаимодействует в течение некоторого отрезка времени лишь с небольшим подмножеством работ. Это послужит основной для оптимизированного функционирования архитектуры. Если же случайно пролистывается большое число работ (например, зажатием колесика мыши), срабатывает механизм debounce.
Архитектура работает следующим образом:
Кэш
TasksCache
хранит некоторое число работ в кольцевом буфере. В буфере резервируется место под несколько страниц работ. По мере пролистывания новые нужные пользователю работы записываются в память на места самых старых и давно не использованных работ. Если же пользователь возвращается к работам, которые он просматривал не так давно, указатель буфера перемещается назад к ним.TasksCache
предоставляет следующие методы:insertTasks
записывает свежий массив работ в буфер. Асимптотика - O(Sp), где Sp - размер страницы работ.checkPage
проверяет, есть ли желаемая страница работ в памяти, и если не находит её, возвращает массив номеров недостающих работ. Благодаря использованию кольцевого буфера, в среднем случае достигается асимптотика O(Sp). В худшем случае (если пользователь резко пролистал к тем работам, с которыми раньше не взаимодействовал) получается асимптотика O(Sb), где Sb - размер буфера.
Класс
HiddenTasks
хранит в себе отрезки свёрнутых работ. Отрезкок работ - это множество работ, имеющее общую родительскую работу. Отрезки хранятся в массиве, отсортированные по началу отрезков. В примере ниже хранится следующий массив: [{ from: 2, to: 3 }, { from: 6, to: 9 }, { from: 9, to: 9}]. Это позволяет предоставлять следующие эффективные методы:check
проверяет, скрыта ли работа с указанным индексом. Для этого линейно просматриваются все отрезки, и проверяется, попадает ли указанный индекс в какой-либо из них. Класс хранит в себе указатель ptr на отрезок, в котором в последний раз была найдена некоторая работа. Линейный просмотр отрезков начинается с этого указателя и проходит в массиве по кругу. Учитывая, что зачастую данный метод вызывается с соседними индексами работ, данная оптимизация позволяет в большинстве случаев получить асимптотику O(1); в худшем случае асимптотика O(Sh), где Sh - кол-во отрезков.hide
скрывает указанный отрезок. Асимптотика O(Sh) (линейно находится место для вставки нового отрезка)show раскрывает указанный отрезок. Асимптотика также O(Sh).
findNthVisible
находит n-ную по счету не скрытую работу. В примере ниже для n = 3 данный метод вернул бы 5. Метод линейно проходит по отрезкам и останавливается тогда, когда число не скрытых работ между ранее пройденными отрезками = n. Именно для работы данного массив отрезков поддерживается отсортированным. Асимптотика O(Sh).
Хук
useScrollTasksPage
отслеживает, пролистал ли пользователь очередные несколько работ и реализует технику debounce. Какие по счету задачи требуется показать пользователю вычисляется по высоте прокрутки, исходя из высоты одной работы (такой расчёт позволяет пользователю прокручивать с любой скоростью в отличие от использования IntersectionObserver).Хук
useUpdateAndGetTasksPageInTree
загружает новую страницу работ. Для этого он:Вызывает у
HiddenTasks
методfindNthVisible
, чтобы загрузить страницу со сдвигом на кол-во скрытых перед ней работ.Вызывает у
TasksCache
методcheckPage
, чтобы понять, каких именно работ не достает в памяти.Запрашивает недостающие работы и добавляет их в
TasksCache
вызовом методаinsertTasks
. Благодаря использованию буфера вставка работает за O(P).
Важным моментом является то, что фронт-энд не знает, какие именно работы (с какими конкретно id в БД) нужно загрузить следующими. В каждый момент времени он запрашивает работы по их порядковым номерам в сортировке по номеру работы (о ней будет сказано далее). Это позволяет системе быть крайне гибкой и предоставлять возможность удаления/добавления работ.
Бэк-энд
Бэк-энд предоставляет эндпоинт, в который передаётся массив порядковых номеров работ. Для того, чтобы получить данные из базы по порядковым номерам, для каждой работы вводится специальное число k, по которому работы можно сортировать.
В нашем случае максимально есть 4 уровня вложенности (т.е. может быть работа '1.2.3.4'., но не может быть работы '1.2.3.4.5.'). Также на каждом уровне может быть не более 10 тыс. вложенных работ (т.е может быть работ '1.2.9999.1'., но не может быть работы 1.2.12345.1.). Тогда в качестве k для номера работы = a.b.c.d. выбирается число из 16 разрядов, в котором первые 4 разряда будут отведены под d, следующие 4 разряда под c, ещё 4 под b и последние 4 разряда под a.
Например, для работы ‘1.10.2.9999.’ вот так будут заполнены разряды числа:
Т.е. для каждой работы k = d + 1e4 * c + 1e8 * b + 1e12 * a
С использованием числа k можно запрашивать корректно отсортированные работы следующим образом:
select <your_attrs> from <your_table> t
order by
(
(cast(coalesce(nullif(split_part(job, '.', 1), ''), '0') as bigint) * 1e12) +
(cast(coalesce(nullif(split_part(job, '.', 2), ''), '0') as bigint) * 1e8) +
(cast(coalesce(nullif(split_part(job, '.', 3), ''), '0') as bigint) * 1e4) +
cast(coalesce(nullif(split_part(job, '.', 4), ''), '0') as bigint)
)
offset <your_offset>
limit <your_limit>
И с PostgreSQL, можно использовать индекс:
create INDEX <index_name> ON public.<your_table> USING btree ((
(cast(coalesce(nullif(split_part(job, '.', 1), ''), '0') as bigint) * 1e12) +
(cast(coalesce(nullif(split_part(job, '.', 2), ''), '0') as bigint) * 1e8) +
(cast(coalesce(nullif(split_part(job, '.', 3), ''), '0') as bigint) * 1e4) +
cast(coalesce(nullif(split_part(job, '.', 4), ''), '0') as bigint)
))
При использовании индекса на 20 тыс. задач получается приблизительно следующая производительность:
При развёртывании на локали на устройстве с Intel Core i5 1.3 Ghz и 16gb DDR4 среднее время ответа на запрос бэк-энда на Nest.js составило 11.4мс. С максимальной возможной (до включения debounce) скоростью прокрутки на протяжении 15 секунд в Chrome 121 у фронт-энда на React отрисовка заняла ~400 мс, отображение - 200, выполнение скриптов - 2500. То есть производительности данного решения будет более чем достаточно для пользователей.
Благодарю за внимание! :-)