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

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

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

clmoytonrzc4exvj6eeky8j-zw4.jpeg

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

-cwsk-n9eulxk4cd7v9berdltzy.png

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

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

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

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

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

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

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

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


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

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

jf_dervpzmkl2fuprcse34ayoke.png

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

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

node.On().Connect(func(ctx context.Context, client *centrifuge.Client, e centrifuge.ConnectEvent) centrifuge.ConnectReply {

    client.On().Disconnect(func(e centrifuge.DisconnectEvent) centrifuge.DisconnectReply {
        log.Printf("client disconnected")
        return centrifuge.DisconnectReply{}
    })

    log.Printf("client connected via %s", client.Transport().Name())
    return centrifuge.ConnectReply{}
})

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

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


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

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

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

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

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

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

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

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

Результаты для 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

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

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

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

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

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

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

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

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

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

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

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

y43qkcl_yp7fjdgalrqvuzw5hl0.jpeg

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

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

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

© Habrahabr.ru