Последний раз я писал про 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 фрейм для того, чтобы переиспользовать его при возможности и не создавать его каждый раз. В случае Центрифуги, когда в канале могут быть тысячи пользователей и всем отправляется одно и то же сообщение в соединение, это имеет смысл при достаточно большом количестве пользователей (в моих бенчмарках выигрыш появлялся при количестве клиентов >20k в одном канале). Но еще больший смысл это имеет при включенном сжатии 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.
Поделиться с друзьями
-->

Комментарии (24)


  1. gadfi
    12.04.2017 13:48

    рад что проект жив и развивается, в свое время отказался от Centrifugo в пользу Firebase ради увеличения скорости разработки… если коротко то я больше никому не порекомендую firebase и если понадобится для realtime проекта заложу выше сроки и возьму Centrifugo или аналог, в зависимости от ситуации


    1. FZambia
      12.04.2017 13:51

      Cпасибо за добрые слова! Было бы интересно подробнее узнать про проблемы с Firebase, сам я с ним не работал, но вдруг придется когда-нибудь.


      1. gadfi
        12.04.2017 14:11

        я использовал firebase на нескольких небольших android проектах, где в основном нужна была авторизация и realtime database, простой чат с профилями пользователей делался на ура, но на большем проекте вылезло:
        очень медленно работает storage
        из storage нельзя скачать сразу папку (захотите переезжать с файлами придется поиграться)
        realtime работает не стабильно, да я понимаю что если бы я писал realtime сам, даже с использованием Centrifugo или чего то подобного, это не защитило бы меня от проблем с неправильно настроенным сервером, но любую проблему можно было бы решить и принять меры чтобы не повторять, в firebase приходилось просто ждать пока заработает…
        нет нормальных средств администрирования

        справедливости ради стоит заметить что они очень активно развиваются, сапорт работает адекватно (сам не верю гугл и сапорт, но у коллеги были проблемы с firebase в unity и ему ответили, проблему пофиксили)

        Могу сказать что теперь я сильно против подобных сервисов, лучше заложить больше времени/денег но иметь полный контроль над бекендом


        1. FZambia
          12.04.2017 15:32

          Ну это всегда trade-off, в общем случае, используя open-source решение (Centrifugo и т.д.) на своем бэкенде можно столкнуться с подобными же проблемами. Наверное, выбирая решение, нужно исходить из «а чего мне будет стоить от него отказаться в определенный момент».


          1. gadfi
            12.04.2017 16:40

            согласен, open-sourse не панацея, но на будущее хочется больше контроля над проектом


  1. boston
    12.04.2017 16:22

    Для конфигурации есть очень хорошая и совсем лёгкая
    https://github.com/segmentio/conf/, умеет и команды и конфигурацию подтягивает как из ENV, так и из yml и из --флагов, зависимостей в ней самая малость.


  1. Akademic
    12.04.2017 17:09

    Спасибо за ваш труд! Полтора года прошло с тех пор как притащил центрифугу в свой проект.
    После внедрения перестала болеть голова на тему отправки событий в браузер.
    Сервис из разряда «настроил и забыл».


    1. FZambia
      12.04.2017 17:52

      Спасибо! На самом деле иногда все же нужно обновлять:) Например, в недавнем прошлом обнаружилось, что при нескольких подряд разрывах соединения с Redis-ом состояние подписок не восстанавливалось корректно –соответственно, сообщения могли перестать доходить до клиентов. Хорошо, что вы не столкнулись – по сути это воспроизводилось только в той инсталляции, о которой я написал в начале статьи (там очень большое количество активных каналов, на которые нужно переподписываться при реконнекте к Редису).


      1. Akademic
        13.04.2017 09:31

        Я использую без redis. Мне хватает встроенных возможностей. И ещё долго будет хватать.

        В то время, когда я увидел статью про портирование центрифуги на go, я страдал с собственной реализацией сервиса для передачи сообщений на nodejs. Там было всё плохо — куча частных моментов, которые я не обрабатывал и которые валили сервис. Постоянно надо было расследовать эти падения и писать патчи.

        А с centrifugo всё это ушло в небытие. Настолько хорошо работает, что когда сейчас стал внедрять новый функционал, пришлось заново читать документацию, чтобы вспомнить как там всё делается.


  1. dkosenko
    12.04.2017 23:40

    Спасибо, FZambia, за отличный продукт, пользуемся centrifugo со старта нашего проекта. Едиственно, что мне не хватало в реализации из коробки, так это обработчика событий выхода клиентов. Это конечно решается запуском отдельного клиента на сервере, к примеру на go, который бы слушал события leave определенного канала, но на мой взгляд, это немного «из пушки» стрелять. Написать его дело пары минут, тем не менее, хотелось бы может это как встронное решение иметь. В остальном, набор фич из коробки полностью покрывает потребности нашего проекта.


    1. FZambia
      12.04.2017 23:44

      Спасибо:) А отправка AJAX запроса с async: false с клиента при закрытии таба (событие onbeforeunload) не подойдет ли случаем для этой цели? Это проще чем отлавливать событие выхода на серверной стороне?


      1. dkosenko
        13.04.2017 12:55

        Не пойдет. Может зависнуть комп, выключится электричество, отключится интеренет и это все не будет обработано, а запись будет к примеру залочена и другие пользователи не смогут её пользоваться. Придется городить костыли на cron-e.


        1. FZambia
          13.04.2017 13:14

          Да, тогда join/leave на сервере единственный выход сейчас… Как вариант можно слушать Редис на предмет join/leave сообщений — но это внутренний протокол. Оба варианта не красивые. В теории вебхуки от Centrifugo бекенду могли бы помочь – но именно этого я старался избегать — появляется связность, много запросов, дополнительная логика отправки. Так что в целом когда нужно совершать действия при дисконнекте клиента лучше Centrifugo не использовать.


  1. kapioprok
    13.04.2017 12:51

    Показалось к Цукербергу :)

    Пользователи приложения подключаются к Центрифуге


  1. wispoz
    14.04.2017 10:41

    В документации не нашел, как можно в конфиге задать порт для демона?


    1. FZambia
      14.04.2017 12:11

      В конфиге опцией "port", через command-line аргументы вот так


      1. wispoz
        14.04.2017 12:15

        Да спасибо, уже сообразил :)
        Еще один вопрос, настроил перед центрифугой nginx по мануалу (embed to a location of web site), при переходе на страницу центрифуги выпадает 500 ошибка в логах:
        2017/04/14 11:06:35 [error] 14978#14978: *405 the rewritten URI has a zero length, client: 'тут ай пи', server: имя сервера.


        1. FZambia
          16.04.2017 17:25

          Видимо нужно вот так прописывать rewrite:

          rewrite ^/centrifugo(.*) /$1 break;


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


        1. FZambia
          16.04.2017 18:07

          Документацию обновил — теперь должно все работать, включая веб-интерфейс


          1. wispoz
            17.04.2017 12:25

            Да заработало, правда теперь вопрос с CSS и JS) ну это мой косяк. Спасибо!


  1. DjOnline
    19.04.2017 13:09

    Хотелось бы увидеть на странице проекта больше примеров и кейсов использования. Например применимо ли это где-нибудь в в e-commerce?


    1. RidgeA
      20.04.2017 00:00

      поддерживаю :-)


  1. vvzvlad
    21.04.2017 02:10

    Ха, MQTT для веба.


    1. FZambia
      21.04.2017 10:23

      Я бы обобщил — PUB/SUB для веба. Ну а так идея, конечно, не нова и конкурентов достаточно, особенно на основе NodeJS.