ASP.NET
Creación de una aplicación Comet sencilla en Microsoft .NET Framework
Descargar el ejemplo de código
Comet es una técnica para pasar contenido de un servidor web a un explorador sin una solicitud explícita, mediante conexiones AJAX de larga duración. Permite una experiencia de usuario más interactiva y usa menos ancho de banda que el típico recorrido de ida y vuelta del servidor impulsado por la devolución de una página para recuperar más datos. Aunque hay bastante implementaciones Comet disponibles, la mayor parte se basa en Java. En este artículo, me centraré en la compilación de un servicio de C# basado en el ejemplo de código cometbox disponible en code.google.com/p/cometbox.
Existen métodos más nuevos para implementar el mismo comportamiento mediante características de HTML5 como WebSockets y eventos del lado del servidor, pero estos solo están disponibles en las versiones más recientes del explorador. Si debe admitir exploradores más antiguos, Comet es la solución más compatible. Sin embargo, el explorador debe admitir AJAX al implementar el objeto xmlHttpRequest; de lo contrario, no podrá admitir la comunicación de estilo Comet.
Arquitectura de alto nivel
La Figura 1 muestra la comunicación estilo Comet, mientras que la Figura 2 muestra la arquitectura de mi ejemplo. Comet usa el objeto xmlHttpRequest del explorador, lo cual es esencial para la comunicación AJAX, a fin de establecer una conexión HTTP de larga duración con un servidor. El servidor mantiene la conexión abierta y pasa contenido al explorador cuando está disponible.
Figura 1 Comunicación de estilo Comet
Figura 2 Arquitectura de la aplicación Comet
Entre el explorador y el servidor hay una página proxy, la cual reside en la misma ruta de l aplicación web que la página web que contiene el código de cliente y no hace nada, salvo reenviar los mensajes desde el explorador al servidor y viceversa. ¿Por qué necesita una página proxy? En seguida lo explico.
El primero paso es seleccionar un formato para los mensajes que se intercambian entre el explorador y el servidor: JSON, XML o un formato personalizado. Para hacerlo más sencillo, elegí JSON porque se admite naturalmente en JavaScript, jQuery y Microsoft .NET Framework, y puede transmitir la misma cantidad de datos que XML mediante menos bytes y, por tanto, menos ancho de banda.
Para configurar la comunicación estilo Comet, abra una conexión AJAX al servidor. La manera más fácil de hacerlo es usar jQuery, porque admite varios exploradores y proporciona algunas buenas funciones de contenedor como $.ajax. Esta función es esencialmente un contenedor para cada objeto xmlHttpRequest del explorador y casi proporciona controladores de evento que se pueden implementan para procesar mensajes entrantes desde el servidor.
Antes de iniciar la conexión, cree una instancia del mensaje que se va a enviar. Para hacerlo, declare una variable y use JSON.stringify para dar formato a los datos como un mensaje JSON, tal como se muestra en la Figura 3.
Figura 3 Dé formato a los datos como mensaje 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
});
A continuación, inicialice la función con la URL a la cual se va a conectar, el método HTTP de comunicación que usará, el estilo de comunicación y el parámetro de tiempo de espera de la conexión. JQuery suministra esta función en una llamada de biblioteca denominada ajaxSetup. Establecí el tiempo de expiración para este ejemplo en 10 minutos, porque solo voy a compilar una solución de prueba de concepto aquí; puede cambiar la configuración del tiempo de expiración como lo desee.
Ahora abra una conexión al servidor mediante el método jQuery $.ajax, con la definición del controlador de evento de éxito como el único parámetro:
$.ajax({
success: function (msg) {
// Alert("ajax.success().");
if (msg == null || msg.Message == null) {
getResponse();
return;
}
El controlador prueba el objeto del mensaje devuelto para garantizar que contiene información válida antes del análisis; esto es necesario porque si se devuelve un código de error, jQuery generará un error y mostrará un mensaje indefinido al usuario. Frente a un mensaje nulo, el controlador debe llamar de forma recursiva a la función AJAX nuevamente y volver; descubrí que agregar la devolución evita que el código continúe. Si el mensaje está bien, simplemente léalo y escriba el contenido en la página:
$("#_receivedMsgLabel").append(msg.Message + "<br/>");
getResponse();
return;
}
});
Esto crea un cliente sencillo que ilustra cómo funciona la comunicación estilo Comet, así como proporciona un medio para ejecutar pruebas de rendimiento y escalabilidad. Para mi ejemplo, puse el código JavaScript getResponse en un control de usuario web y lo registré en el código subyacente para que la conexión de AJAX se abra inmediatamente cuando el control se carga en la página 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);
}
}
El servidor
Ahora que tengo un cliente que puede enviar y recibir mensajes, compilaré un servicio que pueda recibirlos y responderlos.
Traté de implementar varias técnicas diferentes para la comunicación estilo Comet, incluido el uso de páginas ASP.NET y controladores HTTP, ninguno de los cuales salió bien. Lo que parece que no podía hacer eta obtener un mensaje único que difundir a varios clientes. Afortunadamente, después de mucha investigación me topé con el proyecto de cometbox y me pareció que era el enfoque más fácil. Hice algunos ajustes para que se ejecutara como un servicio de Windows de manera que fuera más fácil de usar y le di la capacidad de mantener una conexión de larga duración y pasar contenido en el explorador. (Lamentablemente, al hacerlo, dañé algo de la compatibilidad entre plataformas.) Por último, agregué compatibilidad para JSON y mis propios tipos de mensaje de contenido HTTP.
Para comenzar, cree un proyecto de servicio Windows en su solución de Visual Studio y agregue un componente de instalador de servicio (encontrará las instrucciones en bit.ly/TrHQ8O) de manera que pueda activar o desactivar su servicio en el applet Servicios de las Herramientas administrativas del Panel de control. Una vez hecho esto, debe crear dos subprocesos: uno que enlazará al puerto TCP y recibirá así como transmitirá mensajes; y uno que bloqueará una cola de mensajes para garantizar que el contenido se transmita solo cuando se recibe un mensaje.
Primero, debe crear una clase que preste atención en el puerto TCP a nuevos mensajes y transmita las respuestas. Ahora bien, hay varios estilos de comunicación Comet que se pueden implementar y en la implementación hay una clase Server (consulte el archivo de código Comet_Win_Service HTTP\Server.cs en el código de ejemplo) para abstraerlos. Para hacerlo más sencillo, sin embargo, me centraré en lo que se requiere para una recepción muy básica de un mensaje JSON por HTTP y para mantener la conexión hasta que haya contenido para devolver.
En la clase Server, crearé algunos miembros protegidos para que sostengan objetos a los cuales deberé obtener acceso desde el objeto Server. Estos incluyen el subproceso que se enlazará al puerto TCP y que prestará atención a conexiones de HTTP, algunos semáforos y una lista de objetos de cliente, cada uno de los cuales representará una conexión única al servidor. De importancia es _isListenerShutDown, la cual se expondrá como una propiedad pública para que pueda modificarse en el evento Stop de servicio.
A continuación, en el constructor, crearé una instancia del objeto Listener de TCP contra el puerto, la configuraré para uso exclusivo del puerto y luego la iniciaré. Después, iniciaré un subproceso para recibir y controlar clientes que se conectan a la escucha de TCP.
El subproceso que presta atención a las conexiones de cliente contiene un bucle while que continuamente restablece una marca que indica si se elevó el evento Stop de servicio (vea la Figura 4). Configuré la primera parte de este bucle en una exclusión mutua para bloquear todos los subprocesos de escucha a fin de comprobar si se elevó el evento Stop de servicio. Si es así, la propiedad _isListenerShutDown será verdadera. Cuando la comprobación se completa, la exclusión mutua se libera y si el servicio sigue en ejecución, llamaré a TcpListener.AcceptTcpClient, el cual devolverá un objeto TcpClient. Opcionalmente, compruebo los TcpClient existentes para asegurarme de que no estoy agregando un cliente existente. Sin embargo, dependiendo de la cantidad de clientes que espera, quizás le conviene reemplazar esto por un sistema donde el servicio genere una identificación única y la envíe al cliente del explorador, el cual la recuerda y la vuelve a enviar cada vez que se comunica con el servidor para garantizar que solo mantiene una conexión única. No obstante, esto se puede volver problemático, si el servicio genera un error; restablece el contador de identificación y podría dar a clientes nuevos identificaciones ya usadas.
Figura Prestar atención a conexiones de cliente
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);
}
}
Por último, el subproceso revisa la lista de clientes y elimina cualquiera que ya no esté activo. Para hacerlo más sencillo, puse este código en el método que se llama cuando el escucha de TCP acepta una conexión de cliente, pero esto puede afectar el rendimiento cuando la cantidad de clientes entra a los cientos de miles. Si pretender usar esto en aplicaciones web de uso público, sugiero agregar un temporizador que se active de vez en cuando y realice la limpieza.
Cuando un objeto TcpClient se devuelve en el método Loop de la clase Server, se usa para crear un objeto de cliente que representa al cliente de explorador. Debido a que cada objeto de cliente se crea en un subproceso único, como con el constructor de servidor, el constructor de la clase de cliente debe esperar que una exclusión mutua garantice que el cliente no se ha cerrado antes de continuar. Después, reviso la secuencia de TCP y comienzo a leerla, e inicio un controlador de devolución de llamada para que se ejecute una vez finalizada la lectura. En el controlador de la devolución de llamada, simplemente leo los bytes y los analizo mediante el método ParseInput, que puede ver en el código de ejemplo proporcionado con este artículo.
En el método ParseInput de la clase Client, compilo un objeto Request con miembros que corresponden a las diferentes partes del típico mensaje de HTTP y los relleno según corresponda. Primero, analizo la información del encabezado al buscar los caracteres token, como “\r\n,” para así determinar la información del encabezado del formato del encabezado de HTTP. Luego llamo al método ParseRequestContent para llegar al cuerpo del mensaje de HTTP: El primer paso de ParseInput es determinar el método de comunicación HTTP usado y la URL a la cual se envió la solicitud. A continuación, los encabezados del mensaje de HTTP se extraen y almacenan en la propiedad Headers del objeto Request, la cual es un diccionario de tipos y valores de encabezados. Una vez más, eche una mirada al código de ejemplo descargable para ver cómo se hace. Por último, cargo el contenido de la solicitud en la propiedad Body del objeto Request, la cual es simplemente la variable String que contiene todos los bytes del contenido. El contenido aún debe analizarse en este punto. Al final, si hay algún problema con la solicitud de HTTP recibida desde el cliente, envío un mensaje de respuesta de error adecuado.
Separé el método para analizar el contenido de la solicitud de HTTP para poder agregar compatibilidad para diferentes tipos de mensaje, como texto sin formato, XML, JSON y otros:
public void ParseRequestContent()
{
if (String.IsNullOrEmpty(request.Body))
{
Trace.WriteLineIf(_traceSwitch.TraceVerbose,
"No content in the body of the request!");
return;
}
try
{
Primero el contenido se escribe en un MemoryStream de manera que, si es necesario, pueda deserializarse en tipos de objetos, dependiendo del Content-Type de la solicitud, ya que determinados deserializadores solo funcionan con secuencias:
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
{
Tal como se muestra en la Figura 5, mantuve la acción predeterminada de controlar los mensajes con formato XML porque este sigue siendo un formato popular.
Figura 5 Controlador de mensaje XML predeterminado
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 el caso de aplicaciones web, sin embargo, recomiendo encarecidamente dar formato a los mensajes en JSON, ya que, a diferencia de XML, no tiene la sobrecarga de iniciar y cancelar las etiquetas, y se admite de manera nativa en JavaScript. Simplemente uso el encabezado Content-Type de la solicitud de HTTP para indicar si el mensaje se envió en JSON y deserializar el contenido mediante la clase JavaScriptSerializer del espacio de nombres System.Web.Script.Serialization. Esta clase facilita mucho deserializar un mensaje JSON en un objeto C#, tal como se muestra en la Figura 6.
Figura 6 Deserialización de un mensaje 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
}
Por último, para fines de evaluación, agregué un Content-Type de ping que simplemente responde con un HTTP de texto que contiene solo la palabra PING. De esta manera, puedo probar fácilmente para ver si mi servidor Comet se está ejecutando al enviarle un mensaje JSON con el "ping" Content-Type, tal como se muestra en la Figura 7.
Figura 7 "Ping" Content-Type
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);
}
}
En última instancia, ParseRequestContent es solo un método de análisis de cadenas, nada más ni nada menos. Como puede ver, analizar datos XML requiere un poco más de atención, ya que el contenido tiene que escribirse en un MemoryStream primero y luego deserializarse, mediante la clase XmlSerializer, en una clase creada para representar el mensaje del cliente.
Para organizar de mejor manera el código fuente, creo una clase Request, como se muestra en la Figura 8, que simplemente contiene miembros que mantienen los encabezados y otra información enviada en la solicitud de HTTP de una manera fácilmente accesible dentro del servicio. Si lo desea, puede agregar métodos de clases auxiliares para determinar si la solicitud tiene contenido o no, además de comprobaciones de autenticación. Sin embargo, no hice esto aquí para que el servicio fuera sencillo y fácil de implementar.
Figura 8 Clase 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 clase Response, al igual que la clase Request, contiene métodos para almacenar la información de la respuesta de HTTP de una manera fácilmente accesible a través de un servicio Windows C#. En el método SendResponse, agregué lógica para adjuntar encabezados de HTTP personalizados según se requiere para uso compartido de recursos entre orígenes (CORS) e hice que esos encabezados se cargaran desde un archivo de configuración para que se pudieran modificar fácilmente. La clase Response también contiene métodos para producir mensajes para algunos estados de HTTP comunes, como 200, 401, 404, 405 y 500.
El miembro SendResponse de la clase Response simplemente escribe el mensaje en la secuencia de respuesta de HTTP que aún debe estar activa, ya que el tiempo de expiración establecido por el cliente es bastante largo (10 minutos):
public void SendResponse(NetworkStream stream, Client client) {
Tal como se muestra en la Figura 9, los encabezados adecuados se agregan a la respuesta de HTTP para ajustarse a la especificación W3C para CORS. Para hacerlo más sencillo, los encabezados se leen desde el archivo de configuración, de manera que el contenido del encabezado pueda modificarse con facilidad.
Ahora agrego los encabezados y el contenido de respuesta de HTTP regulares, como se muestra en la Figura 10.
Figura 9 Cómo agregar encabezados de 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();
Figura 10 Cómo agregar los encabezados de respuesta de HTTP regulares
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");
Aquí todo el mensaje de respuesta de HTTP, que se compiló como String, ahora se escribe en la secuencia de respuesta de HTTP, que se pasó como un parámetro para el método SendResponse:
byte[] htext = Encoding.ASCII.GetBytes(r.ToString());
stream.Write(htext, 0, htext.Length);
Transmisión de mensajes
El subproceso para transmitir mensajes es esencialmente nada más que un bucle While que bloquea una cola de mensajes de Microsoft. Tiene un evento SendMessage que se eleva cuando el subproceso recoge un mensaje de la cola. Al evento lo controla un método en el objeto del servidor que básicamente llama al método SendResponse de cada cliente, con lo cual difunde el mensaje a cada explorador conectado.
El subproceso espera en la cola de mensajes adecuada hasta que hay un mensaje en ella, lo cual indica que el servidor tiene algún contenido que desea difundir a los clientes:
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();
Cuando el mensaje se recibe, se convierte en el tipo de objeto esperado:
msg.Formatter = new XmlMessageFormatter(new Type[] { typeof(string) });
string cometMsg = msg.Body.ToString();
args.Message = cometMsg;
Después de determinar qué se enviará a los clientes, elevo un evento Windows en el servidor, lo cual indica que hay un mensaje por difundir.
if (SendMessageEvent != null)
{
SendMessageEvent(this, args);
Trace.WriteLineIf(_traceSwitch.TraceVerbose,
"Message loop raised SendMessage event.");
}
A continuación, necesito un método que compile el cuerpo verdadero de la respuesta de HTTP, el contenido del mensaje que el servidor difundirá a todos los clientes. El mensaje anterior toma el contenido del mensaje descargado en la cola de mensajes de Microsoft y le da formato como un objeto JSON para su transmisión a los clientes a través de un mensaje de respuesta de HTTP, tal como se muestra en la Figura 11.
Figura 11 Creación del cuerpo de la respuesta de 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;
A continuación, debo crear una instancia del objeto JavaScriptSerializer para poner el contenido del mensaje en formato JSON. Agrego el siguiente control de error try/catch porque a veces hay problemas para crear la instancia de un objeto JavaScriptSerializer.
try
{
jsonSerializer = new JavaScriptSerializer();
}
catch (Exception ex)
{
errorInSendResponse = true;
Trace.WriteLine("Cannot instantiate JSON serializer: " +
ex.ToString());
}
Luego creo una variable de cadena para contener el mensaje con formato JSON y una instancia de la clase Response para enviar el mensaje JSON.
Inmediatamente hago una comprobación básica de errores para asegurarme de que estoy trabajando con una solicitud de HTTP válida. Como este servicio Comet genera un subproceso para cada cliente de TCP, así como para los objetos del servidor, me pareció que lo más seguro era incluir estas comprobaciones de seguridad de tanto en tanto, para que la depuración sea más sencilla.
Una vez que compruebo que es una solicitud válida, armo un mensaje JSON para enviarlo a la secuencia de respuesta de HTTP. Observe que no bien creo el mensaje JSON, lo deserializo y lo uso para crear un mensaje de respuesta de 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);
}
Para unir todo, primero creo instancias del objeto del bucle de mensajes y el objeto del bucle del servidor durante el evento Start de servicio. Observe que estos objetos deben ser miembros protegidos de la clase de servicio de manera que los métodos en ellos se puedan llamar durante otros eventos de servicio. Ahora al evento de envío de mensaje al bucle de mensaje debe controlarlo el método BroadcastMessage del objeto del servidor:
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);
}
}
BroadcastMessage acaba de enviar el mismo mensaje a todos los clientes. Si lo desea, puede modificarlo para enviar el mensaje solo a los clientes que desea; de esta manera, puede usar este servicio para controlar, por ejemplo, varias salas de charla en línea.
El método OnStop se llama cuando el servicio se detiene. Posteriormente, llama al método Shutdown del objeto de servidor, el cual revisa la lista de objetos de cliente todavía válidos y los cierra.
En este punto, tengo un servicio Comet que funciona razonablemente bien, el cual puedo instalar en el applet de servicios desde el aviso de comando mediante el comando installutil (para obtener más información, consulte bit.ly/OtQCB7). También podría crear su propio instalador de Windows para implementarlo, dado que ya agregó los componentes del instalador de servicios al proyecto de servicios.
¿Por qué no funciona? El problema con CORS
Ahora bien, trate de configurar la URL en la llamada $.ajax del cliente del explorador para que apunte a la URL de servicio Comet. Inicie el servicio Comet y abra el cliente del explorador en Firefox. Asegúrese de contar con la extensión Firebug instalada en el explorador Firefox. Inicie Firebug y actualice la página; observará que obtiene un error en el área de salida de la consola que indica "Acceso denegado". Esto se debe a CORS, donde, por motivos de seguridad, JavaScript no puede obtener acceso a recursos fuera de la misma aplicación web y directorio virtual donde reside su página de alojamiento. Por ejemplo, si su página de cliente del explorador está en http://www.somedomain.com/somedir1/somedir2/client.aspx, entonces cualquier llamada de AJAX realizada en esa página puede ir solo a recursos en el mismo directorio virtual o a un subdirectorio. Esto es excelente si va a llamar otra página o controlador de HTTP dentro de la aplicación web, pero no desea páginas ni controladores que bloqueen una cola de mensajes al transmitir el mismo mensaje a todos los clientes, de manera que debe usar el servicio Comet de Windows y necesita una manera de salvar la restricción de CORS.
Para hacerlo, recomiendo crear una página proxy en el mismo directorio virtual, cuya única función es interceptar el mensaje de HTTP desde el cliente de explorador, extraer todos los encabezados y contenido pertinentes, y crear otro objeto de solicitud de HTTP que se conecte con el servicio Comet. Como esta conexión está lista en el servidor, no se ve afectada por CORS. Por tanto, a través de un proxy, puede mantener una conexión de larga duración entre su cliente de explorador y el servicio Comet. Además, ahora puede transmitir un mensaje único cuando llega en una cola de mensajes a todos los clientes de explorado conectados al mismo tiempo.
Primero, tomo la solicitud de HTTP y la transmito en secuencias en una matriz de bytes para poder pasarla a un nuevo objeto de solicitud de HTTP para el cual crearé instancias dentro de poco:
byte[] bytes;
using (Stream reader = Request.GetBufferlessInputStream())
{
bytes = new byte[reader.Length];
reader.Read(bytes, 0, (int)reader.Length);
}
A continuación, creo un nuevo objeto HttpWebRequest y lo apunto al servidor Comet, cuya URL puse en el archivo web.config para poder modificarlo fácilmente más tarde:
string newUrl = ConfigurationManager.AppSettings["CometServer"];
HttpWebRequest cometRequest = (HttpWebRequest)HttpWebRequest.Create(newUrl);
Esto crea una conexión con el servidor Comet para cada usuario, pero como el mismo mensaje se difunde a cada usuario, simplemente puede encapsular el objeto cometRequest en un singleton de bloqueo doble para reducir la carga de conexión en el servidor Comet y dejar que IIS haga el equilibrio de la carga de conexión por usted.
Luego relleno los encabezados HttpWebRequest con los mismos valores que recibí del cliente jQuery, sobre todo al configurar la propiedad KeepAlive en verdadero de manera que mantengo la conexión de HTTP de larga duración, la cual es la técnica fundamental detrás de la comunicación de estilo Comet.
Aquí compruebo si hay un encabezado Origin, el cual la especificación W3C requiere cuando trata problemas relacionados con CORS:
for (int i = 0; i < Request.Headers.Count; i++)
{
if (Request.Headers.GetKey(i).Equals("Origin"))
{
containsOriginHeader = true;
break;
}
}
Luego paso el encabezado Origin a HttpWebRequest para que el servidor Comet lo reciba:
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.");
A continuación, tomo los bytes del contenido de la solicitud de HTTP del cliente de jQuery y los escribo en la secuencia de solicitud de HttpWebRequest, que se enviará al servidor Comet, tal como se muestra en la Figura 12.
Figura 12 Escritura en la secuencia 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));
Después de reenviar el mensaje al servidor Comet, llamo al método GetResponse del objeto HttpWebRequest, el cual proporciona un objeto HttpWebResponse que me permite procesar la respuesta del servidor. También agrego los encabezados de HTTP necesarios que enviaré con el mensaje de vuelta al cliente:
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.");
Entonces espero por la respuesta del servidor:
Stream s = res.GetResponseStream();
Cuando recibo el mensaje del servidor Comet, lo escribo en la secuencia de respuesta de la solicitud de HTTP original para que el cliente lo pueda recibir, tal como se muestra en la Figura 13.
Figura 13 Escritura del mensaje de servidor en la secuencia de respuesta de 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();
}
Pruebe la aplicación
Para probar su aplicación, cree un sitio web para contener las páginas de aplicación de ejemplo. Asegúrese de que la URL a su servicio Windows esté correcta y que la cola de mensajes esté configurada como corresponde y se pueda usar. Inicie el servicio y abra la página de cliente Comet en un explorador y la página para enviar mensajes en otra. Escriba un mensaje y presione enviar; después de unos 10 minutos, debería ver aparecer el mensaje en la otra ventana del explorador. Intente esto con diversos explorados, especialmente con algunos de los antiguos. Siempre y cuando admitan el objeto xmlHttpRequest, debe funcionar. Esto proporciona un comportamiento web casi en tiempo real (en.wikipedia.org/wiki/Real-time_web), donde el contenido se pasa al explorador casi de manera instantánea sin requerir la acción del usuario.
Antes de implementar una aplicación nueva, tiene que hacer las pruebas de rendimiento y carga. Para hacerlo, primero debe identificar la métrica que desea recopilar. Sugiero medir la carga de uso contra los tiempos de respuesta y el tamaño de la transferencia de datos. Además, debe probar los escenarios de uso pertinentes respecto de Comet, en especial al difundir un mensaje único a varios clientes sin devolución.
Para hacer las pruebas, construí una utilidad que abre varios subprocesos, cada uno con una conexión al servidor Comet, y espera hasta que el servidor emite una respuesta. Esta utilidad de prueba me permite configurar unos cuantos parámetros, como la cantidad total de usuarios que se conectarán a mi servidor Comet y el número de veces que reabren la conexión (actualmente la conexión se cierra después de enviada la respuesta del servidor).
Luego creé una utilidad que descarga un mensaje de x cantidad de bytes en la cola de mensajes, con el número de bytes establecido por un campo de texto en la pantalla principal y un campo de texto para establecer la cantidad de milisegundos que se esperarán entre los mensajes enviados desde el servidor. Usaré esto para enviar de vuelta el mensaje de prueba al cliente. Luego inicié el cliente de prueba, especifiqué la cantidad de usuarios más el número de veces que el cliente reabrirá la conexión Comet y los subprocesos abrieron las conexiones contra mi servidor. Esperé unos segundos para que todas las conexiones se abrieran, luego fui a la utilidad que envió el mensaje y envié un número determinado de bytes. Repetí esto para diversas combinaciones de usuarios totales, repeticiones totales y tamaños de mensajes.
El primer muestreo de datos que tomé fue para un usuario único con mayores repeticiones, pero con el mensaje de respuesta de un tamaño constante (pequeño) durante las pruebas. Como puede ver en la Figura 14, el número de repeticiones no parece tener efecto en el rendimiento o la confiabilidad del sistema.
Figura 14 Variación de la cantidad de usuarios
Usuarios | Repeticiones | Tamaño de mensaje (en bytes) | Tiempo de respuesta (en milisegundos) |
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 |
Los tiempos comienzan a aumentar gradualmente de manera lineal/constante, lo cual significa que el código en el servidor Comet suele ser robusto. En la Figura 15 se grafica la cantidad de usuarios frente al tiempo de respuesta para un mensaje de 512 bytes. En la Figura 16 se muestran algunas estadísticas para un tamaño de mensaje de 1.024 bytes. Finalmente, en la Figura 17 se muestra una representación de la Figura 16 en formato gráfico. Todas estas pruebas se realizaron en un portátil único con 8 GB de RAM y una CPU Intel Core i3 de 2.4 GHz.
Figura 15 Tiempos de respuesta para cantidades de usuarios variables para mensaje de 512 bytes
Figura 16 Pruebas con un tamaño de mensaje de 1.024 bytes
Usuarios | Repeticiones | Tiempo de respuesta (en milisegundos) |
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 |
Figura 17 Carga de usuario frente a tiempo de respuesta para un mensaje de 1 KB
Los números no muestran una tendencia determinada, salvo que los tiempos de respuesta son razonables, ya que permanecen por debajo de un segundo para tamaños de mensaje de hasta 18 KB. No me molesté en hacer un seguimiento del uso del ancho de banda, porque se ve afectado por el formato del mensaje. Además, como todas las pruebas se realizaron en un único equipo, se eliminó la latencia de red como factor. Podría haberlo probado contra mi red doméstica, pero no me pareció que valiera la pena, porque la conexión por Internet pública es mucho más compleja que la configuración de mi enrutador inalámbrico y módem por cable. Sin embargo, como el punto clave de las técnicas de comunicación Comet es reducir los recorridos de ida y vuelta del servidor al pasar contenido desde este según se actualiza, en teoría la mitad del uso del ancho de banda de red debe reducirse a través de las técnicas Comet.
Conclusión
Espero que ahora pueda implementar satisfactoriamente sus propias aplicaciones estilo Comet y las use eficazmente para reducir el ancho de banda de red y aumentar el rendimiento de la aplicación del sitio web. Desde luego, le conviene revisar las nuevas tecnologías incluidas con HTML5, las cuales pueden reemplazar a Comet, como WebSockets (bit.ly/UVMcBg) y los eventos enviados por servidor (SSE) (bit.ly/UVMhoD). Estas tecnologías contienen la promesa de proporcionar una manera más sencilla de pasar contenido al explorador, pero sí requieren que el usuario cuente con un explorador compatible con HTML5. Si todavía tiene que admitir usuarios en exploradores más antiguos, la comunicación estilo Comet sigue siendo la mejor alternativa.
Derrick Lau es un experimentado líder de equipo de desarrollo de software con aproximadamente 15 años de vasta experiencia. Ha trabajo en tiendas de TI de firmas financieras y el gobierno, así como en secciones de desarrollo de software de empresas centradas en tecnología. Obtuvo el premio mayor en un concurso de desarrollo de EMC en 2010 y fue finalista en 2011. También cuenta con certificación como MCSD y como desarrollador de administración de contenido.
Gracias a los siguientes expertos técnicos por su ayuda en la revisión de este artículo: Francis Cheung