Уверен каждый web-разработчик сталкивался с задачей оперативного обновления WEB UI по событию на back-end. Классический пример – web-чат (если вы уже написали свой огненный web-чат дальше можете не читать, скорее всего все нижесказанное вам уже известно).
В Х5 с подобными задачами я сталкиваюсь с завидной периодичностью. Должен сказать всевозможные чаты, чат-боты и прочее RealTime взаимодействие с конечным пользователем сейчас в тренде. В этой статье хочу резюмировать свой опыт в этом вопросе и поделиться им с читателями Хабра.
Постановка задачи
Нужен универсальный и производительный транспорт для доставки событий с WEB UI (browser пользователя) на сервер и обратно от сервера к WEB UI
Вариант №1 - ПЕРИОДИЧЕСКИЕ ОПРОСЫ
самый простой и самый неэффективный способ
Смысл понятен из названия, со стороны Client2 периодически, например, раз в 1 секунду, на сервер улетают запросы, типа «Есть чё?».
У данного подхода есть два существенных недостатка:
Пока у вас 1-10 клиентов, это вполне норм, но когда к вам придет 1-10К пользователей, ваш сервер, мягко говоря, нагреется. При такой реализации, не отправляя ни одно запроса с полезной нагрузкой, вы на ровном месте получаете хайлоад в 1-10к RPS.
Второй минус этого решения — вы не получаете желаемый RealTime. Чем ниже частота опросов, тем дальше вы от RealTime.
Вариант №2 - ДЛИННЫЕ ОПРОСЫ
так же известны как long polling запросы
Похож на вариант №1, но отличие в том, что сервер, получив http-запрос, не торопиться на него отвечать (т.е. закрывать установленное соединение), а некоторое время ждёт, не появятся ли на сервера данные, которые можно отправить клиенту. Как только данные появляются, сервер отправляет их клиенту и соединение закрывается. Если данных нет продолжительное время, соединение так же закрывается по TimeOut
В свою очередь клиент, как только соединение закрылось, открывает новое и ждет новые данные от сервера.
Плюсы решения:
Существенно экономит ресурсы сервера, т.к. в разы сокращается количество устанавливаемых http-соединений, а это, как мы все знаем, очень ресурсоемкая операция.
Вы получаете желаемый 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 уведомления и т.п.):
FrontEnd подписывается на событие BackEnd (WS)
BackEnd отправляет подписчикам свои события (WS)
FrontEnd реагирует на событие и при необходимости делает доп. Запрос (REST) на сервер и забирает все необходимые данные для обработки наступившего события (например, обновления интерфейса)
Технически 3-й пункт можно исключить и все данные передавать сразу в п.2, но хотелось создать инструмент, который способен работать адски быстро с большим количеством подписчиков и высокой частотой событий.
Кейс 2 - нужно в большом объеме(часто) отправлять мелкие сообщения на сервер, например, кастомный сбор метрик с фронта
BackEnd подписывается на событие (WS)
FrontEnd отправляет событии (WS)
Как это работает
Запуск сервиса и подключение клиентов (вся магия здесь)
При запуске сервиса выполняются следующие действия:
поднимается интерфейс подключения клиентов к сервису (http://your_domain/ws)
запускается Go рутина «HUB», она будет отвечать за маршрутизацию запросов между клиентами
Для подключения каждый новый клиент должен по http обратиться на адрес http://your_domain/ws После чего сервер выполнит два основных действия:
для каждого нового клиента создаст пару Go рутин (одна на чтение, другая на запись в ws канал)
изменит 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:
Функции - обертки реализующие процессы подключения, подписки, отписки и публикации сообщений. Перечисленные функции сформируют нужные сообщения и отправят их на сервер
Автореконнект – если соединение порвалось, 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 вы должны увидеть нечто подобное
Shiny2
А можно ли подключиться не по http а по TCP и использовать стандартный юниксовский сокет, построенный на FIFO? Тоже двунаправленное соединение.
Ptimofeev Автор
В данной реализации нет. Но на эту тему есть отличная статья от mail.ru, в ней коллеги пишут как можно использовать zero-copy upgrade на «голом» TCP-соединении. TCP работает сильно быстрее HTTP:
BenchmarkUpgradeHTTP: 5156 ns/op 8576 B/op 9 allocs/op
BenchmarkUpgradeTCP: 973 ns/op 0 B/op 0 allocs/op