Comet for ASP.NET DIY
Not so long ago, as part of the development of a large ASP.NET project, the following subtask arose: to implement a visual display of tabular data updated in real-time. The update scheme is quite simple, namely: data is sent to the server via QueryString, which should replace obsolete data on the page as quickly as possible, and without having to refresh the page without it. The first solution that immediately occurred to me was to use the already established technique of timer AJAX requests, say, every 5 seconds. However, the obvious shortcomings of applying this approach immediately became apparent: firstly, a rather impressive number of potential clients twitching the server every 5 seconds with the creation of a new connection each time, and secondly, this is still a rather crude real-time emulation,
The idea of the solution came quite unexpectedly from a work colleague who shared a link to an article on Habré describing the implementation of Comet technology on Perl in order to create a web chat. “ Comet is what you need! ”, We thought, and I began to figure out how this thing can be screwed to ASP.NET. What, in fact, will be discussed under the cut.
First of all, we will understand what is Comet. Here's what Wikipedia tells us about this:
Comet(in web development) - a neologism that describes the model of a web application, in which a constant HTTP connection allows the web server to send (push) data to the browser, without any additional request from the browser. Comet is a hyperonym used to denote a variety of techniques to achieve this interaction. What these methods have in common is that they are based on technologies directly supported by the browser, such as JavaScript, and not on proprietary plug-ins. Theoretically, the Comet approach differs from the original concept of the World Wide Web, in which the browser requests the page in whole or in part in order to refresh the page. However, in practice, Comet applications typically use Ajax with long polling to check for new information on the server.
So, the key words that we can take for ourselves from this definition are “Ajax with long polling”. What is it and what is it eaten with? When using the “long polling” technology, the client sends a request to the server and ... waits. Waits for new data to appear on the server. As soon as the data has arrived, the server sends it to the client, after which it sends a new request and waits again. An alternative technology of "endless request", implemented, for example, through the so-called “Forever iframe” (you can read a little more here ) is far from always applicable, because such a thing as a timeout so far no one has canceled.
Well, the task is very clear - you need to implement the above-mentioned long polling using available tools (AJAX + ASP.NET). This leads to the first problem: how to preserve incoming requests and not give out responses until the server has fresh data that could be sent to customers (and we obviously have more than one client). And here the asynchronous HTTP Handler comes to the rescue.
Note that we will not inherit our class from the IHttpHandler interface, but from IHttpAsyncHandler, which will bring two new methods along with the familiar ProcessRequest method: BeginProcessRequest and EndProcessRequest. We will be interested in, in particular, the first of them, because Namely, at the beginning of the processing of the request, we need to grab this request by the hand and not let go until the time comes X. As you can see, BeginProcessRequest returns an object that implements the IAsyncResult interface.
We will create a new class that will implement the specified interface and also serve as a repository for the request data sent to BeginProcessRequest and our own clientGuid parameter, which we will use in the future as a unique identifier of the client connecting to the server in order to somehow identify its requests.
As you can see, until we ourselves call the CompleteRequest function, the request will not be considered completed. Great - that is what we need. It remains only somewhere to store these incoming requests. For this function, as well as for the request processing function, create the static class CometClientProcessor:
CometClientProcessor contains a list of currently held requests, AddClient functions for adding requests (when connecting a new client), UpdateClient for updating requests (when an already connected client sends a new request) and RemoveClient for deleting requests (when the client disconnects), as well as the main PushData method. For clarity, we will “push” the simplest data, namely the line that comes to the server through the parameter in the URL. As you can see, everything is extremely simple - we run through the current held requests, write the data that came from the server in the response, and call the CompleteRequest function, which releases the request, and sends a response to the client. The PushData call is made in this example from the Page_Load function of our only page:
As mentioned above, data comes to us through the parameter in the URL, in this case, for clarity, it is called "x". In the server part, it remains only to implement, in fact, the asynchronous handler itself. But first, let's turn to the client part and write (not without the help of the jQuery library) some pretty banal JavaScript functions:
As soon as the document is loaded, we check the URL for the presence of parameters in it (a parameterized URL, let me remind you once again - this is sending data to the server for “pushing”) and call the Connect function. That, in turn, is already starting to communicate with our handler. Service words that define the action (CONNECT / DISCONNECT), as we see, for simplicity, are passed through the cpsp parameter. Accordingly, Connect should initiate an AddClient call on the server, and Disconnect - RemoveClient. When the connection is established and the client has received its clientGuid, the SendRequest function is called, which will “long-fill” the server until the client decides to disconnect from it. Each call to SendRequest will initiate the execution of the UpdateClient function on the server, which for this client will update the context and callback.
Well, almost everything is ready, the time has come to implement the core of the entire mechanism presented above - the asynchronous handler.
After all of the above, the only question that an attentive reader may have is “why use custom thread pool”? The answer is quite simple, although not entirely obvious: in order to “release” the workflow of the ASP.NET treadpool as soon as possible so that it can continue to process incoming requests and transfer the direct processing of the request to the “internal” thread. If this is not done, then with a sufficiently large number of incoming requests, a banal “gag” may occur for a rather ridiculous at first glance reason: “ASP.NET has run out of workflows”. For the same reason, it will not be possible to use either the asynchronous delegate excited by the BeginInvoke method or the standard threadpool method ThreadPool.QueueUserWorkItem, because in both of these cases, the thread will be removed from the same ASP.NET treadmill, which leads us to the “sewed on soap” situation. In this example, a custom pool used by Mike Woodring is used; this and many other of his developments can be seenhere .
That’s basically all. Not so difficult as it seemed at the beginning. Clients connect to our Comet server by calling Default.aspx, and we push the data by passing the GET parameter ala Default.aspx? X = Happy_New_Year to the same page. Unfortunately, mass testing of the scalability of such an approach has not yet been possible, but if anyone has ideas on this, write, do not be shy.
Thanks for attention.
UPD I add a link to the archive with a sample project(~ 30 KB). How to look: in VS we set the start page CometPage.aspx, launch, open several tabs with the same URL in the browser / browsers (just remember the limitation on the number of simultaneous connections in these browsers), then add the parameter ? X = [any_text] and observe how the value of the parameter appears in all open tabs.
The idea of the solution came quite unexpectedly from a work colleague who shared a link to an article on Habré describing the implementation of Comet technology on Perl in order to create a web chat. “ Comet is what you need! ”, We thought, and I began to figure out how this thing can be screwed to ASP.NET. What, in fact, will be discussed under the cut.
First of all, we will understand what is Comet. Here's what Wikipedia tells us about this:
Comet(in web development) - a neologism that describes the model of a web application, in which a constant HTTP connection allows the web server to send (push) data to the browser, without any additional request from the browser. Comet is a hyperonym used to denote a variety of techniques to achieve this interaction. What these methods have in common is that they are based on technologies directly supported by the browser, such as JavaScript, and not on proprietary plug-ins. Theoretically, the Comet approach differs from the original concept of the World Wide Web, in which the browser requests the page in whole or in part in order to refresh the page. However, in practice, Comet applications typically use Ajax with long polling to check for new information on the server.
So, the key words that we can take for ourselves from this definition are “Ajax with long polling”. What is it and what is it eaten with? When using the “long polling” technology, the client sends a request to the server and ... waits. Waits for new data to appear on the server. As soon as the data has arrived, the server sends it to the client, after which it sends a new request and waits again. An alternative technology of "endless request", implemented, for example, through the so-called “Forever iframe” (you can read a little more here ) is far from always applicable, because such a thing as a timeout so far no one has canceled.
Well, the task is very clear - you need to implement the above-mentioned long polling using available tools (AJAX + ASP.NET). This leads to the first problem: how to preserve incoming requests and not give out responses until the server has fresh data that could be sent to customers (and we obviously have more than one client). And here the asynchronous HTTP Handler comes to the rescue.
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.Note that we will not inherit our class from the IHttpHandler interface, but from IHttpAsyncHandler, which will bring two new methods along with the familiar ProcessRequest method: BeginProcessRequest and EndProcessRequest. We will be interested in, in particular, the first of them, because Namely, at the beginning of the processing of the request, we need to grab this request by the hand and not let go until the time comes X. As you can see, BeginProcessRequest returns an object that implements the IAsyncResult interface.
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.We will create a new class that will implement the specified interface and also serve as a repository for the request data sent to BeginProcessRequest and our own clientGuid parameter, which we will use in the future as a unique identifier of the client connecting to the server in order to somehow identify its requests.
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.As you can see, until we ourselves call the CompleteRequest function, the request will not be considered completed. Great - that is what we need. It remains only somewhere to store these incoming requests. For this function, as well as for the request processing function, create the static class CometClientProcessor:
public static class CometClientProcessor
{
private static Object _lockObj;
private static List _clientStateList;
static CometClientProcessor()
{
_lockObj = new Object();
_clientStateList = new List();
}
public static void PushData(String pushedData)
{
List currentStateList = new List();
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 contains a list of currently held requests, AddClient functions for adding requests (when connecting a new client), UpdateClient for updating requests (when an already connected client sends a new request) and RemoveClient for deleting requests (when the client disconnects), as well as the main PushData method. For clarity, we will “push” the simplest data, namely the line that comes to the server through the parameter in the URL. As you can see, everything is extremely simple - we run through the current held requests, write the data that came from the server in the response, and call the CompleteRequest function, which releases the request, and sends a response to the client. The PushData call is made in this example from the Page_Load function of our only page:
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.As mentioned above, data comes to us through the parameter in the URL, in this case, for clarity, it is called "x". In the server part, it remains only to implement, in fact, the asynchronous handler itself. But first, let's turn to the client part and write (not without the help of the jQuery library) some pretty banal JavaScript functions:
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.As soon as the document is loaded, we check the URL for the presence of parameters in it (a parameterized URL, let me remind you once again - this is sending data to the server for “pushing”) and call the Connect function. That, in turn, is already starting to communicate with our handler. Service words that define the action (CONNECT / DISCONNECT), as we see, for simplicity, are passed through the cpsp parameter. Accordingly, Connect should initiate an AddClient call on the server, and Disconnect - RemoveClient. When the connection is established and the client has received its clientGuid, the SendRequest function is called, which will “long-fill” the server until the client decides to disconnect from it. Each call to SendRequest will initiate the execution of the UpdateClient function on the server, which for this client will update the context and callback.
Well, almost everything is ready, the time has come to implement the core of the entire mechanism presented above - the asynchronous handler.
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.After all of the above, the only question that an attentive reader may have is “why use custom thread pool”? The answer is quite simple, although not entirely obvious: in order to “release” the workflow of the ASP.NET treadpool as soon as possible so that it can continue to process incoming requests and transfer the direct processing of the request to the “internal” thread. If this is not done, then with a sufficiently large number of incoming requests, a banal “gag” may occur for a rather ridiculous at first glance reason: “ASP.NET has run out of workflows”. For the same reason, it will not be possible to use either the asynchronous delegate excited by the BeginInvoke method or the standard threadpool method ThreadPool.QueueUserWorkItem, because in both of these cases, the thread will be removed from the same ASP.NET treadmill, which leads us to the “sewed on soap” situation. In this example, a custom pool used by Mike Woodring is used; this and many other of his developments can be seenhere .
That’s basically all. Not so difficult as it seemed at the beginning. Clients connect to our Comet server by calling Default.aspx, and we push the data by passing the GET parameter ala Default.aspx? X = Happy_New_Year to the same page. Unfortunately, mass testing of the scalability of such an approach has not yet been possible, but if anyone has ideas on this, write, do not be shy.
Thanks for attention.
UPD I add a link to the archive with a sample project(~ 30 KB). How to look: in VS we set the start page CometPage.aspx, launch, open several tabs with the same URL in the browser / browsers (just remember the limitation on the number of simultaneous connections in these browsers), then add the parameter ? X = [any_text] and observe how the value of the parameter appears in all open tabs.