Freigeben über


ASP.NET

Erstellen einer einfachen Comet-Anwendung in Microsoft .NET Framework

Derrick Lau

 

Comet ist eine Technik zum Übertragen von Inhalten mithilfe von Push. Dabei werden die Inhalte ohne explizite Anforderung über langlebige AJAX-Verbindungen von einem Webserver an einen Browser übertragen. Comet macht eine interaktivere Einbindung des Benutzers möglich. Außerdem wird im Vergleich zum üblichen Serverroundtrip, der durch ein Seitenpostback zum Abrufen von weiteren Daten ausgelöst wird, weniger Bandbreite benötigt. Zwar stehen viele Comet-Implementierungen zur Verfügung, die meisten basieren jedoch auf Java. In diesem Artikel beschreibe ich deshalb die Erstellung eines C#-Diensts, der auf dem cometbox-Beispielcode (code.google.com/p/cometbox) basiert.

Das gleiche Verhalten lässt sich auch mit neueren Methoden unter Verwendung von HTML5-Features wie WebSockets oder serverseitigen Ereignissen implementieren. Diese sind aber nur in den neuesten Browserversionen verfügbar. Müssen ältere Browser unterstützt werden, ist Comet die kompatibelste Lösung. Dennoch muss der Browser für die Kommunikation im Comet-Format AJAX unterstützen, indem das xmlHttpRequest-Objekt implementiert wird.

Die grundlegende Architektur

Abbildung 1 zeigt ein einfaches Schema der Kommunikation im Comet-Format, und in Abbildung 2 ist die Architektur meines Beispiels zu sehen. Comet baut über das xmlHttpRequest-Objekt des Browsers, das für die AJAX-Kommunikation benötigt wird, eine langlebige HTTP-Verbindung zu einem Server auf. Der Server hält die Verbindung aufrecht und überträgt die Inhalte, sobald sie verfügbar sind, mithilfe von Push an den Browser.

Comet-Style CommunicationAbbildung 1: Kommunikation im Comet-Format

Architecture of the Comet ApplicationAbbildung 2: Architektur der Comet-Anwendung

Zwischen dem Browser und dem Server befindet sich eine Proxyseite. Sie liegt in demselben Webanwendungspfad wie die Webseite mit dem Clientcode, und ihre einzige Aufgabe besteht darin, Nachrichten vom Browser an den Server und vom Server an den Browser weiterzuleiten. Wofür eine Proxyseite benötigt wird, erkläre ich in Kürze.

Als Erstes wird ein Format für die Nachricht gewählt, die zwischen Browser und Server ausgetauscht wird. Das kann JSON, XML oder ein benutzerdefiniertes Format sein. Der Einfachheit halber habe ich mich für JSON entschieden, da es von JavaScript, jQuery und Microsoft .NET Framework unterstützt wird. Außerdem kann das Format dieselbe Datenmenge wie XML übermitteln, braucht dafür jedoch weniger Bytes und somit weniger Bandbreite.

Um die Kommunikation im Comet-Format aufzubauen, wird eine AJAX-Verbindung zum Server geöffnet. Das lässt sich am einfachsten über jQuery erreichen, da die Bibliothek mehrere Browser unterstützt und einige praktische Wrapperfunktionen wie „$.ajax“ bietet. Bei dieser Funktion handelt es sich im Grunde genommen um einen Wrapper für jedes xmlHttpRequest-Objekt eines Browsers. Die Funktion stellt praktischerweise Ereignishandler bereit, die zum Verarbeiten der vom Server ankommenden Nachrichten implementiert werden können.

Vor dem Aufbau der Verbindung wird die zu sendende Nachricht instanziiert. Zu diesem Zweck wird eine Variable deklariert, und die Daten werden mit der JSON.stringify-Funktion als JSON-Nachricht formatiert (siehe Abbildung 3).

Abbildung 3: Formatieren der Daten als JSON-Nachricht

function getResponse() {
  var currentDate = new Date();
  var sendMessage = JSON.stringify({
    SendTimestamp: currentDate,
    Message: "Message 1"
  });
  $.ajaxSetup({
    url: "CometProxy.aspx",
    type: "POST",
    async: true,
    global: true,
    timeout: 600000
  });

Als Nächstes folgt das Initialisieren der Funktion mit der URL, zu der die Verbindung hergestellt werden soll, mit der HTTP-Methode für die zu verwendende Kommunikation, mit dem Kommunikationsformat und mit dem Verbindungszeitlimit-Parameter. Diese Funktion stellt jQuery in einer Bibliothek namens „ajaxSetup“ bereit. In diesem Beispiel habe ich das Zeitlimit auf zehn Minuten festgelegt, da ich hier nur einen Machbarkeitsnachweis erarbeite. Die Zeitlimiteinstellung kann ansonsten auf einen beliebigen anderen Wert festgelegt werden.

Jetzt wird mit der jQuery-Methode „ $.ajax“ eine Verbindung zum Server geöffnet. Dabei ist der einzige Parameter die Definition des success-Ereignishandlers:

$.ajax({
  success: function (msg) {
    // Alert("ajax.success().");
    if (msg == null || msg.Message == null) {
      getResponse();
      return;
    }

Vor dem Parsing testet der Handler das zurückgegebene Nachrichtenobjekt auf gültige Informationen. Dieser Test ist notwendig, da bei einem zurückgegebenen Fehlercode jQuery fehlschlägt und eine unspezifische Meldung an den Benutzer ausgibt. Bei der Nachricht „null“ sollte der Handler die AJAX-Funktion erneut rekursiv aufrufen und zurückgeben. Ich habe festgestellt, dass durch Hinzufügen der Rückgabe die weitere Codeausführung verhindert wird. Wenn die Nachricht in Ordnung ist, wird der Nachrichteninhalt gelesen und auf die Seite geschrieben:

$("#_receivedMsgLabel").append(msg.Message + "<br/>");
getResponse();
return;
    }
  });

Damit wird ein einfacher Client erstellt, der die Funktionsweise der Kommunikation im Comet-Format veranschaulicht. Außerdem bietet er die Möglichkeit für Tests von Leistung und Skalierbarkeit. In meinem Beispiel habe ich den JavaScript-Code „getResponse“ in ein Web-Benutzersteuerelement eingegeben und in dem dahinterliegenden Code registriert. Auf diese Weise wird die AJAX-Verbindung sofort geöffnet, wenn das Steuerelement auf der ASP.NET-Seite geladen wird:

public partial class JqueryJsonCometClientControl :
  System.Web.UI.UserControl
{
  protected void Page_Load(object sender, EventArgs e)
  {
    string getResponseScript =
      @"<script type=text/javascript>getResponse();</script>";
    Page.ClientScript.RegisterStartupScript(GetType(),
      "GetResponseKey", getResponseScript);
  }
}

Der Server

Nachdem ich nun über einen Client zum Senden und Empfangen von Nachrichten verfüge, erstelle ich einen Dienst, der diese Nachrichten empfangen und auf sie antworten kann.

Für die Kommunikation im Comet-Format habe ich mehrere verschiedene Techniken ausprobiert, darunter auch ASP.NET-Seiten und HTTP-Handler. Keine dieser Techniken war erfolgreich. Was mir offensichtlich Schwierigkeiten bereitete, war, eine einzelne Nachricht auf mehrere Clients übertragen zu lassen. Glücklicherweise fand ich nach längerer Recherche zufällig das cometbox-Projekt, das sich als einfachste Herangehensweise herausstellte. Ich musste ein paar Kleinigkeiten ändern, damit der Code als Windows-Dienst ausgeführt werden konnte und so eine einfachere Nutzung möglich wurde. Dann habe ich die Möglichkeit implementiert, eine langlebige Verbindung aufrechtzuerhalten und Inhalte mithilfe von Push an den Browser zu übertragen. (Leider habe ich dabei einen Teil der plattformübergreifenden Kompatibilität beschädigt.) Zum Schluss habe ich noch die Unterstützung für JSON und meine eigenen Nachrichtentypen für HTTP-Inhalte hinzugefügt.

Zunächst wird in einer Visual Studio-Lösung ein Windows-Dienstprojekt erstellt und eine Komponente für Dienstinstallationsprogramme hinzugefügt (die Anleitung dazu steht unter bit.ly/TrHQ8O). Dadurch lässt sich der Dienst in der Systemsteuerung unter „Verwaltung“ im Applet „Dienste“ an- und abschalten. Im Anschluss daran müssen zwei Threads erstellt werden: ein Thread, der an den TCP-Port gebunden wird und Nachrichten empfängt sowie überträgt, und ein zweiter Thread, der eine Nachrichtenwarteschlange blockiert und sicherstellt, dass Inhalte nur dann übertragen werden, wenn eine Nachricht empfangen wurde.

Zunächst muss eine Klasse erstellt werden, die auf dem TCP-Port auf neue Nachrichten lauscht und die Antworten überträgt. Es können verschiedene Comet-Kommunikationsformate implementiert werden, und die Implementierung verfügt über eine Serverklasse (siehe die Codedatei „Comet_Win_Service HTTP\Server.cs“ im Beispielcode), um diese Formate zu abstrahieren. Der Einfachheit halber beschränke ich mich hier aber auf das, was nötig ist, um auf ganz einfache Weise eine JSON-Nachricht über HTTP zu empfangen und die Verbindung zu halten, bis Inhalte für den Pushback vorhanden sind.

In der Serverklasse erstelle ich ein paar geschützte Mitglieder für die Objekte, auf die ich vom Serverobjekt aus zugreifen muss. Zu den Objekten zählen der Thread, der an den TCP-Port für HTTP-Verbindungen gebunden wird und lauscht, einige Semaphore und eine Liste mit Clientobjekten, von denen jedes für eine einzelne Verbindung zum Server steht. Wichtig ist die _isListenerShutDown-Eigenschaft. Sie wird als öffentliche Eigenschaft verfügbar gemacht, damit sie im stop-Dienstereignis geändert werden kann.

Im Anschluss instanziiere ich im Konstruktor das TCP-Listener-Objekt gegen den Port, lege für das Objekt die alleinige Nutzung des Ports fest und starte das Objekt. Dann starte ich einen Thread, um Clients, die eine Verbindung zum TCP-Listener herstellen, zu empfangen und zu steuern.

Der Thread, der auf Clientverbindungen lauscht, enthält eine while-Schleife, die kontinuierlich eine Kennzeichnung zurücksetzt. Diese gibt an, ob das stop-Dienstereignis eingetreten ist (siehe Abbildung 4). Ich habe für den ersten Teil der Schleife ein Mutex gewählt, damit die Schleife bei allen lauschenden Threads blockiert und das Auftreten des stop-Dienstereignisses überprüft werden kann. In solch einem Fall ist die _isListenerShutDown-Eigenschaft „true“. Ist die Überprüfung abgeschlossen, wird das Mutex freigegeben. Wird der Dienst dann weiterhin ausgeführt, rufe ich TcpListener.Accept­TcpClient auf, wodurch ein TcpClient-Objekt zurückgegeben wird. Optional kann ich noch vorhandene TcpClient-Objekte überprüfen, um sicherzugehen, dass ich keinen vorhandenen Client hinzufüge. Je nach Anzahl der zu erwartenden Clients ist es aber auch möglich, stattdessen ein System anzuwenden, bei dem der Dienst eine eindeutige ID generiert und diese an den Browserclient sendet. Der Browserclient merkt sich diese ID und sendet sie bei jeder Kommunikation mit dem Server zurück, um sicherzugehen, dass nur eine Verbindung gehalten wird. Fällt der Dienst hierbei allerdings aus, gibt es ein Problem: Der ID-Zähler wird zurückgesetzt, was dazu führen kann, dass neue Clients bereits ausgegebene IDs erhalten.

Abbildung 4: Lauschen auf Clientverbindungen

private void Loop()
{
  try
  {
    while (true)
    {
      TcpClient client = null;
      bool isServerStopped = false;
      _listenerMutex.WaitOne();
      isServerStopped = _isListenerShutDown;
      _listenerMutex.ReleaseMutex();
      if (!isServerStopped)
      {
        client = listener.AcceptTcpClient();
      }
    else
    {
      continue;
    }
    Trace.WriteLineIf(_traceSwitch.TraceInfo, "TCP client accepted.",
      "COMET Server");
    bool addClientFlag = true;
    Client dc = new Client(client, this, authconfig, _currentClientId);
    _currentClientId++;
    foreach (Client currentClient in clients)
    {
      if (dc.TCPClient == currentClient.TCPClient)
      {
        lock (_lockObj)
        {
          addClientFlag = false;
        }
      }
    }
    if (addClientFlag)
    {
      lock (_lockObj)
      {
        clients.Add(dc);
      }
    }

Zum Schluss arbeitet der Thread die Clientliste ab und entfernt alle inaktiven Clients. Um es einfach zu halten, habe ich den Code in die Methode eingebunden, die aufgerufen wird, wenn der TCP-Listener eine Clientverbindung zulässt. Bei Hunderttausenden von Clients kann das allerdings die Leistung beeinträchtigen. Wenn diese Vorgehensweise für öffentlich verfügbare Webanwendungen vorgesehen ist, empfiehlt es sich, einen Timer hinzuzufügen, der von Zeit zu Zeit aktiv wird, um die Bereinigung auszuführen.

Wenn ein TcpClient-Objekt in der Loop-Methode der Server-Klasse zurückgegeben wird, wird mit dem Objekt ein Clientobjekt erstellt, das den Browserclient darstellt. Da jedes Clientobjekt, wie der Serverkonstruktor auch, in einem eindeutigen Thread erstellt wird, muss der Clientklassenkonstruktor erst ein Mutex abwarten, um sicherzugehen, dass der Client nicht geschlossen wurde. Im Anschluss überprüfe ich den TCP-Datenstrom und lese ihn. Dabei initiiere ich einen Rückrufhandler, der nach dem Lesevorgang ausgeführt wird. Im Rückrufhandler lese ich einfach die Bytes und parse sie mit der ParseInput-Methode. Diese Methode ist ebenfalls in dem Beispielcode zu diesem Artikel enthalten.

In der ParseInput-Methode der Clientklasse erstelle ich ein Anforderungsobjekt. Die Mitglieder entsprechen den verschiedenen Teilen einer typischen HTTP-Nachricht und werden passend aufgefüllt. Zunächst parse ich die Headerinformationen, indem ich nach den Tokenzeichen, wie etwa „\r\n“, suche. Dabei bestimme ich die verschiedenen Headerinformationen anhand des HTTP-Headerformats. Dann rufe ich die ParseRequestContent-Methode auf, um zum Textkörper der HTTP-Nachricht zu gelangen. Als Erstes bestimmt die ParseInput-Methode die Methode der verwendeten HTTP-Kommunikation und die URL, an die die Anforderung gesendet wurde. Dann werden die HTTP-Nachrichtenheader extrahiert und in der Headers-Eigenschaft des Request-Objekts gespeichert, einer Art Lexikon der Headertypen und -werte. Wie das genau geht, ist ebenfalls am zum Download verfügbaren Beispielcode zu sehen. Zum Schluss lade ich den Inhalt der Anforderung in die Body-Eigenschaft des Request-Objekts. Dabei handelt es sich einfach um eine String-Variable mit allen Bytes des Inhalts. Der Inhalt ist an diesem Punkt noch nicht geparst worden. Sollten zum Schluss noch Probleme mit der vom Client empfangenen HTTP-Anforderung auftreten, sende ich eine entsprechende Fehlerantwort ab.

Die Methode zum Parsen des HTTP-Anforderungsinhalts habe ich getrennt, damit ich die Unterstützung für verschiedene Nachrichtentypen wie Nur-Text, XML, JSON usw. aufnehmen kann:

public void ParseRequestContent()
{
  if (String.IsNullOrEmpty(request.Body))
  {
    Trace.WriteLineIf(_traceSwitch.TraceVerbose,
      "No content in the body of the request!");
    return;
  }
  try
  {

Dafür wird zuerst der Inhalt in ein MemoryStream-Objekt geschrieben, damit er bei Bedarf abhängig vom Content-Type-Header der Anforderung in Objekttypen deserialisiert werden kann. Das ist erforderlich, da bestimmte Deserialisierungsprogramme nur mit Datenströmen funktionieren:

MemoryStream mem = new MemoryStream();
mem.Write(System.Text.Encoding.ASCII.GetBytes(request.Body), 0,
  request.Body.Length);
mem.Seek(0, 0);
if (!request.Headers.ContainsKey("Content-Type"))
{
  _lastUpdate = DateTime.Now;
  _messageFormat = MessageFormat.json;
}
else
{

Wie in Abbildung 5 zu sehen ist, habe ich die Standardaktion zum Umgang mit Nachrichten im XML-Format beibehalten, da XML nach wie vor ein beliebtes Format ist.

Abbildung 5: Der Standardhandler für XML-Nachrichten

if (request.Headers["Content-Type"].Contains("xml"))
{
  Trace.WriteLineIf(_traceSwitch.TraceVerbose, 
    "Received XML content from client.");
  _messageFormat = MessageFormat.xml;
  #region Process HTTP message as XML
  try
  {
    // Picks up message from HTTP
    XmlSerializer s = new XmlSerializer(typeof(Derrick.Web.SIServer.SIRequest));
    // Loads message into object for processing
    Derrick.Web.SIServer.SIRequest data =
      (Derrick.Web.SIServer.SIRequest)s.Deserialize(mem);
  }
  catch (Exception ex)
  {
    Trace.WriteLineIf(_traceSwitch.TraceVerbose,
      "During parse of client XML request got this exception: " + 
        ex.ToString());
  }
  #endregion Process HTTP message as XML
}

Für Webanwendungen empfehle ich jedoch dringend, die Nachrichten in JSON zu formatieren, da anders als bei XML nicht so viele öffnende und schließende Tags vorhanden sind und das Format von JavaScript unterstützt wird. Ich verwende einfach den Content-Type-Header der HTTP-Anforderung, um anzugeben, ob die Nachricht in JSON gesendet wurde. Den Inhalt deserialisiere ich mit der JavaScriptSerializer-Klasse des System.Web.Script.Serialization-Namespace. Mit dieser Klasse lässt sich eine JSON-Nachricht sehr leicht in ein C#-Objekt deserialisieren (siehe Abbildung 6).

Abbildung 6: Deserialisieren einer JSON-Nachricht

else if (request.Headers["Content-Type"].Contains("json"))
{
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Received json content from client.");
  _messageFormat = MessageFormat.json;
  #region Process HTTP message as JSON
  try
  {
    JavaScriptSerializer jsonSerializer = new JavaScriptSerializer();
    ClientMessage3 clientMessage =
      jsonSerializer.Deserialize<ClientMessage3>(request.Body);
    _lastUpdate = clientMessage.SendTimestamp;
    Trace.WriteLineIf(_traceSwitch.TraceVerbose,
      "Received the following message: ");
    Trace.WriteLineIf(_traceSwitch.TraceVerbose, "SendTimestamp: " +
      clientMessage.SendTimestamp.ToString());
    Trace.WriteLineIf(_traceSwitch.TraceVerbose, "Browser: " +
      clientMessage.Browser);
    Trace.WriteLineIf(_traceSwitch.TraceVerbose, "Message: " +
      clientMessage.Message);
  }
  catch (Exception ex)
  {
    Trace.WriteLineIf(_traceSwitch.TraceVerbose,
      "Error deserializing JSON message: " + ex.ToString());
  }
  #endregion Process HTTP message as JSON
}

Zu Testzwecken habe ich zum Schluss noch den Content-Type-Header „ping“ hinzugefügt, der mit einer HTTP-Textantwort antwortet, die nur das Wort „PING“ enthält. So kann ich ganz einfach testen, ob mein Comet-Server ausgeführt wird, indem ich ihm eine JSON-Nachricht mit dem Content-Type-Header „ping“ sende (siehe Abbildung 7).

Abbildung 7: Content-Type-Header „ping“

else if (request.Headers["Content-Type"].Contains("ping"))
{
  string msg = request.Body;
  Trace.WriteLineIf(_traceSwitch.TraceVerbose, "Ping received.");
  if (msg.Equals("PING"))
  {
    SendMessageEventArgs args = new SendMessageEventArgs();
    args.Client = this;
    args.Message = "PING";
    args.Request = request;
    args.Timestamp = DateTime.Now;
    SendResponse(args);
  }
}

Letztendlich handelt es sich bei der ParseRequestContent-Methode einfach nur um eine Methode zum Zeichenfolgenparsing, mehr nicht. Wie man sieht, ist beim Parsen von XML-Daten etwas mehr zu tun, da der Inhalt zunächst in ein Memory­Stream-Objekt geschrieben werden muss. Anschließend wird er über die XmlSerializer-Klasse deserialisiert, wodurch eine Klasse entsteht, die die Nachricht vom Client darstellt.

Um den Quellcode besser zu strukturieren, erstelle ich eine Request-Klasse (siehe Abbildung 8), die nur Mitglieder enthält, um die Header und andere über die HTTP-Anforderung gesendete Informationen aufzunehmen und innerhalb des Diensts leicht zugänglich zu machen. Bei Bedarf können auch Helper-Methoden hinzugefügt werden, die bestimmen, ob die Anforderung Inhalte oder Authentifizierungsprüfungen enthält. Darauf habe ich in diesem Fall aber verzichtet, damit der Dienst nicht zu komplex wird und sich leicht implementieren lässt.

Abbildung 8: Die Request-Klasse

public class Request
{
  public string Method;
  public string Url;
  public string Version;
  public string Body;
  public int ContentLength;
  public Dictionary<string, string> Headers = 
    new Dictionary<string, string>();
  public bool HasContent()
  {
    if (Headers.ContainsKey("Content-Length"))
    {
      ContentLength = int.Parse(Headers["Content-Length"]);
      return true;
    }
    return false;
  }

Ebenso wie die Request-Klasse enthält auch die Response-Klasse Methoden, um die HTTP-Antwortinformationen so zu speichern, dass sie für einen C#-Windows-Dienst einfach zugänglich sind. Der SendResponse-Methode habe ich eine Logik hinzugefügt, damit benutzerdefinierte HTTP-Header als für Cross-Origin Resource Sharing (CORS) erforderlich angefügt werden. Diese Header lasse ich von einer Konfigurationsdatei laden, damit sie einfach geändert werden können. Zudem enthält die Response-Klasse Methoden zur Ausgabe von Nachrichten zu einigen häufigen HTTP-Statuscodes, wie 200, 401, 404, 405 und 500.

Das SendResponse-Mitglied der Response-Klasse schreibt die Nachricht einfach in den HTTP-Antwortdatenstrom, der noch aktiv sein sollte, da das vom Client festgelegte Zeitlimit mit zehn Minuten recht lang ist:

public void SendResponse(NetworkStream stream, Client client)
{

Wie in Abbildung 9 zu sehen, werden die entsprechenden Header der HTTP-Antwort hinzugefügt, um der W3C-Spezifikation für CORS zu entsprechen. Zur Vereinfachung werden die Header aus der Konfigurationsdatei gelesen, damit die Headerinhalte problemlos geändert werden können.

Jetzt füge ich die regulären HTTP-Antwortheader und Inhalte hinzu (siehe Abbildung 10).

Abbildung 9: Hinzufügen der CORS-Header

if (client.Request.Headers.ContainsKey("Origin"))
{
  AddHeader("Access-Control-Allow-Origin", client.Request.Headers["Origin"]);
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Access-Control-Allow-Origin from client: " +
    client.Request.Headers["Origin"]);
}
else
{
  AddHeader("Access-Control-Allow-Origin",
    ConfigurationManager.AppSettings["RequestOriginUrl"]);
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Access-Control-Allow-Origin from config: " +
    ConfigurationManager.AppSettings["RequestOriginUrl"]);
}
AddHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
AddHeader("Access-Control-Max-Age", "1000");
// AddHeader("Access-Control-Allow-Headers", "Content-Type");
string allowHeaders = ConfigurationManager.AppSettings["AllowHeaders"];
// AddHeader("Access-Control-Allow-Headers", "Content-Type, x-requested-with");
AddHeader("Access-Control-Allow-Headers", allowHeaders);
StringBuilder r = new StringBuilder();

Abbildung 10: Hinzufügen der regulären HTTP-Antwortheader

r.Append("HTTP/1.1 " + GetStatusString(Status) + "\r\n");
r.Append("Server: Derrick Comet\r\n");
r.Append("Date: " + DateTime.Now.ToUniversalTime().ToString(
  "ddd, dd MMM yyyy HH':'mm':'ss 'GMT'") + "\r\n");
r.Append("Accept-Ranges: none\r\n");
foreach (KeyValuePair<string, string> header in Headers)
{
  r.Append(header.Key + ": " + header.Value + "\r\n");
}
if (File != null)
{
  r.Append("Content-Type: " + Mime + "\r\n");
  r.Append("Content-Length: " + File.Length + "\r\n");
}
else if (Body.Length > 0)
{
  r.Append("Content-Type: " + Mime + "\r\n");
  r.Append("Content-Length: " + Body.Length + "\r\n");
}
r.Append("\r\n");

Hier wird jetzt die gesamte HTTP-Antwortnachricht, die als String-Variable erstellt wurde, in den HTTP-Antwortdatenstrom geschrieben, der als Parameter der SendResponse-Methode eingeleitet wurde:

byte[] htext = Encoding.ASCII.GetBytes(r.ToString());
stream.Write(htext, 0, htext.Length);

Übertragen von Nachrichten

Bei einem Thread zum Übertragen von Nachrichten handelt es sich im Grunde genommen um nichts weiter als eine while-Schleife, die bei einer Microsoft-Nachrichtenwarteschlange blockiert. Das SendMessage-Ereignis des Threads wird ausgelöst, wenn der Thread eine Nachricht aus der Warteschlange aufnimmt. Das Ereignis wird von einer Methode im Serverobjekt verarbeitet, das im Grunde die SendResponse-Methode jedes Clients aufruft und so die Nachricht an jeden verbundenen Browser überträgt.

Der Thread wartet bei der entsprechenden Nachrichtenwarteschlange darauf, dass eine Nachricht darin platziert wird. So wird signalisiert, dass der Server über Inhalte verfügt, die an die Clients übertragen werden sollen:

Message msg = _intranetBannerQueue.Receive(); 
// Holds thread until message received
Trace.WriteLineIf(_traceSwitch.TraceInfo,
  "Message retrieved from the message queue.");
SendMessageEventArgs args = new SendMessageEventArgs();
args.Timestamp = DateTime.Now.ToUniversalTime();

Nach Eingang der Nachricht wird sie in den erwarteten Objekttyp konvertiert:

msg.Formatter = new XmlMessageFormatter(new Type[] { typeof(string) });
string cometMsg = msg.Body.ToString();
args.Message = cometMsg;

Nachdem festgelegt wurde, was an die Clients gesendet werden soll, löse ich ein Windows-Ereignis auf dem Server aus, wodurch signalisiert wird, dass eine Nachricht zur Übertragung bereitsteht:

if (SendMessageEvent != null)
{
  SendMessageEvent(this, args);
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Message loop raised SendMessage event.");
}

Als Nächstes benötige ich eine Methode zum Erstellen des eigentlichen HTTP-Antworttextkörpers, also des Inhalts der Nachricht, der vom Server an alle Clients übertragen wird. Die vorhergehende Nachricht nimmt den Nachrichteninhalt, der in der Microsoft-Nachrichtenwarteschlange abgelegt wurde, und formatiert ihn als JSON-Objekt, das dann über eine HTTP-Antwortnachricht an die Clients übertragen wird (siehe Abbildung 11).

Abbildung 11: Erstellen des HTTP-Antworttextkörpers

public void SendResponse(SendMessageEventArgs args)
{
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Client.SendResponse(args) called...");
  if (args == null || args.Timestamp == null)
  {
    return;
  }
  if (_lastUpdate > args.Timestamp)
  {
    return;
  }
  bool errorInSendResponse = false;
  JavaScriptSerializer jsonSerializer = null;

Im Anschluss muss ich eine Instanz des JavaScriptSerializer-Objekts instanziieren, um den Nachrichteninhalt in das JSON-Format zu überführen. Dabei füge ich die folgende try/catch-Fehlerbehandlung hinzu, da es manchmal Schwierigkeiten mit dem Instanziieren von Instanzen eines JavaScriptSerializer-Objekts geben kann:

try
{
  jsonSerializer = new JavaScriptSerializer();
}
catch (Exception ex)
{
  errorInSendResponse = true;
  Trace.WriteLine("Cannot instantiate JSON serializer: " + 
    ex.ToString());
}

Dann erstelle ich eine Zeichenfolgenvariable zum Aufnehmen der JSON-formatierten Nachricht sowie eine Instanz der Response-Klasse zum Senden der JSON-Nachricht.

Direkt im Anschluss nehme ich ein paar einfache Fehlerprüfungen vor, um sicherzugehen, dass ich mit einer gültigen HTTP-Anforderung arbeite. Da bei diesem Comet-Dienst für die Serverobjekte sowie für jeden TCP-Client ein Thread erzeugt wird, halte ich es hier für das sicherste, diese Sicherheitsüberprüfungen gelegentlich durchzuführen, um so das Debugging zu erleichtern.

Nachdem die Gültigkeit der Anforderung überprüft ist, stelle ich eine JSON-Nachricht zum Senden an den HTTP-Antwortdatenstrom zusammen. Das ist ganz einfach: Ich erstelle die JSON-Nachricht, serialisiere sie und verwende sie zum Erstellen einer HTML-Antwortnachricht:

if (request.HasContent())
{
  if (_messageFormat == MessageFormat.json)
  {
    ClientMessage3 jsonObjectToSend = new ClientMessage3();
    jsonObjectToSend.SendTimestamp = args.Timestamp;
    jsonObjectToSend.Message = args.Message;
    jsonMessageToSend = jsonSerializer.Serialize(jsonObjectToSend);
    response = Response.GetHtmlResponse(jsonMessageToSend,
      args.Timestamp, _messageFormat);
    response.SendResponse(stream, this);
  }

Um jetzt alles zusammenzuführen, erstelle ich während des Start-Dienstereignisses zunächst Instanzen des Nachrichtenschleifenobjekts und des Serverschleifenobjekts. Hierbei ist zu beachten, dass diese Objekte geschützte Mitglieder der Dienstklasse sein sollten, damit zu ihnen gehörende Methoden auch während anderer Dienstereignisse aufgerufen werden können. Jetzt sollte das Nachrichtenschleifenereignis zum Senden der Nachricht von der BroadcastMessage-Methode des Serverobjekts behandelt werden:

public override void BroadcastMessage(Object sender, 
  SendMessageEventArgs args)
{
  // Throw new NotImplementedException();
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Broadcasting message [" + args.Message + "] to all clients.");
  int numOfClients = clients.Count;
  for (int i = 0; i < numOfClients; i++)
  {
    clients[i].SendResponse(args);
  }
}

Die BroadcastMessage-Methode sendet dieselbe Nachricht an alle Clients. Wenn gewünscht, kann die Methode auch so geändert werden, dass die Nachricht nur an bestimmte Clients gesendet wird. Dadurch kann der Dienst beispielsweise zum Behandeln von mehreren Onlinechaträumen verwendet werden.

Wenn der Dienst beendet wird, wird die OnStop-Methode aufgerufen. Daraufhin wird die Shutdown-Methode des Serverobjekts aufgerufen. Sie geht die Liste der noch gültigen Clientobjekte durch und schaltet sie ab.

An diesem Punkt habe ich jetzt einen ganz gut funktionierenden Comet-Dienst, den ich in dem Dienstapplet über die Eingabeaufforderung und den installutil-Befehl installieren kann (weitere Informationen siehe bit.ly/OtQCB7). Da dem Dienstprojekt bereits die Dienstinstallationsprogramm-Komponenten hinzugefügt wurden, lässt sich auch ein eigenes Windows-Installationsprogramm zur Bereitstellung erstellen.

Warum funktioniert es nicht? Das Problem mit CORS

Versuchen Sie, die URL im $.ajax-Aufruf des Browserclients so einzustellen, dass auf die Comet-Dienst-URL gezeigt wird. Starten Sie den Comet-Dienst, und öffnen Sie den Browserclient in Firefox. Stellen Sie sicher, dass Sie im Firefox-Browser die Firebug-Erweiterung installiert haben. Starten Sie Firebug, und aktualisieren Sie die Seite. Im Konsolenaufgabenbereich wird der Fehler „Zugriff verweigert“ angezeigt. Das liegt an CORS, bei dem JavaScript aus Sicherheitsgründen keinen Zugriff auf Ressourcen außerhalb derselben Webanwendung und des virtuellen Verzeichnisses mit der Lagerseite hat. Wenn beispielsweise Ihre Browserclientseite unter http://www.somedomain.com/somedir1/somedir2/client.aspx liegt, kann ein AJAX-Aufruf auf dieser Seite nur zu den Ressourcen in demselben virtuellen Verzeichnis oder einem Unterverzeichnis geleitet werden. Das ist eine gute Sache, wenn Sie eine andere Seite oder einen HTTP-Handler innerhalb der Webanwendung aufrufen, dabei eine Nachrichtenwarteschlange aber nicht bei der Übertragung derselben Nachricht an alle Clients von Seiten und Handlern blockiert werden soll. Aus diesem Grund müssen Sie den Windows-Comet-Dienst verwenden und die CORS-Einschränkung umgehen können.

Dafür empfehle ich den Aufbau einer Proxyseite innerhalb desselben virtuellen Verzeichnisses, deren einzige Funktion darin besteht, die HTTP-Nachricht vom Browserclient abzufangen, alle relevanten Header und Inhalte zu extrahieren und ein weiteres HTTP-Anforderungsobjekt zu erstellen, das eine Verbindung zum Comet-Dienst herstellt. Da diese Verbindung auf dem Server stattfindet, wird sie von CORS nicht beeinträchtigt. So können Sie über einen Proxy eine langlebige Verbindung zwischen Ihrem Browserclient und dem Comet-Dienst aufrechterhalten. Darüber hinaus können Sie an alle verbundenen Browserclients gleichzeitig eine einzelne Nachricht übertragen, sobald sie in einer Nachrichtenwarteschlange eingetroffen ist.

Zuerst nehme ich die HTTP-Anforderung und streame sie in ein Bytearray, damit ich sie an ein neues HTTP-Anforderungsobjekt weiterreichen kann, das ich kurz darauf instanziiere:

byte[] bytes;
using (Stream reader = Request.GetBufferlessInputStream())
{
  bytes = new byte[reader.Length];
  reader.Read(bytes, 0, (int)reader.Length);
}

Als Nächstes erstelle ich ein neues HttpWebRequest-Objekt und lasse es auf den Comet-Server zeigen. Die URL des Servers schreibe ich in die web.config-Datei, damit sie später einfach geändert werden kann:

string newUrl = ConfigurationManager.AppSettings["CometServer"];
HttpWebRequest cometRequest = (HttpWebRequest)HttpWebRequest.Create(newUrl);

Auf diese Weise wird für jeden Benutzer eine Verbindung zum Comet-Server hergestellt. Da dieselbe Nachricht an jeden Benutzer übertragen wird, kann das cometRequest-Objekt in ein doppeltsperrendes Singleton gekapselt werden, um die Verbindungslast auf dem Comet-Server zu senken. Den Ausgleich der Verbindungslast übernimmt dabei IIS.

Anschließend befülle ich die HttpWebRequest-Header mit denselben Werten, die vom jQuery-Client empfangen wurden. Dabei lege ich die KeepAlive-Eigenschaft auf „true“ fest, sodass eine langlebige HTTP-Verbindung aufrechterhalten bleibt. Hierbei handelt es sich um die grundlegende Technik hinter der Kommunikation im Comet-Format.

Jetzt mache ich eine Überprüfung auf einen Origin-Header, der von der W3C-Spezifikation beim Umgang mit CORS-bezogenen Problemen vorgeschrieben wird:

for (int i = 0; i < Request.Headers.Count; i++)
{
  if (Request.Headers.GetKey(i).Equals("Origin"))
  {
    containsOriginHeader = true;
    break;
  }
}

Dann reiche ich den Origin-Header an die HttpWebRequest-Anforderung weiter, damit der Comet-Server ihn empfangen kann:

if (containsOriginHeader)
{
  // cometRequest.Headers["Origin"] = Request.Headers["Origin"];
  cometRequest.Headers.Set("Origin", Request.Headers["Origin"]);
}
else
{
  cometRequest.Headers.Add("Origin", Request.Url.AbsoluteUri);
}
System.Diagnostics.Trace.WriteLineIf(_proxySwitch.TraceVerbose,
  "Adding Origin header.");

Im Anschluss nehme ich die Bytes aus dem Inhalt der HTTP-Anforderung vom jQuery-Client und schreibe sie in den Anforderungsdatenstrom der HttpWebRequest-Anforderung, die an den Comet-Server gesendet wird (siehe Abbildung 12).

Abbildung 12: Schreiben in den HttpWebRequest-Anforderungsdatenstrom

Stream stream = null;
if (cometRequest.ContentLength > 0 && 
  !cometRequest.Method.Equals("OPTIONS"))
{
  stream = cometRequest.GetRequestStream();
  stream.Write(bytes, 0, bytes.Length);
}
if (stream != null)
{
  stream.Close();
}
// Console.WriteLine(System.Text.Encoding.ASCII.GetString(bytes));
System.Diagnostics.Trace.WriteLineIf(_proxySwitch.TraceVerbose, 
  "Forwarding message: " 
  + System.Text.Encoding.ASCII.GetString(bytes));

Nachdem die Nachricht an den Comet-Server weitergeleitet wurde, rufe ich die GetResponse-Methode des HttpWebRequest-Objekts auf, woraufhin ein HttpWebResponse-Objekt erzeugt wird, mit dem die Antwort des Servers verarbeitet werden kann. Außerdem füge ich die erforderlichen HTTP-Header hinzu, die ich mit der Nachricht zurück an den Client sende:

try
{
  Response.ClearHeaders();
  HttpWebResponse res = (HttpWebResponse)cometRequest.GetResponse();
  for (int i = 0; i < res.Headers.Count; i++)
  {
    string headerName = res.Headers.GetKey(i);
    // Response.Headers.Set(headerName, res.Headers[headerName]);
    Response.AddHeader(headerName, res.Headers[headerName]);
  }
  System.Diagnostics.Trace.WriteLineIf(_proxySwitch.TraceVerbose,
    "Added headers.");

Dann warte ich auf die Antwort des Servers:

Stream s = res.GetResponseStream();

Wenn ich die Nachricht des Comet-Servers erhalten habe, schreibe ich sie in den Antwortdatenstrom der ursprünglichen HTTP-Anforderung, damit sie vom Client empfangen werden kann (siehe Abbildung 13).

Abbildung 13: Schreiben der Servernachricht in den HTTP-Antwortdatenstrom

string msgSizeStr = ConfigurationManager.AppSettings["MessageSize"];
int messageSize = Convert.ToInt32(msgSizeStr);
byte[] read = new byte[messageSize];
// Reads 256 characters at a time
int count = s.Read(read, 0, messageSize);
while (count > 0)
{
  // Dumps the 256 characters on a string and displays the string to the console
  byte[] actualBytes = new byte[count];
  Array.Copy(read, actualBytes, count);
  string cometResponseStream = Encoding.ASCII.GetString(actualBytes);
  Response.Write(cometResponseStream);
  count = s.Read(read, 0, messageSize);
}
Response.End();
System.Diagnostics.Trace.WriteLineIf(_proxySwitch.TraceVerbose, 
  "Sent Message.");
s.Close();
}

Testen der Anwendung

Erstellen Sie zum Testen der Anwendung eine Website mit den Anwendungsseiten aus dem Beispiel. Achten Sie darauf, dass die URL zum Windows-Dienst korrekt ist und dass die Nachrichtenwarteschlange richtig konfiguriert und nutzbar ist. Starten Sie den Dienst, und öffnen Sie die Comet-Clientseite in einem Browser und die Seite zum Senden von Nachrichten in einem anderen Browser. Geben Sie eine Nachricht ein, und schicken Sie sie ab. Nach etwa 10 ms sollte die Nachricht in dem anderen Browserfenster angezeigt werden. Machen Sie diesen Test mit verschiedenen Browsern, besonders mit älteren Versionen. Wenn der Browser das xmlHttpRequest-Objekt unterstützt, sollte es funktionieren. Auf diese Weise wird Webverhalten nahezu in Echtzeit realisiert (en.wikipedia.org/wiki/Real-time_web), bei dem Inhalte mithilfe von Push fast sofort an den Browser übertragen werden, ohne dass ein Eingreifen des Benutzers erforderlich ist.

Bevor eine neue Anwendung bereitgestellt werden kann, müssen Leistung und Auslastung getestet werden. Für die Tests sollten zunächst die gewünschten Kennzahlen ermittelt werden. Ich empfehle die Ermittlung der Auslastung anhand der Reaktionszeiten und der Datentransfergröße. Darüber hinaus empfiehlt sich das Testen von Verwendungsszenarien, die für Comet relevant sind, insbesondere das Übertragen einer einzelnen Nachricht an mehrere Clients ohne Postback.

Für die Tests habe ich ein Dienstprogramm erstellt, das mehrere Threads mit jeweils einer Verbindung zum Comet-Server öffnet und abwartet, bis der Server eine Antwort ausgibt. Bei diesem Testdienstprogramm kann ich einige Parameter festlegen, wie beispielsweise die Gesamtanzahl der Benutzer, die eine Verbindung zu meinem Comet-Server herstellen, und wie oft sie die Verbindung wieder öffnen (derzeit wird die Verbindung nach dem Absenden der Serverantwort geschlossen).

Im Anschluss habe ich ein Dienstprogramm erstellt, das eine Nachricht mit x Bytes in die Nachrichtenwarteschlange legt. Dabei wird die Anzahl der Bytes über ein Textfeld auf dem Hauptbildschirm festgelegt. Außerdem wird in einem Textfeld die Anzahl der Millisekunden festgelegt, die zwischen dem Senden der einzelnen Nachrichten vom Server abgewartet werden soll. Mit diesen Einstellungen soll die Testnachricht zurück an den Client gesendet werden. Dann habe ich den Testclient gestartet und die Anzahl der Benutzer angegeben und wie häufig der Client die Comet-Verbindung wieder öffnen soll. Daraufhin haben die Threads die Verbindung zu meinem Server geöffnet. Ich habe ein paar Sekunden abgewartet, bis alle Verbindungen geöffnet waren. Dann habe ich über das Dienstprogramm zum Senden von Nachrichten eine bestimmte Anzahl an Bytes gesendet. Diesen Vorgang habe ich für verschiedene Kombinationen aus Benutzeranzahl, Wiederholungen und Nachrichtengrößen wiederholt.

Als erste Datenprobe hatte ich einen einzelnen Benutzer mit zunehmenden Wiederholungen, wobei die Antwortnachricht während des gesamten Testdurchlaufs gleich (klein) blieb. Wie in Abbildung 14 zu sehen ist, hat die Anzahl der Wiederholungen anscheinend keine Auswirkungen auf Systemleistung oder Zuverlässigkeit.

Abbildung 14: Unterschiedliche Anzahl an Benutzern

Benutzer Wiederholungen Nachrichtengröße (in Bytes) Reaktionszeit (in Millisekunden)
1,000 10 512 2.56
5,000 10 512 4.404
10,000 10 512 18.406
15,000 10 512 26.368
20,000 10 512 36.612
25,000 10 512 48.674
30,000 10 512 64.016
35,000 10 512 79.972
40,000 10 512 99.49
45,000 10 512 122.777
50,000 10 512 137.434

Die Reaktionszeiten steigen allmählich in linearer/konstanter Weise an, was bedeutet, dass der Code auf dem Comet-Server im Allgemeinen stabil ist. Abbildung 15 zeigt die Anzahl der Benutzer im Verhältnis zu den Reaktionszeiten bei einer 512 Byte großen Nachricht. Abbildung 16 zeigt einige Werte für eine Nachrichtengröße von 1.024 Byte. Und in Abbildung 17 ist die Tabelle aus Abbildung 16 als Graph zu sehen. Sämtliche Tests wurden auf demselben Laptop mit 8 GB RAM und 2,4-GHz-Intel Core i3-Prozessor durchgeführt.

Response Times for Varying Numbers of Users for a 512-Byte Message
Abbildung 15: Reaktionszeiten bei unterschiedlicher Anzahl an Benutzern und einer 512 Byte großen Nachricht

Abbildung 16: Tests mit einer Nachrichtengröße von 1.024 Byte

Benutzer Wiederholungen Reaktionszeit (in Millisekunden)
1,000 10 144.227
5,000 10 169.648
10,000 10 233.031
15,000 10 272.919
20,000 10 279.701
25,000 10 220.209
30,000 10 271.799
35,000 10 230.114
40,000 10 381.29
45,000 10 344.129
50,000 10 342.452

User Load vs Response Time for a 1KB Message
Abbildung 17: Benutzerlast im Verhältnis zur Reaktionszeit bei einer 1 KB großen Nachricht

An den Zahlen ist keine besondere Tendenz zu erkennen, außer dass die Reaktionszeiten recht gut sind und bei Nachrichten von bis zu 1 KB unter 1 Sekunde bleiben. Die Bandbreitennutzung habe ich nicht erfasst, da sie vom Nachrichtenformat abhängt. Außerdem wurde die Netzwerklatenz als Faktor ausgeschlossen, da sämtliche Tests auf demselben Computer durchgeführt wurden. Ich hätte die Tests zwar auch in meinem Netzwerk zu Hause durchführen können, aber das habe ich nicht für nötig gehalten, da das öffentliche Internet weitaus komplexer ist als meine Konfiguration mit WLAN-Router und Kabelmodem zu Hause. Da jedoch der zentrale Punkt bei den Comet-Kommunikationstechniken darin besteht, Serverroundtrips durch die Übertragung von aktualisierten Inhalten mithilfe von Push vom Server zu reduzieren, müsste theoretisch die Hälfte der Netzwerkbandbreitennutzung durch Comet-Techniken reduziert werden.

Zusammenfassung

Ich hoffe, dass Sie jetzt in der Lage sind, Ihre eigenen Anwendungen im Comet-Format zu implementieren und sie effektiv für die Reduzierung der Netzwerkbandbreite und die Steigerung der Leistung von Websiteanwendungen einsetzen können. Sicherlich möchten Sie sich auch mit den neuen Technologien zu HTML5 befassen, mit denen Comet ersetzt werden kann, wie WebSockets (bit.ly/UVMcBg) und Server-Sent Events (SSE) (bit.ly/UVMhoD). Diese Technologien halten ihr Versprechen und bieten eine einfachere Möglichkeit, Inhalte mithilfe von Push an einen Browser zu übertragen. Dafür ist es jedoch erforderlich, dass der verwendete Browser HTML5 unterstützt. Wenn Sie weiter Benutzer mit älteren Browsern unterstützen müssen, ist die Kommunikation im Comet-Format weiterhin die beste Möglichkeit.

Derrick Lau ist ein erfahrener Teamleiter in der Softwareentwicklung mit etwa 15 Jahren Erfahrung in diesem Bereich. Er hat in den IT-Abteilungen von Finanzdienstleistern und der Regierung gearbeitet und war außerdem in Technologieunternehmen in der Softwareentwicklung tätig. Bei einem Entwicklerwettbewerb von EMC hat er 2010 den ersten Platz belegt, 2011 kam er bei dem gleichen Wettbewerb ins Finale. Zudem ist er als MCSD und als EMC Content Management Developer zertifiziert.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Francis Cheung