Веб-приложения реального времени. Веб-сокеты, IIS 8, библиотека SignalR и их использование в приложениях ASP.NET. Часть шестая, пример использования SignalR.

В данной статье я постараюсь показать пример использования библиотеки SignalR. Напомню, что в предыдущей была описана библиотека в целом. Для демонстрации будет создано веб-приложение, которое позволяет редактировать таблицу данных одновременно нескольким пользователям в режиме реального времени. Идея даноого примера не новая, а позаимствована отсюда. Но в этот раз, она будет реализована с использованием SignalR. Поскольку код этого проекта был написан максимально гибко (это было запланировано заранее), то заменить часть функционала по обеспечению дуплексной связи будет просто, и всё это будет показано детально. И так приступим. Создадим пустое приложение ASP.NET Web Forms 4.5.



Устанавливаем библиотеку SignalR используя NuGet. На момент написания статьи это версия 1.1.3.
 

Нужно подключить систему маршрутизации, для этого в файл web.config добавим список управляемых модулей.
<system.webServer>
  <modules runAllManagedModulesForAllRequests="true" />
</system.webServer>
Регистрируем маршрут для SignalR, он должен быть первым в таблице. Хотя в данном случае он единственный.
using System.Web.SessionState;
 
namespace SignalRwebApplication
{
  public class Global : System.Web.HttpApplication
  {
 
    protected void Application_Start(object sender, EventArgs e)
    {
      RouteTable.Routes.MapHubs();
    }
  }
}
Добавим нашу страницу, это обычная веб-форма, со следующим содержимым.
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Products.aspx.cs" 
  Inherits="SignalRwebApplication.Products" %>
 
<!DOCTYPE html>
 
<html xmlns="http://www.w3.org/1999/xhtml">
<head id="Head1" runat="server">
  <title></title>
  <link href="<%#ResolveUrl("~/Content/Styles/ProductsPage.css")%>" rel="stylesheet" />
  <script src="<%#ResolveUrl("~/Scripts/jquery-1.6.4.js")%>"
    type="text/javascript"></script>
  <script src="<%#ResolveUrl("~/Scripts/jquery.signalR-1.1.3.js")%>" 
    type="text/javascript"></script>
 
  <%--Данный файл автоматически генерируется на сервере.--%>
  <script src="<%#ResolveUrl("~/signalr/hubs")%>" type="text/javascript"></script>
  <script src="<%#ResolveUrl("~/Scripts/ProductsPage/ProductsPage.js")%>"
    type="text/javascript"></script>
</head>
<>
  <form id="form1" runat="server">
    <div class="tdiv">
      <span id="webSocketStatusSpan"></span>
      <asp:Repeater ID="ProductsRepeater" runat="server">
        <HeaderTemplate>
          <table id="ProdTable" class="itable">
            <tr id="ProdTableHd">
              <th>Ид</th>
              <th>Имя</th>
              <th>Описание</th>
            </tr>
        </HeaderTemplate>
        <ItemTemplate>
          <tr>
            <td>
              <asp:Label ID="ProductsRepeaterId" ClientIDMode="Static" runat="server"
                Text='<%#Eval("Id")%>'></asp:Label>
            </td>
            <td>
              <asp:Label ID="ProductsRepeaterName" ClientIDMode="Static" runat="server"
                Text='<%#Eval("Name")%>'></asp:Label>
            </td>
            <td>
              <asp:Label ID="ProductsRepeaterDescription" ClientIDMode="Static" runat="server"
                Text='<%#Eval("Description")%>'></asp:Label>
            </td>
          </tr>
        </ItemTemplate>
        <FooterTemplate>
          </table>
        </FooterTemplate>
      </asp:Repeater>
    </div>
    <br />
    <div class="ediv">
      <ul class="elist">
        <li><span>Ид</span><input type="text" id="ProductIdText" disabled="disabled" /></li>
        <li><span>Имя</span><input type="text" id="ProductNameText" /></li>
        <li><span>Описание</span><input type="text" id="ProductDescriptionText" /></li>
      </ul>
      <input id="InsertProductButton" type="button" value="Добавить запись" />
      <input id="UpdateProductButton" type="button" disabled="disabled"
        value="Сохранить запись" />
      <input id="DeleteProductButton" type="button" disabled="disabled"
        value="Удалить запись" />
    </div>
    <br />
    <div class="mdiv">
      <ul id="MessagesList">
      </ul>
    </div>
  </form>
</>
</html>
Тут надо отметить, что менеджер NuGet автоматически добавил старую версию библиотеки jQuery (версия 1.6.4), хотя она вполне пригодна для использования. Поэтому я не стал обновлять на более новую версию. Обратите внимание на порядок использования скриптов.
<script src="<%#ResolveUrl("~/Scripts/jquery-1.6.4.js")%>" 
    type="text/javascript"></script>
<script src="<%#ResolveUrl("~/Scripts/jquery.signalR-1.1.3.js")%>" 
    type="text/javascript"></script>
<%--Данный файл автоматически генерируется на сервере.--%>
<script src="<%#ResolveUrl("~/signalr/hubs")%>" type="text/javascript"></script>
<script src="<%#ResolveUrl("~/Scripts/ProductsPage/ProductsPage.js")%>"
    type="text/javascript"></script>
Сначала, как обычно, подключается библиотека jQuery. После основная библиотека SignalR, которая основана и использует первую. А вот файла hubs физически нет на сервере, он генерируется "на лету" (динамически) системой. Он содержит прокси-объекты, посредством которорых приложение будет работать с сервером. Об этом чуть позже. Теперь нам нужен код, который будет упралять всеми этими соединениями, а также посылать и принимать данные. Напомню, что в примере из этой статьи, этим занимался обработчик HTTP-данных (ProductsDataHandler). Но в данном случае библиотека предоставляет абстракцию более высокого уровня, а именно – концентратор (Hub). Добавим его в проект.



Отредактируем его, добавив следующее содержимое.
using System;
using Microsoft.AspNet.SignalR;
 
namespace SignalRwebApplication
{
  public class ProductMessageHub : Hub
  {
    //Метод обрабатывающий запросы.
    public void HandleProductMessage(string receivedString)
    {
      string responseString = String.Empty;
 
      //Обрабатываем входную строку.
      bool dataProcessedSuccessfully =
        ProductMessageHandler.HandleMessage(receivedString, ref responseString);
 
      //В случае обновления отправляем данные всем подключённым пользователям.
      if(dataProcessedSuccessfully)
        Clients.All.handleProductMessage(responseString);
      //Иначе, только приславшему.
      else
        Clients.Caller.handleProductMessage(responseString);
    }
  }
}
Что касается остального серверного кода, то он позаимствован из этого проекта, вы можете посмотреть его там. Чтобы не загромаждать статью листингами я не стал его повторно приводить. Теперь о клиентском коде, вот он.
/// <reference path="../jquery-1.6.4.js" />
/// <reference path="../jquery.signalR-1.1.3.js" />
 
var lastSelectedTableRow;
//Кеш со ссылками на строки таблицы идентифицируемые по Ид продукта.
var productsDataTableRows = new Array();
 
//Объект концентратора.
var productMessageHubProxy = $.connection.productMessageHub;
 
//Метод который будет получать приходящие сообщения.
productMessageHubProxy.client.handleProductMessage = function (message)
{
    //Метод обрабатывающий сообщения.
    ReceivedMessageHandler(message);
}
 
var rowSelected = false, rowInserted = false;
 
$(document).ready(function () {
 
    //Устанавливаем соединение с сервером.
    $.connection.hub.start().done(function () {
        $("#webSocketStatusSpan").text("Соединение установлено.");
    });
 
    $("#ProdTable tr").each(function (index, element) {
        if ($(element).attr("id") != "ProdTableHd") {
            productsDataTableRows.push(FillProductsTableRowObject(element));
        };
    });
 
    $("#ProdTable tr").click(ProdTableTrClickHandler);
    /////////////////////////////////////////////////////////////////////////////
    $("#InsertProductButton").click(function () {
        rowSelected = false;
        rowInserted = true;
        ClearDataForm();
        $("#ProductIdText").removeAttr('disabled');
        $("#UpdateProductButton").removeAttr('disabled');
        $(this).attr('disabled', 'disabled');
        $("#DeleteProductButton").attr('disabled', 'disabled');
 
    });
    /////////////////////////////////////////////////////////////////////////////
    $("#UpdateProductButton").click(function () {
        if (!rowSelected & !rowInserted)
            alert("Строка для обновления не выбрана.");
        else if (rowSelected & !rowInserted) {
            //Тип сообщения – 2, данные для обновления.
            SendProductDataMessage(2);
        }
        else if (!rowSelected & rowInserted) {
            if ($("#ProductIdText").val() == '')
                alert("Пустой идентификатор продукта.");
            else {
                //Тип сообщения – 1, данные для вставки.
                SendProductDataMessage(1);
                rowInserted = false;
                $("#UpdateProductButton").attr('disabled', 'disabled');
            }
        }
        $("#InsertProductButton").removeAttr('disabled');
    });
    /////////////////////////////////////////////////////////////////////////////
    $("#DeleteProductButton").click(function () {
        if (!rowSelected)
            alert("Строка для удаления не выбрана.");
        else {
            if (confirm("Удалить выбранную запись?")) {
                //Тип сообщения – 3, данные для удаления.
                SendProductDataMessage(3);
            }
        }
    }
    );
    $("#ProductNameText").change(function () {
        $("#UpdateProductButton").removeAttr('disabled');
    });
    $("#ProductDescriptionText").change(function () {
        $("#UpdateProductButton").removeAttr('disabled');
    });
 
});
/////////////////////////////////////////////////////////////////////////////
function ProdTableTrClickHandler() {
    if ($(this).attr("id") != "ProdTableHd") {
        if (lastSelectedTableRow != undefined)
            $(lastSelectedTableRow).removeClass("srow");
        $(this).addClass("srow");
        lastSelectedTableRow = this;
        FillDataForm();
        rowSelected = true;
        rowInserted = false;
        $("#ProductIdText").attr('disabled', 'disabled');
        $("#InsertProductButton").removeAttr('disabled');
        $("#UpdateProductButton").attr('disabled', 'disabled');
        $("#DeleteProductButton").removeAttr('disabled');
    }
}
/////////////////////////////////////////////////////////////////////////////
function FillProductsTableRowObject(tableRow) {
    var tableRowObject = new Object();
    tableRowObject.RowLink = tableRow;
    tableRowObject.Id = $(tableRow)
        .find("span[id*='ProductsRepeaterId']").first().text();
 
    return tableRowObject;
}
/////////////////////////////////////////////////////////////////////////////
function FillDataForm() {
    var selectedRow = $("#ProdTable tr.srow");
    $("#ProductIdText").val($(selectedRow)
        .find("span[id*='ProductsRepeaterId']").first().text());
    $("#ProductNameText").val($(selectedRow)
        .find("span[id*='ProductsRepeaterName']").first().text());
    $("#ProductDescriptionText").val($(selectedRow)
        .find("span[id*='ProductsRepeaterDescription']").first().text());
}
/////////////////////////////////////////////////////////////////////////////
function ClearDataForm() {
    $("#ProductIdText").val('');
    $("#ProductNameText").val('');
    $("#ProductDescriptionText").val('');
}
/////////////////////////////////////////////////////////////////////////////
function FindTableRowById(id) {
    for (i = 0; i < productsDataTableRows.length; i++) {
        if (productsDataTableRows[i].Id == id)
            return productsDataTableRows[i];
    }
 
    return null;
}
/////////////////////////////////////////////////////////////////////////////
function InsertTableRow(productDataMessage) {
    var tableRowHtmlString = '<tr><td><span id="ProductsRepeaterId">'
        + productDataMessage.Product.Id
        + '</span></td><td><span id="ProductsRepeaterName">' + productDataMessage.Product.Name
        + '</span></td><td><span id="ProductsRepeaterDescription">'
        + productDataMessage.Product.Description
        + '</span></td></tr>';
 
    $("#ProdTable tr:last").after(tableRowHtmlString);
    $("#ProdTable tr:last").click(ProdTableTrClickHandler);
    productsDataTableRows.push(FillProductsTableRowObject($("#ProdTable tr:last")));
}
/////////////////////////////////////////////////////////////////////////////
function UpdateTableRow(productDataMessage) {
 
    var tableRow = FindTableRowById(productDataMessage.Product.Id);
 
    $(tableRow.RowLink).find("span[id*='ProductsRepeaterName']")
        .first().text(productDataMessage.Product.Name);
    $(tableRow.RowLink).find("span[id*='ProductsRepeaterDescription']")
        .first().text(productDataMessage.Product.Description);
}
/////////////////////////////////////////////////////////////////////////////
function RemoveTableRow(productDataMessage) {
    var tableRow = FindTableRowById(productDataMessage.Product.Id);
    $(tableRow.RowLink).remove();
}
/////////////////////////////////////////////////////////////////////////////
function SendProductDataMessage(messageType) {
 
    //Создаём новое сообщение для отправки.
    var productDataMessage = new Object();
    productDataMessage.Product = new Object();
 
    //Устанавливаем тип сообщения.
    productDataMessage.MessageType = messageType;
 
    //Устанавливаем введённые данные.
    productDataMessage.Product.Id = $("#ProductIdText").val();
    productDataMessage.Product.Name = $("#ProductNameText").val();
    productDataMessage.Product.Description = $("#ProductDescriptionText").val();
 
    SendPoductsData(JSON.stringify(productDataMessage));
}
function ReceivedMessageHandler(data) {
    var productDataMessage = JSON.parse(data);
    if (productDataMessage.DataProcessedSuccessfully) {
 
        switch (productDataMessage.MessageType) {
            case 1: //Новая запись.
                InsertTableRow(productDataMessage);
                break;
            case 2: //Обновление текущей записи.
                UpdateTableRow(productDataMessage);
                break;
            case 3: //Удаление записи.
                RemoveTableRow(productDataMessage);
                break;
            default:
                return;
        }
    }
    SetOperationResulStatus(productDataMessage.ResponseMessage);
}
/////////////////////////////////////////////////////////////////////////////
function SetOperationResulStatus(statusString) {
    var date = new Date();
    var dateString = date.getHours() + ':' + date.getMinutes() + ':' + date.getSeconds();
    $("#MessagesList").prepend('<li>' + dateString + ' ' + statusString + '</li>');
}
/////////////////////////////////////////////////////////////////////////////
//Метод отправляющий данные.
function SendPoductsData(productDataJsonString) {
 
    //Отправляет данные на сервер.
    productMessageHubProxy.server.handleProductMessage(productDataJsonString);
}
Разберём его по порядку. Самое главное тут объект концентратора,
//Объект концентратора.
var productMessageHubProxy = $.connection.productMessageHub;
но откуда он создаётся? Помните о порядке подключения скриптов и о файле hubs, именно там и автоматически создаётся код прокси-объектов, его можно создать и вручную (о том как это сделать читайте на сайте asp.net). Вот, что мы получаем для данного случая.
/*!
 * ASP.NET SignalR JavaScript Library v1.1.3
 * http://signalr.net/
 *
 * Copyright Microsoft Open Technologies, Inc. All rights reserved.
 * Licensed under the Apache 2.0
 * https://github.com/SignalR/SignalR/blob/master/LICENSE.md
 *
 */
 
/// <reference path="....SignalR.Client.JSScriptsjquery-1.6.4.js" />
/// <reference path="jquery.signalR.js" />
(function ($, window) {
  /// <param name="$" type="jQuery" />
  "use strict";
 
  if (typeof ($.signalR) !== "function") {
    throw new Error("SignalR: SignalR is not loaded. Please ensure 
      jquery.signalR-x.js is referenced before ~/signalr/hubs.");
  }
 
  var signalR = $.signalR;
 
  function makeProxyCallback(hub, callback) {
    return function () {
      // Call the client hub method
      callback.apply(hub, $.makeArray(arguments));
    };
  }
 
  function registerHubProxies(instance, shouldSubscribe) {
    var key, hub, memberKey, memberValue, subscriptionMethod;
 
    for (key in instance) {
      if (instance.hasOwnProperty(key)) {
        hub = instance[key];
 
        if (!(hub.hubName)) {
          // Not a client hub
          continue;
        }
 
        if (shouldSubscribe) {
          // We want to subscribe to the hub events
          subscriptionMethod = hub.on;
        }
        else {
          // We want to unsubscribe from the hub events
          subscriptionMethod = hub.off;
        }
 
        // Loop through all members on the hub and find 
        //client hub functions to subscribe/unsubscribe
        for (memberKey in hub.client) {
          if (hub.client.hasOwnProperty(memberKey)) {
            memberValue = hub.client[memberKey];
 
            if (!$.isFunction(memberValue)) {
              // Not a client hub function
              continue;
            }
 
            subscriptionMethod.call(hub, memberKey, makeProxyCallback(hub, memberValue));
          }
        }
      }
    }
  }
 
  $.hubConnection.prototype.createHubProxies = function () {
    var proxies = {};
    this.starting(function () {
      // Register the hub proxies as subscribed
      // (instance, shouldSubscribe)
      registerHubProxies(proxies, true);
 
      this._registerSubscribedHubs();
    }).disconnected(function () {
      // Unsubscribe all hub proxies when we "disconnect".  
      //This is to ensure that we do not re-add functional call backs.
      // (instance, shouldSubscribe)
      registerHubProxies(proxies, false);
    });
 
    proxies.productMessageHub = this.createHubProxy('productMessageHub');
    proxies.productMessageHub.client = {};
    proxies.productMessageHub.server = {
      handleProductMessage: function (receivedString) {
        return proxies.productMessageHub.invoke.apply(proxies.productMessageHub,
          $.merge(["HandleProductMessage"], $.makeArray(arguments)));
      }
    };
 
    return proxies;
  };
 
  signalR.hub = $.hubConnection("/signalr", { useDefaultPath: false });
  $.extend(signalR, signalR.hub.createHubProxies());
 
}(window.jQuery, window));
Думаю, что теперь уже ясно, что сервер используя рефлексию генерирует скрипт прокси-кода основываясь на имени класса концентратора и его методов. Только вот имена клиентских объектов начинаются с буквы в нижнем регистре. Метод обрабатывающий приходящие данные выглядит так.
//Метод который будет получать приходящие сообщения.
productMessageHubProxy.client.handleProductMessage = function (message)
{
    //Метод обрабатывающий сообщения.
    ReceivedMessageHandler(message);
}
А тот, который посылает данные, так.
function SendPoductsData(productDataJsonString) {
 
    //Отправляет данные на сервер.
    productMessageHubProxy.server.handleProductMessage(productDataJsonString);
}
Осталось запустить приложение и посмотреть.



Готовый проект можно скачать отсюда.