Эффективное управление подключениями SignalR

    Здравствуй, Хабрахабр. В настоящий момент я работаю над созданием движка чата в основе которого лежит библиотека SignalR. Помимо увлекательного процесса погружения в мир real-time приложений пришлось столкнуться и с рядом вызовов технического характера. Об одном из них я и хочу с вами поделиться в этой статье.

    Введение


    Что такое SignalR — это свое рода фасад над технологиями WebSockets, Long polling, Server-send events. Благодаря этому фасаду можно единообразно работать с любой из этих технологий и не беспокоиться о деталях. Кроме того, благодаря технологии Long polling можно поддерживать клиентов, которые по каким-то причинам не могут работать по веб-сокетам, например IE-8. Фасад представлен высокоуровневым API, работающим по принципу RPC. Кроме того, SignalR предлагает выстраивать коммуникации по принципу «publisher-subscriber» что в терминологии API называется группами. Об этом и пойдет речь далее.

    Вызовы


    Пожалуй самое интересное в программировании это возможность решать нестандартные задачи. И одну из таких задач мы сегодня обозначим и рассмотрим ее решение.

    В эпоху развития идей масштабирования и в первую очередь горизонтального основным вызовом является необходимость иметь более одного сервера. И с этим вызовом уже справились разработчики указанной библиотеки, с описанием решения можно ознакомиться на MSDN. Если вкратце, то предлагается, используя принцип «publisher-subscriber», синхронизировать вызовы между серверами. Каждый сервер подписывается на общую шину и все отправленные с этого сервера команды направляется сперва на шину. Далее команда распространяется на все сервера и только потом на клиентов:

    image

    Здесь важно отметить, что каждый подключенный к серверу клиент имеет свой уникальный идентификатор подключения — ConnectionId — и все сообщения в итоге адресуются с использованием этого идентификатора. Поэтому каждый сервер хранит у себя эти подключения.

    Однако по непонятным причинам API библиотеки SignalR не предоставляет доступ к этим данным. И здесь перед нами весьма остро встает вопрос доступа к этим подключениям. Это и есть наш вызов.

    Зачем нам подключения


    Как уже было отмечено ранее, SignalR предлагает к использованию модель «publisher-subscriber». Здесь единицей роутинга сообщений становится не ConnectionId а группа. Группа — это совокупность подключений. Отправляя сообщение в группу, мы отправляем сообщение на все ConnectionId, которые в этой группе состоят. Группы удобно строить — при подключении клиента к серверу просто вызываем API метод AddToGroupAsync:

    public override async Task OnConnectedAsync()
            {
                foreach (var chat in _options.Chats)
                    await Groups.AddToGroupAsync(ConnectionId, chat);
    
                await Groups.AddToGroupAsync(ConnectionId, Client);
            }

    А каким образом выйти из группы? Разработчики предлагают API метод RemoveFromGroupAsync:

    public override async Task OnDisconnectedAsync(Exception exception)
            {
                foreach (var chat in _options.Chats)
                    await Groups.RemoveFromGroupAsync(ConnectionId, chat);
                
                await Groups.RemoveFromGroupAsync(ConnectionId, Client);
            }

    Обратите внимание, что единицей данных является ConnectionId. Однако с точки зрения доменной модели ConnectionId не существуют, зато существуют клиенты. В связи с этим организация отображения клиента на массив ConnectionId и обратно возлагается на пользователей указанной библиотеки.

    Нужен именно массив всех ConnectionId клиента, при выходе его из группы. Однако такого массива не существует. Его нужно организовывать самому. Задача становится много интереснее в случае горизонтально масштабированной системы. В этом случае часть подключений могут находится на одном сервере, остальные — на других серверах.

    Способы отображения клиентов на подключения


    Этому вопросу посвящен целый раздел на MSDN. К рассмотрению предлагаются следующие способы:

    • In-Memory хранилище
    • «Юзер-группа»
    • Постоянное внешнее хранилище

    Как отслеживать подключения ?
    Отслеживать подключения можно используя методы хаба OnConnectedAsync и OnDisconnectedAsync.

    Сразу же отмечу, что варианты не поддерживающие масштабирование не рассматриваются. К таким относится вариант хранения подключений в памяти сервера. Здесь нет доступа к подключениям клиента на других серверах, если таковые имеются. Вариант хранения во внешнем постоянном хранилище сопряжен со своими недостатками, к которым относится и проблема очистки неактивных подключений. Такие подключения возникают в случае жесткой перезагрузки сервера. Обнаруживать и чистить эти подключения нетривиальная задача.

    Среди приведенных выше вариантов интересен вариант «юзер-группы». К его плюсам безусловно относится простота — не требуется никаких библиотек, хранилищ. Так же немаловажно и следствие простоты этого метода — надежность.

    А как же Redis ?
    Кстати, использовать для хранения подключений Redis тоже неудачный вариант. Здесь остро стоит проблема организации данных в памяти. С одной стороны ключом является клиент, с другой — группа.

    «Юзер-группа»


    Что же из себя представляет «юзер-группа»? Это группа в терминологии SignalR где клиентом может быть только один клиент — он сам. Это гарантирует 2 вещи:

    1. Сообщения будут доставлены только одному человеку
    2. Сообщения будут доставлены на все устройства человека

    Каким образом это нам поможет? Напомню, что наш вызов — решение проблемы выхода клиента из группы. Нам требовалось, что бы выходя из группы с одного устройства, остальные тоже отписались, но у нас не было списка подключений этого клиента, кроме того, с которого мы инициировали выход.

    «Юзер-группа» — это первый шаг на пути решения указанной проблемы. Вторым шагом будет построение «зеркала» на клиенте. Да да, именно зеркала.

    «Зеркало»


    Источником команд, посылаемых с клиента на сервер служат действия пользователя. Постим сообщение — посылаем команду на сервер:

    this.state.hubConnection
          .invoke('post', {message, group, nick})
          .catch(err => console.error(err));

    И уведомляем всех клиентов группы о новом посте:

    public async Task PostMessage(PostMessage message)
            {
                await Clients.Group(message.Group).SendAsync("message", new
                {
                    Message = message.Message,
                    Group = message.Group,
                    Nick = ClientNick
                });
            }

    Однако ряд команд должны выполняться синхронно на всех устройствах. Как этого достичь? Либо иметь массив подключений и выполнять команду для каждого подключения по конкретному клиенту, либо использовать метод описанный ниже. Рассмотрим этот метод на примере выхода из чата.

    Команда пришедшая от клиента сперва отправится в «юзер-группу» на специальный метод, который ее просто-напросто перенаправит обратно на сервер, т.е. «отзеркалирует». Таким образом не сервер будет отписывать устройства, а сами устройства попросят их отписать.

    Вот пример команды отписки от чата сервера:

    public async Task LeaveChat(LeaveChatMessage message)
            {
                await Clients.OthersInGroup(message.Group).SendAsync("lost", new ClientCommand
                {
                    Group = message.Group, Nick = Client
                });
                await Clients.Group(Client).SendAsync("mirror", new MirrorChatCommand
                {
                    Method = "unsubscribe",
                    Payload = new UnsubscribeChatMessage
                    {
                        Group = message.Group
                    }
                });
            }

    public async Task Unsubscribe(UnsubscribeChatMessage message)
            {
                await Groups.RemoveFromGroupAsync(ConnectionId, message.Group);
            }

    А вот код клиента:

    connection.on('mirror', (message) => {
              connection
                .invoke(message.method, message.payload)
                .catch(err => console.error(err));
            }); 

    Разберем подробнее что тут происходит:

    1. Клиент инициирует отписку — посылает команду «leave» на сервер
    2. Сервер посылает в «юзер-группу» на «зеркало» команду «unsubscribe»
    3. Сообщение доставляется на все устройства клиента
    4. Сообщение на клиенте отправляется обратно на сервер на указанный сервером метод
    5. На каждом сервере происходит отписка клиента из группы

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

    Так зачем нам подключения ?


    Имея «юзер-группу» и «зеркало» на клиенте отпадает необходимость работать с подключениями. А что думаете по этому поводу вы, уважаемые читатели? Поделитесь своим мнением в комментариях.

    Исходный код примеров:

    github.com/aesamson/signalr-server
    github.com/aesamson/signalr-client

    Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

    Уже решали эту проблему, каким способом?

    Поделиться публикацией

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

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 5

      +1
      Расскажите, пожалуйста, поподробнее, почему Redis — плохая идея? Вроде как это решение идёт в коробке, ну или почти в коробке с SignalR
        +2

        Redis хорошо справляется с синхронизацией вызовов исходящих от хаба, и это рекомендуемый способ синхронизации.


        Однако для хранения списка подключений по каждому клиенту Redis неудобен:


        1. В процессе работы приходится делать выборки как по клиенту, так и по группе. Отсюда двоякость ключей. Это неудобно.
        2. Нет простой и надежной стратегии управления жизненным циклом записи: с одной стороны записи должны протухать, с другой стороны мы не должны удалять записи, по которым есть живое соединение, но нет активности.
        3. Достаточно много операций записи одновременно с операциями чтения, придётся бороться с коллизиями.
          0
          Спасибо! Жду продолжения, интересны способы горизонтального масштабирования SignalR без применения Redis)
        0
        Добрый день! У меня пара вопросов, возможно глупых.

        1) У меня mvc проект, не spa. Хочу использовать в нем signalr. Подключение клиента к хабу идет от js.

        $.connection.hub.start().done(function() {

        Если один и тот же пользователь будет переключаться, бегать по меню сайта, то будет каждый раз вызываться $.connection.hub.start().done(function() {. Это нормально? Тут нет оверхада? А если пользователей много…

        2) Были ли у вас проблемы с протуханием, когда signalr перестает работать, и как вы их решали? p.s Юзер закрыл ноут, через день открыл — а signalr отвалился.

          +1
          Добрый вечер,

          Давайте обо всем по порядку.
          Когда устанавливается соединение, оно занимает ресурсы системы. Это память и открытые порты. В случае если соединение часто рвется, то эти ресурсы будут удерживаться определенное время. На это время влияют как настройки системы, так и настройки сервера SignalR. И если память не критичный ресурс, то ресурс портов весьма ограничен — их всего 2^16 на каждый ip адрес. Поскольку клиенты подключаются к одному адресу, в случае использования reverse proxy, этот лимит не выглядит таким уж и большим. Проблема тем более усугубится в случае постоянных обрывов соединения со стороны клиента.

          Что бы избежать этой ситуации, необходимо корректно завершать соединения со стороны клиента или по-возможности снизить значения KeepAliveInterval и ClientTimeoutInterval.

          По поводу протухания — возможно при закрытии ноута обрывается связь с сервером, в таком случае в соответствии со значением параметра ClientTimeoutInterval связь будет принудительно разорвана. Клиент при этом получит уведомление на onclose.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое