Эффективное управление подключениями 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

© Habrahabr.ru