Уверен каждый web-разработчик сталкивался с задачей оперативного обновления WEB UI по событию на back-end. Классический пример – web-чат (если вы уже написали свой огненный web-чат дальше можете не читать, скорее всего все нижесказанное вам уже известно).

В Х5 с подобными задачами я сталкиваюсь с завидной периодичностью. Должен сказать всевозможные чаты, чат-боты и прочее RealTime взаимодействие с конечным пользователем сейчас в тренде. В этой статье хочу резюмировать свой опыт в этом вопросе и поделиться им с читателями Хабра.

Постановка задачи

Нужен универсальный и производительный транспорт для доставки событий с WEB UI (browser пользователя) на сервер и обратно от сервера к WEB UI

Вариант №1 - ПЕРИОДИЧЕСКИЕ ОПРОСЫ

самый простой и самый неэффективный способ

Смысл понятен из названия, со стороны Client2 периодически, например, раз в 1 секунду, на сервер улетают запросы, типа «Есть чё?».

У данного подхода есть два существенных недостатка:

  1. Пока у вас 1-10 клиентов, это вполне норм, но когда к вам придет 1-10К пользователей, ваш сервер, мягко говоря, нагреется. При такой реализации, не отправляя ни одно запроса с полезной нагрузкой, вы на ровном месте получаете хайлоад в 1-10к RPS.

  2. Второй минус этого решения — вы не получаете желаемый RealTime. Чем ниже частота опросов, тем дальше вы от RealTime.

Вариант №2 - ДЛИННЫЕ ОПРОСЫ

так же известны как long polling запросы

Похож на вариант №1, но отличие в том, что сервер, получив http-запрос, не торопиться на него отвечать (т.е. закрывать установленное соединение), а некоторое время ждёт, не появятся ли на сервера данные, которые можно отправить клиенту. Как только данные появляются, сервер отправляет их клиенту и соединение закрывается. Если данных нет продолжительное время, соединение так же закрывается по TimeOut

В свою очередь клиент, как только соединение закрылось, открывает новое и ждет новые данные от сервера.

Плюсы решения:

  1. Существенно экономит ресурсы сервера, т.к. в разы сокращается количество устанавливаемых http-соединений, а это, как мы все знаем, очень ресурсоемкая операция.

  2. Вы получаете желаемый RealTime, т.к. в большинстве случаев сообщение улетает клиенту моментально, как только оно появляется на сервере. Исключения составляют те случаи, когда серверное сообщение появилось в момент установки нового http соединения. Если частота событий на сервере не высокая, в большинстве случаев этой погрешностью можно пренебречь, т.к. скорее всего она не окажет сильного влияния.

Минус решения - чуть более сложная реализация по сравнению с вариантом №1.

Вариант №3 - SERVER SENT EVENT (SSE)

постоянное соединение + реализация клиента в API браузера

Спецификацию Server-Sent Events реализует браузерный класс EventSource, который позволяет поддерживать постоянное соединение с сервером и получать от него события. Безусловно соединение может в любой момент по любой причине оборваться, в этом случае EventSource попытается его восстановить. По умолчанию между попытками возобновить соединение будет небольшая пауза в несколько секунд. Сервер может выставить рекомендуемую задержку, указав в ответе retry: (в миллисекундах)

УРААА!!! на этом статью можно заканчивать – РЕШЕНИЕ НАЙДЕНО!

Но нет, у этого решения так же есть свои особенности, например, однонаправленность.

Для многих случаев это ограничение может быть не существенно. Но если рассмотреть пример реализации чата, то с использованием SSE вы можете оперативно получать сообщения с сервера, но отправлять вы их будете скорее всего по REST. А это означает что на каждое сообщение вы будете открывать и закрывать http-соединение. А если у вас, например, 1-10К сообщений в секунду (в большом чате это легко может быть), см Вариант - №1 :(, единственное, что утешает, этот хайлоад уже будет с полезной нагрузкой.

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

Вариант №4 - WEBSOCKET

WEBSOCKET в отличие от SSE уже является двунаправленным и поддерживает бинарный формат передачи данных. Ниже краткое сравнение SSE и WEBSOCKET.

WEBSOCKET

SSE

Двунаправленность: и сервер, и клиент могут обмениваться сообщениями

Однонаправленность: данные посылает только сервер

Бинарные и текстовые данные

Только текст

Протокол WebSocket

Обычный HTTP

Далее я хотел бы поделиться своими наработками в части реализации FrontEnd <-> BackEnd брокера сообщений с использованием Websocket и Golang.

Где и кому это может пригодиться?

Кейс №1 - нужно оперативно обновлять web-интерфейс пользователя на основе серверных событий (сообщения из чата, app уведомления и т.п.):

  1. FrontEnd подписывается на событие BackEnd (WS)

  2. BackEnd отправляет подписчикам свои события (WS)

  3. FrontEnd реагирует на событие и при необходимости делает доп. Запрос (REST) на сервер и забирает все необходимые данные для обработки наступившего события (например, обновления интерфейса)

Технически 3-й пункт можно исключить и все данные передавать сразу в п.2, но хотелось создать инструмент, который способен работать адски быстро с большим количеством подписчиков и высокой частотой событий.

Кейс 2 - нужно в большом объеме(часто) отправлять мелкие сообщения на сервер, например, кастомный сбор метрик с фронта

  1. BackEnd подписывается на событие (WS)

  2. FrontEnd отправляет событии (WS)

Как это работает

Запуск сервиса и подключение клиентов (вся магия здесь)

При запуске сервиса выполняются следующие действия:

  1. поднимается интерфейс подключения клиентов к сервису (http://your_domain/ws)

  2. запускается Go рутина «HUB», она будет отвечать за маршрутизацию запросов между клиентами

Для подключения каждый новый клиент должен по http обратиться на адрес http://your_domain/ws После чего сервер выполнит два основных действия:

  1. для каждого нового клиента создаст пару Go рутин (одна на чтение, другая на запись в ws канал)

  2. изменит http соединение на ws и ответит клиенту “CLIENT_CONNECTED”

Структура сообщений

// Message ...
type Message struct {
    Type        string `json:"type,omitempty"`
    Event       string `json:"event"`
    Data        string `json:"data"`
}

Event – наименование события

Data – данные события

Type – определяет способ обработки сообщения на сервере

Если поле Type = publish, содержимое Data будет доставлено всем подписчикам события, значение которого указано в поле Event

Если поле Type = broadcast, содержимое Data будет доставлено всем клиентам

Если поле Type = subscribe, сообщение будет обработано как подписка клиента на событие, значение которого будет находиться в поле Event

Если поле Type = unsubscribe, сообщение будет обработано как отписка клиента от события, значение которого будет находиться в поле Event

Примеры сценариев клиент-серверного взаимодействия

«Подписка на событие»
«Подписка на событие»

«Публикация события»
«Публикация события»
«Широковещательная рассылка»
«Широковещательная рассылка»
«Отписка от события»
«Отписка от события»

Изложенный выше подход показал себя очень стабильным и высоко производительным. Так например, на MacBook Pro i5 8Gb сервис стабильно держал 12K RPS

Из минусов - решение можно масштабировать пока что только вертикально. В планах доработать сервис и посадить его на общую шину. В этом случае появится возможность горизонтального масштабирования.

Исходный код сервиса можно найти здесь. Для удобства в него сразу встроен легкий js/sdk (6Kb) для для простого подключения и использования в web-проектах.

Основные фичи SDK:

  1. Функции - обертки реализующие процессы подключения, подписки, отписки и публикации сообщений. Перечисленные функции сформируют нужные сообщения и отправят их на сервер

  2. Автореконнект – если соединение порвалось, SDK это отследит и попытается переподключиться с увеличением интервала между неудачными попытками. При успешном подключении все подписки восстанавливаются

Подключить sdk к проекту можно так:

<script src="http://localhost:9000/sdk/js" async onload="initEventTube()"></script>

где localhost:9000 – это хост и порт на котором запущен сервис

Пример подключение клиента к сервису:

function initEventTube(){
  var options={
    connection:{
      host:'localhost',
      port:'9000'
    }
  }
  var eventTube=new EventTube(options);
  window.EventTube=eventTube;
  window.EventTube.connect();
}

Пример подписки на событие:

var self=this;
var subscriptionId=null;
window.EventTube.sub('YOUR_EVENT_NAME',function(data){
  //Обработчик события
  console.log(data);
}).then(function(subId){
  // Обработчик успешной подписки
  subscriptionId = subId;
  сonsole.log('subId:',subId);
},function(err){
  // Обработчик ошибки подписки
  console.log(err);
});

Пример публикация события:

window.EventTube.pub('YOUR_EVENT_NAME', 'YOUR_EVENT_DATA');

Пример отписки от события:

window.EventTube.unsub('YOUR_EVENT_NAME', 'OPTIONAL_SUB_ID');

Технически у одного события может быть несколько обработчиков. Если указать параметр OPTIONAL_SUB_ID, то от события отпишется конкретный обработчик, если параметр не задан, отписываются все обработчики события. Значение SUB_ID возвращается при каждой успешной подписке (см. пример «Подписка клиента на событие»)

Запуск сервиса из исходников:

Для запуска потребуется настроенное Golang окружение. Подробности настройки Golang можно почитать здесь

$ git clone git@github.com:colber/eventtube-server.git your_dir
$ cd your_dir
$ go run main.go

Запуск сервиса из Docker

$ docker pull ptimofeev/eventtube:latest
$ docker run --name eventtube --rm -p 9000:9000 ptimofeev/eventtube

после запуска сервис будет доступен по адресу: localhost:9000

Запуск демо чата

Для проверки работоспособности сервиса можно воспользоваться демо приложением - чат (исходники здесь)

$ git clone git@github.com:colber/eventtube-client.git your_dir
$ cd your_dir
$ yarn install
$ yarn serve

Если все прошло успешно по адресу http://localhost:8080 вы должны увидеть нечто подобное