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

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

В качестве тестовой таблицы будет выступать некая таблица с продуктами. Начнём с интерфейса пользователя. Для этого добавим обычную веб-форму со следующей разметкой.
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Products.aspx.cs"
  Inherits="WebSocketApplication.WebForm" ViewStateMode="Disabled" %>
 
<!DOCTYPE html>
 
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
  <title></title>
  <link href="<%#ResolveUrl("~/Content/Styles/ProductsPage.css")%>" rel="stylesheet" />
  <script src="<%#ResolveUrl("~/Scripts/jquery-2.0.2.js")%>" 
    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>
Не будем использовать СУБД для хранения данных, будем эмулировать хранилище данных при помощи следующего кода.
//Эмулируем хранилище данных.
public class ProductsDataStore
{
  private static readonly IDictionary<int, Product> products;
  private static ReaderWriterLockSlim productsLock = new ReaderWriterLockSlim();
  static ProductsDataStore()
  {
    products = new Dictionary<int, Product>();
 
    products.Add(1, new Product(){ Id=1, Name="Product 1", Description= "Описание 1"});
    products.Add(2, new Product(){ Id=2, Name="Product 2", Description= "Описание 2"});
    products.Add(3, new Product(){ Id=3, Name="Product 3", Description= "Описание 3"});
    products.Add(4, new Product(){ Id=4, Name="Product 4", Description= "Описание 4"});
  }
 
  public static IList<Product> Products 
  { 
    get
    {
      productsLock.EnterReadLock();
      try
      {
        return products.Values.ToList();
      }
      finally
      {
        productsLock.ExitReadLock();
      }
    } 
  }
 
  public static void Insert(Product product)
  {
    //Contract.Requires<ArgumentNullException>(product != null);
    if(product == null)
      throw new ArgumentNullException("product");
 
    productsLock.EnterWriteLock();
 
    try
    {
      products.Add(product.Id, product);
    }
    finally
    {
      productsLock.ExitWriteLock();
    }
  }
 
  public static void Update(Product product)
  {
    //Contract.Requires<ArgumentNullException>(product != null);
    if(product == null)
      throw new ArgumentNullException("product");
 
    productsLock.EnterWriteLock();
    try
    {
      products[product.Id] = product;
    }
    finally
    {
      productsLock.ExitWriteLock();
    }
  }
 
  public static void Delete(int productId)
  {
    productsLock.EnterWriteLock();
    try
    {
      products.Remove(productId);
    }
    finally
    {
      productsLock.ExitWriteLock();
    }
  }
}
Получается, что первоначальные данные для показа на странице будут выбираться из данного хранилища.
public partial class WebForm : System.Web.UI.Page
{
  protected void Page_Load(object sender, EventArgs e)
  {
    IProductsRepository rep = new ProductsRepository();
    ProductsRepeater.DataSource = rep.GetAllProducts();
    ProductsRepeater.DataBind();
    Page.DataBind();
  }
}
В приложении используется всего одна таблица и естественно всего один класс сущности, который будет представлять единицу данных в ней.
public class Product
{
  public int Id { get; set; }
  public string Name { get; set; }
  public string Description { get; set; }
}
Определяем специальный абстрактный тип для работы с хранилищем
public interface IProductsRepository
{
  IList<Product> GetAllProducts();
  void Insert(Product product);
  void Update(Product product);
  void Delete(int productId);
}
и его реализацию.
public class ProductsRepository : IProductsRepository
{
  #region IProductsRepository Members
 
  public IList<Product> GetAllProducts()
  {
    return ProductsDataStore.Products;
  }
 
  public void Insert(Product product)
  {
    ProductsDataStore.Insert(product);
  }
  public void Update(Product product)
  {
    ProductsDataStore.Update(product);
  }
  public void Delete(int productId)
  {
    ProductsDataStore.Delete(productId);
  }
 
  #endregion
}
Теперь всё самое интересное. Для передачи данных между клиентом и сервером по протоколу веб-сокет будет использован следующий специальный тип.
public enum MessageType
{
  Insert = 1,
  Update = 2,
  Delete = 3
}
 
public class ProductDataMessage
{
  public MessageType MessageType { get; set; }
  public Product Product { get; set; }
  public string ResponseMessage { get; set; }
  public bool DataProcessedSuccessfully { get; set; }
}
Обрабатывать запросы по протоколу веб-сокет у нас будет обработчик HTTP-данных, код которого приведён ниже.
public class ProductsDataHandler : IHttpHandler
{
  #region IHttpHandler Members
 
  private static List<WebSocket> Sockets = new List<WebSocket>();
  private static ReaderWriterLockSlim socketsLock = new ReaderWriterLockSlim();
  public bool IsReusable { get { return true; } }
 
  public void ProcessRequest(HttpContext context)
  {
    //Проверяем, является ли запрос, запросом по протоколу WebSocket.
    if(context.IsWebSocketRequest)
    {
      //Если да, назначаем асинхронный обработчик.
      context.AcceptWebSocketRequest(WebSocketRequestHandler);
    }
  }
 
  //Асинхронный обработчик запроса.
  public async Task WebSocketRequestHandler(AspNetWebSocketContext webSocketContext)
  {
    //Получаем текущий объект веб-сокета.
    WebSocket currentWebSocket = webSocketContext.WebSocket;
 
    //Проверяем наличие сокета, если нет, добавляем.
    if(!Sockets.Contains(currentWebSocket))
    {
      socketsLock.EnterWriteLock();
      try
      {
        Sockets.Add(currentWebSocket);
      }
      finally
      {
        socketsLock.ExitWriteLock();
      }
    }
 
    /*Определяем некую константу, которая будет представлять
    максимльный размер входных данных. Её устанавливаем мы и значение
    можем задать любым. Мы знаем, что в данном случае размер пересылаемых
    данных очень мал.
    */
    const int maxMessageSize = 2048;
 
    //Буфер битов, в который будут записываться полученные данные.
    ArraySegment<Byte> receivedDataBuffer = new ArraySegment<Byte>(new Byte[maxMessageSize]);
 
    //Токен отмены, в данном примере не используется.
    var cancellationToken = new CancellationToken();
 
    //Проверяем состояние веб-сокета.
    while(currentWebSocket.State == WebSocketState.Open)
    {
      //Читаем данные.
      WebSocketReceiveResult webSocketReceiveResult =
        await currentWebSocket.ReceiveAsync(receivedDataBuffer, cancellationToken);
      //Смотрим, если входной фрейм закрывающий, посылаем ответ на закрытие.
      if(webSocketReceiveResult.MessageType == WebSocketMessageType.Close)
      {
        await currentWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure,
          String.Empty, cancellationToken);
      }
      else
      {
        //Поскольку мы знаем, что это строка, конвертируем.
        string receivedString =
          System.Text.UTF8Encoding.UTF8.
          GetString(receivedDataBuffer.Array, 0, webSocketReceiveResult.Count);
 
        string responseString = String.Empty;
 
        //Обрабатываем входную строку.
        bool dataProcessedSuccessfully = 
          ProductMessageHandler.HandleMessage(receivedString, ref responseString);
 
        //Формируем ответ из байтов.
        Byte[] bytes = System.Text.UTF8Encoding.UTF8.GetBytes(responseString);
 
        //Если обработка данных была успешной, то отправляем данные всем клиентам,
        //иначе, только приславшему.
        if(dataProcessedSuccessfully)
        {
          //Сокетые которые были закрыты.
          List<WebSocket> removedWebSockets = new List<WebSocket>();
          socketsLock.EnterUpgradeableReadLock();
          try
          {
            foreach(var webSocket in Sockets)
            {
              //Отсылаем данные обратно браузеру.
              try
              {
                if(webSocket.State == WebSocketState.Open)
                  await webSocket.SendAsync(new ArraySegment<byte>(bytes),
                    WebSocketMessageType.Text, true, cancellationToken);
              }
              catch(ObjectDisposedException e)
              {
                System.Diagnostics.Trace.WriteLine("Socket removed.");
                //Добавляем объект в коллекцию для последующего удаления.
                removedWebSockets.Add(webSocket);
              }
            }
            if(removedWebSockets.Count > 0)
            {
              socketsLock.EnterWriteLock();
              try
              {
                //Удаляем закрытые сокеты.
                Sockets.RemoveAll(w => removedWebSockets.Contains(w));
              }
              finally
              {
                socketsLock.ExitWriteLock();
              }
            }
          }
          finally
          {
            socketsLock.ExitUpgradeableReadLock();
          }
        }
        else
        {
          if(currentWebSocket.State == WebSocketState.Open)
            await currentWebSocket.SendAsync(new ArraySegment<byte>(bytes),
                  WebSocketMessageType.Text, true, cancellationToken);
        }
      }
    }
  }
 
  #endregion
}
Часть работы обработчика будет возложена на специальный класс ProductMessageHandler, который будет заниматься непосредственной обработкой приходящих сообщений.
public static class ProductMessageHandler
{
  public static bool HandleMessage(string receivedString, ref string responseString)
  {
    var responseMessage = new ProductDataMessage() { DataProcessedSuccessfully = true };
    var jsSerializer = new JavaScriptSerializer();
 
    try
    {
      ProductDataMessage receivedMessage = 
        jsSerializer.Deserialize<ProductDataMessage>(receivedString);
      IProductsRepository repository = new ProductsRepository();
      switch(receivedMessage.MessageType)
      {
        case MessageType.Insert:
          try
          {
            responseMessage.MessageType = MessageType.Insert;
            repository.Insert(receivedMessage.Product);
            responseMessage.Product = receivedMessage.Product;
            responseMessage.ResponseMessage = "Запись с идентификатором "
              + receivedMessage.Product.Id + " успешно добавлена.";
          }
          catch(ArgumentException e)
          {
            System.Diagnostics.Trace.WriteLine(e.Message);
            responseMessage.DataProcessedSuccessfully = false;
            responseMessage.ResponseMessage = "Запись с идентификатором " 
              + receivedMessage.Product.Id + " уже существует.";
          }
 
          break;
        case MessageType.Update:
          try
          {
            responseMessage.MessageType = MessageType.Update;
            repository.Update(receivedMessage.Product);
            responseMessage.Product = receivedMessage.Product;
            responseMessage.ResponseMessage = "Запись с идентификатором "
              + receivedMessage.Product.Id + " успешно обновлена.";
          }
          catch(KeyNotFoundException e)
          {
            System.Diagnostics.Trace.WriteLine(e.Message);
            responseMessage.DataProcessedSuccessfully = false;
            responseMessage.ResponseMessage = "Запись с идентификатором "
              + receivedMessage.Product.Id + " отсутствует или удалена.";
          }
          break;
        case MessageType.Delete:
          responseMessage.MessageType = MessageType.Delete;
          repository.Delete(receivedMessage.Product.Id);
          responseMessage.Product = receivedMessage.Product;
          responseMessage.ResponseMessage = "Запись с идентификатором "
              + receivedMessage.Product.Id + " успешно удалена.";
          break;
      }
    }
    catch(ArgumentNullException e)
    {
      System.Diagnostics.Trace.WriteLine(e.Message);
      responseMessage.DataProcessedSuccessfully = false;
      responseMessage.ResponseMessage = "Входные данные отсутствуют.";
    }
    catch(ArgumentException e)
    {
      System.Diagnostics.Trace.WriteLine(e.Message);
      responseMessage.DataProcessedSuccessfully = false;
      responseMessage.ResponseMessage = "Входные данные имели неверный формат.";
    }
    catch(InvalidOperationException e)
    {
      System.Diagnostics.Trace.WriteLine(e.Message);
      responseMessage.DataProcessedSuccessfully = false;
      responseMessage.ResponseMessage = "Ошибка обработки данных на сервере.";
    }
 
    StringBuilder stringBuilder = new StringBuilder();
    jsSerializer.Serialize(responseMessage, stringBuilder);
    responseString = stringBuilder.ToString();
 
    return responseMessage.DataProcessedSuccessfully;
  }
}
И последний недостающий кусок, код работающий на клиенте.
/// <reference path="../jquery-2.0.2.js" />
var lastSelectedTableRow; //, connectionBlocked = false, updAnimTimer;
//Кеш со ссылками на строки таблицы идентифицируемые по Ид продукта.
var productsDataTableRows = new Array();
var productsWebSocket,
    productsHandlerUrl = "ws://localhost/WebSocketApplication/ProductsDataHandler.ashx";
var rowSelected = false, rowInserted = false;
 
$(document).ready(function () {
 
    InitWebSocket();
 
    $("#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));
 
    //Добавляем данные в очередь обновления.
    //waitingForReplyProductsDataMessages.push(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) {
    //Метод инициализирующий веб-сокет.
    InitWebSocket();
    //Смотрим, если веб-сокет открыт и готов к использованию
    //отправляем данные.
    if (productsWebSocket.OPEN && productsWebSocket.readyState == 1)
        productsWebSocket.send(productDataJsonString);
    //Если веб-сокет закрывается или закрыт, выводим сообщение.
    if (productsWebSocket.readyState == 2 || productsWebSocket.readyState == 3)
        webSocketStatusSpan.innerText = "Веб-сокет закрыт, отправить данные невозможно."
}
function CloseWebSocket() {
    //Функция для программного закрытия веб-сокета.
    //После закрытия, получать или отправлять данные не получится.
    productsWebSocket.close();
}
function InitWebSocket() {
    //Если объект веб-сокета не инициализирован, инициализируем его.
    if (productsWebSocket == undefined) {
        productsWebSocket = new WebSocket(productsHandlerUrl);
 
        //Устанавливаем обработчик открытия соединения.
        productsWebSocket.onopen = function () {
            $("#webSocketStatusSpan").text("Веб-сокет открыт.");
        };
 
        //Устанавливаем обработчик получения данных.
        productsWebSocket.onmessage = function (e) {
            ReceivedMessageHandler(e.data);
        };
 
        //Устанавливаем обработчик закрытия соединения.
        productsWebSocket.onclose = function () {
            $("#webSocketStatusSpan").text("Веб-сокет закрыт.");
        };
 
        //Устанавливаем обработчик ошибки.
        productsWebSocket.onerror = function (e) {
            $("#webSocketStatusSpan").text(e.message);
        }
    }
}
Теперь если всё собрать и запустить, то можно получить примерно следующее.



А чтобы не терять время и собрать всё, что было приведено выше, можно скачать готовый проект отсюда. Данный пример был написан за очень короткое время, так что некоторые ошибки не исключены, хотя их замечено не было. Повторюсь, что примеры из данной и предыдущей статьи демонстрируют работу с веб-сокетами на достаточно низком уровне. Чтобы абстрагироваться от всего этого и была создана библиотека SignalR. Следующие статьи будут посвящены последней.
Sm1le
09.08.2013 22:25
А на MVC будет пример?
12.08.2013 13:49
В данном случае особой разницы нет. Можно легко заменить Repeater используя Razor, остальное всё будет работать. Тут важен сам принцип работы веб-сокетов.
Григорий
13.09.2013 16:11
А данный пример работает только на W8? На W7 нет?
13.09.2013 16:17
Да, только на Windows 8. Но Вы можете использовать данный пример, с SignalR.