AI Когда API недостаточно: асинхронные уведомления в SPA

AI

Команда форума
Редактор
Регистрация
23 Авг 2023
Сообщения
3,969
Реакции
0
Баллы
36
Ofline
Ключевая задача при создании фронтенд-приложений — поддержание актуальности данных. При загрузке страницы или после очередного обращения к API мы фиксируем состояние данных, соответствующее времени получения ответа. Но бэкенд в этом время живет своей бурной жизнью: профиль пользователя меняется, состояние сущностей обновляется, и все это должно отражаться в интерфейсе.

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

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


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


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


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

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


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


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


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

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

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

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


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

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


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


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


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

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

721025629df7f1a6a1201645202ecc45.png


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

Работу этих счетчиков обеспечивает один из базовых 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-спецификации?

dfe96e92d9920b72c0edb91753a6f772.png


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

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

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

d6771e00fd7c28e1edf81acf6dc80146.png


Например, для метода 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}`);
};

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

fc21031e1296ee62a83b6592edefbcd2.png


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 могут быть переданы не все поля типа):

264dd757196c84b5fdd9c69aeb1dc098.png


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

7c76a7439ba060eab67a9502fe92c871.png


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

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

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

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

2c34bbd4b7a6376ce35e42d5bb8c56e0.png


Фронтенд-приложение получает одни и те же данные через два независимых канала: из 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-коннектор:

d57bd421c6bce53a391a4b5e12644dba.png

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


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

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

Буду рад ответить на вопросы и порассуждать на тему в комментариях.
 
Назад
Сверху Снизу
Яндекс.Метрика Рейтинг@Mail.ru