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

Меня зовут Станислав Решетнев, я руковожу отделом разработки �� компании Sape по направлению Link Building (инструменты для продвижения в поисковых системах). В этой статье хочу рассказать об оригинальном архитектурном решении, которое мы внедрили, чтобы пользовательский интерфейс всегда оставался актуальным.

Зачем нужны асинхронные уведомления

Для поддержания актуальности данных на фронтенде обычно используют два подхода:

  • Pull. Фронтенд сам запрашивает изменения, периодически опрашивая API. Высокой актуальности здесь не добиться, она ограничена частотой опроса. К тому же велики накладные расходы: приложение создает лишний трафик и нагружает бэкенд.

  • Push. Фронтенд подписывается на изменения и получает данные от бэкенда по мере их появления. Для этого нужен канал связи — открытое соединение, по которому приходят пакеты с данными. Мы используем SSE (об этом подробнее ниже).

Поговорим немного о назначении асинхронных уведомлений. Вот несколько ситуаций, в которых они помогают:

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

  2. Клиент загрузил контент, и тот прошел модерацию. Требуется обновить статус контента в интерфейсе.

  3. Клиент отправил тикет в поддержку и через некоторое время получил ответ. Этот ответ нужно показать как можно быстрее.

У клиента недостаточно средств на балансе, чтобы купить ссылку
У клиента недостаточно средств на балансе, чтобы купить ссылку

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

Наше решение: объединяем стандартный API и канал SSE

Чтобы понять, как это работает, посмотрим на конкретный пример. В нашей системе есть авторизованный пользователь, который может выступать в двух ролях: рекламодатель (покупает размещения) и исполнитель (выполняет размещения). У каждой роли есть счетчики, которые могут меняться фоново.

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

  • число заявок, требующих действия;

  • число заявок с непрочитанными комментариями;

  • число заявок на проверку размещения и т.д.

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

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

Работу этих счетчиков обеспечивает один из базовых API-методов — получение информации о текущем авторизованном пользователе (operationId в OpenAPI – getInfo). Посмотрим ту часть спецификации, которая касается этих счетчиков:

    "/rest/User/info": {
      "get": {
        "tags": [
          "User"
        ],
        "summary": "Returns information about the current user",
        "operationId": "getInfo",
        "responses": {
          "200": {
            "description": "Information about the current user",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer",
                      "format": "int32",
                      "minimum": 0,
                      "title": "User ID"
                    },
                    "locale": {
                      "type": "string",
                      "title": "locale (language) of user interfaces in all Links subsystems",
                      "enum": [
                        "ru",
                        "en"
                      ]
                    },
                    "login": {
                      "type": "string",
                      "title": "user login",
                      "minimum": 3,
                      "maximum": 320
                    },
// (пропущено)
                    "seoCounters": {
                      "$ref": "#/components/schemas/SeoCounters"
                    },
// (пропущено)

А вот схема самих счетчиков:

     "SeoCounters": {
        "type": "object",
        "title": "SEO counters",
        "description": "Number of links requiring action",
        "readOnly": true,
        "properties": {
          "nofRequests": {
            "type": "integer",
            "format": "int32",
            "title": "Applications (number of links)",
            "minimum": 0,
            "default": 0
          },
          "nofLinksStatusNeedApprove": {
            "type": "integer",
            "format": "int32",
            "title": "Number of links requiring confirmation",
            "minimum": 0,
            "default": 0
          },
          "nofLinksWithUnreadComments": {
            "type": "integer",
            "format": "int32",
            "title": "Number of links, with unread comments",
            "minimum": 0,
            "default": 0
          },
          "nofChanges": {
            "type": "integer",
            "format": "int32",
            "title": "Changes in links (number of links)",
            "minimum": 0,
            "default": 0
          },
          "nofPlacementCheck": {
            "type": "integer",
            "format": "int32",
            "title": "Check placement (number of links)",
            "minimum": 0,
            "default": 0
          },
// (и так далее)

Бэкенд готовит эти данные, хранит их в таблицах БД и по запросу с фронтенда отдает через API getInfo. Когда данные в БД меняются, мы могли бы отправлять их на фронтенд через альтернативный канал. И тут возникает естественный вопрос: почему бы для таких пушей не переиспользовать уже готовую структуру из OpenAPI-спецификации?

На схеме видно: данные о счетчиках хранятся в таблице user_counters. Левая часть нашей схемы вполне стандартная. Но в правой части у нас появляется интеграция с Платформой Данных (Data Platform) через CDC (Change Data Capture — съем изменяемых данных). CDC позволяет нам в режиме реального времени отслеживать изменения в таблице и в виде потока изменений переправлять их для дальнейшей обработки.

Data Platform — это название корпоративной подсистемы, которая дает ряд стандартных инструментов для организации ETL-процессов (Extract-Transform-Load). Одним из таких инструментов является подсистема асинхронных уведомлений для UI. Подробнее о ней расскажу ниже.

Как уже было отмечено: фронтенд-приложение общается с понятным ему API. У нас это OpenAPI, поэтому протокол взаимодействия формализован и является единым источником истины как для фронтенда, так и для бэкенда. Более того, в нашей архитектуре OpenAPI-спецификация не принадлежит ни той ��и другой стороне. Она определяет протокол взаимодействия, на базе которого автоматически генерируется слой REST-адаптеров приложений (в терминологии гексагональной архитектуры):

Например, для метода getInfo на фронтенде автоматически генерируются TypeScript-типы:

/**
 * Generated by orval v7.1.1 🍺
 * Do not edit manually.
 * OAS API
 * OpenAPI spec version: 1.0.0
 */
import type { GetInfo200Locale } from './getInfo200Locale';
import type { SeoCounters } from './seoCounters';

export type GetInfo200 = {
  /** @minimum 0 */
  id: number;
  locale: GetInfo200Locale;
  /**
   * @minimum 3
   * @maximum 320
   */
  login: string;
  seoCounters?: SeoCounters;
// (пропущено)
};

По нашим соглашениям название TypeScript-типа формируется как название operationId в CamelCase + HTTP-код ответа (200). Структуры $ref из спецификации генерируются как отдельные типы:

/**
 * Generated by orval v7.1.1 🍺
 * Do not edit manually.
 * OAS API
 * OpenAPI spec version: 1.0.0
 */

/**
 * Number of links requiring action
 */
export interface SeoCounters {
  /** @minimum 0 */
  nofRequests?: number;
  /** @minimum 0 */
  nofLinksStatusNeedApprove?: number;
  /** @minimum 0 */
  nofLinksWithUnreadComments?: number;
  /** @minimum 0 */
  nofChanges?: number;
  /** @minimum 0 */
  nofPlacementCheck?: number;
// (и так далее)
}

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

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

Что такое SSE и как он интегрирован с нашим приложением

Раньше для отправки данных в фронтенд-приложения использовали протокол WebSocket, но работать с ним не так просто: он бинарный, поэтому требует немалой обвязки. Современным стандартом для push-уведомлений в UI стал SSE.

SSE (Server-Sent Events) — дословно «события, отправляемые сервером». Эта технология широко поддерживается браузерами с 2020 года. Технически в JavaScript появляется новый объект EventSource, который нужно привязать к URL — поставщику событий. Взаимодействие работает по протоколу HTTP/2, но только в одну сторону (можно получать события из браузера, но не отправлять на сервер). После успешной подписки достаточно установить обработчик onmessage, чтобы начать реагировать на приходящие события:

const evtSource = new EventSource("//api.example.com/sse-demo.php", {
  withCredentials: true,
});

evtSource.onmessage = (event) => {
  console.log(`Сообщение: ${event.data}`);
};

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

Mercure — open-source платформа, созданная для быстрой и надежной коммуникации между приложениями на основе SSE. В центре платформы находится Mercure Hub, который принимает сообщения из внешних систем через REST-интерфейс и обеспечивает подписку через SSE у получателей. Хаб поднимается в виде контейнера с внешней базой данных.

Mercure поддерживает авторизацию при публикации и подписке через JWT. Можно гибко управлять топиками уведомлений и разграничивать доступы к ним в JWT.

Пример запроса на публикацию события в Mercure Hub (взято из документации):

curl -d 'topic=https://example.com/books/1' -d 'data={"foo": "updated value"}' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdLCJzdWJzY3JpYmUiOlsiaHR0cHM6Ly9leGFtcGxlLmNvbS9teS1wcml2YXRlLXRvcGljIiwie3NjaGVtZX06Ly97K2hvc3R9L2RlbW8vYm9va3Mve2lkfS5qc29ubGQiLCIvLndlbGwta25vd24vbWVyY3VyZS9zdWJzY3JpcHRpb25zey90b3BpY317L3N1YnNjcmliZXJ9Il0sInBheWxvYWQiOnsidXNlciI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdXNlcnMvZHVuZ2xhcyIsInJlbW90ZUFkZHIiOiIxMjcuMC4wLjEifX19.KKPIikwUzRuB3DTpVw6ajzwSChwFw5omBMmMcWKiDcM' -X POST https://localhost/.well-known/mercure

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

Мы написали корпоративную библиотеку для подписки на SSE через Mercure Hub. Приложение, которое использует эту библиотеку, автоматически подписывается на нужные топики Mercure Hub (определяется на уровне корпоративной платформы) и настраивает обработчики на типы поступающих событий. Как я описал выше, фронтенд-приложение использует ровно те же структуры, что и в API. Например, мы переиспользуем тот самый тип GetInfo200 в приходящих по SSE уведомлениях.

Подключение выглядит примерно так:

import { emitter } from '@/plugins/emitter';
import {
  User,
} from '@/types';
import { initSSE } from '@sape/vue-ui-next/sse';

initSSE(
  {
    GetInfo200(payload) {
      emitter.emit('sse-update-user-info', payload as Partial<User>);
    },
  }
);

Здесь User — алиас на GetInfo200 (TypeScript-тип Partial задан как часть платформенных соглашений, согласно которым в SSE могут быть переданы не все поля типа): 

Получив асинхронное уведомление по SSE, библиотека, согласно конфигурации, генерирует событие, на которое подписаны обработчики в нужных компонентах: 

Приложение при инициализации подписывается на хаб:

Скриншот из Google Chrome
Скриншот из Google Chrome

В итоге схематически взаимодействие выглядит вот так:

Фронтенд-приложение получает одни и те же данные через два независимых канала: из API по запросу (например, когда пользователь выполняет явное действие) и из Mercure Hub по SSE о тех событиях, которые произошли в бэкенде асинхронно.

Инфраструктурные нюансы внедрения

Первое ограничение, с которым мы столкнулись при вводе в прод, — отсутствие кластерной реализации в open-source версии. Дело в том, что в Mercure Hub существует понятие транспорта (transport), который используется для хранения состояния хаба. Состояние включает в себя, в частности, журнал событий (наши уведомления), который используется в случаях, когда у хаба возникает очередь на отправку. И в open-source версии есть только два таких транспорта:

  • BoltDB — встраиваемая БД.

  • Local — хранение состояния в виде локального файла.

Оба этих транспорта не кластеризуемы, что не позволяет масштабировать Mercure Hub и добиться надежности хранения данных. Чтобы обойти это ограничение, мы написали собственный транспорт для работы с Postgres. Упаковали эту реализацию в корпоративный Docker-образ.

Вуаля, кластерная версия готова:

Графики стандартного мониторинга Mercure Hub
Графики стандартного мониторинга Mercure Hub

Далее потребовалось готовить данные и отправлять их на хаб. В Sape существует Платформа Данных и ее составляющая — Шина Данных. Это подсистема, основанная на Kafka, которая переправляет данные из приложений-поставщиков потребителям (если вам будет интересно, расскажу о ней в отдельной статье).

Пока же остановимся на том, что существует конвейер данных, который, получая изменения из БД приложений по CDC, выполняет преобразования и формирует сообщения в Kafka-топике:

Скриншот из Kafka UI
Скриншот из Kafka UI

Оттуда в Mercure Hub их переправляет Sink-коннектор:

Заключительные соображения

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

В этой статье я постарался показать, как нам удалось благодаря подходу Manifest First и OpenAPI придумать новую подсистему, использующую уже существующие контракты для передачи уведомлений в UI.

Буду рад ответить на вопросы и порассуждать на тему в комментариях.