Pull to refresh

SignalR в помощь, или как оживить web

Reading time 7 min
Views 103K

Введение


Во многих web проектах присутствуют элементы, значение которых необходимо часто менять. Это могут быть счётчики, индикаторы, уведомления и подобные элементы. Показывать ли актуальные значения после обновления страницы или же можно реализовать автообновление всех этих данных? Для нас ответ очевиден: если есть возможность динамически менять элементы, то для обновления страниц не остается места.

Для небольших проектов, которые не подвержены критичным нагрузкам, пока у них нет нескольких тысяч пользователей онлайн в часы пик, приемлемым решением было бы использовать AJAX. Логика для этого решения следующая: клиент с заданной периодичностью опрашивает сервер, в поисках обновлений на странице, если сервер сообщил, что есть обновленные данные, то javascript внесен обновления в элементы страницы или отобразит уведомление. То действие, которое будет наиболее подходящим.

Но для больших проектов решение с AJAX, где каждый клиент будет опрашивать сервер, это создаст слишком большую нагрузку. Конечно, мы можем оптимизировать свои мощности и создать цепочку серверов по всей стране, которые будут готовы обрабатывать все запросы клиентов. Это не наш метод. Мы хотим, что бы сервер сам оповещал клиента о новых данных. Подобная практика используется в Desktop-приложениях — cервер, к которому подключаются клиенты при помощи сокетов. Эта логика пригодилась бы нам и в web. Уже есть Websockets, с которыми можно работать, даже .Net взял под крыло поддержку websockets. Но, объективно о повседневном использовании websockets говорить еще рано. Нужно что-то ещё. Возможно использование longpolling, где мы откроем соединение на клиенте и не будем закрывать его вовсе, ожидая события от сервера. Нет, продолжаем искать дальше.

Мы обратили внимание на SignalR. Опишем, как он работает. SignalR может использовать в качестве транспорта и websockets, и longpolling. Транспорт можно задать, а можно оставить на откуп SignalR, который сам выберет нужный. В случае, если можно использовать websocket, то он будет работать через websocket, если такой возможности нет, то он будет спускаться дальше, пока не найдёт приемлемый транспорт.

Более подробно рассмотреть работы SignalR можно при реализации определенной задачи. Что у нас есть: проект, с зарегистрированными пользователями, у каждого пользователя есть личный кабинет, в котором есть раздел сообщений, адресованных этому пользователю. Там же есть и счётчик новых сообщений. Мы хотим, чтобы как только один пользователь (user1) отправил сообщение другому пользователю (user2), на открытой странице пользователя user2 сразу же обновился счётчик новых сообщений.

Чтобы приступить к реализации, нам нужно подключить SignalR к нашему проекту. Как это сделать можно посмотреть на странице signalR, там же можно найти и необходимую документацию.
Если вы используете NuGet, то достаточно будет выполнить:

Install-Package Microsoft.AspNet.SignalR -pre

Работаем


SignalR будет использовать серверную и клиентскую части. На сервере самой сутью является Хаб (Hub). Это Класс, который позволит нам описать поведение SignalR. Так это выглядит в нашем примере:

[HubName("msg")]
    public class Im : Hub
    {
        public Task Send(dynamic message)
        {
            return Clients.All.SendMessage(message);
        }

        public void Register(long userId)
        {
Groups.Add(Context.ConnectionId, userId.ToString(CultureInfo.InvariantCulture));
                
        }

        public Task  ToGroup(dynamic id, string message)
        {
            return Clients.Group(id.ToString()).SendMessage(message);
        }
	}       
       
    }

Мы создали несколько методов.
  • Send — отправит всем клиентам сообщение.
  • Register – поможет SignalR найти того или иного пользователя, которому нужно отослать сообщение. Здесь можно увидеть использование Groups, расскажем об этом позже.
  • ToGroup – отправит сообщение той или иной группе пользователей. Говоря «пользователи» мы подразумеваем «Группа, объединяющие соединения».

Посмотрим на пример клиентского кода, и начнём разбираться что к чему:

<script src="http://code.jquery.com/jquery-1.8.2.min.js" type="text/javascript"></script>

<script src="Scripts/jquery.signalR-1.0.0-rc1.min.js" type="text/javascript"></script>

<script src="/signalr/hubs" type="text/javascript"></script>

<script type="text/javascript">
    $(function () {
       // приготовим Id пользователя.
        var userId = <%= UserId %>
        // прокси         
        var chat = $.connection.chat;

        // объявляем callback, который среагирует на событие сервера          
        chat.client.SendMessage = function (message) {
            // обновляем счётчик сообщений
            UpdateMsgCounter(message);
        };

        // Запускаем хаб
        $.connection.hub.start().done(function() {
             // расскажем серверу кто подключился
               chat.server.register(userId);
            
        });
    });
</script>

Подробнее о клиентском коде. SignalR сгенерирует для нас хаб. Его можно посмотреть для любопытства /signalr/hubs

В коде, при запуске хаба (для этого используется метод start), мы обращаемся к методу register. Мы уже использование его в серверном коде. Как будет получен UserId или другой идентификатор сейчас не принципиально. Мы используем самое простое решение для простоты понимания. Если мы все сделали правильно, то при обращении к странице с нашим клиентским кодом, стартует хаб, и методом register сообщает серверу, что пользователь userId подключился.

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

Для нас доступны следующие возможности:
// разослать всем
 Clients.All.send(message);

 // всем, кроме меня
 Clients.Others.send(message);

// всем, кроме определённого идентификатора соединения
Clients.AllExcept(Context.ConnectionId).send(message);

// тому, кто прислал
Clients.Caller.send(message);

// отослать всем в группе "foo"
Clients.Group("foo").send(message);

// отослать всем из группы "foo", кроме того кто прислал
Clients.OthersInGroup("foo").send(message);

// отослать всем из группы "foo", кроме определённого идентификатора соединения
Clients.Group("foo", Context.ConnectionId).send(message);

// отослать клиенту с определённым идентификатором соединения
Clients.Client(Context.ConnectionId).send(message);

Важный момент: при каждом новом обращении к странице (перезагрузка страницу), в том числе и открытие её в новой вкладке, создает соединение с новым идентификатором соединения SignalR. Это означает, что идентификатора UserId будет недостаточно для того, чтобы оповестить пользователя в каждой открытой вкладке браузера. Нам нужно оповестить все ConnectionId, принадлежащие к пользователю UserId. Для этого в нашем примере класса хаба, в методе Register мы добавили группу с именем UserId каждый новый ConnectionId. Теперь мы можем оперировать UserId как именем группы и оповещать все ConnectionId пользователя.

Частная практика


Давайте рассмотрим еще одну ситуацию. Пользователь user2 написал сообщение пользователю user1 и нажал кнопку «Отправить». Какие наши дальнейшие действия? Можно написать дополнительные методы класса хаба и отправлять сообщения при помощи SignalR. Хаб примет сообщение, обработает и оповестит пользователя. Помимо отправки самого сообщения с этим действием, как правило, связана дополнительная логика: запись сообщения в базу данных, логирование, отправка сообщения на модерацию и многое другое. Могут быть и более сложные примеры. Поэтому, мы ограничимся использованием хаба только по назначению: прием и отправка сообщения. Сначала мы воспользуемся старым добрым AJAX-ом и отправим сообщение от пользователя на HttpHandler. Затем сделаем с ним всё, что необходимо (запишем в базу данных или отправим на модерацию) и в итоге отправим хабу, который оповестит пользователя user1. Но есть сложность — HttpHandler находится в недрах одной из многочисленных библиотек, совсем в другом проекте. Воспользуемся возможностями SignalR чтобы устранить эту сложность. Создадим прокси-класс для соединения с хабом:

static HubConnection connection;
static IHubProxy hub;

static string Url = "http://im.myProjectSite.com"; // адрес нашего хаба
connection = new HubConnection(Url);
hub = connection.CreateHubProxy("msg");
connection.Start().Wait();
hub.Invoke("ToGroup", userId, message);


Посмотрите на использование метода Invoke. Мы вызываем метод ToGroup нашего хаба, который разошлет нужное сообщение по всем соединениям (connectioId), сопоставленными с нужным пользователем UserId. Здесь мы так же задействовали объекты static. Достаточно при старте приложения инициализировать прокси к хабу, скажем, в global.asax и при необходимости вызывать метод, в котором происходит Invoke.

С появлением SignalR в .Net, появилась необходимость в добавлении ещё нескольких строчек кода:

RouteTable.Routes.MapHubs("~/signalr");
RouteTable.Routes.MapHubs();
GlobalHost.HubPipeline.EnableAutoRejoiningGroups();

Использование роутинга SignalR несколько выходит за рамки нашей задачи. Скажем только, что EnableAutoRejoiningGroups поможет нам не потерять соединение в группе для пользователя при создании нового соединения.

С чем Вы обязательно столкнётесь и как это решить


После того как мы разобрались с описанными примерами и собрали демо-проект (или внедрили в существующий) мы пытаемся проверить его на работоспособность. Если всё сделано правильно, пользователь будет оповещен о новом сообщении, как мы этого и хотели. Мы можем даже открыть несколько вкладок браузера, чтобы убедиться в том, что оповещение приходит во все вкладки (все connectionId пользователя). Но стоит нам открыть чуть больше вкладок, как мы обнаружим, что на N вкладке уже ничего не работает. Для разных обозревателей N разное (4 или 6, слишком маленькое). Это ограничение не позволяют создавать больше N одновременных соединений к одному хабу. В некоторых проектах, даже достаточно крупных, можно увидеть решение, в котором пользователю сообщается, что он уже где-то открыл подобный диалог и предложение либо перейти обратно на старый, либо выключить старый и переключиться на этот. Ограничение не должно влиять на пользователя, он может открыть столько вкладок, сколько ему угодно и мы покажем уведомления в каждой из них. Чтобы это работает, нам необходимо каким-то образом дать понять браузеру, что используются разные хабы, а не на один. Ранее при инициализации хаба мы указали его Url: im.myProjectSite.com. Создадим обычные зеркала:

im1.myProjectSite.com
im2.myProjectSite.com
im3.myProjectSite.com


А в клиентском коде мы будем подставлять адрес хаба по определенному алгоритму. Самым простым способом будет каждый раз при обращении к странице (при открытии в новой вкладке), подставлять im (j+1), и j=1 снова после im3. В этом примере мы увеличили ограничение до 3N.

Заключение


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


Автор статьи: Дмитрий Лубенский, ведущий разработчик Дневник.ру. Отвечает за работу биллинговой системы и участвует в развитии международного направления.
Tags:
Hubs:
+22
Comments 26
Comments Comments 26

Articles

Information

Website
dnevnik.ru
Registered
Founded
Employees
101–200 employees
Location
Россия