Последний раз я писал про Centrifugo чуть больше года назад. Пришло время напомнить о существовании проекта и рассказать, что произошло за этот период времени. Чтобы статья не скатилась в скучное перечисление изменений, я попробую сконцентрировать внимание на некоторых Go библиотеках, которые помогли мне в разработке – возможно, вы почерпнете для себя что-то полезное.
Самое приятное, что за этот год появилось приличное количество проектов, использующих Centrifugo в бою – и каждая такая история очень вдохновляет. На текущий момент самая большая инсталляция Центрифуги, о которой я знаю, это:
- 300 тысяч пользователей онлайн
- 3.5 млн fan-out сообщений в минуту
- 4 ноды Centrifugo на Amazon c4.xlarge
- ноды связаны PUB/SUB механизмом одного инстанса Redis
- потребление CPU в среднем 40%
По традиции, я обязан напомнить, о чем же я вам тут пишу. Попробую упростить себе жизнь и процитирую прошлый пост:
Centrifugo — это сервер, который работает рядом с бэкендом вашего приложения (бэкенд может быть написан на любом языке/фреймворке). Пользователи приложения подключаются к Центрифуге, используя протокол Websocket или полифил-библиотеку SockJS. Подключившись и авторизовавшись с помощью HMAC-токена (полученного с бэкенда приложения), они подписываются на интересующие каналы. Бэкенд приложения, узнав о новом событии, отправляет его в нужный канал в Центрифугу, используя HTTP API или очередь в Redis. Центрифуга, в свою очередь, моментально рассылает сообщение всем подключенным заинтересованным (подписанным на канал) пользователям.
Год назад последней версией была 1.4.2, а сейчас уже 1.7.3 – работы было проделано немало.
В прошлом году Центрифуга получила поддержку HTTP/2. Это стоило мне огромных трудов и многих часов работы. Шучу:) С релизом Go 1.6 проекты на Go получили поддержку HTTP/2 автоматически. На самом деле для Centrifugo, где основной транспорт это все же Websocket, поддержка HTTP/2 может показаться бесполезной. Однако это не совсем так – ведь Centrifugo в том числе является SockJS сервером. SockJS предоставляет fallback до транспортов, использующих HTTP протокол (Eventsource, XHR-streaming и т.д.), в случае если браузер по каким-то причинам не может установить Websocket-соединение. Ну или на случай если вам по каким-то причинам не хочется использовать Websocket. Много лет мы боролись с лимитом на постоянные соединения к одному хосту, которые устанавливает спецификация HTTP (в реальности 5-6 в зависимости от браузера), – и вот настало время, когда благодаря HTTP/2, соединения из разных табов браузера мультиплексируются в одно. Табов с постоянными HTTP соединениями теперь можно открыть очень много. Вот и пойми — какой же транспорт в настоящее время лучше для в большей степени однонаправленного потока real-time сообщений от сервера клиенту – Websocket или что-то вроде Eventsource поверх HTTP/2.
Чуть позже появилось другое интересное нововведение, касающееся HTTP сервера, – поддержка автоматического получения HTTPS сертификата с Let’s Encrypt. Опять хотелось бы сказать, что пришлось попотеть, но нет – благодаря пакету golang.org/x/crypto/acme/autocert написать сервер, умеющий работать с Let’s Encrypt — это вопрос нескольких строк кода:
manager := autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist("example.org"),
}
server := &http.Server{
Addr: ":https",
TLSConfig: &tls.Config{GetCertificate: manager.GetCertificate},
}
server.ListenAndServeTLS("", "")
В версии 1.5.0 важным изменением стало то, что между Центрифугой и Редисом вместо JSON’a стал летать protobuf. Для работы с protobuf я взял библиотеку github.com/gogo/protobuf — за счет кодогенерации и отказа от использования пакета reflect скорость сериализации и десериализации просто бешеная. Особенно в сравнении с JSON:
BenchmarkMsgMarshalJSON 2022 ns/op 432 B/op 5 allocs/op
BenchmarkMsgMarshalGogoprotobuf 124 ns/op 48 B/op 1 allocs/op
Поначалу использовался protobuf версии 2, но чуть позже получилось перейти на актуальную 3 версию.
На графике заметно насколько меньше время обработки запроса в версии 1.5, использующей protobuf. При этом чем сложнее запрос и больше данных (больше каналов, в которые нужно опубликовать сообщения) он содержит — тем более заметна разница.
Чтобы добавить поддержку protobuf в ваш проект на Go — достаточно написать proto файл, похожий на этот:
syntax = "proto3";
package proto;
import "github.com/gogo/protobuf/gogoproto/gogo.proto";
option (gogoproto.equal_all) = true;
option (gogoproto.populate_all) = true;
option (gogoproto.testgen_all) = true;
message Message {
string UID = 1 [(gogoproto.jsontag) = "uid"];
string Channel = 2 [(gogoproto.jsontag) = "channel"];
bytes Data = 3 [(gogoproto.customtype) = "github.com/centrifugal/centrifugo/libcentrifugo/raw.Raw", (gogoproto.jsontag) = "data", (gogoproto.nullable) = false];
}
Как можно увидеть — в proto-файле есть возможность использовать не только базовые типы, но и свои кастомные типы.
После того как proto-файл написан – остается лишь натравить на этот файл protoc (скачать можно со страницы релизов) с использованием одного из генераторов кода, предоставляемых библиотекой gogoprotobuf
– в результате будет создан файл со всеми необходимыми методами сериализации и десериализации описанных структур. Если вам интересно почитать про это подробней, то вот статья, правда на английском.
Также я много экспериментировал с альтернативными парсерами JSON для десериализации входящих в API сообщений — ffjson, easyjson, gjson, jsonparser. Лучшую производительность показал jsonparser
— он действительно ускоряет разбор JSON в заявленные 10 раз и практически не аллоцирует память. Однако добавлять его в Centrifugo я не решился — пока это не стало узким местом отходить в сторону от использования стандартной библиотеки не хочется. Однако приятно осознавать, что есть возможность столь существенно улучшить производительность парсинга JSON данных.
Также JSON используется для общения с клиентом — в некоторых особенно горячих участках (например, для новых сообщений в канале) я создаю JSON не с помощью функции Marshal
, а вручную, это выглядит примерно вот так:
func writeMessage(buf *bytebufferpool.ByteBuffer, msg *Message) {
buf.WriteString(`{"uid":"`)
buf.WriteString(msg.UID)
buf.WriteString(`",`)
buf.WriteString(`"channel":`)
EncodeJSONString(buf, msg.Channel, true)
buf.WriteString(`,"data":`)
buf.Write(msg.Data)
buf.WriteString(`}`)
}
При этом используется библиотека github.com/valyala/bytebufferpool — предоставляющая пул []byte-буфферов, чтобы дополнительно сократить количество аллокаций памяти.
Также рекомендую замечательную библиотеку github.com/nats-io/nuid — в Центрифуге каждое сообщение получает уникальный id, данная библиотека от разработчиков Nats.io позволяет генерировать уникальные идентификаторы очень быстро. Однако стоит учитывать, что использовать ее можно только там, где вы не боитесь, что злоумышленник сможет вычислить следующий id по существующему. Но во многих местах эта библиотека может стать хорошей заменой uuid.
Версия 1.6.0 стала результатом полного рефакторинга кода сервера, над которым я работал месяца три — вот уж где я действительно пришлось попотеть. Я по-прежнему пилю Центрифугу в нерабочее время, поэтому эти 3 месяца — на самом деле не так много в переводе на чистое время. Но все же.
Результатом рефакторинга стало разделение кода на небольшие пакеты с понятным публичным API и взаимодействием между собой — до этого весь код по большей части лежал в одной папке. Также получилось сделать определенные части сервера заменяемыми на этапе инициализации. Сейчас, когда с тех пор прошло уже почти полгода, я не скажу, что это разбиение на отдельные небольшие пакеты оказало какое-то существенное влияние или дало ощутимые преимущества впоследствии — нет, ничего такого не было. Но, скорее всего, это упрощает чтение кода для остальных программистов – которые не знакомы с проектом с самых первых дней.
В процессе рефакторинга получилось существенно улучшить некоторые части кода — например, метрики, которые теперь чем-то напоминают то, как добавление метрик устроено в Prometheus клиенте для Go.
Центрифуга использует пакет github.com/spf13/viper для конфигурации — это одна из самых лучших библиотек для конфигурации приложения, с которой мне доводилось работать – так как с минимальными усилиями со стороны программиста есть возможность настроить конфигурацию приложения с помощью переменных среды, флагов при запуске и файла с настройками (используя популярные форматы – YAML, JSON, TOML и др.) + viper работает в связке с github.com/spf13/cobra — одним из самых удобных пакетов для создания cli-утилит. Но есть одно большое НО! Viper тянет за собой какое-то непомерное количество внешних зависимостей, некоторые из которых тянут свои — причем большая часть из этих зависимостей в Centrifugo вообще не используется — remote конфигурация (Consul, Etcd), поддержка файловой системы afero, fsnotify (кому вообще нужно чтобы серверное приложения рестартилось автоматом при изменении конфига на диске?), HCL и Java форматы конфигурационных файлов тоже не нужны. Поэтому пришлось форкнуть viper и сделать свою “lite” версию, в которой нет ненужных мне зависимостей. На самом деле это не лучший вариант – хотелось бы, чтобы viper поддерживал плагины и пользователи библиотеки сами определяли на этапе инициализации какие кусочки функционала им нужны.
В версии 1.6 добавилось шардирование Redis по имени канала, чтобы распределить нагрузку между несколькими инстансами Redis’a. Меня всегда смущало ограничение одним инстансом Redis’а — хоть он и чрезвычайно быстр на операциях, которые использует Центрифуга, все равно хотелось иметь способ масштабировать эту точку. Теперь с наличием шардирования вместо вот такой схемы:
Мы получаем вот такую:
К сожалению без решардинга, но в случае с Центрифугой решардинг не так уж и важен на самом деле — модель доставки сообщений и так at most once, а благодаря тому, как Центрифуга работает, состояние само восстанавливается спустя некоторое время. Внутри используется быстрый и не аллоцирующий много памяти алгоритм консистентного шардирования, который называется Jump — используется код из библиотеки github.com/dgryski/go-jump. Совсем недавно появилась история успешного применения шардирования в продакшене — в Mesos среде с тремя шардами. Однако, в каких-то своих проектах мне пока шардирование не довелось использовать.
Возможно вы знаете, что в Центрифуге есть web-интерфейс, написанный на ReactJS, этот интерфейс лежит в отдельном репозитории и эмбеддится в сервер на этапе сборки. Таким образом бинарник включает в себя всю статику, необходимую для работы web-интерфейса — встроенный в Go FileServer позволяет с легкостью отдавать статику по нужному адресу. Изначально для этих целей я использовал github.com/jteeuwen/go-bindata в связке с github.com/elazarl/go-bindata-assetfs. Однако я натолкнулся на более легковесную и простую на мой взгляд библиотеку github.com/rakyll/statik — от хорошо знакомой в Go сообществе Jaana B. Dogan.
Наконец, последнее, что хотелось бы отметить из серверных изменений это интеграция с PreparedMessage структурой из библиотеки Gorilla Websocket. Появился PreparedMessage
в библиотеке Gorilla Websocket совсем недавно. Суть этой структуры сводится к тому, что она кеширует созданный websocket фрейм для того, чтобы переиспользовать его при возможности и не создавать его каждый раз. В случае Центрифуги, когда в канале могут быть тысячи пользователей и всем отправляется одно и то же сообщение в соединение, это имеет смысл при достаточно большом количестве пользователей. Но еще больший смысл это имеет при включенном сжатии Websocket трафика — в случае Websocket протокола это расширение permessage-deflate, которое, будучи включено и поддерживается клиентом и сервером, позволяет сжимать трафик используя flate-сжатие. В Go структура flate.Writer
весит больше 600kb (!), поэтому при большом fan-out сообщений – PreparedMessage
очень помогает.
Немного больное место — это клиенты для мобильных устройств. Так как я не знаю ни Objective-C/Swift, ни Javа на достаточном уровне – то я не могу помочь с разработкой мобильных клиентов для Centrifugo, позволяющих подключаться к серверу с iOS и Android девайсов. Эти клиенты были написаны участниками open-source сообщества, за что я им безмерно благодарен. Однако, написав клиенты, авторы, по большому счету, потеряли интерес к их поддержке – и какие-то фичи там по-прежнему отсутствуют. Однако это рабочие клиенты, которые доказали возможность использования Центрифуги и с мобильных устройств.
Эта ситуация меня не может не расстраивать — поэтому со своей стороны я предпринял шаг попробовать написать клиента на Go и использовать Gomobile для генерации биндингов к клиенту для iOS (Objective-C/Swift) и Android (Java). Ну и в целом, мне это удалось — github.com/centrifugal/centrifuge-mobile. Было увлекательно — самая сложная часть была попробовать полученные биндинги в деле — для этого пришлось освоить XCode и Android Studio, а также написать небольшие примеры использования Websocket клиента Центрифуги для всех трех языков — Objective-C, Swift и Java. Про особенности Gomobile я написал статью — возможно, кому-то будут интересны подробности.
Из недостатков gomobile хотелось бы отметить даже не строгие ограничения на поддерживаемые типы (с которыми на самом деле вполне можно жить), а то, что Go не генерирует LLVM биткод (bitcode), который Apple советует добавлять к каждому приложению. Этот биткод в теории позволяет Apple самостоятельно проводить оптимизации приложений в App Store. На текущий момент при создании приложения под iOS можно отключить биткод в настройках проекта в XCode, но что будет если Apple решит сделать его наличие обязательным? Не понятно. И отсутствие контроля над ситуацией немного печалит.
Самое удивительное для меня — это то, что узнал я об этом только когда код моей библиотеки был готов, протестирован на Android-девайсе и я был в полной уверенности, что и на iOS все пройдет гладко – нигде в документации gomobile я упоминаний об этом не нашел (отвлекся и проглядел?).
Вот в общем-то и все из ярких событий. Попробовать Centrifugo не сложно — есть пакеты под популярные Linux дистрибутивы, Docker образ, бинарные релизы и пара строчек, чтобы поставить на MacOS с помощью brew — все полезные ссылки можно найти в README на Github.
Автор: FZambia