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

в 13:20, , рубрики: .net, signalr core, Разработка веб-сайтов, распределенные системы

Здравствуй, Хабрахабр. В настоящий момент я работаю над созданием движка чата в основе которого лежит библиотека 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

Автор: Andrew

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js