- Регистрация
- 23 Авг 2023
- Сообщения
- 3,969
- Реакции
- 0
- Баллы
- 36
Ofline
Ключевая задача при создании фронтенд-приложений — поддержание актуальности данных. При загрузке страницы или после очередного обращения к API мы фиксируем состояние данных, соответствующее времени получения ответа. Но бэкенд в этом время живет своей бурной жизнью: профиль пользователя меняется, состояние сущностей обновляется, и все это должно отражаться в интерфейсе.
Меня зовут Станислав Решетнев, я руковожу отделом разработки в компании Sape по направлению Link Building (инструменты для продвижения в поисковых системах). В этой статье хочу рассказать об оригинальном архитектурном решении, которое мы внедрили, чтобы пользовательский интерфейс всегда оставался актуальным.
Для поддержания актуальности данных на фронтенде обычно используют два подхода:
Поговорим немного о назначении асинхронных уведомлений. Вот несколько ситуаций, в которых они помогают:
У клиента недостаточно средств на балансе, чтобы купить ссылку
Я привел эти примеры для того, чтобы показать, что асинхронные уведомления, с одной стороны, являются неотъемлемой частью современных приложений, но с другой, решают важнейшие бизнес-задачи. Когда все, что происходит на бэкенде, отражается в интерфейсе, появляются возможности для новых бизнес-функций.
Чтобы понять, как это работает, посмотрим на конкретный пример. В нашей системе есть авторизованный пользователь, который может выступать в двух ролях: рекламодатель (покупает размещения) и исполнитель (выполняет размещения). У каждой роли есть счетчики, которые могут меняться фоново.
Рекламодатель создает заявки на размещения. Они проходят рабочий процесс, пока не будут приняты системой и самим рекламодателем. Соответственно, у него появляются счетчики:
Все это мы отображаем в интерфейсе. Когда счетчики меняются, интерфейс тоже меняется: разблокируются или предлагаются действия:
Задача нашей системы асинхронных уведомлений в том, чтобы пользователь постоянно видел актуальную информацию. Для этого не должна требоваться перезагрузка страницы.
Работу этих счетчиков обеспечивает один из базовых API-методов — получение информации о текущем авторизованном пользователе (operationId в OpenAPI – getInfo). Посмотрим ту часть спецификации, которая касается этих счетчиков:
А вот схема самих счетчиков:
Бэкенд готовит эти данные, хранит их в таблицах БД и по запросу с фронтенда отдает через 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-типы:
По нашим соглашениям название TypeScript-типа формируется как название operationId в CamelCase + HTTP-код ответа (200). Структуры $ref из спецификации генерируются как отдельные типы:
Таким образом, у нас уже есть структуры в фронтенд-приложении, которые описывают ответ API. Их же мы будем использовать и для альтернативного канала получения данных в приложении — пушей через систему асинхронных уведомлений через SSE.
Давайте познакомимся с этой технологией и посмотрим, как подружить ее с приложением.
Раньше для отправки данных в фронтенд-приложения использовали протокол WebSocket, но работать с ним не так просто: он бинарный, поэтому требует немалой обвязки. Современным стандартом для push-уведомлений в UI стал SSE.
SSE (Server-Sent Events) — дословно «события, отправляемые сервером». Эта технология широко поддерживается браузерами с 2020 года. Технически в JavaScript появляется новый объект EventSource, который нужно привязать к URL — поставщику событий. Взаимодействие работает по протоколу HTTP/2, но только в одну сторону (можно получать события из браузера, но не отправлять на сервер). После успешной подписки достаточно установить обработчик onmessage, чтобы начать реагировать на приходящие события:
Mercure — open-source платформа, созданная для быстрой и надежной коммуникации между приложениями на основе SSE. В центре платформы находится Mercure Hub, который принимает сообщения из внешних систем через REST-интерфейс и обеспечивает подписку через SSE у получателей. Хаб поднимается в виде контейнера с внешней базой данных.
Mercure поддерживает авторизацию при публикации и подписке через JWT. Можно гибко управлять топиками уведомлений и разграничивать доступы к ним в JWT.
Пример запроса на публикацию события в Mercure Hub (взято из документации):
Итак, теперь у нас есть реализация SSE-хаба, и мы можем переправлять через него наши уведомления. Но как быть с приложением? Нужно стандартизировать передаваемые сообщения и интегрировать их с фронтендом.
Мы написали корпоративную библиотеку для подписки на SSE через Mercure Hub. Приложение, которое использует эту библиотеку, автоматически подписывается на нужные топики Mercure Hub (определяется на уровне корпоративной платформы) и настраивает обработчики на типы поступающих событий. Как я описал выше, фронтенд-приложение использует ровно те же структуры, что и в API. Например, мы переиспользуем тот самый тип GetInfo200 в приходящих по SSE уведомлениях.
Подключение выглядит примерно так:
Здесь User — алиас на GetInfo200 (TypeScript-тип Partial задан как часть платформенных соглашений, согласно которым в SSE могут быть переданы не все поля типа):
Получив асинхронное уведомление по SSE, библиотека, согласно конфигурации, генерирует событие, на которое подписаны обработчики в нужных компонентах:
Приложение при инициализации подписывается на хаб:
Скриншот из Google Chrome
В итоге схематически взаимодействие выглядит вот так:
Фронтенд-приложение получает одни и те же данные через два независимых канала: из API по запросу (например, когда пользователь выполняет явное действие) и из Mercure Hub по SSE о тех событиях, которые произошли в бэкенде асинхронно.
Первое ограничение, с которым мы столкнулись при вводе в прод, — отсутствие кластерной реализации в open-source версии. Дело в том, что в Mercure Hub существует понятие транспорта (transport), который используется для хранения состояния хаба. Состояние включает в себя, в частности, журнал событий (наши уведомления), который используется в случаях, когда у хаба возникает очередь на отправку. И в open-source версии есть только два таких транспорта:
Оба этих транспорта не кластеризуемы, что не позволяет масштабировать Mercure Hub и добиться надежности хранения данных. Чтобы обойти это ограничение, мы написали собственный транспорт для работы с Postgres. Упаковали эту реализацию в корпоративный Docker-образ.
Вуаля, кластерная версия готова:
Графики стандартного мониторинга Mercure Hub
Далее потребовалось готовить данные и отправлять их на хаб. В Sape существует Платформа Данных и ее составляющая — Шина Данных. Это подсистема, основанная на Kafka, которая переправляет данные из приложений-поставщиков потребителям (если вам будет интересно, расскажу о ней в отдельной статье).
Пока же остановимся на том, что существует конвейер данных, который, получая изменения из БД приложений по CDC, выполняет преобразования и формирует сообщения в Kafka-топике:
Скриншот из Kafka UI
Оттуда в Mercure Hub их переправляет Sink-коннектор:
Обеспечить актуальный пользовательский интерфейс не так-то просто. В дополнение к стандартной работе с API необходимо решать проблему доставки тех изменений на бэкенде, которые происходят фоново. Их нужно правильно обработать в фронтенд-приложении. Если существует общекорпоративная платформа, это большой плюс: на базе ее стандартных компонентов можно решать новые задачи изящно и органично.
В этой статье я постарался показать, как нам удалось благодаря подходу Manifest First и OpenAPI придумать новую подсистему, использующую уже существующие контракты для передачи уведомлений в UI.
Буду рад ответить на вопросы и порассуждать на тему в комментариях.
Меня зовут Станислав Решетнев, я руковожу отделом разработки в компании Sape по направлению Link Building (инструменты для продвижения в поисковых системах). В этой статье хочу рассказать об оригинальном архитектурном решении, которое мы внедрили, чтобы пользовательский интерфейс всегда оставался актуальным.
Зачем нужны асинхронные уведомления
Для поддержания актуальности данных на фронтенде обычно используют два подхода:
Pull. Фронтенд сам запрашивает изменения, периодически опрашивая API. Высокой актуальности здесь не добиться, она ограничена частотой опроса. К тому же велики накладные расходы: приложение создает лишний трафик и нагружает бэкенд.
Push. Фронтенд подписывается на изменения и получает данные от бэкенда по мере их появления. Для этого нужен канал связи — открытое соединение, по которому приходят пакеты с данными. Мы используем SSE (об этом подробнее ниже).
Поговорим немного о назначении асинхронных уведомлений. Вот несколько ситуаций, в которых они помогают:
Клиент пополнил счет в личном кабинете, через некоторое время деньги поступили. Нужно показать актуальный баланс и дать возможность сделать покупку.
Клиент загрузил контент, и тот прошел модерацию. Требуется обновить статус контента в интерфейсе.
Клиент отправил тикет в поддержку и через некоторое время получил ответ. Этот ответ нужно показать как можно быстрее.
У клиента недостаточно средств на балансе, чтобы купить ссылку
Я привел эти примеры для того, чтобы показать, что асинхронные уведомления, с одной стороны, являются неотъемлемой частью современных приложений, но с другой, решают важнейшие бизнес-задачи. Когда все, что происходит на бэкенде, отражается в интерфейсе, появляются возможности для новых бизнес-функций.
Наше решение: объединяем стандартный 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}`);
};
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
В итоге схематически взаимодействие выглядит вот так:
Фронтенд-приложение получает одни и те же данные через два независимых канала: из API по запросу (например, когда пользователь выполняет явное действие) и из Mercure Hub по SSE о тех событиях, которые произошли в бэкенде асинхронно.
Инфраструктурные нюансы внедрения
Первое ограничение, с которым мы столкнулись при вводе в прод, — отсутствие кластерной реализации в open-source версии. Дело в том, что в Mercure Hub существует понятие транспорта (transport), который используется для хранения состояния хаба. Состояние включает в себя, в частности, журнал событий (наши уведомления), который используется в случаях, когда у хаба возникает очередь на отправку. И в open-source версии есть только два таких транспорта:
BoltDB — встраиваемая БД.
Local — хранение состояния в виде локального файла.
Оба этих транспорта не кластеризуемы, что не позволяет масштабировать Mercure Hub и добиться надежности хранения данных. Чтобы обойти это ограничение, мы написали собственный транспорт для работы с Postgres. Упаковали эту реализацию в корпоративный Docker-образ.
Вуаля, кластерная версия готова:
Графики стандартного мониторинга Mercure Hub
Далее потребовалось готовить данные и отправлять их на хаб. В Sape существует Платформа Данных и ее составляющая — Шина Данных. Это подсистема, основанная на Kafka, которая переправляет данные из приложений-поставщиков потребителям (если вам будет интересно, расскажу о ней в отдельной статье).
Пока же остановимся на том, что существует конвейер данных, который, получая изменения из БД приложений по CDC, выполняет преобразования и формирует сообщения в Kafka-топике:
Скриншот из Kafka UI
Оттуда в Mercure Hub их переправляет Sink-коннектор:
Заключительные соображения
Обеспечить актуальный пользовательский интерфейс не так-то просто. В дополнение к стандартной работе с API необходимо решать проблему доставки тех изменений на бэкенде, которые происходят фоново. Их нужно правильно обработать в фронтенд-приложении. Если существует общекорпоративная платформа, это большой плюс: на базе ее стандартных компонентов можно решать новые задачи изящно и органично.
В этой статье я постарался показать, как нам удалось благодаря подходу Manifest First и OpenAPI придумать новую подсистему, использующую уже существующие контракты для передачи уведомлений в UI.
Буду рад ответить на вопросы и порассуждать на тему в комментариях.