Хабрахабр

Centrifugo v2 — будущее сервера real-time сообщений и библиотека для Go

В данной статье речь пойдет о разработке второй версии сервера и новой real-time библиотеке для языка Go, лежащей в его основе. Возможно, некоторые из читателей уже слышали про Centrifugo раньше.

Летом прошлого года я присоединился к команде Авито, где сейчас помогаю разрабатывать бэкенд мессенджера Авито. Меня зовут Александр Емелин. Новая работа, напрямую связанная с быстрой доставкой сообщений пользователям, и новые коллеги вдохновили меня продолжать работу над open-source проектом Centrifugo.

В качестве транспорта используется Websocket или полифилл SockJS, умеющий, при невозможности установить Websocket-соединение, работать через Еventsource, XHR-streaming, long-polling и другие основанные на HTTP транспорты. В двух словах — это сервер, который берет на себя задачу держать постоянные соединения от пользователей вашего приложения. Другими словами – это PUB/SUB сервер. Клиенты подписываются на каналы, в которые бекенд через API Центрифуги публикует новые сообщения по мере их возникновения – после чего сообщения доставляются подписанным на канал пользователям.

Среди них, например, некоторые проекты Mail. На текущий момент сервер используется в достаточно большом количестве проектов. Ru (интранет, обучающие платформы Технопарк/Техносфера, центр Сертификации и др.), с помощью Centrifugo работает красивейший дашборд на ресепшн в московском офисе Badoo, а в сервисе spot.im 350 тысяч пользователей одновременно подключены к Центрифуге.

Несколько ссылок на предыдущие статьи, посвященные серверу и его применению, для тех, кто первый раз слышит про проект:

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

Иногда я замечал, как разработчики в ответ на это советуют посмотреть в сторону Centrifugo. В Go-сообществе время от времени встает вопрос — а есть ли альтернативы socket.io на Go? Также несколько раз меня спрашивали, можно ли переиспользовать код Centrifugo для того, чтобы писать real-time приложения на языке Go. Однако Centrifugo это self-hosted сервер, а не библиотека — сравнение не справедливое. Понятно, что рисковать так никому причин особых нет, а форкать тоже вариант так себе. И ответ был: теоретически можно, но на свой страх и риск — обратную совместимость API внутренних пакетов я гарантировать не мог. Плюс я бы не сказал, что API внутренних пакетов вообще было подготовлено к такому использованию.

Я верю, что это имеет смысл, принимая во внимание, сколько фич имеет Центрифуга для того, чтобы быть приспособленной к production. Поэтому одна из амбициозных задач, которые я хотел решить в процессе работы над второй версией сервера — попытаться выделить ядро сервера в отдельную библиотеку на Go. Об этих особенностях я писал ранее и еще обозначу некоторые из них ниже. Есть много доступных из коробки особенностей, призванных помочь с построением масштабируемых real-time приложений, снимая с разработчика необходимость писать собственное решение.

Большинство пользователей Centrifugo — это разработчики, которые пишут бекенд на языках/фреймворках со слабой поддержкой concurrency (например, Django/Flask/Laravel/...): работать с большим количеством постоянных соединений если и можно, то неочевидным или неэффективным способом. Попробую обосновать еще один плюс существования такой библиотеки. Поэтому даже совсем небольшое community Go-разработчиков вокруг библиотеки сможет помочь и в развитии использующего ее сервера Centrifugo. Соответственно, помочь с разработкой сервера, написанного на Go, могут далеко не все пользователи (банально из-за незнания языка).

Это все еще WIP, но абсолютно все заявленные в описании на Github фичи реализованы и работают. В итоге получилась библиотека Centrifuge. Таких пока нет. Поскольку библиотека предоставляет достаточно богатое API, прежде чем гарантировать обратную совместимость, хотелось бы услышать о нескольких успешных примерах использования в реальных проектах на Go. Никаких нет. Равно как и неуспешных:).

Но я считаю это правильный выбор, так как клиенты (такие как centrifuge-js, centrifuge-go) работают и с библиотекой Centrifuge, и с сервером Centrifugo. Я понимаю, что, назвав библиотеку практически так же как сервер, буду вечно иметь дело с путаницей. И все же для чуть большей ясности уточню еще раз: Плюс название уже достаточно прочно закрепилось в умах пользователей, и не хочется эти ассоциации терять.

  • Centrifuge — библиотека для языка Go,
  • Centrifugo — готовое решение, отдельный сервис, который в версии 2 будет построен на библиотеке Centrifuge.

Что имеется в виду? Centrifugo из-за своего дизайна (отдельно стоящий сервис, не знающий о вашем бекенде ничего) предполагает, что поток сообщений по real-time транспорту будет идти от сервера клиенту. Библиотека это ограничение снимает, позволяя организовать двунаправленный обмен асинхронными сообщениями между сервером и клиентом, а также RPC-вызовы. Если, например, пользователь пишет сообщение в чат, то это сообщение нужно сначала отправить на бекенд приложения (например, AJAX-ом в браузере), на стороне бекенда его провалидировать, сохранить в базу данных при необходимости, а затем отправить в API Центрифуги.

Сервер будет принимать сообщения от браузерных клиентов по Websocket, на клиенте будет текстовое поле, в которое можно вбить сообщение, нажать Enter — и сообщение отправится всем подписанным на канал пользователям. Давайте посмотрим на простой пример: реализуем небольшой сервер на Go с использованием библиотеки Centrifuge. Мне показалось, что удобнее всего будет разместить это в виде gist. То есть максимально упрощенный вариант чата.

Запустить можно как обычно:

git clone https://gist.github.com/2f1a38ae2dcb21e2c5937328253c29bf.git
cd 2f1a38ae2dcb21e2c5937328253c29bf
go get -u github.com/centrifugal/centrifuge
go run main.go

И затем переходите по адресу http://localhost:8000, откройте несколько вкладок браузера.

Connect() коллбек-функции: Как вы можете заметить, точка входа в бизнес-логику приложения происходит при навешивании On().

node.On().Connect(func(ctx context.Context, client *centrifuge.Client, e centrifuge.ConnectEvent) centrifuge.ConnectReply }) log.Printf("client connected via %s", client.Transport().Name()) return centrifuge.ConnectReply{}
})

Плюс похожий, только слабо типизированный, подход применяется в реализации socket-io сервера на Go. Подход на основе callback-функций мне показался наиболее удобным для взаимодействия с библиотекой. Если вдруг у вас есть мысли, как API можно было бы сделать более идиоматично — буду рад услышать.

Кто-то может отметить, что для таких целей проще взять библиотеку для работы с Websocket. Это очень простой пример, который не демонстрирует всех возможностей библиотеки. Это на самом деле так. Например, Gorilla Websocket. А что если: Правда, даже в таком случае вам придется скопировать приличный кусок кода сервера из примера в репозитории Gorilla Websocket.

  • вам нужно масштабировать приложение на несколько машин,
  • или вам нужен не один общий канал, а несколько – причем пользователи могут динамически подписываться и отписываться от них по мере навигации по вашему приложению,
  • или вам нужно работать тогда, когда Websocket-соединение установить не получилось (нет поддержки в браузере клиента, стоит браузерное расширение, какой-то прокси на пути между клиентом и сервером режет соединение),
  • или нужно восстанавливать сообщения, пропущенные клиентом во время коротких разрывов интернет-соединения не нагружая основную бд,
  • или нужен контроль авторизации пользователя в канале,
  • или нужно отключать постоянное подключение от пользователей, которых деактивировали в приложении,
  • или нужна информация о том, кто в данный момент присутствует в канале или события о том, что кто-то подписался/отписался от канала,
  • или нужны метрики и мониторинг?

Больше примеров, демонстрирующих заявленные выше пункты, можно найти на Github. Библиотека Centrifuge может вам с этим помочь — по сути она унаследовала все основные возможности, которые раньше были доступны в Centrifugo.

Я старался организовать код таким образом, чтобы неиспользуемые фичи никак не сказывались на общей производительности. Сильное наследие Centrifugo может быть и минусом, так как библиотека переняла и всю механику сервера, которая достаточно самобытна и, возможно, кому-то может показаться неочевидной или перегруженной ненужными возможностями.

Это объединение нескольких сообщений в один Websocket frame для экономии на системных вызовах Write или, например, использование Gogoprotobuf для сериализации Protobuf сообщений и другие. В библиотеке есть некоторые оптимизации, которые позволяют более эффективно использовать ресурсы. Кстати о Protobuf.

Теперь весь протокол описан в виде Protobuf-схемы. Я очень хотел, чтобы Centrifugo могла работать с бинарными данными (и не только я), поэтому в новой версии хотелось добавить бинарный протокол помимо имеющегося на основе JSON. Это позволило сделать его более структурированным, переосмыслить некоторые неочевидные решения в протоколе первой версии.

Есть и недостаток в виде нечитаемости, однако теперь у пользователей есть возможность решить, что им важнее в той или иной ситуации. Думаю, не нужно долго рассказывать какие есть преимущества у Protobuf над JSON — компактность, скорость сериализации, строгость схемы.

В те же ~2 раза уменьшилось и потребление CPU в моих синтетических нагрузочных тестах по сравнению с JSON. В целом трафик, генерируемый протоколом Centrifugo при использовании Protobuf вместо JSON, должен уменьшиться в ~2 раза (без учета данных приложения). Эти цифры на самом деле мало о чем говорят, на практике все будет зависеть от профиля нагрузки конкретного приложения.

4 и 32-мя Intel® Xeon® Platinum 8168 CPU @ 2. Интереса ради я запустил на машине с Debian 9. Было 1000 подписчиков на 1 канал. 70GHz vCPU бенчмарк, который позволил сравнить пропускную способность клиент-серверного взаимодействия в случае использования JSON-протокола и Protobuf-протокола. Размер каждого сообщения составлял 128 байт. В этот канал в 4 потока публиковались сообщения и доставлялись всем подписчикам.

Результаты для JSON:

$ go run main.go -s ws://localhost:8000/connection/websocket -n 1000 -ns 1000 -np 4 channel
Starting benchmark [msgs=1000, msgsize=128, pubs=4, subs=1000]
Centrifuge Pub/Sub stats: 265,900 msgs/sec ~ 32.46 MB/sec Pub stats: 278 msgs/sec ~ 34.85 KB/sec [1] 73 msgs/sec ~ 9.22 KB/sec (250 msgs) [2] 71 msgs/sec ~ 9.00 KB/sec (250 msgs) [3] 71 msgs/sec ~ 8.90 KB/sec (250 msgs) [4] 69 msgs/sec ~ 8.71 KB/sec (250 msgs) min 69 | avg 71 | max 73 | stddev 1 msgs Sub stats: 265,635 msgs/sec ~ 32.43 MB/sec [1] 273 msgs/sec ~ 34.16 KB/sec (1000 msgs) ... [1000] 277 msgs/sec ~ 34.67 KB/sec (1000 msgs) min 265 | avg 275 | max 278 | stddev 2 msgs

Результаты для Protobuf случая:

$ go run main.go -s ws://localhost:8000/connection/websocket?format=protobuf -n 100000 -ns 1000 -np 4 channel
Starting benchmark [msgs=100000, msgsize=128, pubs=4, subs=1000] Centrifuge Pub/Sub stats: 681,212 msgs/sec ~ 83.16 MB/sec Pub stats: 685 msgs/sec ~ 85.69 KB/sec [1] 172 msgs/sec ~ 21.57 KB/sec (25000 msgs) [2] 171 msgs/sec ~ 21.47 KB/sec (25000 msgs) [3] 171 msgs/sec ~ 21.42 KB/sec (25000 msgs) [4] 171 msgs/sec ~ 21.42 KB/sec (25000 msgs) min 171 | avg 171 | max 172 | stddev 0 msgs Sub stats: 680,531 msgs/sec ~ 83.07 MB/sec [1] 681 msgs/sec ~ 85.14 KB/sec (100000 msgs) ... [1000] 681 msgs/sec ~ 85.13 KB/sec (100000 msgs) min 680 | avg 680 | max 685 | stddev 1 msgs

Клиентский скрипт можно найти вот тут — это адаптированный под реалии Centrifuge бенчмарк-скрипт Nats. Можно заметить что пропускная способность такой установки в 2 с лишним раза больше в случае Protobuf.

Например, в Centrifugo первой версии JSON сериализуется вручную с использованием библиотеки, предоставляющей пул буферов. Стоит также отметить, что производительность сериализации JSON на сервере можно «прокачать» используя тот же самый подход, что и в gogoprotobuf — пул буферов и генерацию кода — в данный момент JSON сериализуется пакетом из стандартной библиотеки Go, построенном на reflect. Что-то подобное можно будет в будущем сделать и в рамках второй версии.

Javascript клиент использует для этого библиотеку protobuf.js. Стоит подчеркнуть, что protobuf можно использовать и при общении с сервером из браузера. Для других сред, где размер ресурсов не играет столь критичной роли, клиенты могут о таком разделении не беспокоиться. Так как библиотека protobufjs достаточно тяжелая, а количество пользователей бинарного формата будет невелико, с помощью webpack и его tree shaking алгоритма мы генерируем две версии клиента — одна только с поддержкой JSON протокола, а другая с поддержкой и JSON, и protobuf.

А аутентифицировать подключения каким-то образом нужно. Одна из проблем в использовании такого standalone сервера, как Centrifugo, состоит в том, что он ничего не знает о ваших юзерах и методе их аутентификации, о том, какой механизм сессий использует ваш бекенд.

Это гарантировало то, что передаваемый клиентом User ID действительно принадлежит ему. Для этого в Центрифуге первой версии при подключении использовалась SHA-256 HMAC подпись, основанная на секретном ключе, известном только бекенду и Центрифуге.

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

Сейчас, несколько лет спустя, библиотеки для генерации JWT есть для большинства популярных языков. Когда Центрифуга появилась, стандарт JWT еще не был столь популярен. Во второй версии HMAC подпись, генерируемая вручную, уступила место использованию JWT. Основная идея JWT — именно та, что нужна Центрифуге: подтверждение подлинности передаваемых данных. Это позволило убрать необходимость поддержки функций-хелперов для правильной генерации токена в библиотеках для разных языков.

Например, на Python токен для подключения к Centrifugo можно сгенерировать следующим образом:

import jwt
import time token = jwt.encode({"user": "42", "exp": int(time.time()) + 10*60}, "secret").decode() print(token)

Примеры есть в репозитории. Важно отметить, что в случае использования библиотеки Centrifuge аутентифицировать пользователя можно нативным для языка Go способом — внутри middleware.

Что можно сказать? В процессе разработки я попробовал GRPC bidirectional streaming в качестве транспорта для общения между клиентом и сервером (помимо Websocket и основанных на HTTP фоллбеков SockJS). Однако я не нашел ни одного сценария, где двунаправленный стриминг GRPC был бы лучше, чем Websocket. Он работал. Я смотрел в основном на метрики сервера: на генерируемый трафик через сетевой интерфейс, на потребление CPU сервером при наличии большого кол-ва входящих соединений, на потребление памяти на соединение.

GRPC уступил Websocket по всем статьям:

  • GRPC генерирует на 20% больше трафика в аналогичных сценариях,
  • GRPC потребляет в 2-3 раза больше CPU (в зависимости от конфигурации подключений – все подписаны на разные каналы или все подписаны на один канал),
  • GRPC потребляет в 4 раза больше оперативной памяти на соединение. Например, на 10k подключений Websocket-сервер отъел 500Mb памяти, а GRPC — 2Gb.

В общем, в GRPC в качестве клиентского транспорта я большого смысла не увидел — и удалил код с чистой совестью до, возможно, лучших времен. Результаты оказались достаточно… ожидаемы.

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

Это делает достаточно сложной реализацию клиентов. Изменениями, сделанными во второй версии, я убрал обязательность поддержки библиотек для серверного API — интегрироваться на серверной стороне стало проще, однако, клиентский протокол в проекте свой, изменился и имеет достаточное количество особенностей. Есть клиент на Go и построенные на его основе и на основе проекта gomobile биндинги под iOS и Android. Для второй версии у нас сейчас есть клиент для Javascript, который работает в браузерах, должен работать с NodeJS и React-Native.

Для первой версии Centrifugo их законтрибьютили ребята из open-source сообщества. Для полного счастья не хватает нативных библиотек под iOS и Android. Хочется верить, примерно так случится и теперь.

Причина — недостаточно активное сообщество на Github. Недавно я попытал счастья, отправив заявку на MOSS грант от Mozilla, собираясь вложить деньги в разработку клиентов, но получил отказ. К сожалению, это правда, но, как видите, какие-то шаги я предпринимаю, чтобы ситуацию улучшить.

Релиз сервера пока не состоялся, но он в скором времени случится. Я не озвучил все фичи, которые появятся в Centrifugo v2 — чуть больше информации есть в issue на Github. Прототип документации можно посмотреть по ссылке. Есть еще незаконченные моменты, в том числе нужно дописать документацию. Время, когда не так страшно что-то сломать, чтобы впоследствии сделать лучше. Если вы пользователь Centrifugo, то сейчас правильное время, чтобы повлиять на вторую версию сервера. Для заинтересовавшихся: разработка сосредоточена в ветке c2.

На данный момент я доволен, что смог довести ее до текущего состояния. Мне сложно судить, насколько будет востребована библиотека Centrifuge, лежащая в основе Centrifugo v2. Мой ответ — да. Самый важный показатель для меня сейчас это ответ на вопрос «а стал бы я сам использовать эту библиотеку в личном проекте?». Да. На работе? Поэтому я верю, что и другие разработчики оценят.

S. P. Без вас было бы туго. Хотелось бы поблагодарить ребят, которые помогали делом и советами — Дмитрия Королькова, Артемия Рябинкова, Олега Кузьмина.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»