Эффективное управление подключениями SignalR
Здравствуй, Хабрахабр. В настоящий момент я работаю над созданием движка чата в основе которого лежит библиотека SignalR. Помимо увлекательного процесса погружения в мир real-time приложений пришлось столкнуться и с рядом вызовов технического характера. Об одном из них я и хочу с вами поделиться в этой статье.
Введение
Что такое SignalR — это свое рода фасад над технологиями WebSockets, Long polling, Server-send events. Благодаря этому фасаду можно единообразно работать с любой из этих технологий и не беспокоиться о деталях. Кроме того, благодаря технологии Long polling можно поддерживать клиентов, которые по каким-то причинам не могут работать по веб-сокетам, например IE-8. Фасад представлен высокоуровневым API, работающим по принципу RPC. Кроме того, SignalR предлагает выстраивать коммуникации по принципу «publisher-subscriber» что в терминологии API называется группами. Об этом и пойдет речь далее.
Вызовы
Пожалуй самое интересное в программировании это возможность решать нестандартные задачи. И одну из таких задач мы сегодня обозначим и рассмотрим ее решение.
В эпоху развития идей масштабирования и в первую очередь горизонтального основным вызовом является необходимость иметь более одного сервера. И с этим вызовом уже справились разработчики указанной библиотеки, с описанием решения можно ознакомиться на MSDN. Если вкратце, то предлагается, используя принцип «publisher-subscriber», синхронизировать вызовы между серверами. Каждый сервер подписывается на общую шину и все отправленные с этого сервера команды направляется сперва на шину. Далее команда распространяется на все сервера и только потом на клиентов:
Здесь важно отметить, что каждый подключенный к серверу клиент имеет свой уникальный идентификатор подключения — 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 тоже неудачный вариант. Здесь остро стоит проблема организации данных в памяти. С одной стороны ключом является клиент, с другой — группа.
«Юзер-группа»
Что же из себя представляет «юзер-группа»? Это группа в терминологии SignalR где клиентом может быть только один клиент — он сам. Это гарантирует 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));
});
Разберем подробнее что тут происходит:
- Клиент инициирует отписку — посылает команду «leave» на сервер
- Сервер посылает в «юзер-группу» на «зеркало» команду «unsubscribe»
- Сообщение доставляется на все устройства клиента
- Сообщение на клиенте отправляется обратно на сервер на указанный сервером метод
- На каждом сервере происходит отписка клиента из группы
В итоге все устройства сами отпишутся от серверов, к которым они подключены. Каждый отпишется от своего и нам ничего хранить не нужно. Никаких проблем также не возникнет в случае жесткой перезагрузки сервера.
Так зачем нам подключения ?
Имея «юзер-группу» и «зеркало» на клиенте отпадает необходимость работать с подключениями. А что думаете по этому поводу вы, уважаемые читатели? Поделитесь своим мнением в комментариях.
Исходный код примеров:
github.com/aesamson/signalr-server
github.com/aesamson/signalr-client