Using IHttpAsyncHandler and XMLHttpRequest to “push” messages to the client

I've been playing around with “comet” a little and trying to make it work in ASP.NET without modifying anything in the IIS. There are a few web servers or IIS enhancements available that provide comet functionality, but they require you to have control over IIS or even over the complete system allowing you to replace IIS with a proprietary web server. But you might want to use comet in a shared or hosted environment, where modifying the web server is not an option. 

The solution I found - so far - was inspired by the reactions on 
Aaron Lerch's blog post. Aaron is definitely in to something when he writes his article, but the big issue with his sample is that the code doesn't scale to well, as he writes. The responses suggest using asynchronous handling of the requests, and there's even a link to a Comet-enabled GridView on CodeProject. That GridView worked for me, but it was far too complicated for what I wanted: simply pushing a message to a user that is on my website. 

Here's what I did:

I started with a simple “default.aspx” in a normal web site. This page will call the async handler using the XMLHttpRequest object (well-known from AJAX implementations).
When a user visits the website, we’ll somehow have to let the server know that he’s there. There are a number of ways to solve this; I chose to create a List of Session IDs and add the users’ session id to the list in the Session_Start event handler in Global.asax. Of course, there are better solutions, but it’ll do for the purpose of this example.

public class Global : System.Web.HttpApplication
{
    public static List<string> Sessions;

    protected void Application_Start(object sender, EventArgs e)
    {
        Sessions = new List<string>();
    }

    protected void Session_Start(object sender, EventArgs e)
    {
        if (!Sessions.Contains(Session.SessionID))
            Sessions.Add(Session.SessionID);
    }

    protected void Session_End(object sender, EventArgs e)
    {
        if (Sessions.Contains(Session.SessionID))
            Sessions.Remove(Session.SessionID);

    }
}

We need a message handler on the server that will deliver the message to the right session. I created a synchronous handler. Since it handles the messages immediately, it doesn’t hold on any threads or resources after the message is delivered.

///


/// An IHttpHandler for the messages that are sent from a session
///
public class MyMessageHandler : IHttpHandler
{
    #region IHttpHandler Members

    public bool IsReusable
    {
        get { return true; }
    }

    public void ProcessRequest(HttpContext context)
    {
        // Find the handle in the queue, identified by its session id
        var recipient = context.Request["recipient"];
        var handle    = MyAsyncHandler.Queue.Find(q => q.SessionId == recipient);

        // just a small check to prevent NullReferenceException
        if (handle == null)
            return;
       
        // Dump the message in the handle;
        handle.Message = context.Request["message"];

        // Set the handle to complete, this triggers the callback
        handle.SetCompleted(true);
    }

    #endregion
}

Next is the asynchronous handler to handle requests from the XMLHttpRequest object on the Default.aspx page and deliver a message back. I called it MyAsyncHandler and let it inherit from the System.Web.IHttpAsyncHandler interface. There’s some logic there and I decided to just explain the most important parts. First of all, the static constructor initializes the queue (a System.Collections.Generic.List) that is intended to hold all the asynchronous results.
Next, when the request comes in it calls the BeginProcessRequest method. This method checks if the session already was registered in the queue before or if it should add the session to the queue.
Finally, the EndProcessRequest uses its result parameter (of type MyAsyncResult) and finds the HttpContext object in there. Using the HttpContext.Response.Write, it “pushes” the message to the session that is intended to be the recipient of the message, identified by its session id.

///


/// An IHttpAsyncHandler to "push" messages to the intended recipients
///
public class MyAsyncHandler : IHttpAsyncHandler
{
    ///


    /// The queue holds a list of asynchronous results with information about registered sessions
    ///
    public static List<MyAsyncResult> Queue;

    ///


    /// Static constructor
    ///
    static MyAsyncHandler()
    {
        // Initialize the queue
        Queue = new List<MyAsyncResult>();
    }

    #region IHttpAsyncHandler Members

    public IAsyncResult BeginProcessRequest(HttpContext context, AsyncCallback cb, object extraData)
    {
        // Fetch the session id from the request
        var sessionId   = context.Request["sessionId"];
       
        // Check if the session is already registered
        if (Queue.Find(q => q.SessionId == sessionId) != null)
        {
            var index = Queue.IndexOf(Queue.Find(q => q.SessionId == sessionId));

            // The session has already been registered, just refresh the HttpContext and the AsyncCallback
            Queue[index].Context  = context;
            Queue[index].Callback = cb;

            return Queue[index];
        }

        // Create a new AsyncResult that holds the information about the session
        var asyncResult = new MyAsyncResult(context, cb, sessionId);

        // This session has not been registered yet, add it to the queue
        Queue.Add(asyncResult);

        return asyncResult;
    }

    public void EndProcessRequest(IAsyncResult result)
    {
        var rslt  = (MyAsyncResult) result;
       
        // send the message to the recipient using the recipients HttpContext.Response object
        rslt.Context.Response.Write(rslt.Message);

        // reset the message object
        rslt.Message = string.Empty;
    }

    #endregion

    #region IHttpHandler Members

    public bool IsReusable
    {
        get { return true; }
    }

    ///


    /// In an asychronous solution, this message shouldn't be called
    ///    
    public void ProcessRequest(HttpContext context)
    {
        throw new NotImplementedException();
    }

    #endregion
}


As soon as the client gets a response, the XMLHttpRequest object notices a “ready state” change and the “onreadystatechange” handler kicks in. The message is parsed into an object on the page (e.g. a DIV) and directly thereafter, the page sends a new asynchronous request, signaling the server that it's ready to receive another message.

Don’t forget to register the handlers in the Web.config. You web application needs to know what classes handle the requests from the clients. Add the following lines to the system.web/httpHandlers section:

NB! If you’re using IIS7, you add these lines to the system.webserver/handlers section instead.

<add verb="GET,POST" path="MyAsyncHandler.ashx" type="SandBox.CometSample.MyAsyncHandler, SandBox.CometSample" validate="false"/>
<add verb="GET,POST" path="MyMessageHandler.ashx" type="SandBox.CometSample.MyMessageHandler, SandBox.CometSample" validate="false"/>

The Default.aspx looks as follows:

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title>title>

    <script type="text/javascript">
        function init() {
            var send = document.getElementById('btnSend');

            if (!send.addEventListener) {
                send.addEventListener = function(type, listener, useCapture) {
                    attachEvent('on' + type, function() { listener(event) });
                }
            }

            send.addEventListener('click', function() { send(); }, false);
           
            hook();
        }

        function hook() {
            var url     = 'MyAsyncHandler.ashx?sessionId=';
            var request = getRequestObject();

            request.onreadystatechange = function() {
                try {

                    if (request.readyState == 4) {
                        if (request.status == 200) {
                            document.getElementById('incoming').innerHTML += request.responseText + '
'
;

                            // immediately send a new request to tell the async handler that the client is
                            // ready to receive new messages;
                            hook();
                        }
                        else {
                            document.getElementById('incoming').innerHTML += request.responseText + '
'
;
                        }
                    }
                }
                catch (e) {
                    document.getElementById('incoming').innerHTML = "Error: " + e.message;
                }
            };

            request.open('POST', url, true);
            request.send(null);
        }

        function send() {
            var message   = document.getElementById('message').value;
            var recipient = document.getElementById('').value;
            var request   = getRequestObject();
            var url       = 'MyMessageHandler.ashx?message=' + message + '&recipient=' + recipient;
            var params    = 'message=' + message + '&recipient=' + recipient;

            document.getElementById('incoming').innerHTML += '' + message + '
'
;
           
            request.onreadystatechange = function() {
                if (request.readyState == 4 && request.status != 200)
                    alert('Error ' + request.status + ' trying to send message');
            };

            request.open('POST', url, true);
            request.send(params);
        }

        function getRequestObject() {
            var req;

            if (window.XMLHttpRequest && !(window.ActiveXObject)) {
                try {
                    req = new XMLHttpRequest();
                }
                catch (e) {
                    req = false;
                }
            }
            else if (window.ActiveXObject) {
                try {
                    req = new ActiveXObject('Msxml2.XMLHTTP');
                }
                catch (e) {
                    try {
                        req = new ActiveXObject('Microsoft.XMLHTTP');
                    }
                    catch (e) {
                        req = false;
                    }
                }
            }

            return req;
        }
    script>

head>

<body onload="setTimeout('init();', 500);">
    <form id="form1" runat="server">
    <div>
        Self:      <asp:Literal ID="ltlSessionId" runat="server" /><br />
        Message:   <input type="text" id="message" /><br />
        Recipient: <asp:DropDownList ID="ddlSessions" runat="server" />
        <br />
        <input type="button" id="btnSend" value="Send Message!" onclick="send();" />
        <hr />
        <div id="incoming">
        div>
    div>
    form>
body>
html>

The only C# code for this page is to populate the DropDownList with the sessions.

public partial class Default : System.Web.UI.Page
{
    protected void Page_Init(object sender, EventArgs e)
    {
        if (IsPostBack)
            return;

        ltlSessionId.Text = Session.SessionID;
       
        foreach (var sessionId in Global.Sessions)
            if (sessionId == Session.SessionID)
                ddlSessions.Items.Add(new ListItem("Myself", sessionId));
            else
                ddlSessions.Items.Add(new ListItem(sessionId, sessionId));
    }
}


There are – at least – three things with this example that you immediately might want to do better. First of all, sending a message to a Session ID is pretty vague. You’d let the website visitor enter his name so that other users can send messages to a name rather than a Session ID.

Next, this example doesn’t check if the Session is still active. The Session might have timed-out or abandoned, so that the Session_End method in the Global.asax never got called. To deal with this, you should implement a neat solution to keep track of Sessions.

Last but not least: you’ve to refresh your website manually to check if new sessions are available. You could use the same Comet technique to update the DropDownList automatically when a new visitor enters the website. Or you could use the “old fashioned” AJAX way and poll your page periodically to update the list.

The source code was created in Visual Studio 2008 with .NET 3.5 and can be downloaded 
here

3 comments:

Johnboy said...

This is a good first post on a topic that I am keen to explore myself.

Good work! I've added you to my RSS reader.

Anonymous said...

Nice example. My only concern would be that you should put up some protection around any shared state, otherwise at some point concurrency issues will come to bite you. Locking on interactions with Queue seems like a fairly prudent idea to me.

Anonymous said...

Nice post! Just thought you might want to check out our implementation of a full comet server for ASP.NET/IIS - http://www.frozenmountain.com/websync

Post a Comment