Пользователи веб-приложений (а значит и заказчики) всё больше любят SPA с богатой логикой и всё меньше любят, когда страницы перегружаются. До определенного времени большинство сценариев можно было сделать с помощью Ajax и pull-технологий, но сейчас этого уже мало.
Если вы не веб-разработчик, то чтобы лучше понять проблематику, представьте себе Facebook или Twitter, которые перегружают страницу каждый раз, когда вы доходите до конца своей ленты новостей, чтобы после перезагрузки показать вам следующую порцию новостей. Думаю момент перегрузки будет вас сильно раздражать.
Ниже будет приведет код на JavaScript и C# только для демонстрации без использования лучших практик дизайна и архитектуры систем, другими словами не повторяйте его один в один.
Проект SignalR
Сейчас мы будем активно работать с проектом SignalR, поэтому коротко о нем расскажу. Официальный сайт проекта signalr.net, исходный код полностью открыт и лежит на github.com/SignalR/SignalR.
С помощью SignalR вы можете реализовать Push-технологию. SignalR открывает соединение между клиентом и сервером, через это открытое соединение происходит передача данных.
Чтобы вам лучше познакомиться с этой библиотеки, я бы рекомендовал последовательно реализовать каждую из описанных возможностей github.com/SignalR/SignalR/wiki.
Типовые сценарии
Хочу рассмотреть 3 довольно популярных сценария, они могут встретиться вам в большинстве текущих проектов. По каждому из них мы реализуем два подхода: через ajax-запросы и через работу с SignalR.
Реализацию всех сценариев в обоих вариантах можно скачать с GitHub
№1 Загрузка веб-страницы по частям
Сценарий: Пользователь запрашивает страницу с множеством разных частей (графики, индексы и т.д.). По бизнес-сценарию ему главное увидеть первую часть остальные могут появляться со временем, когда рассчитаются.
Типовое решение через ajax-запросы
Мы не можем заставить пользователя ждать пока загрузится вся страница, поэтому оптимизируем выдачу первой части, а на остальные посылаем ajax-запросы.
На UI при загрузке страницы мы запрашиваем данные по двум блокам. Отсылаем через jQuery 2 ajax-запроса. Когда получаем ответ, то заполняет блоки полученными данными:
<script> $(document).ready(function() { $('#block2').load('@Url.Action("Block2", "Home")'); $('#block3').load('@Url.Action("Block3", "Home")'); }); </script>
На бэкенде простая симуляция долгой загрузки:
public ActionResult Block2() { Thread.Sleep(new Random().Next(2000, 5000)); return PartialView(); } public ActionResult Block3() { Thread.Sleep(new Random().Next(4000, 10000)); return PartialView(); }
Пример запуска:
Видно, что первый блок отобразился сразу без лишних запросов с UI, второй загрузился через ajax-запрос, а третий еще висит в ожидании ответа.
Главная проблема при таком подходе - это ограничения браузеров. Если блоков с информацией будет больше 5-6 и одновременных запросов 5-6 соответственно, то браузер выстроит запросы в очередь и не будет выполнять параллельно, как нам хотелось бы. Для эксперимента я сделал 10 одинаковых запросов с одинаковой задержкой и запустил их в Chrome последней версии:
Решение с SignalR
Для начала работы создадим пустой Web-проект на ASP.NET MVC и через Nuget добавим сборки SignalR. Для быстрого старта советую потратить время и по шагам пройти www.asp.net/signalr/overview/getting-started/tutorial-getting-started-with-signalr.
Создаем Hub:
public class SimpleHub : Hub { public string Block2() { Thread.Sleep(3000); return "Какие-то графики"; }
Выполняем подключение и запрос данных через единственное соединение:
$(function () { var simpleHub = $.connection.simpleHub; $.connection.hub.start().done(function() { simpleHub.server.block2().done(function(data) { $('#block2').html(data); }); simpleHub.server.block3().done(function(data) { $('#block3').html(data); }); }); });
В результате данные для всех блоков загрузятся через одно подключение:
№2 Отображаем процент загрузки
Сценарий: Запускаем формирование отчета, который создается несколько минут. Пользователю желательно показать прогресс создания отчета, чтобы он понимал, когда ждать результат.
Типовое решение через ajax-запросы
Пользователь запустит какой-то тяжелый и долгий процесс на бэкэнде, сэмулируем это таким кодом:
public class HomeController : Controller { private static int percent; public ActionResult Index() { return View(); } public void StartLongProcess() { // здесь мы как будто запускаем большой процесс на бэкэнде Task.Factory.StartNew(() => { while (true) { Thread.Sleep(1000); // это не переменная, а например ячейка в БД или сервис percent++; } }); } public ActionResult GetProgress() { return Content(percent.ToString()); } }
На UI пользователь нажимает кнопку и каждые 3 секунды опрашивает сервер, например, базу данных, на наличие прогресса по запущенной задаче:
<button id="start-creation">Начать построение отчета</button> Percents: <span id="progress">0</span>% <script> $(function() { $('#start-creation').on('click', function() { $(this).attr("disabled", "disabled"); // начинаем "бомбить" бэкэнд периодическими запросами setInterval(function () { $('#progress').load('@Url.Action("GetProgress", "Home")'); }, 3000); }); }); </script>
Заглянем в консоль и увидим, что мы периодически отсылаем ajax-запросы на получение данных:
Недостатки подхода:
- Как в прошлый раз, мы забиваем очередь на выполнение ajax-запросов в браузере. Может наступить момент, когда они не будут успевать выполняться
- Мы выбираем определенные промежутки времени для опроса сервера, но что если в этот промежуток произойдет что-то важное? Пользователь увидит это только при следующем запросе. Заказчик может потребовать как можно быстрее реагировать на изменения, что заставит нас опрашивать сервер чаще, а это только ухудшит положение
- Если отчет делается длительное время, то большая часть запросов уйдет в холостую, они вернут одно и то же число. Это значит, что мы делаем лишние запросы, которые тратят ресурсы сервера
Решение с SignalR
Как и в прошлый раз мы запустим процесс на бэкэнде, но в этот раз не станем делать отдельный Action, который возвращет результат. Прямо в самом процессе будем через SignalR делать отсылку уведомлений на клиента:
public class HomeController : Controller { private static int percent; public ActionResult Index() { return View(); } public void StartLongProcess() { percent = 0; // здесь мы как будто запускаем большой процесс на бэкэнде Task.Factory.StartNew(() => { while (true) { Thread.Sleep(1000); // это не переменная, а например ячейка в БД или сервис percent++; // в примере рассылаем всем, но в реальном проекте будем отсылать только нашему пользователю IHubContext context = GlobalHost.ConnectionManager.GetHubContext<SimpleHub>(); context.Clients.All.showPercent(percent); } }); } }
Осталось на клиенте реализовать обработку функции showPercent, которую мы вызывали из бэкэнд кода:
<button id="start-creation">Начать построение отчета</button> Percents: <span id="progress">0</span>% <script> $(function() { $('#start-creation').on('click', function() { $(this).attr("disabled", "disabled"); $.connection.hub.start().done(function() { $.get('@Url.Action("StartLongProcess", "Home")'); }); }); var simpleHub = $.connection.simpleHub; simpleHub.client.showPercent = function (percent) { $('#progress').html(percent); }; }); </script>
Запустим приложение и увидим, что проценты прибавляются, при этом у нас создано только одно подключение. Причем изменения в процентах приходят только тогда, когда на бэкэнде эти изменения на самом деле произошли:
№3 Запущен сторонний сервис
Сценарий: С веб-интерейса запускается процесс, который в свою очередь стартует цепочку других сервисов. Например, это может быть работа с очередями. Во время работы сервиса мы хотим отображать пользователю результаты работы на каждом шагу.
Типовое решение через ajax-запросы и расшаренную БД
В качестве расшаренной БД у нас будет текстовый файл, в который будем записывать результаты стороннего процесса. ASP.NET MVC будет считывать данные из БД, а сервис (консольное приложение) будет туда данные писать.
На стороне сервиса у нас код по генерации данных:
private static void Main() { int step = 0; while (true) { var message = string.Format("Step #{0}: done", step); File.WriteAllText("~/../../../../Web/DistibutedDatabase.txt", message); Console.WriteLine(message); step++; Thread.Sleep(new Random().Next(1000, 4000)); } }
В методе контроллера считываем данные из этого файла и отображаем их пользователю:
public ActionResult GetProgress() { string databasePath = Server.MapPath("~/DistibutedDatabase.txt"); string result = System.IO.File.ReadAllText(databasePath); return Content(result); }
Таким образом, постоянно опрашивая БД, мы показываем пользователю текущее состояние работы стороннего сервиса:
Недостатки способа уже описаны в предыдущих примерах. Мы делаем множество лишних запросов к БД и ajax-запросов в UI.
Решение с SignalR
В этом примере мы не будем создавать БД. Она может понадобиться только, если мы захотим хранить результаты работы сервиса. Сейчас у нас в требованиях только отображение результатов пользователю.
Сервис будет как и раньше генерировать названия шагов, только теперь он не будет записывать их в БД, а будет отсылать напрямую в веб-приложение:
private static void Main(string[] args) { // Подключаемся к веб-приложению var hubConnection = new HubConnection("http://localhost:37255"); var hubProxy = hubConnection.CreateHubProxy("simpleHub"); hubConnection.Start().Wait(); int step = 0; while (true) { string message = string.Format("Step #{0}: done", step); File.WriteAllText("~/../../../../Web/DistibutedDatabase.txt", message); // Вызываем на стороне веб-приложения метод ShowProgress hubProxy.Invoke("ShowProgress", message); Console.WriteLine(message); step++; Thread.Sleep(new Random().Next(1000, 4000)); } }
Со стороны веб-приложения в классе SimpleHub реализуем метод ShowProgress:
public class SimpleHub : Hub { public void ShowProgress(string message) { Clients.All.showPercent(message); } }
В веб-интерфейсе код, который мы уже видели:
<button id="start-creation">Начать построение отчета</button> <br/> Percents: <span id="progress">not started</span> <script> $(function() { $('#start-creation').on('click', function() { $(this).attr("disabled", "disabled"); $.connection.hub.start().done(function() { $.get('@Url.Action("StartLongProcess", "Home")'); }); }); var simpleHub = $.connection.simpleHub; simpleHub.client.showPercent = function (percent) { $('#progress').html(percent); }; }); </script>
Таким образом, сторонний сервис "проталкивает" данные через SignalR напрямую в наше веб-приложение через единожды открытое соединение:
Pull или Push
Рассмотренные примеры показывают нам два разных подхода. Первые примеры - это метод вытягивания, pull. С клиента мы пытаемся периодически тянуть данные сервера, при этом не зная что реально на сервере происходит.
Вторые примеры - это метод проталкивания, push. В них клиент ведет себя пассивно, ожидая пока в него "впихнут" данные.
Я думаю, что большинство современных приложений должны посмотреть в сторону push-технологий, чтобы улучшить UX и отклик клиентских приложений.
Реализацию всех сценариев в обоих вариантах можно скачать с GitHub
Ссылки