Фёдор Борщёв

Сервис уведомлений и веб-сокетов для строительной компании

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

Современная стройка

Все слышали анекдоты, а многие и лично сталкивались с тем, насколько неэффективными бывают стройки, сколько там можно украсть да и просто сглупить. При этом экономия даже единиц процентов на крупной стройке — это миллионы рублей. Это — идеальная среда для инноваций. Крупные строительные компании активно вкладываются в разработку. Строители пытаются перенести в цифру весь процесс, начиная от архитектурного и инженерного плана здания (BIM, Building information modeling) до того, сколько материалов и когда нужно подвезти на площадку и контроля над тем, насколько точно выдержаны стандарты в процессе работ. У нас был целый эпизод подкаста про это с техническим директором группы компаний ПИК. 

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

Наша задача

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

  1. сообщения дошли до адресата — по электронной почте, в телеграм, да хоть смской, но дошли — пропущенные правки в тендер или согласование проекта могут стоить миллионы в сутки;
  2. не забомбить пользователей лишними сообщениями — система должна уметь собирать не критичные уведомления в «дайджесты» и присылать их с настраиваемой периодичностью;
  3. дать возможность посмотреть «входящие» в едином интерфейсе;
  4. возможность управления системой без программирования — например, поменять шаблоны, поменять частоту дайджестов и так далее.

Проработка архитектуры. Интеграция с существующей системой.

Система клиента построена на сервисной архитектуре. Каждый сервис является законченным модулем с точки зрения решаемых бизнес-задач. Кроме этого есть служебные микро-сервисы, например, сервис авторизации или сервис веб-сокетов (который мы кстати тоже переписали, об этом ниже).

Обмен информацией между модулями происходит синхронно через REST API и асинхронно через брокер сообщений (RabbitMQ).

MVP сервиса нотификаций, который изначально находился у клиента, работал просто и надежно. Спойлер: он очень удачно нас спас, в качестве fallback-сервиса, при досадных ошибках при запуске.

Выглядел он так:

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

Новая система, в своей основе, также взаимодействует с шиной событий, но кроме этого, имеет свое АПИ для настроек пользователя, интерфейс администратора и удобное подключение к любым каналам доставки.

Спроектированный сервис представляет собой Django монолит (один репозиторий с кодом), который контейнеризуется и деплоится 4 разными способами:

  • Django Web App - реплицируемый uWSGI сервер приложений за балансировщиком нагрузки для предоставления доступа к административному веб-интерфейсу и REST API
  • Celery App - реплицируемый воркер для асинхронных задач
  • Celery Beat App - единственный инстанс планировщика асинхронных задач
  • Event Consumer - единственный инстанс консьюмера событий для нотификаций

Отдельным микро-сервисом реализован Websocket сервер - инстанс для мгновенного оповещения пользователя в браузер.

Разработка сервиса нотификаций

Наш Django-монолит состоит из консьюмера и двух воркеров: воркера-процессора и воркера-отправителя уведомлений.

Получение события

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

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

Такая развязка освобождает консьюмер от высокой нагрузки и обеспечивает персистентность (сохранность) данных в случае сбоев.

Процессинг нотификаций

Задача второго этапа - превратить “сырое” событие в объект нотификации и подготовленных сообщений (доставок) по каналам. Нотификации могут быть двух видов: прямые нотификации и подписки.

Под прямыми нотификациями мы подразумеваем сообщения адресованные непосредственно участникам события. Например, когда на тебя поставили задачу — ты должен быть уведомлен. Это — прямая нотификация.

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

Общий подход такой, что процессор получает на вход событие и выполняет следующие действия:

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

Важная задача процессора - это обеспечить идемпотентность операций. Повторный процессинг сырого события не должен создать новых нотификаций.

В чем опасность? Например, при перегруженной очереди (красным на схеме) и частым повтором тасок (event1 -> repeat task for event1) может получиться так, что процессинг выполнится несколько раз. При неудачном сценарии можно отправить несколько десятков одинаковых уведомлений на одно событие.

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

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

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

Еще один важный момент: процессинг всего события в рамках одной БД-транзакции. В случае ошибки на любом этапе процесса, все изменения, сделанные в рамках транзакции, откатываются. Это предотвращает возникновение неконсистентных данных или частично обработанных событий. Такой подход сделал систему более предсказуемой и контролируемой.

Процессинг подписок на события

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

Например, вы можете получать все события в Замечаниях, у которых «срок сдачи == неделя до дедлайна, с ответственными == проектировщики, секция дома == подвал».

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

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

Приятная особенность в том, что при процессинге мы обогащаем данные через отдельный микро-сервис о каждом пользователе, компании или роли из события. Поэтому, например, при подписке на «роль = проектировщик», мы успешно рассылаем все события с Дмитрием Ивановым (проектировщиком), и не посылаем события с Василием Петровым (монтажником).

Существенных нюансов при реализации добавляют два требования:

Первое — важно не послать пользователю повторяющиеся нотификации в один канал доставки, если несколько фильтров подошли под событие. Диаграмма состояний для события в результате становится такой:

Слева направо в Статусе Доставки показано как вычисляются статусы для отправки - SKIPPED (пропускает отправку) и PENDING (ожидает отправку).

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

Доставка уведомлений

Каналы доставки могут быть любые. На данный момент подключена электронная почта и телеграмм-бот.

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

Благодаря тому, что тексты нотификаций уже подготовлены заранее в процессоре, отправка получается не сложной. Нам достаточно передать на вход провайдера параметры — кто получатель и готовые тексты (или вёрстку) для заголовка и тела письма.

Отправка происходит асинхронно, в фоне. Результатом работы процессора на предыдущем этапе является Доставка. Воркер доставки с помощью периодической задачи подхватывает Доставки, который находятся в статусе “ожидание”. В случае успеха, статус меняется на “доставлено”, в случае неудачи по вине провайдера - возврат к статусу “ожидание”.

Единственное исключение по каналам доставки — отправка уведомлений в веб-браузеры.

Отправка в браузер. Сервис Web-сокетов.

Отдельная часть проекта — уведомления пользователей в браузере. Представьте, что у вас открыта страница, где вы работаете с тендерами. Она открыта у вас и ещё у 500 пользователей. Вася добавил новое требование, Петя стёр пару папок — вы хотите оперативно узнать об этом. Но не о каждом чихе, а только о нужных вам событиях.

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

У ребят уже был такой сервис, но больше похожий на прототип: не было аутентификации, сложно добавить новое событие и нужно тратить время на техническую поддержку, а свободных рук нет. Решили добавить эту функциональность в наш сервис уведомлений.

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

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

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

Общая схема сервиса

Чтобы удовлетворить всем этим требованиям, мы добавили в сервис аутентификацию и авторизацию. Перед тем как пользователь сможет подписаться на уведомления, получаем и проверяем JWT-токен. В результате, когда отправляем уведомление, можем проверить, имеет ли пользователь право его получить.

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

Как делаем из события уведомление для браузера

Сервис написали на асинхронном python. Для работы с веб-сокетами используем библиотеку websockets. Чтоб получать события из RabbitMQ — aio-pika. Код — простой и лёгкий: один инстанс сервиса может обслуживать сотни и даже тысячи одновременных подключений — для наших требований достаточно.

Убедились на тестах, что сервис справится с нагрузкой: написали скрипт, который имитирует действия пользователей — подключается, подписывается, а затем получает уведомления. Проверили на 4 тысячах подключений: всё в порядке — не задыхается у успевает обрабатывать наш поток сообщений. Запустили на самом нагруженном окружении с более чем 5000 пользователями и сервис даже не напрягся. 🚀

Периодическая рассылка новостей - дайджест

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

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

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

Проверки прав на объекты (контроль доступа)

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

Это касается любых уведомлений — от сервиса нотификаций и сервера веб-сокетов до любого другого источника в будущем. При этом права надо проверять именно в момент отправки сообщения, а не когда-то заранее, например, при подписке на объект.

Каждый модуль в системе — законченный сервис со своей жизнью. Внутри себя сервисы знают о правах пользователей на работу с объектами. Чтобы решить нашу задачу, пришлось бы отдельно интегрироваться с каждым модулем и запрашивать права на объект. Вместо этого мы сделали сервис-медиатор. Его роль — прослойка между потребителями данных о правах и источниками этих данных. Это обеспечивает единый контракт для всех сервисов и снижает нагрузку на все остальные модули.

Чтобы получить права, нужно передать данные об объекте: имя модуля, id проекта, тип объекта, id объекта и пользователя. В ответ вы получаете обогащенные данные с правами на чтение или запись.

Сервис принимает сразу пачку объектов, по которым надо проверить права. Он заботливо сходит в каждый модуль, соберет права и вернем вам такую же пачку ресурсов с правами. Повторные запросы кешируются на небольшое время, поэтому это дополнительно разгружает бекенды модулей. Все работает асинхронно и написано на FastAPI.

Мониторинг

Сервис работает стабильно. На самом нагруженном окружении, в день он обрабатывает под тысячу событий и отправляет несколько тысяч email-писем и столько же уведомлений в браузер.

Для технического мониторинга, показатели CPU, RAM, количество тасок — выведены в Graphana.

В среднем нагрузка на процессор — несколько процентов CPU, в пиках доходит до 10% CPU.

Потребление памяти - 1,8 Гбайт.

Потребление сервиса веб-сокетов совсем мизерное:

CPU:

RAM:

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

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

Выводы

Кто думает об уведомлениях веб-сервисов как об источнике технической сложности? Дьявол, как всегда, в деталях (требований).

Можно ли было достичь того же самого результата меньшими усилиями, изменив принципиальную архитектуру решения? Например, перенести большинство функциональности в индивидуальные модули, в которых рождаются сообщения? Но что делать тогда с дайджестами? Рассуждение для иной статьи.

Мы сделали этот сервис в плотном сотрудничестве с технарями и продакт-директором заказчика. Почти все схемы-иллюстрации — только части более подробных диаграмм, которые мы готовили в процессе согласования архитектуры с техническим директором клиента и которые стали частью документации, которую мы передали вместе с работающим проектом.

Над проектом работали:

Архитектор — Алексей Чудин

Бекенд, соавторы этой статьи — Вячеслав Набатчиков и Николай Кирьянов

Фронтенд — Иван Седов

Менеджер — Анастасия Шаркова