Реализация Server Push для Nancy +7


В этой статье я хочу рассказать о своей реализации паттерна под названием Long Polling для фреймворка Nancy. Коду моего модуля уже более четырёх лет, в течение которых он успешно работал в ряде проектов на ASP .Net MVC. На этой неделе я решил оформить его в виде модуля Nancy и выложить на гитхаб для всеобщего блага, поскольку аналогичного решения найти мне не удалось.

С помощью моего модуля буквально за минуту и парой простых действий можно получить надёжный канал обратной связи от сервера к браузеру. Хотите узнать подробности?

Немного предыстории


Пока вы приходите в себя после просмотра картинки для привлечения внимания, я вкратце расскажу, откуда вообще появился этот мой модуль. В конце 2011 года в одном из проектов нам потребовалось организовать реалтаймовый канал связи от сервера к веб-интерфейсу. На тот момент я был знаком с техникой решения такой задачи, благо на хабре уже было несколько статей про Comet, Server Push и Long Polling. Более того, по моим ощущениям, именно в тот период пришла мода на веб-интерфейсы и веб-приложения, которые обновляют данные в реальном времени без перезагрузки всей страницы в условиях web’а.

Я решил не изобретать велосипед и найти готовое решение. Проект наш был реализован на ASP .Net MVC 3 (или ещё 2?), поэтому очевидный выбор пал на SignalR, который чудесным образом зарелизился как раз за месяц до того, как он нам понадобился. SignalR подключился и работал, на первый взгляд, без проблем, однако через некоторое время выяснились некоторые досадные особенности, касающиеся обнаружения потери связи с сервером и восстановления соединения. Не сомневаюсь, что сейчас в SignalR всё хорошо, однако на тот момент эти “особенности” раздражали, в результате чего естественное желание сделать всё своё с нуля с блекджеком и шлюпками пересилило желание разбираться, что там не так в чужом коде. За вечер я написал контроллер и клиентский скрипт, которые просуществовали практически в неизменном виде до сегоднящнего дня, кочуя из одного проекта в другой.

Год назад мы с нашей командой открыли для себя прекрасную альтернативу майкрософтовской MVC “в лице” фреймворка Nancy. Поскольку теперь новые проекты мы делаем на нём, наши старые наработки стали потихоньку оформляться в виде модулей Nancy. В итоге черёд дошёл и до модуля long polling, который получился, на мой взгляд, достаточно самодостаточным и приличным, чтобы поделиться им с сообществом. Скачать код с примером и тестами вы можете здесь.

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

Инструкция по применению


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

  • скачать и сбилдить проект Nancy.LongPoll, подключить его к своему проекту;
  • зарегистрировать в IoC-контейнере приложения класс PollService как синглтон;
  • на каждой веб-странице, где вы хотите принимать уведомления от сервера, вызвать startPoll(), а также переопределить функцию pollEvent(messageName, stringData), где stringData будет приходить в виде строки. Обычно, удобнее всего в этой строке присылать json, после чего делать JSON.parse(stringData).

После того, как вы выполните эти действия, всё будет готово для того, чтобы начать слать сообщения от сервера к браузерам. Для отправки сообщений у класса PollService предусмотрено несколько методов, вынесенных в интерфейс IPollService:

interface IPollService
{
  // Отправка сообщения списку клиентов
  void SendMessage(List<string> clientIds, string messageName, string message);
  // Отправка сообщения клиенту
  void SendMessage(string clientId, string messageName, string message);
  // Отправка сообщения всем клиентам
  void SendMessageToAllClients(string messageName, string message);
  // Отправка сообщения клиентам с идентификатором сеанса sessId
  void SendMessageToSession(string sessId, string messageName, string message);
  // Отправка сообщения клиентам с сеансами из списка sessIds
  void SendMessageToSessions(List<string> sessIds, string messageName, string message);
  // Отключить клиента
  void StopClient(string clientId);
}

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

Сеанс — соответствует общепринятому в веб-технологиях понятию сеанса. Идентификатор сеанса по умолчанию хранится в cookie с именем nancy_long_poll_session_id. Отправляя сообщение сеансу вы, скорее всего, отправляете его конкретному браузеру, т.е. это сообщение получат сразу все вкладки браузера. Вы можете переопределить серверную генерацию идентификатора сеанса, реализовав интерфейс ISessionProvider и зарегистрировав свой класс в IoC-контейнере приложения или запроса. Переопределение сеанса вам может понадобиться, если у вас уже есть, например, привязка пользователей к сеансам. Таким образом вы сможете слать сообщения конкретным пользователям. Если вы этого не сделаете, будет использоваться реализация по умолчанию, которая генерирует идентификатор сеанса в виде случайной строки.

Кроме того, вы можете реализовать интерфейс ILogger для того, чтобы получать диагностические сообщения от модуля. По умолчанию используется реализация EmptyLogger, которая не делает ничего.

И, наконец, в любой момент вы можете остановить опрос по инициативе клиента, JavaScipt-вызовом stopPoll(), хотя обычно это не требуется. Возобновить опрос вы можете снова вызвав startPoll().

Пример использования модуля


Чтобы вы могли опробовать всё это в действии, я написал небольшой пример использования модуля, который доступен по ссылке: https://github.com/AIexandr/Nancy.LongPoll/tree/master/Nancy.LongPoll.Example



Пример реализован как self host консольное приложение, запускающееся на 80м порту, поэтому для его работы вам, возможно, потребуется запустить Visual Studio с правами администратора. Кроме того, не забывайте, что порт может быть занят, наример, скайпом. Сервер раз в секунду шлёт всем клиентам значение инкрементируемого счётчика, которое отображается в окне браузера. Вы можете открыть сразу несколько окон, чтобы убедиться в том, что счётчик обновляется синхронно.

В верхней части страницы приложения отображается состояние связи с сервером. Нажатием кнопки Stop poll вы можете разорвать соединение по инициативе клиента. Возобновить соединение можно нажатием кнопки Start poll. С помощью кнопки Stop notifications on server можно приостановить работу примера серверного модуля рассылки уведомлений. Кнопка Start notifications on server позволяет возобновить его работу.

С помощью отладочной панели хрома можно проследить, как работет модуль опроса (см. скриншот):



  1. После загрузки страницы произошла регистрация клиента.
  2. Примерно раз в секунду приходят уведомления от сервера.
  3. Была нажата кнопка Stop notifications on server. На сервер ушёл POST-запрос, по которому отключился демонстрационный сервис генерации уведомлений. Отправка уведомлений сервером прекратилась и в течение 2.8 мин. висел длинный HTTP-запрос к серверу (см. длинная горизонтальня зелёная полоска на скриншоте).
  4. Была нажата кнопка Start notifications on server, на сервер был отправлен POST-запрос, возобновляющий работу демонстрационного сервиса, после чего немедленно снова раз в секунду начали приходить сообщения.
  5. Была нажата кнопка Stop poll. Опрос прекратился по инициативе клиента.
  6. Была нажата кнопка Start poll. Опрос возобновился.

В примере не показан случай отключения клиентов по инициативе сервера. Данную функцию вы можете попробовать освоить самостоятельно, за неё отвечает метод StopClient() сервиса опроса.

Структура проекта примера:

  • Program.cs — стандартный класс для консольного приложения. Запускает Nancy Self Host и открывает два окна браузера.
  • Bootstrapper.cs — переопределяет стандарный бутстраппер Nancy. Регистрирует в контейнере IoC сервис опроса и демонстрационный сервис генерации уведомлений.
  • ExampleModule.cs — демонстрационный NancyModule. Возвращает Index.html по запросу браузера и отвечает на POST-запросы /Start и /Stop, запускающие и останавливающие демонстрационный сервис уведомлений.
  • ExampleNotificationService.cs — демонстрационный сервис уведомлений. Раз в секунду шлёт в сервис опроса “широковещательное” (всем клиентам) уведомление с новым значением инкрементируемого счётчика.
  • Index.html — собственно страница веб-интерфейса. Находится в проекте как Embedded Resource.

Устройство модуля


Библиотека Nancy.LongPoll.dll включает в себя серверные .Net-классы и клиентский скрипт poll.js, встроенный в библиотеку как Embedded Resource. Вот список файлов бибилиотеки:

  • ContentModule.cs — модуль Nancy, отвечающий за выдачу встроенных ресурсов по HTTP-запросам. В рамках Nancy.LongPoll.dll используется для выдачи poll.js.
  • Logger.cs — содержит интерфейс и пустую реализацию логгера. Позволяет отвязать Nancy.LongPoll от конкретной реализации логгера.
  • poll.js — скрипт опроса, содержащий реализацию клиентской логики лонг-поллинга.
  • DefaultSessionProvider.cs — содержит интерфейс и реализацию по умолчанию провайдера идентификатора сеанса.
  • PollModule.cs — рализация модуля Nancy, необходимая для работы сервиса опроса.
  • PollService.cs — собственно сервис опроса.

Поначалу я хотел детально описать устройство и работу модуля, но потом решил не перегружать статью лишними подробностями. Вместо этого приведу наиболее значимые факты о poll.js и классе PollService:

  • Класс PollService содержит в себе список текущих подключенных клиентов с привязкой к идентификаторам клиентов и сеансов. Клиенты описываются классом PollService.Client.
  • Отправка сообщений от сервера к клиентам производится в виде структуры json. Структура сообщения описана классом PollService.Message и содержит поля: признак успешности действия, код возврата, имя сообщения, строку с данными. Конечно, более правильно было бы передавать успешность действия и код возврата кодами статуса HTTP, но я не считаю это таким уж большим недостатком реализации.
  • Взаимодействие poll.js и PollService начинается с регистрации клиента. В процессе регистрации клиенту присваивается идентификатор, очередь сообщений и seqNumber — номер сообщения, обработанного клиентом.
  • poll.js открывает “длинное” соединение к PollService, сообщая номер последнего принятого сообщения. В случае, когда для клиента есть сообщение с номером больше, чем последнее обработанное клиентом, сообщение извлекается из очереди и передаётся в качестве ответа клиенту, после чего удаляется из очереди. Если же сообщений нет, то PollService не отпускает HTTP-соединение и “тянет время”. Тут есть что улучшить: если в очереди сообщений с момента последнего обращения клиента накопилось несколько сообщений, они будут отправляться клиенту по одному, хотя более правильно было бы отдавать клиенту сразу массив сообщений, после чего разбирать его в poll.js.
  • PollService помнит время последнего обращения каждого клиента. Если со времени последнего клиента пройдёт более, чем определено в поле PollService.CLIENT_TIMEOUT, клиент считается отключенным. Опять же, тут есть что улучшать. Клиент никак не уведомляет сервер о своем намерении отключиться, даже если вызвать stopPoll().
  • Число одновременно подключенных клиентов можно ограничить с помощью значения поля PollService.MAX_CLIENTS.
  • poll.js после вызова startPoll() начинает активные попытки установить связь с сервером и зарегистрироваться. Если связь обрывается или происходит ошибка при передаче данных, poll.js автоматически возобновляет попытки установления связи. Состояние активности скрипта находится в переменной isPollActive, состояние связи отражено в переменной isPollConnected. Однако, если на сервере вызвать метод PollService.StopClient(clientId), poll.js прекратит попытки установить связь до следующего вызова startPoll(). В качестве дальнейшего улучшения модуля тут можно добавить версии метода для остановки подключения сеанса и вообще всех клиентов.
  • Класс PollService спроектирован и реализован с учётом многопоточной сущности происходящих тут процессов. На мой взгляд, получилось неплохо, однако я использовал простые lock’и, хотя хорошо бы переделать на ReaderWriterLockSlim.

Вот, собственно, всё, что я хотел рассказать. Не стесняйтесь задавать вопросы, а также использовать мой модуль. Буду также рад рассмотреть толковые pull requests.




К сожалению, не доступен сервер mySQL