English version: From Action To Any

Подкапотные нюансы AnyCable для мигрирующих с ActionCable.

Что будет интересного:

  • Как можно посчитать количество пользователей онлайн в ActionCable и почему это сломается в AnyCable
  • Чем отличается current_user в базовом классе Connection при использовании AnyCable от ActionCable.
  • Почему AnyCable гарантировано ломает devise при использовании Rails сессии и как с этим быть.
  • Чем отличается stop_all_streams в AnyCable от своего аналога в ActionCable
  • Как разорвать соединение извне в ActionCable и как это можно сделать AnyCable
  • Где была и куда делась проверка заголовка Origin в AnyCable.

Это небольшое мемо будет интересно для тех кто хочет копнуть на полштыка глубже в теме ActionCable в контексте его альтернативы AnyCable.

Обращаю внимание читателя на то, что все описанные отличия AnyCable от ActionCable актуальны для версии anycable-rails 0.5.2, anycable 0.5.0, и actioncable 5.1.4! Многое из указанного уже вынесено в issue или будет вынесено в ближайшее время и в скором времени будет исправлено или изменено.

Введение

Используемой памяти:

Benchmark: handle 20 thousand idle clients to the server (no subscriptions, no transmissions). Источник: AnyCable — ActionCable на стероидах

Загрузки ядер:

ActionCable VS AnyCable WebSocket Shootout benchmark, источник: AnyCable — ActionCable на стероидах

Задержки сообщений при росте числа подключений:

Задержка броадкастинга в секундах от количества подключений, источник: AnyCable — ActionCable на стероидах

Так или иначе, вот три причины, каждая из которых уже сама по себе достаточна для того, чтобы задуматься о внедрении AnyCable на место ActionCable.

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

Вот хорошая табличка текущей совместимости обощенная Владимиром Дементьевым, автором AnyCable:

Но помимо этой таблички есть еще кое-какие нюансы, которые стоит понимать, при замене ActionCable на AnyCable.

Начнем со стороны того, что никак не цепляет такая перемена: фронтэнда.

Front-end

Back-end

Сравнение архитектур AnyCable VS ActionCable. Источник: anycable-rails repo
Более подробно архитектура AnyCable. Источник: ActionCable on steroids

Что мы видим: вебсокет сервер вынесен отдельно, ActionCable заменен на Anycable RPC с вызовом по gRPC.

Minus Rack middleware

Самый простой и яркий пример, который коснется практически всех, кто будет переводить свое приложение с ActionCable: недоступно Warden middleware, это значит, что если у вас была аутентификация с использованием devise в ActionCable, она сломается. Stackoverflow приводит, например, вот такой способ использования devise совместно с ActionCable:

verified_user = env['warden'].user

В AnyCable это бессмысленно, потому что middle нет и env['warden'] == nil.

Как это можно подлечить? Например так:

gRPC и некоторые некомсомольские методы

Например, метод stop_all_streams в исходном варианте непосредственно отписывал стримы от подписок, и после вызова можно считать, что команда отписаться уже ушла на редис, в случае с AnyCable до тех пор пока не произойдет возврата из вызова gRPC в ws-сервер процедура отписки стримов не запустится.

В целом знание этой особенности реализации может быть актуально в двух случаях: если после вызова stop_all_streams далее по коду выкинет эксепшен, то поведение ActionCable будет отличным от AnyCable, и это может помочь быстрее найти проблему на стыке AnyCable и ws-сервера, если таковая возникнет.

Long live Connection!

Channel instances are long-lived. A channel object will be instantiated when the cable consumer becomes a subscriber, and then lives until the consumer disconnects.

Long-lived channels (and connections) also mean you're responsible for ensuring that the data is fresh. If you hold a reference to a user record, but the name is changed while that reference is held, you may be sending stale data if you don't take precautions to avoid it.

Вот тут мы пришли к следующему отличию AnyCable: в AnyCable нет долгоживущих объектов! Все объекты создаются только на время вызова по gRPC, и сериализуются/десериализуются посредством globalid gem’а.

Таким образом, все ActiveRecord объекты, которые были привязаны в ActionCable::Connection через вызов identified_by, теперь релоадятся перед использованием, т.е. то что раньше было просто current_user, на самом деле теперь всегда current_user.reload.

С одной стороны это удобно: у объекта всегда актуальное на момент вызова состояние. С другой, теперь каждый вызов методов Connection/Channel из JS может приводить к дополнительным обращениям к БД, по числу таких idetified_by объектов, и в ряде случаев, имеет смысл отказаться от использования ActiveRecord объектов заменив их на простые структуры.

Броадкастну, а в ответ тишина…

Если мы запустим обычный демо пример action cable, который приводится в описании ActionCable, присоединимся из двух браузеров к паре каналов и после этого посмотрим как дела в Redis.pubsub, мы увидим вот такой вывод:

2.4.1 :020 > redis.pubsub('channels') => ["action_cable/Z2lkOi8vYWN0aW9uY2FibGUtZXhhbXBsZXMvVXNlci8x", 
"messages:1:comments",
"messages:2:comments",
"_action_cable_internal", "action_cable/Z2lkOi8vYWN0aW9uY2FibGUtZXhhbXBsZXMvVXNlci8z"]

Где каналы с префиксом messages — это каналы чат комнат, которые и приводятся в коде демо-приложения, а каналы с префиксом action_cable/ — это внутренние каналы соединений, о которых было сказано выше. Если мы запустим AnyCable приложение и попробуем заглянуть в pubsub redis’а то мы увидим там только одну очередь сообщений, по умолчанию она называется __anycable__!

На что влияет такая реализация? Ну во-первых пропускная способность всех ваших кабелей теперь зависит от пропускной способности одной очереди pubsub в редисе. Во-вторых полезные советы как определить кто присоединен к вашим сокетам и сколько их, стали теперь вредными, в смысле, нерабочими.

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

ActionCable.server
.remote_connections
.where(current_user: User.find(1))
.disconnect

и в методе дисконнект:

server.broadcast internal_channel, type: "disconnect"

Таким образом в ActionCable пройдет сообщение disconnect и соединение закроется, но в AnyCable каналов таких нет, напомню в anycable у нас только одна очередь __anycable__, и этот броадкаст молча уйдет в молоко.

В общем как и говорила нам табличка совместимости:

Disconnect remote clients : —

Как в итоге разорвать соединение извне в AnyCable? Без внесения изменений в ws-сервер, в общем-то, нет гарантированного способа выборочно разорвать соединение из основного rails-приложения, есть только ненадежный: допилить на front-end’е класс канала и отправить на такой доработанный класс специальное сообщение, которое заставит сокет пересоединиться, в чем мы ему можем с удовольствием отказать. Если по каким-то причинам JS проигнорирует наше сообщение, придется ждать или обновления вашего любимого варианта AnyCable ws-сервера, или на худой конец его перезапуска.

Пара слов про конфиг

Про конфигурацию Anycable есть кратко в официальном репозитории Основные отличия:

  • Не используется cable.yml для своих настроек, во-первых потому что pubsub адаптера как такового на уровне gRPC-сервиса нет и в нем нет потребности, потому что на сообщения подписывается непосредственно ws-сервер, а во-вторых потому что для целей броадкастинга используется только redis его можно только настроить, но выбрать вместо него другой движок для pubsub, например postgreSQL, нет возможности*.
  • Для заполнения настроек используется гем anyway_config, так что настройки можно выставить и через окружение, и через config/anycable.yml, и вообще через вызов Anycable.configure.

Также подробнее про связь настроек в config/anycable.yml с параметрами запуска ws-сервера можно прочитать в комментариях к дефолтным настройкам config/anycable.yml добавленным в генератор с версии anycable-rails 0.5.2.

*Речь про текущую версию AnyCable и anycable-rails ( 0.5.2 ), добавление других pubsub адаптеров указано в планах и обсуждается

Cross Site WebSocket Hijacking (CSWSH).

В контексте AnyCable это проявляется на старте ws-server’а, ему указываются заголовки, которые он будет передавать на gRPC сервис:

-headers=cookie,x-api-token,origin

Как видно можно разрешить передавать куки для определения сессии и origin для его проверки. Но в отличе от ActionCable, в Anycable gRPC отсутствует встроенная проверка (allow_request_origin?):

# ActionCable::Connection::Base
# Called by the server
when a new WebSocket connection is established. This configures the callbacks intended for overwriting by the user.
# This method should not be called directly -- instead rely upon on the #
connect (and #disconnect) callbacks.
def process #:nodoc:
logger.info started_request_message
if websocket.possible? && allow_request_origin?
respond_to_successful_request
else
respond_to_invalid_request
end
end

Проверку origin легко можно вернуть в приложение самостоятельно, используя вызов allow_request_origin? и настройки ActionCable:

config.action_cable.allowed_request_origins = ['http://rubyonrails.com', %r{http://ruby.*}]

Или же переложить проверку на плечи nginx.

Заключение

Мои благодарности Владимиру Дементьеву автору AnyCable за сам гем и замечания к данной статье.

Ссылки

ActionCable:

  1. Ответы на вопросы как определить кто присоединен к вашим сокетам и сколько их
  2. Chat app rails 5 + ActionCable + devise
  3. ActionCable examples

Anycable:

  1. Anycable.io
  2. Anycable на гитхабе

Безопасность сокетов:

  1. Большое и подробное мемо на тестирование Вебсокетов на безопасность от горячих финских парней, в том числе по вопросу Crossite ws Hijacking
  2. Еще про кроссайтовые сокеты и как уберечься от Кристиана Шнайдера.
  3. Ответы на security.stackexchange.com: Preventing CSRF against Websockets и Is Origin header useful for websocket protection.
  4. Как подделать ориджин в JS ( ну в общем практически никак, либо куки не прилипнут, либо ориджин будет исходный)
  5. Еще статья про безопасность WS с обсуждениями

Chief Software Architect / CTO, Ruby and PostgresSQL fan.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store