📜 ⬆️ ⬇️

Comet for ASP.NET do it yourself

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 mode. The update scheme is quite simple, namely: data are sent to the server via the QueryString, which should replace the outdated data on the page as quickly as possible, without needing to refresh this page. The first solution that immediately occurred to me was to use the already-accepted AJAX timer technique, say, every 5 seconds. However, there were immediately obvious shortcomings in the use of this approach: firstly, a rather impressive number of potential customers pulling the server every 5 seconds with the creation of a new connection each time, and secondly, it’s still a rather rough real-time emulation, because the data to the server hypothetically can come even once a second (or they may not come even for a few minutes, and this already refers to the “first”).

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 the Comet technology in Perl in order to create a web chat. " Comet is what you need! ", We thought, and I started to figure out how this thing can be screwed to ASP.NET. What, in fact, will be discussed under the cut.


')
First of all, let's see what is Comet. This is what Wikipedia tells us about this:

Comet (in web development) is a neologism that describes the model of the web application, in which a permanent HTTP connection allows the web server to send (push) data to the browser, without an additional request from the browser. Comet is a hyperonym used to refer to a variety of techniques that allow such interactions to be achieved. 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 plugins. Theoretically, the Comet approach is different 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 keywords we can learn from this definition for ourselves 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. Waiting for new data to appear on the server. As soon as the data has arrived, the server sends them to the client, after which it sends a new request and waits again. An alternative technology of "infinite query", implemented, for example, through the so-called. "Forever iframe" (you can read a little more here ), is not always applicable, because such a thing as timeout has not been canceled.

Well, the task is very clear - you need to implement the aforementioned long polling with improvised means (AJAX + ASP.NET). This also leads to the first problem: how to preserve incoming requests and not to issue responsa until the server has fresh data that could be given to clients (and we obviously have more than one client). And here 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 .


Notice that we will inherit our class not from the IHttpHandler interface, but from IHttpAsyncHandler, which will bring two new methods along with the familiar ProcessRequest method to our implementation: BeginProcessRequest and EndProcessRequest. In particular, the first of them will be of interest to us, since Immediately at the beginning of processing a request, we need to grab this request and not let go until X-hour comes. 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 will also serve as a repository for the request data transferred to the BeginProcessRequest and our own clientGuid parameter, which we will use later as a unique identifier of the client connected 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 call the CompleteRequest function ourselves, the request will not be considered complete. Great - what we need. It remains only somewhere to store these incoming requests. For this function, as well as for the query processing function, we will create a static class 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 contains a list of currently held requests, AddClient functions for adding rekvests (when a new client is connected), UpdateClient for updating rekvests (when the already attached client sends a new request) and RemoveClient for removing rekvests (when the client disconnects), as well as the main PushData method. To "push" for clarity, we will be the simplest data, namely the string that comes to the server through a parameter in the URL. As you can see, everything is extremely simple - we run over the current retained requests, write down the response data from the server to the response, and call the CompleteRequest function, which releases the request, and sends a response to the client. The call to PushData is done in this example from the Page_Load function of our single 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, the data comes to us through a parameter in the URL, in this case, for clarity, it bears the name "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) a few rather trivial 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 (the parameterized URL, once again let me remind you - this is the transfer of data to the server for “pushing”) and call the Connect function. That, in turn, is already beginning to communicate with our handler. Service words that define an action (CONNECT / DISCONNECT), as we can see, are transmitted for simplicity through the cpsp parameter. Accordingly, Connect should initiate an AddClient call on the server, and Disconnect should initiate RemoveClient. When the connection is established and the client has received its clientGuid, the SendRequest function is called, which will “longpoll” the server until the client decides to disconnect from it. Each SendRequest will initiate the execution of the UpdateClient function on the server, which for this client will update the context and the return point (callback).

Well, almost everything is ready, the time has come to implement the core of the whole above-presented mechanism - 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 may arise from an attentive reader is "why use custom tredpool"? The answer is quite simple, though not entirely obvious: in order to “release” the ASP.NET workflow workflow as quickly as possible, so that it can continue processing incoming requests, and transfer the request to the “internal” flow directly. If this is not done, then with a sufficiently large number of incoming requests, a banal “plug” may occur for a reasonably ridiculous at first glance: “ASP.NET has run out of workflows”. For the same reason, neither the asynchronous delegate excited by the BeginInvoke method, nor the standard threadPool.QueueUserWorkItem method can be used. in both of these cases, the thread will be withdrawn from the same ASP.NET traffic pool, which leads us to the situation of an “awl on soap”. This example uses a custom threadpool, implemented by Mike Woodring; This and many other of his developments can be found here .

Here, in general, that's 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, we have not mastered the testing of the scalability of this approach, but if anyone has any ideas on this subject, write, do not hesitate.

Thanks for attention.

UPD Add a link to the archive with the sample project (~ 30 KB). How to look: in VS we set CometPage.aspx as the start page, launch, open several tabs in the browser / browsers with the same URL (just remember the restriction in these browsers to the number of simultaneous connections), then in one of the tabs we add the parameter to the URL ? X = [any_text] and observe how the value of the parameter appears in all open tabs.

Source: https://habr.com/ru/post/82015/


All Articles