ASP.NET

Création d'une application Comet simple dans le Microsoft .NET Framework

Derrick Lau

Télécharger l'exemple de code

 

Comet est une technique qui permet de pousser du contenu d'un serveur Web vers un navigateur sans requête explicite, à l'aide de connexions AJAX à long terme. Elle permet une expérience utilisateur plus interactive et utilise moins de bande passante que l'aller-retour serveur type déclenché par une publication de page afin d'extraire des données supplémentaires. Bien que de nombreuses implémentations Comet soient disponibles, la plupart d'entre elles reposent sur du Java. Dans le cadre de cet article, je me concentrerai sur la création d'un service C# reposant sur l'exemple de code cometbox disponible à l'adresse code.google.com/p/cometbox.

Il existe des méthodes plus récentes pour implémenter le même comportement à l'aide de fonctionnalités HTML5, par exemple les WebSockets et les événements côté serveur, mais elles sont uniquement disponibles dans les dernières versions des navigateurs. Si vous devez prendre en charge des navigateurs plus anciens, Comet est la solution la plus largement compatible. Toutefois, le navigateur doit prendre en charge AJAX en implémentant l'objet xmlHttpRequest, faute de quoi il ne pourra pas prendre en charge la communication de type Comet.

L'architecture de haut niveau

La figure 1 représente la communication de type Comet de base, tandis que la figure 2 décrit l'architecture de mon exemple. Comet utilise l'objet xmlHttpRequest du navigateur, un objet essentiel à toute communication AJAX, afin d'établir une connexion HTTP à long terme avec un serveur. Le serveur laisse la connexion ouverte et pousse le contenu vers le navigateur lorsque celui-ci est disponible.

Comet-Style CommunicationFigure 1 Communication de type Comet

Architecture of the Comet ApplicationFigure 2 Architecture de l'application Comet

Une page de proxy se trouve entre le navigateur et le serveur. Elle réside dans le même chemin d'application Web que la page Web contenant le code client et n'effectue aucune action, à l'exception du transfert des messages du navigateur vers le serveur, et du serveur vers le navigateur. Quelle est l'utilité d'une page de proxy ? Je reviendrai sur ce point un peu plus tard.

La première étape consiste à sélectionner un format pour les messages échangés entre le navigateur et le serveur : JSON, XML ou un format personnalisé. Dans un but de simplicité, j'ai opté pour JSON qui est naturellement pris en charge dans JavaScript, jQuery et le Microsoft .NET Framework, et qui peut transmettre la même quantité de données que le XML en utilisant moins d'octets, et donc moins de bande passante.

Pour configurer la communication de type Comet, vous ouvrez une connexion AJAX avec le serveur. Pour cela, la solution la plus simple consiste à utiliser jQuery car il prend en charge plusieurs navigateurs et fournit des fonctions wrapper intéressantes, telles que $.ajax. Cette fonction est surtout un wrapper pour chaque objet xmlHttpRequest du navigateur et elle fournit des gestionnaires d'événements pratiques, susceptibles d'être implémentés pour traiter les messages entrants provenant du serveur.

Avant de démarrer la connexion, vous allez instancier le message à envoyer. Pour cela, vous devez déclarer une variable et utiliser JSON.stringify de façon à mettre les données au format de message JSON, comme illustré à la figure 3.

Figure 3 Mettre les données au format de message JSON

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
  });

Vous initialisez ensuite la fonction avec l'URL à laquelle vous souhaitez vous connecter, la méthode de communication HTTP à utiliser, le type de communication et le paramètre d'expiration du délai de connexion. JQuery fournit cette fonctionnalité dans un appel de bibliothèque nommé ajaxSetup. Dans le cadre de cet exemple, j'ai défini l'expiration du délai de connexion sur 10 minutes car je crée simplement une solution de validation technique ici. Vous pouvez modifier ce paramètre en fonction de vos besoins.

Ouvrez maintenant une connexion vers le serveur à l'aide de la méthode jQuery $.ajax, avec la définition du gestionnaire d'événements de réussite comme seul paramètre :

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

Ce gestionnaire teste l'objet de message retourné afin de vérifier s'il contient des informations valides avant l'analyse. Cette opération est nécessaire dans la mesure où, si un code d'erreur est retourné, jQuery échouera et présentera à l'utilisateur un message non défini. Si le message est null, le gestionnaire doit appeler à nouveau la fonction AJAX récursivement et effectuer un retour. J'ai découvert que l'ajout du retour empêche le code de se poursuivre. Si le message est OK, il vous suffit de le lire et d'écrire le contenu sur la page :

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

Cela crée un client simple qui illustre le fonctionnement de la communication de type Comet, tout en fournissant un moyen d'exécuter les tests de performance et d'évolutivité. Pour cet exemple, j'ai inséré le code JavaScript getResponse dans un contrôle utilisateur Web et je l'ai enregistré dans le code-behind afin que la connexion AJAX s'ouvre dès le chargement du contrôle sur la page ASP.NET :

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);
  }
}

Le serveur

Maintenant que je dispose d'un client capable d'envoyer et de recevoir des messages, je vais créer un service pouvant les recevoir et y répondre.

J'ai tenté de mettre en œuvre plusieurs techniques différentes pour la communication de type Comet, et notamment d'utiliser les pages ASP.NET et les gestionnaires HTTP, mais cela s'est soldé par un échec. Je ne parvenais pas à diffuser un seul message à plusieurs clients. Fort heureusement, après de nombreuses recherches, j'ai découvert le projet cometbox qui m'a semblé être l'approche la plus simple. Je l'ai remanié légèrement de façon à ce qu'il soit exécuté comme un service Windows, plus facile à utiliser, puis je lui ai donné la possibilité de tenir une connexion à long terme et de pousser le contenu vers le navigateur. Malheureusement, ces modifications ont détruit une partie de la compatibilité interplateforme. En dernier lieu, j'ai ajouté la prise en charge de JSON et de mes propres types de message avec un contenu HTTP.

Pour commencer, créez un projet de service Windows dans votre solution Visual Studio et ajoutez un composant de programme d'installation du service (vous trouverez les instructions sur bit.ly/TrHQ8O) afin que vous puissiez activer et désactiver votre service dans l'applet Service, dans la section Outils d'administration du Panneau de configuration. Une fois cette opération terminée, vous devez créer deux threads : un premier qui sera lié au port TCP et qui recevra et transmettra des messages, et un second qui restera bloqué sur une file d'attente des messages afin de garantir que le contenu est transmis uniquement lorsqu'un message est reçu.

Vous devez tout d'abord créer une classe qui écoute les nouveaux messages sur le port TCP et transmet les réponses. Il y a plusieurs styles de communication Comet susceptibles d'être implémentés et l'implémentation comporte une classe Server (voir le fichier de code Comet_Win_Service HTTP\Server.cs dans l'exemple de code) afin de permettre l'abstraction. Toutefois, à des fins de simplicité, je me concentrerai sur qui est nécessaire pour procéder à la réception de base d'un message JSON via HTTP et pour conserver la connexion jusqu'à ce qu'il y ait un contenu à pousser.

Dans la classe Server, je crée des membres protégés destinés à contenir les objets auxquels j'aurai besoin d'accéder depuis l'objet Server. Il s'agit notamment du thread qui sera lié au port TCP et écoutera sur celui-ci pour les connexions HTTP, de quelques sémaphores et d'une liste d'objets clients, chacun d'entre eux représentant une connexion unique au serveur. L'objet _isListenerShutDown est important car il sera exposé comme propriété publique afin de pouvoir être modifié dans l'événement d'arrêt de service.

Ensuite, dans le constructeur, j'instancie l'objet écouteur TCP par rapport au port, je le définis pour une utilisation exclusive du port, puis je le lance. Je lance ensuite un thread pour recevoir et gérer les clients qui se connectent à l'écouteur TCP.

Le thread qui écoute les connexions client contient une boucle while qui réinitialise en permanence un indicateur signalant si l'événement d'arrêt de service a été déclenché (voir la figure 4). Je définis la première partie de cette boucle sur un mutex de façon à bloquer tous les threads d'écoute pour vérifier si l'événement d'arrêt de service a été déclenché. Le cas échéant, la propriété _isListenerShutDown est vraie. Une fois la vérification terminée, le mutex est libéré et, si le service est toujours en cours d'exécution, j'appelle TcpListener.AcceptTcpClient qui retourne un objet TcpClient. Je peux éventuellement vérifier les objets TcpClient existants afin de m'assurer que je n'ajoute pas de client existant. Toutefois, selon le nombre de clients attendus, vous pouvez remplacer cela par un système dans lequel le service génère un ID unique et l'envoie au client navigateur, qui se souvient et envoie à nouveau l'ID chaque fois qu'il communique avec le serveur pour s'assurer qu'il maintient une seule connexion. Cela peut cependant poser un problème en cas d'échec du service car le compteur d'ID est alors réinitialisé et des ID déjà utilisés pourraient être donnés à de nouveaux clients.

Figure 4 Écoute des connexions client

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);
      }
    }

En dernier lieu, le thread parcourt la liste de clients et supprime ceux qui ne sont plus actifs. Dans un but de simplicité, j'ai mis ce code dans la méthode qui est appelée lorsque l'écouteur TCP accepte une connexion client, mais cela peut avoir un impact sur les performances lorsque le nombre de clients atteint les centaines de milliers. Si vous avez l'intention d'utiliser cela dans les applications Web destinées au public, je vous suggère d'ajouter un minuteur qui se déclenche de temps à autre et effectue le nettoyage nécessaire.

Lorsqu'un objet TcpClient est retourné dans la méthode Loop de la classe Server, il est utilisé afin de créer un objet client représentant le client navigateur. Dans la mesure où chaque objet client est créé dans un thread unique, comme avec le constructeur de serveur, le constructeur de classe client doit attendre un mutex afin de s'assurer que le client n'a pas été fermé avant de poursuivre. Je vérifie ensuite le flux TCP et je commence à le lire, puis je lance l'exécution d'un gestionnaire de rappel une fois la lecture terminée. Dans le gestionnaire de rappel, je me contente de lire les octets et de les analyser à l'aide de la méthode ParseInput, que vous pouvez voir dans l'exemple de code proposé avec cet article.

Dans la méthode ParseInput de la classe Client, je crée un objet Request avec des membres qui correspondent aux différentes parties du message HTTP type, puis je remplis ces membres de façon appropriée. Tout d'abord, j'analyse les informations d'en-tête en recherchant les caractères jeton, tels que « \r\n », qui déterminent les informations d'en-tête à partir du format de l'en-tête HTTP. J'appelle ensuite la méthode ParseRequestContent pour obtenir le corps du message HTTP. La première étape de ParseInput consiste à déterminer la méthode de communication HTTP utilisée et l'URL vers laquelle la requête a été envoyée. Ensuite, les en-têtes du message HTTP sont extraits et stockés dans la propriété Headers de l'objet Request, c'est-à-dire un dictionnaire des types et des valeurs d'en-tête. Je vous conseille là encore d'observer l'exemple de code téléchargeable afin de voir comment tout cela a été mis en place. En dernier lieu, je charge le contenu de la requête dans la propriété Body de l'objet Request, c'est-à-dire une simple variable string qui contient tous les octets du contenu. À ce stade, le contenu doit encore être analysé. À la fin, s'il y a le moindre problème avec la requête HTTP reçue du client, j'envoie un message de réponse d'erreur approprié.

J'ai séparé la méthode pour analyser le contenu de la requête HTTP afin de pouvoir ajouter la prise en charge de différents types de message, par exemple texte brut, XML, JSON, etc. :

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

Tout d'abord, le contenu est écrit dans un MemoryStream. De cette façon, en cas de besoin, il peut être désérialisé en types d'objets selon le Content-Type de la requête, dans la mesure où certains désérialiseurs ne fonctionnent qu'avec les flux :

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
{

Comme illustré à la figure 5, j'ai conservé l'action par défaut de gestion des messages au format XML dans la mesure où ce dernier reste très populaire.

Figure 5 Le gestionnaire de messages XML par défaut

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
}

En ce qui concerne les applications Web, toutefois, je recommande fortement l'utilisation du format JSON pour les messages. En effet, contrairement au XML, il ne requiert pas le lancement puis l'annulation des balises et il est pris en charge dans JavaScript en mode natif. Je me contente d'utiliser l'en-tête Content-Type de la requête HTTP afin d'indiquer si le message a été envoyé en JSON, puis je désérialise le contenu à l'aide de la classe JavaScriptSerializer de l'espace de noms System.Web.Script.Serialization. Cette classe facilite beaucoup la désérialisation d'un message JSON en objet C#, comme illustré à la figure 6.

Figure 6 Désérialisation d'un message JSON

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
}

En dernier lieu, à des fins de test, j'ai ajouté un Content-Type ping qui répond simplement avec une réponse HTTP texte contenant uniquement le mot PING. De cette façon, je peux aisément vérifier si mon serveur Comet est exécuté en lui envoyant un message JSON avec un type de contenu « ping », comme illustré à la figure 7.

Figure 7 Type de contenu « 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);
  }
}

Et enfin, ParseRequestContent est une simple méthode d'analyse de chaîne, rien de plus, rien de moins. Comme vous pouvez le constater, l'analyse de données XML est légèrement plus impliquée dans la mesure où le contenu doit d'abord être écrit dans un MemoryStream, puis désérialisé, à l'aide de la classe XmlSerializer, en une classe créée de façon à représenter le message du client.

Afin d'organiser le code source de façon optimale, je crée une classe Request, illustrée à la figure 8, qui contient simplement des membres permettant de détenir les en-têtes et d'autres informations envoyées dans la requête HTTP d'une façon aisément accessible au sein du service. Si vous le souhaitez, vous pouvez ajouter des méthodes d'assistance afin de déterminer si la requête comporte un contenu, ainsi que des vérifications de l'authentification. Je n'ai pas procédé ainsi dans le cas présent afin de vous proposer un service simple et facile à implémenter.

Figure 8 La classe Request

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;
  }

La classe Response, au même titre que la classe Request, contient des méthodes permettant de stocker les informations de réponse HTTP de façon à ce qu'elles soient facilement accessibles par un service Windows C#. Dans la méthode SendResponse, j'ai ajouté une logique permettant de joindre les en-têtes HTTP personnalisés comme requis pour le partage de ressources cross-origin, puis j'ai fait charger ces en-têtes depuis un fichier de configuration afin qu'ils puissent être modifiés aisément. La classe Response contient également des méthodes permettant de sortir les messages pour certains états HTTP courants, par exemple 200, 401, 404, 405 et 500.

Le membre SendResponse de la classe Response se contente d'écrire le message dans le flux de réponse HTTP qui doit encore être actif dans la mesure où le délai d'expiration défini par le client est relativement long (10 minutes).

public void SendResponse(NetworkStream stream, Client client)
{

Comme illustré à la figure 9, les en-têtes appropriés sont ajoutés à la réponse HTTP afin de répondre à la spécification W3C pour CORS. Dans un but de simplicité, les en-têtes sont lus dans le fichier de configuration afin que leur contenu puisse être facilement modifié.

J'ajoute maintenant les en-têtes et le contenu de réponse HTTP standard, comme illustré à la figure 10.

Figure 9 Ajout des en-têtes CORS

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();

Figure 10 Ajout des en-têtes de réponse HTTP standard

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");

Ici, tout le message de réponse HTTP, qui a été créé comme une chaîne, est désormais écrit dans le flux de réponse HTTP, qui a été passé comme paramètre à la méthode SendResponse :

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

Transmission des messages

Le thread permettant de transmettre les messages n'est globalement rien de plus qu'une boucle While bloquée sur une file d'attente des messages Microsoft. Il contient un événement SendMessage qui est déclenché lorsqu'il extrait un message de la file d'attente. Cet événement est géré par une méthode de l'objet serveur qui appelle la méthode SendResponse de chaque client et diffuse ainsi le message à chaque navigateur qui lui est connecté.

Le thread attend la file d'attente des messages appropriée jusqu'à ce qu'un message placé dessus indique que le serveur a un contenu qu'il souhaiterait diffuser aux clients :

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();

Une fois le message reçu, il est converti dans le type d'objet attendu :

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

Après avoir déterminé ce qui sera envoyé aux clients, je déclenche un événement Windows sur le serveur afin d'indiquer qu'un message est prêt à être diffusé :

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

J'ai ensuite besoin d'une méthode qui créera le véritable corps de réponse HTTP, c'est-à-dire le contenu du message qui sera ensuite diffusé par le serveur à tous les clients. Le message précédent prend le contenu du message inséré dans la file d'attente des messages Microsoft et le met au format JSON en vue de sa transmission aux clients via un message de réponse HTTP, comme illustré à la figure 11.

Figure 11 Création du corps de la réponse HTTP

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;

J'ai ensuite besoin d'instancier une instance de l'objet JavaScriptSerializer afin de mettre le contenu du message au format JSON. J'ajoute la gestion d'erreur try/catch suivante car il est parfois difficile d'instancier l'instance d'un objet JavaScriptSerializer :

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

Je crée ensuite une variable de chaîne qui contiendra le message au format JSON et une instance de la classe Response pour envoyer le message JSON.

Je procède immédiatement à des vérifications de base des erreurs afin de m'assurer que j'utilise une requête HTTP valide. Dans la mesure où ce service Comet génère un thread pour chaque client TCP, ainsi que pour les objets serveur, il m'a semblé plus sûr d'inclure ces vérifications de sécurité de temps à autre afin de faciliter le débogage par la suite.

Une fois que je me suis assuré qu'il s'agit d'une requête valide, je crée un message JSON afin d'envoyer un flux de réponse HTTP. Notez que je me contente de créer le message JSON, de le sérialiser, puis de l'utiliser pour créer un message de réponse HTML :

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);
  }

Afin de tout regrouper, je commence par créer des instances de l'objet de boucle du message et de l'objet de boucle serveur au cours de l'événement de démarrage du service. Notez que ces objets doivent être des membres protégés de la classe de service afin que les méthodes associées puissent être appelées au cours d'autres événements de service. Maintenant, l'événement du message d'envoi de la boucle de message doit être géré par la méthode BroadcastMessage de l'objet serveur :

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);
  }
}

La méthode BroadcastMessage envoie simplement le même message à tous les clients. Si vous le souhaitez, vous pouvez la modifier de façon à envoyer le message uniquement à certains clients. Vous pouvez ainsi utiliser ce service pour gérer, par exemple, plusieurs salles de conversation en ligne.

La méthode OnStop est appelée lorsque le service est arrêté. Elle appelle ensuite la méthode Shutdown de l'objet serveur, qui parcourt la liste des objets clients encore valides et les ferme.

Je dispose à ce stade d'un service Comet qui fonctionne relativement bien et que je peux installer dans l'applet des services depuis l'invite de commande à l'aide de la commande installutil (pour plus d'informations, consultez bit.ly/OtQCB7). Vous pouvez également créer votre propre programme d'installation Windows afin de le déployer puisque vous avez déjà ajouté les composants du programme d'installation du service au projet de service.

Pourquoi cela ne fonctionne-t-il pas ? Le problème avec CORS

Nous allons maintenant tenter de définir l'URL de l'appel $.ajax du client navigateur de façon à ce qu'elle pointe vers l'URL du service Comet. Démarrez le service Comet et ouvrez le client navigateur dans Firefox. Assurez-vous que l'extension Firebug est installée dans le navigateur Firefox. Démarrez Firebug et rafraîchissez la page. Vous remarquerez que vous obtenez une erreur dans la zone de sortie de la console indiquant « Access denied ». Cela vient de CORS dans lequel, pour des raisons de sécurité, JavaScript ne peut pas accéder à des ressources en dehors de la même application Web et de son répertoire virtuel où se trouve sa page d'hébergement. Par exemple, si la page cliente du navigateur se trouve dans http://www.somedomain.com/somedir1/somedir2/client.aspx, tout appel AJAX effectué sur cette page peut uniquement aller vers des ressources se trouvant dans le même répertoire ou sous-répertoire virtuel. Cela est formidable si vous appelez une autre page ou un autre gestionnaire HTTP au sein de l'application Web, mais il est préférable d'éviter que les pages et les gestionnaires soient bloqués sur une file d'attente des messages lors de la transmission du même message à tous les clients. Vous devez donc utiliser le service Comet Windows et il vous faut une solution pour contourner la restriction CORS.

Pour cela, je vous recommande de créer une page de proxy dans le même répertoire virtuel, dont la seule fonction sera d'intercepter le message HTTP provenant du client navigateur, d'extraire tous les en-têtes et le contenu pertinents, puis de créer un autre objet de requête HTTP connecté au service Comet. Étant donné que cette connexion est effectuée sur le serveur, CORS n'a aucun impact sur elle. Par conséquent, via un proxy, vous pouvez avoir une connexion à long terme entre votre client navigateur et le service Comet. En outre, vous pouvez désormais transmettre simultanément un message unique qui arrive dans une file d'attente des messages à tous les clients navigateur connectés.

Je prends tout d'abord une requête HTTP et je la transmets dans un tableau d'octets afin de pouvoir la passer à un objet de requête HTTP que je vais instancier un peu plus loin :

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

Je crée ensuite un nouvel objet HttpWebRequest et je le pointe vers le serveur Comet, dont j'ai inséré l'URL dans le fichier web.config afin qu'elle puisse être modifiée facilement par la suite :

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

Cela crée une connexion avec le serveur Comet pour chaque utilisateur, mais étant donné que le même message est diffusé à chaque utilisateur, vous pouvez simplement encapsuler l'objet cometRequest dans un singleton à double verrouillage pour réduire la charge de connexion sur le serveur Comet, puis laisser IIS procéder à l'équilibrage de charge de connexion pour vous.

Je remplis ensuite les en-têtes HttpWebRequest avec les valeurs que j'ai reçues du client jQuery, en définissant plus particulièrement la propriété KeepAlive sur true afin de maintenir une connexion HTTP à long terme. Il s'agit là d'une technique essentielle qui sous-tend la communication de style Comet.

Je recherche ici la présence de l'en-tête Origin, qui est requis par la spécification W3C lors du traitement de problèmes liés à CORS :

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

Je passe ensuite l'en-tête Origin à HttpWebRequest afin que le serveur Comet le reçoive :

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.");

Je prends ensuite les octets du contenu de la requête HTTP provenant du client jQuery, puis je les inscris dans le flux de la requête du flux HttpWebRequest, qui sera envoyé au serveur Comet, comme illustré à la figure 12.

Figure 12 Écriture dans le flux HttpWebRequest

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));

Après avoir transféré le message au serveur Comet, j'appelle la méthode GetResponse de l'objet HttpWebRequest, ce qui fournit un objet HttpWebResponse me permettant de traiter la réponse du serveur. J'ajoute également les en-têtes HTTP que je renverrai au client avec le message :

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.");

J'attends ensuite la réponse du serveur :

Stream s = res.GetResponseStream();

Lorsque je reçois le message du serveur Comet, je l'inscris dans le flux de réponse de la requête HTTP initiale afin que le client puisse le recevoir, comme illustré à la figure 13.

Figure 13 Écriture du message du serveur dans le flux de réponse HTTP

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();
}

Tester l'application

Pour tester votre application, créez un site Web qui contiendra les exemples de pages de l'application. Veillez à ce que l'URL menant vers votre service Windows soit correcte et à ce que la file d'attente des messages soit configurée correctement et utilisable. Démarrez le service et ouvrez la page cliente Comet dans un navigateur et la page d'envoi des messages dans un autre. Tapez un message et appuyez sur le bouton d'envoi. Au bout d'environ 10 ms, vous devriez voir un message s'afficher dans la fenêtre de l'autre navigateur. Testez cet exemple avec divers navigateurs, plus particulièrement avec les plus anciens. Tant qu'ils prennent en charge l'objet xmlHttpRequest, cela devrait fonctionner. Cela offre un comportement Web presque en temps réel (en.wikipedia.org/wiki/Real-time_web), où le contenu est poussé vers le navigateur presque instantanément sans qu'aucune action de l'utilisateur ne soit nécessaire.

Avant le déploiement de toute nouvelle application, vous devez effectuer des tests de performances et de charge. Pour cela, commencez par identifier les métriques que vous souhaitez collecter. Je vous suggère de mesurer la charge d'utilisation par rapport aux temps de réponse et à la taille de transfert des données. En outre, vous devez tester des scénarios d'utilisation adaptés à Comet, notamment la diffusion d'un seul message à plusieurs clients sans publication.

Pour effectuer ces tests, j'ai créé un utilisateur qui ouvre plusieurs threads, chacun ayant une connexion avec le serveur Comet, et attend que le serveur envoie une réponse. Cet utilitaire de test me permet de définir quelques paramètres, par exemple le nombre total d'utilisateurs qui seront connectés à mon serveur Comet et le nombre de fois où ils ouvriront à nouveau la connexion (celle-ci est actuellement fermée après l'envoi de la réponse par le serveur).

J'ai ensuite créé un utilitaire qui envoie un message de x octets dans la file d'attente des messages, avec le nombre d'octets défini par un champ de texte dans l'écran principal et un champ de texte pour définir le nombre de millisecondes d'attente entre les messages envoyés depuis le serveur. Je l'utiliserai pour renvoyer le message test au client. J'ai ensuite démarré le client test, spécifié le nombre d'utilisateurs et le nombre de fois où le client ouvrira à nouveau la connexion Comet, puis les threads ont ouvert les connexions avec mon serveur. J'ai attendu quelques secondes que toutes les connexions soient ouvertes, puis j'ai accédé à mon utilitaire d'envoi des messages avant d'envoyer un certain nombre d'octets. J'ai répété cette opération pour diverses combinaisons de nombre total d'utilisateurs, nombre total de répétitions et tailles de messages.

Les premières données que j'ai recueillies concernaient un utilisateur unique avec des répétitions de plus en plus importantes, mais avec un message de réponse dont la (petite) taille restait cohérente durant tout le test. Comme vous pouvez le voir à la figure 14, le nombre de répétitions ne semble pas avoir d'impact sur les performances ni sur la fiabilité du système.

Figure 14 Divers nombres d'utilisateurs

Utilisateurs Répétitions Taille de message (en octets) Temps de réponse (en millisecondes)
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

Ces temps augmentent progressivement de façon linéaire/constante, ce qui signifie que le code du serveur Comet est généralement solide. La figure 15 représente le nombre d'utilisateurs par rapport au temps de réponse pour un message de 512 octets. La figure 16 représente les statistiques d'un message de 1 024 octets. En dernier lieu, la figure 17 illustre le tableau de la figure 16 sous forme de graphique. Tous les tests ont été effectués sur un même ordinateur portable avec 8 Go de RAM et un processeur Intel Core i3 de 2,4 GHz.

Response Times for Varying Numbers of Users for a 512-Byte Message
Figure 15 Temps de réponse pour divers nombres d'utilisateurs et un message de 512 octets

Figure 16 Test avec un message de 1 024 octets

Utilisateurs Répétitions Temps de réponse (en millisecondes)
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
Figure 17 Charge utilisateur par rapport au temps de réponse pour un message de 1 Ko

Les chiffres n'indiquent aucune tendance particulière, si ce n'est que les temps de réponse sont raisonnables et restent en dessous d'une seconde pour les messages allant jusqu'à 1 Ko. Je n'ai pas cherché à suivre l'utilisation de la bande passante car cela dépend du format du message. En outre, étant donné que tous les tests ont été effectués sur un seul ordinateur, le temps de réponse du réseau est un critère qui a été supprimé. J'aurais pu utiliser mon réseau personnel, mais cela ne m'a pas semblé utile puisque l'Internet public est nettement plus complexe que mon routeur sans fil et ma configuration de modem câble. Toutefois, dans la mesure où les techniques de communication Comet ont pour objectif principal de réduire les aller-retour avec le serveur en poussant le contenu provenant du serveur au fur et à mesure des mises à jour, l'utilisation de la bande passante du réseau devrait théoriquement être réduite grâce aux techniques Comet.

Pour résumer

J'espère que vous pourrez maintenant implémenter vos propres applications Comet et les utiliser efficacement pour réduire la bande passante du réseau et accroître les performances des applications de site Web. N'oubliez pas de vous informer sur les nouvelles technologies incluses avec HTML5 et susceptibles de remplacer Comet, par exemple les WebSockets (bit.ly/UVMcBg) et les événements envoyés par le serveur (SSE) (bit.ly/UVMhoD). Ces technologies promettent de fournir une méthode plus simple permettant de pousser le contenu vers le navigateur, mais l'utilisateur doit disposer d'un navigateur prenant en charge HTML5. Si vous devez encore prendre en charge des utilisateurs qui ont recours à des navigateurs plus anciens, la communication Comet reste le meilleur choix.

Derrick Lau bénéficie d'une longue expérience d'environ 15 ans en tant que responsable d'une équipe de développement logiciel. Il a exercé ses compétences au sein des services informatiques de l'État et de cabinets financiers, ainsi que dans les services de développement logiciel d'entreprises spécialisées dans la technologie. Il a remporté le premier prix d'un concours de développement EMC en 2010 et a été finaliste en 2011. Il est également certifié en tant que MCSD et développeur de gestion de contenu EMC.

Merci à l'expert technique suivant d'avoir relu cet article : Francis Cheung