Pull to refresh

Comet для ASP.NET своими руками

Reading time 14 min
Views 11K
Не так давно в рамках разработки крупного ASP.NET проекта возникла следующая подзадача: реализовать визуальное отображение табличных данных, обновляющихся в режиме real-time. Схема обновления довольно проста, а именно: на сервер посредством QueryString присылаются данные, которые должны как можно быстрее заменить собой устаревшие данные на странице, причем без необходимости страницу эту рефрешить. Первым решением, которое сразу же пришло мне в голову, было использовать ставшую уже общепринятой технику AJAX-запросов по таймеру, скажем, каждые 5 секунд. Однако сразу же выявились очевидные недостатки применения такого подхода: во-первых, довольно внушительное число потенциальных клиентов, дергающих сервер каждые 5 секунд с созданием каждый раз нового соединения, а во-вторых, это все-таки довольно грубая эмуляция real-time'а, ведь данные на сервер гипотетически могут приходить даже по несоколько раз в секунду (а могут не приходить и по несколько минут, и это уже больше относится к «во-первых»).

Идея решения пришла довольно неожиданно от коллеги по работе, поделившегося линком на статью на Хабре, описывающую реализацию технологии Comet на Perl в целях создания веб-чата. "Comet — это то что нужно!", — подумали мы, и я начал разбираться, как же эту штуку можно прикрутить к ASP.NET. О чем, собственно, и пойдет речь под катом.



Прежде всего, разберемся, что же такое Comet. Вот что говорит нам по этому поводу Википедия:

Comet (в веб-разработке) — неологизм, описывающий модель работы веб-приложения, при которой постоянное HTTP-соединение позволяет веб-серверу отправлять (push) данные браузеру, без дополнительного запроса со стороны браузера. Comet — это гипероним, используемый для обозначения множества техник, позволяющих достичь такого взаимодействия. Общее у этих методов то, что они основаны на технологиях, непосредственно поддерживаемых браузером, таких как JavaScript, а не на проприетарных плагинах. Теоретически подход Comet отличается от изначальной концепции всемирной паутины, при которой браузер запрашивает страницу полностью или частично для того, чтобы обновить страницу. Однако на практике приложения Comet обычно используют Ajax c long polling для проверки наличия новой информации на сервере.

Итак, ключевые слова, которые мы можем вынести для себя из этого определения, — это «Ajax с long polling». Что же это такое, и с чем его едят? При использовании технологии «long polling» клиент посылает на сервер запрос и… ждет. Ждет, когда на сервере появятся новые данные. Как только данные пришли, сервер отправляет их клиенту, после чего оный посылает новый запрос и снова ждет. Альтернативная технология «бесконечного запроса», реализуемая, например, через т.н. «forever iframe» (немного подробнее можно почитать здесь), далеко не всегда применима, т.к. такую штуку, как таймаут пока никто не отменял.

Что ж, задача предельно ясна — нужно реализовать подручными средствами (AJAX + ASP.NET) вышеупомянутый long polling. Отсюда вытекает и первая проблема: как законсервировать входящие реквесты и не выдавать респонсы, пока на сервере нет свежих данных, которые можно было бы отдать клиентам (а клиент у нас, очевидно, не один). И здесь на помощь нам приходит асинхронный HTTP Handler.

public interface IHttpAsyncHandler : IHttpHandler
{
 IAsyncResult BeginProcessRequest(HttpContext ctx,
                  AsyncCallback cb,
                  object obj);
 void EndProcessRequest(IAsyncResult ar);
}


* This source code was highlighted with Source Code Highlighter.


Заметьте, что наш класс мы будем наследовать не от интерфейса IHttpHandler, а от IHttpAsyncHandler, который привнесет в нашу реализацию наряду со знакомым методом ProcessRequest два новых: BeginProcessRequest и EndProcessRequest. Интересовать нас, в частности, будет первый из них, т.к. иммено при начале обработки реквеста нам этот реквест нужно схватить за руку и не отпускать до тех пор, пока не придет час икс. Как видим, BeginProcessRequest возвращает объект, реализующий интерфейс IAsyncResult.

public interface IAsyncResult
{
 public object   AsyncState            { get; }
 public bool    CompletedSynchronously { get; }
 public bool    IsCompleted            { get; }
 public WaitHandle AsyncWaitHandle     { get; }
}


* This source code was highlighted with Source Code Highlighter.


Создадим новый класс, который будет реализовывать указанный интерфейс, а также послужит хранилищем для передаваемых в BeginProcessRequest данных запроса и нашего собственного параметра clientGuid, который мы будем использовать в дальнейшем в качестве уникального идентификатора приконнектившегося к серверу клиента, дабы как-то идентифицировать его запросы.

public class CometAsyncRequestState : IAsyncResult
{
  private HttpContext _currentContext;
  private AsyncCallback _asyncCallback;
  private Object _extraData;

  private Boolean _isCompleted;
  private Guid _clientGuid;
  private ManualResetEvent _callCompleteEvent = null;

  public CometAsyncRequestState(HttpContext currentContext, AsyncCallback asyncCallback, Object extraData)
  {
    _currentContext = currentContext;
    _asyncCallback = asyncCallback;
    _extraData = extraData;

    _isCompleted = false;
  }

  public void CompleteRequest()
  {
    _isCompleted = true;

    lock (this)
    {
      if (_callCompleteEvent != null)
        _callCompleteEvent.Set();
    }

    if (_asyncCallback != null)
    {
      _asyncCallback(this);
    }
  }

  public HttpContext CurrentContext
  {
    get
    {
      return _currentContext;
    }
    set
    {
      _currentContext = value;
    }
  }

  public AsyncCallback AsyncCallback
  {
    get
    {
      return _asyncCallback;
    }
    set
    {
      _asyncCallback = value;
    }
  }

  public Object ExtraData
  {
    get
    {
      return _extraData;
    }
    set
    {
      _extraData = value;
    }
  }

  public Guid ClientGuid
  {
    get
    {
      return _clientGuid;
    }
    set
    {
      _clientGuid = value;
    }
  }

  // IAsyncResult implementations
  public Boolean CompletedSynchronously
  {
    get
    {
      return false;
    }
  }

  public Boolean IsCompleted
  {
    get
    {
      return _isCompleted;
    }
  }

  public Object AsyncState
  {
    get
    {
      return _extraData;
    }
  }

  public WaitHandle AsyncWaitHandle
  {
    get
    {
      lock (this)
      {
        if (_callCompleteEvent == null)
          _callCompleteEvent = new ManualResetEvent(false);

        return _callCompleteEvent;
      }
    }
  }
}


* This source code was highlighted with Source Code Highlighter.


Как видим, пока мы сами не вызовем функцию CompleteRequest, запрос не будет считаться завершенным. Замечательно — то, что нам и надо. Осталось только где-то эти входящие реквесты хранить. Для этой функции, а также для функции обработки запросов создадим статический класс CometClientProcessor:

public static class CometClientProcessor
{
  private static Object _lockObj;
  private static List<CometAsyncRequestState> _clientStateList;

  static CometClientProcessor()
  {
    _lockObj = new Object();
    _clientStateList = new List<CometAsyncRequestState>();
  }

  public static void PushData(String pushedData)
  {
    List<CometAsyncRequestState> currentStateList = new List<CometAsyncRequestState>();

    lock (_lockObj)
    {
      foreach (CometAsyncRequestState clientState in _clientStateList)
      {
        currentStateList.Add(clientState);
      }
    }

    foreach (CometAsyncRequestState clientState in currentStateList)
    {
      if (clientState.CurrentContext.Session != null)
      {
        clientState.CurrentContext.Response.Write(pushedData);
        clientState.CompleteRequest();
      }
    }
  }

  public static void AddClient(CometAsyncRequestState state)
  {
    Guid newGuid;

    lock (_lockObj)
    {
      while (true)
      {
        newGuid = Guid.NewGuid();
        if (_clientStateList.Find(s => s.ClientGuid == newGuid) == null)
        {
          state.ClientGuid = newGuid;
          break;
        }
      }

      _clientStateList.Add(state);
    }
  }

  public static void UpdateClient(CometAsyncRequestState state, String clientGuidKey)
  {
    Guid clientGuid = new Guid(clientGuidKey);

    lock (_lockObj)
    {
      CometAsyncRequestState foundState = _clientStateList.Find(s => s.ClientGuid == clientGuid);

      if (foundState != null)
      {
        foundState.CurrentContext = state.CurrentContext;
        foundState.ExtraData = state.ExtraData;
        foundState.AsyncCallback = state.AsyncCallback;
      }
    }
  }

  public static void RemoveClient(CometAsyncRequestState state)
  {
    lock (_lockObj)
    {
      _clientStateList.Remove(state);
    }
  }
}


* This source code was highlighted with Source Code Highlighter.


CometClientProcessor содержит в себе список удерживаемых на данный момент реквестов, функции AddClient для добавления реквестов (при коннекте нового клиента), UpdateClient для обновления реквестов (когда уже приконнектившийся клиент послает новый запрос) и RemoveClient для удаления реквестов (когда клиент дисконнектится), а также основной метод PushData. «Пушить» для наглядности мы будем простейшие данные, а именно строку, которая приходит на сервер через параметр в URL. Как видим, все предельно просто — пробегаем по текущим удерживаемым реквестам, записываем в респонс данные, пришедшие с сервера, и вызываем функцию CompleteRequest, которая освобождает запрос, и отправляет клиенту ответ. Вызов PushData осуществляется в данном примере из функции Page_Load нашей единственной страницы:

protected void Page_Load(object sender, EventArgs e)
  {
    if (!IsPostBack)
    {
      if (Request.QueryString["x"] != null)
      {
        CometClientProcessor.PushData(Request.QueryString["x"].ToString());
      }
    }
  }


* This source code was highlighted with Source Code Highlighter.


Как уже говорилось выше, данные к нам приходят через параметр в URL, в данном случае для наглядности он носит имя «x». В серверной части осталось только реализовать, собственно, сам асинхронный хэндлер. Но сперва обратимся к части клиентской и напишем (не без помощи библиотеки jQuery) несколько довольно банальных JavaScript-овых функций:

var clientGuid

$(document).ready(function() {
  var str = window.location.href;
  if (str.indexOf("?") < 0)
    Connect();
});

$(window).unload(function() {
  var str = window.location.href;
  if (str.indexOf("?") < 0)
    Disconnect();
});

function SendRequest() {
  var url = './CometAsyncHandler.ashx?cid=' + clientGuid;
  $.ajax({
    type: "POST",
    url: url,
    success: ProcessResponse,
    error: SendRequest
  });
}

function Connect() {
  var url = './CometAsyncHandler.ashx?cpsp=CONNECT';
  $.ajax({
    type: "POST",
    url: url,
    success: OnConnected,
    error: ConnectionRefused
  });
}

function Disconnect() {
  var url = './CometAsyncHandler.ashx?cpsp=DISCONNECT';
  $.ajax({
    type: "POST",
    url: url
  });
}

function ProcessResponse(transport) {
  $("#contentWrapper").html(transport);
  SendRequest();
}

function OnConnected(transport) {
  clientGuid = transport;
  SendRequest();
}

function ConnectionRefused() {
  $("#contentWrapper").html("Unable to connect to Comet server. Reconnecting in 5 seconds...");
  setTimeout(Connect(), 5000);
}


* This source code was highlighted with Source Code Highlighter.


Как только документ загружается, мы проверяем URL на наличие параметров в нем (параметризованный URL, еще раз позволю себе напомнить — это передача данных серверу для «пушинга») и вызываем функцию Connect. Та, в свою очередь, уже начинает общаться с нашим хэндлером. Служебные слова, определяющие действие (CONNECT/DISCONNECT), как видим, для простоты, передаются через параметр cpsp. Соответственно, Connect должен инициировать на сервере вызов AddClient, а Disconnect — RemoveClient. Когда соединение установлено и клиент получил свой clientGuid, вызывается функция SendRequest, которая и будет «лонгполлить» сервер, пока клиент не решит от него отсоединиться. Каждый вызов SendRequest будет инициировать выполнение на сервере функции UpdateClient, которая для данного клиента обновит контекст и точку возврата (callback).

Что-ж, практически все готово, настало время для реализации ядра всего вышепредставленного механизма — асинхронного хэндлера.

public enum ConnectionCommand
{
  CONNECT,
  DISCONNECT
}

public static class ConnectionProtocol
{
  public static String PROTOCOL_GET_PARAMETER_NAME = "cpsp";
  public static String CLIENT_GUID_PARAMETER_NAME = "cid";
}


* This source code was highlighted with Source Code Highlighter.


<%@ WebHandler Language="C#" Class="CometAsyncHandler" %>

using System;
using System.Web;

using DevelopMentor;

public class CometAsyncHandler : IHttpAsyncHandler, System.Web.SessionState.IRequiresSessionState
{
  static private ThreadPool _threadPool;

  static CometAsyncHandler()
  {
    _threadPool = new ThreadPool(2, 50, "Comet Pool");
    _threadPool.PropogateCallContext = true;
    _threadPool.PropogateThreadPrincipal = true;
    _threadPool.PropogateHttpContext = true;
    _threadPool.Start();
  }

  public IAsyncResult BeginProcessRequest(HttpContext ctx, AsyncCallback cb, Object obj)
  {
    CometAsyncRequestState currentAsyncRequestState = new CometAsyncRequestState(ctx, cb, obj);
    _threadPool.PostRequest(new WorkRequestDelegate(ProcessServiceRequest), currentAsyncRequestState);

    return currentAsyncRequestState;
  }

  private void ProcessServiceRequest(Object state, DateTime requestTime)
  {
    CometAsyncRequestState currentAsyncRequestState = state as CometAsyncRequestState;

    if (currentAsyncRequestState.CurrentContext.Request.QueryString[ConnectionProtocol.PROTOCOL_GET_PARAMETER_NAME] ==
      ConnectionCommand.CONNECT.ToString())
    {
      CometClientProcessor.AddClient(currentAsyncRequestState);
      currentAsyncRequestState.CurrentContext.Response.Write(currentAsyncRequestState.ClientGuid.ToString());
      currentAsyncRequestState.CompleteRequest();
    }
    else if (currentAsyncRequestState.CurrentContext.Request.QueryString[ConnectionProtocol.PROTOCOL_GET_PARAMETER_NAME] ==
      ConnectionCommand.DISCONNECT.ToString())
    {
      CometClientProcessor.RemoveClient(currentAsyncRequestState);
      currentAsyncRequestState.CompleteRequest();
    }
    else
    {
      if (currentAsyncRequestState.CurrentContext.Request.QueryString[ConnectionProtocol.CLIENT_GUID_PARAMETER_NAME] != null)
      {
        CometClientProcessor.UpdateClient(currentAsyncRequestState,
          currentAsyncRequestState.CurrentContext.Request.QueryString[ConnectionProtocol.CLIENT_GUID_PARAMETER_NAME].ToString());
      }
    }
  }

  public void EndProcessRequest(IAsyncResult ar)
  {
  }

  public void ProcessRequest(HttpContext context)
  {
  }

  public bool IsReusable
  {
    get
    {
      return true;
    }
  }
}

* This source code was highlighted with Source Code Highlighter.

После всего вышесказанного, единственный вопрос, который может возникнуть у внимательного читателя — «зачем использовать кастом тредпул»? Ответ довольно прост, хоть и не совсем очевиден: чтобы как можно быстрее «отпустить» рабочий поток тредпула ASP.NET, дабы он мог продолжить обрабатывать входящие реквесты, а непосредственную обработку запроса передать «внутреннему» потоку. Если этого не сделать, то при достаточно большом количестве входящих реквестов, может произойти банальный «затык» по довольно смешной на первый взгляд причине: «у ASP.NET кончились рабочие потоки». По этой же причине не получится использовать ни асинхронный делегат, возбуждаемый методом BeginInvoke, ни метод стандартного тредпула ThreadPool.QueueUserWorkItem, т.к. в обоих этих случая поток будет изыматься из того же тредпула ASP.NET, что приводит нас к ситуации «шило на мыло». В данном примере используется кастом тредпул, реализованный Майком Вудрингом (Mike Woodring); эту и многие другие из его наработок можно посмотреть здесь.

Вот, в общем-то и все. Не так уж и сложно, как казалось вначале. Клиенты коннектятся к нашему Comet-серверу посредством вызова Default.aspx, а данные мы пушим передавая той же странице GET-параметр аля Default.aspx?x=Happy_New_Year. К сожелению провести массирование тестирование масштабируемости такого подхода пока не было возможности, однако если у кого-то есть идеи по этому поводу — пишите, не стесняйтесь.

Спасибо за внимание.

UPD Добавляю ссылку на архив с сэмпловым проектом (~30 KB). Как посмотреть: в VS ставим стартовой страницей CometPage.aspx, запускаем, открываем в браузере/браузерах несколько вкладок с тем же URL (только помним про ограничение в оных браузерах количества одновременных подключений), затем в одном из табов к URL дописываем параметр ?x=[любой_текст] и наблюдаем, как значение параметра появится во всех открытых вкладках.
Tags:
Hubs:
+37
Comments 45
Comments Comments 45

Articles