Partilhar via


ASP.NET

Criação de um aplicativo Comet simples no Microsoft .NET Framework

Derrick Lau

Baixar o código de exemplo

 

Comet é uma técnica para enviar por push o conteúdo de um servidor Web para um navegador sem uma solicitação explícita, usando conexões AJAX de vida longa. Permite uma experiência do usuário mais interativa e usa menos largura de banda do que a ida e volta típica ao servidor disparada por um postback de página para recuperar mais dados. Embora existam várias implementações do Comet disponíveis, a maioria é baseada em Java. Neste artigo, me concentrarei em criar um serviço C# baseado no exemplo de código do cometbox disponível em code.google.com/p/cometbox.

Há métodos mais novos para implementar o mesmo comportamento usando recursos do HTML5, como WebSockets e eventos no servidor, mas estão disponíveis apenas nas versões mais recentes do navegador. Se precisar oferecer suporte a navegadores mais antigos, o Comet é a solução mais compatível. No entanto, o navegador deve oferecer suporte a AJAX, implementando o objeto xmlHttpRequest; caso contrário, não poderá oferecer suporte à comunicação estilo Comet.

A arquitetura de alto nível

A Figura 1 mostra a comunicação estilo Comet básica, ao passo que a Figura 2 representa a arquitetura do meu exemplo. O Comet usa o objeto xmlHttpRequest do navegador, que é essencial para a comunicação AJAX, para estabelecer uma conexão HTTP de vida longa com um servidor. O servidor mantém a conexão aberta e envia por push o conteúdo para o navegador, quando disponível.


Figura 1 Comunicação estilo Comet


Figura 2 Arquitetura do aplicativo Comet

Entre o navegador e o servidor há uma página proxy, que reside no mesmo caminho do aplicativo Web que a página da Web que contém o código de cliente e não faz nada além de encaminhar as mensagens do navegador para o servidor e vice-versa. Por que você precisa de uma página proxy? Explicarei isso em breve.

A primeira etapa é selecionar um formato para as mensagens trocadas entre o navegador e o servidor — JSON, XML ou um formato personalizado. Para simplificar, escolhi JSON porque tem suporte nativo em JavaScript, jQuery e Microsoft .NET Framework, e consegue transmitir a mesma quantidade de dados que XML usando menos bytes e, portanto, menos largura de banda.

Para configurar a comunicação estilo Comet, você deve abrir uma conexão AJAX com o servidor. A maneira mais fácil de fazer isso é usar jQuery, pois oferece suporte a vários navegadores e fornece algumas boas funções de wrapper, como $.ajax. Essa função é essencialmente um wrapper para cada objeto xmlHttpRequest do navegador e fornece de forma organizada manipuladores de eventos que podem ser implementados para processar mensagens de entrada do servidor.

Antes de iniciar a conexão, você deve criar uma instância da mensagem a ser enviada. Para fazer isso, declare uma variável e use JSON.stringify para formatar os dados como uma mensagem JSON, conforme mostrado na Figura 3.

Figura 3 Formatar os dados como uma mensagem 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   });

Em seguida, inicialize a função com a URL para conexão, o método HTTP de comunicação para uso, o estilo de comunicação e o parâmetro de limite da conexão. JQuery fornece esse recurso em uma chamada para a biblioteca nomeada ajaxSetup. Defini o tempo limite nesse exemplo para 10 minutos, pois estou criando apenas uma solução de verificação de conceito. Você pode alterar a configuração de tempo limite como desejar.

Agora, abra uma conexão com o servidor usando o método jQuery $.ajax, com a definição do manipulador de evento com êxito como o único parâmetro:

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

O manipulador testa o objeto de mensagem retornado para garantir que contém informações válidas antes da análise. Isso é necessário porque se um código de erro for retornado, jQuery irá falhar e exibir uma mensagem indefinida para o usuário. No caso de uma mensagem nula, o manipulador deve chamar recursivamente a função AJAX e retornar. Descobri que a adição do retorno interrompe a continuação do código. Se a mensagem estiver correta, basta lê-la e escrever o conteúdo da página:

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

Isso cria um cliente simples que ilustra como a comunicação estilo Comet funciona, bem como fornece um meio de executar testes de desempenho e escalabilidade. Para o meu exemplo, coloco o código getResponse de JavaScript em um controle de usuário da Web e o registro no code-behind, para que a conexão AJAX abra imediatamente quando o controle for carregado na 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);   } }

O servidor

Agora que tenho um cliente que pode enviar e receber mensagens, crio um serviço que possa receber e responder a essas mensagens.

Tentei implementar várias técnicas diferentes para a comunicação estilo Comet, incluindo o uso de páginas ASP.NET e manipuladores HTTP, mas nenhuma delas teve êxito. Não conseguia fazer com que uma única mensagem fosse difundida para vários clientes. Felizmente, depois de muitas pesquisas, me deparei com o projeto do cometbox e achei que essa era a abordagem mais fácil. Fiz alguns ajustes para que ele fosse executado como um serviço do Windows, para facilitar o uso, e possibilitei que ele mantivesse uma conexão de vida longa e enviasse conteúdo por push ao navegador. (Infelizmente, ao fazer isso, prejudiquei uma parte da compatibilidade entre plataformas.) Por fim, adicionei suporte para JSON e meus próprios tipos de mensagem com conteúdo HTTP.

Para começar, crie um projeto de serviço do Windows em sua solução do Visual Studio e adicione um componente de instalador do serviço (você encontrará as instruções em bit.ly/TrHQ8O) para que você possa ativar e desativar seu serviço no miniaplicativo Serviços de Ferramentas Administrativas no Painel de Controle. Depois de fazer isso, você precisa criar dois threads: um que será associado à porta TCP e irá receber e transmitir as mensagens; e outro que bloqueará uma fila de mensagens para garantir que o conteúdo será transmitido apenas quando uma mensagem for recebida.

Primeiro, você deve criar uma classe que escuta novas mensagens na porta TCP e transmite as respostas. Agora, há vários estilos de comunicação do Comet que podem ser implementados, e na implementação há uma classe Server (consulte o arquivo de código Comet_Win_Service HTTP\Server.cs no código de exemplo) para abstraí-los. Para simplificar, no entanto, me concentrarei no que é necessário para fazer uma recepção bem básica de uma mensagem JSON por HTTP e para manter a conexão até que haja conteúdo para ser enviado de volta por push.

Na classe Server, criarei alguns membros protegidos para armazenar objetos que precisarei acessar no objeto Server. Isso inclui o thread que será associado e escutará conexões HTTP na porta TCP, alguns semáforos e uma lista de objetos de cliente, cada um dos quais representará uma única conexão com o servidor. _isListenerShutDown é importante e será apresentada como uma propriedade pública, para que possa ser modificada no evento Stop do serviço.

Em seguida, no construtor, irei criar uma instância do objeto TCP Listener em relação à porta, defini-la para uso exclusivo da porta e iniciá-la. Em seguida, iniciarei um thread para receber e lidar com os clientes que se conectam ao TCP Listener.

O thread que escuta as conexões de cliente contém um loop while que redefine continuamente um sinalizador indicando se o evento Stop do serviço foi gerado (consulte a Figura 4). Defini a primeira parte desse loop como mutex para bloquear todos os threads de escuta e verificar se o evento Stop do serviço foi gerado. Em caso positivo, a propriedade _isListenerShutDown será verdadeira. Quando a verificação for concluída, mutex será liberado e, se o serviço ainda estiver em execução, chamo TcpListener.Accept­TcpClient, que retornará um objeto TcpClient. Como opção, verifico TcpClients existentes para garantir que não adicionarei um cliente existente. No entanto, dependendo do número de clientes que você espera, convém substituí-lo por um sistema em que o serviço gera uma ID exclusiva e a envia ao cliente de navegador, que se lembra e envia novamente a ID cada vez que se comunica com o servidor, para garantir que apenas uma única conexão será mantida. Entretanto, isso pode se tornar problemático se o serviço falhar; ele redefine o contador de ID e pode oferecer IDs já usadas a novos clientes.

Figura 4 Escutando conexões 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 fim, o thread passa pela lista de clientes e remove todos os que não estiverem mais ativos. Para simplificar, coloquei esse código no método que é chamado quando o TCP Listener aceita uma conexão de cliente, mas isso pode afetar o desempenho quando o número de clientes chegar a centenas de milhares. Se você pretender usar essa opção em aplicativos Web públicos, sugiro que adicione um temporizador que é acionado em intervalos frequentes e fazer a limpeza.

Quando um objeto TcpClient é retornado no método Loop da classe Server, é usado para criar um objeto de cliente que representa o cliente de navegador. Como cada objeto de cliente é criado em um thread exclusivo, assim como o construtor do servidor, o construtor da classe de cliente deve aguardar um mutex para garantir que o cliente não foi fechado antes de continuar. Mais tarde, verifico o fluxo TCP e começo a lê-lo, além de iniciar um manipulador de retorno de chamada para ser executado assim que a leitura tiver sido concluída. No manipulador de retorno de chamada, simplesmente leio os bytes e os analiso usando o método ParseInput, que pode ser visto no código de exemplo fornecido neste artigo.

No método ParseInput da classe Client, crio um objeto Request com membros que correspondem a diferentes partes da mensagem HTTP típica e preencho esses membros da maneira apropriada. Primeiro, analiso as informações de cabeçalho, pesquisando os caracteres de token, como “\r\n”, determinando essas informações com base no formato do cabeçalho HTTP. Em seguida, chamo o método ParseRequestContent para obter o corpo da mensagem HTTP. A primeira etapa de ParseInput é determinar o método da comunicação HTTP usado e a URL para a qual a solicitação foi enviada. Em seguida, os cabeçalhos da mensagem HTTP são extraídos e armazenados na propriedade Headers do objeto Request, que é um dicionário de tipos e valores de cabeçalho. Mais uma vez, observe o código de exemplo baixável para ver como isso é feito. Por fim, carrego o conteúdo da solicitação na propriedade Body do objeto Request, que é apenas uma variável String que contém todos os bytes do conteúdo. Neste momento, o conteúdo ainda precisa ser analisado. No final, se houver problemas com a solicitação HTTP recebida do cliente, envio uma mensagem de resposta de erro apropriada.

Separei o método para análise do conteúdo da solicitação HTTP para que fosse possível adicionar suporte para diferentes tipos de mensagem, como texto sem formatação, XML, JSON e assim por diante.

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

Primeiro, o conteúdo é gravado em um MemoryStream para que, se necessário, possa ser desserializado em tipos de objeto, dependendo do Content-Type da solicitação, pois determinados desserializadores funcionam apenas com fluxos:

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 {

Conforme mostrado na Figura 5, mantive a ação padrão de manipulação de mensagens formatadas em XML, pois XML ainda é um formato popular.

Figure 5 O manipulador de mensagens XML padrão

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 }

Para aplicativos Web, no entanto, é altamente recomendável formatar as mensagens em JSON, pois ao contrário de XML, não há a sobrecarga de iniciar e cancelar marcas, pois tem suporte nativo em JavaScript. Simplesmente uso o cabeçalho Content-Type da solicitação HTTP para indicar se a mensagem foi enviada em JSON e desserializo o conteúdo usando a classe JavaScriptSerializer do namespace System.Web.Script.Serialization. Essa classe facilita a desserialização da mensagem JSON em um objeto C#, conforme mostrado na Figura 6.

Figura 6 Desserializando uma mensagem 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 fim, para testar, adicionei um Content-Type ping que simplesmente gera uma resposta de texto HTTP contendo apenas a palavra PING. Dessa maneira, posso testar facilmente para ver se meu servidor do Comet está em execução, enviando uma mensagem JSON com Content-Type “ping”, conforme mostrado na Figura 7.

Figura 7 Content-Type “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);   } }

Essencialmente, ParseRequestContent é apenas um método de análise de cadeia de caracteres — nada mais e nada menos que isso. Como você pode ver, a análise de dados XML é um pouco mais envolvida porque o conteúdo deve ser gravado em um Memory­Stream primeiro e depois desserializado, usando a classe XmlSerializer, em uma classe criada para representar a mensagem do cliente.

Para organizar melhor o código-fonte, criei uma classe Request, mostrada na Figura 8, que simplesmente contém os membros para armazenar os cabeçalhos e outras informações enviadas na solicitação HTTP de uma maneira facilmente acessível no serviço. Caso queira, você pode adicionar métodos auxiliares para determinar se a solicitação tem algum conteúdo ou não, bem como verificações da autenticação. No entanto, não fiz isso aqui para manter esse serviço simples e fácil de implementar.

Figura 8 A 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;   }

A classe Response, assim como a classe Request, contém métodos para armazenar as informações de resposta HTTP de uma maneira facilmente acessível por um serviço C# do Windows. No método SendResponse, adicionei lógica para anexar cabeçalhos HTTP personalizados, conforme necessário para compartilhamento de recursos entre origens (CORS), e carreguei esses cabeçalhos de um arquivo de configuração para que possam ser facilmente modificados. A classe Response também contém métodos para gerar mensagens para alguns status HTTP comuns, como 200, 401, 404, 405 e 500.

O membro SendResponse da classe Response simplesmente grava a mensagem no fluxo de resposta HTTP que ainda deve estar ativo, pois o tempo limite definido pelo cliente é muito longo (10 minutos):

public void SendResponse(NetworkStream stream, Client client) {

Conforme mostrado na Figura 9, os cabeçalhos apropriados são adicionados à resposta HTTP para atender à especificação do W3C para CORS. Para simplificar, os cabeçalhos são lidos no arquivo de configuração, para que o conteúdo do cabeçalho seja facilmente modificado.

Agora, adiciono os cabeçalhos e o conteúdo da resposta HTTP regular, conforme mostrado na Figura 10.

Figura 9 Adicionando cabeçalhos 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 Adicionando os cabeçalhos da resposta HTTP regular

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

Aqui, a mensagem de resposta HTTP inteira, que foi criada como uma cadeia de caracteres, é gravada no fluxo de resposta HTTP, que foi passado como um parâmetro para o método SendResponse:

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

Transmitindo mensagens

O thread para transmitir as mensagens essencialmente não é nada além de um loop While que bloqueia uma fila de mensagens da Microsoft. Possui um evento SendMessage que é gerado quando o thread seleciona uma mensagem da fila. O evento é manipulado por um método no objeto de servidor que basicamente chama o método SendResponse de cada cliente, difundindo a mensagem para cada navegador conectado a ele.

O thread aguarda a fila de mensagens apropriada até que haja uma mensagem posicionada nela, indicando que o servidor possui algum conteúdo que deseja difundir aos 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();

Quando a mensagem é recebida, é convertida no tipo de objeto esperado:

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

Depois de determinar o que será enviado aos clientes, gero um evento do Windows no servidor indicando que há uma mensagem a ser difundida:

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

Em seguida, preciso de um método que criará o corpo real da resposta HTTP — o conteúdo da mensagem que o servidor difundirá a todos os clientes. A mensagem anterior usa o conteúdo da mensagem despejado na fila de mensagens da Microsoft e o formata como um objeto JSON para transmissão aos clientes via uma mensagem de resposta HTTP, conforme mostrado na Figura 11.

Figura 11 Criando o corpo da resposta 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;

Em seguida, preciso criar uma instância do objeto JavaScriptSerializer para colocar o conteúdo da mensagem no formato JSON. Adicionei o seguinte tratamento de erro try/catch porque, às vezes, há dificuldades para criar uma instância do objeto JavaScriptSerializer:

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

Em seguida, crio uma variável de cadeia de caracteres para manter a mensagem no formato JSON e uma instância da classe Response para enviar a mensagem JSON.

Imediatamente, faço uma verificação básica de erros para garantir que estou trabalhando com uma solicitação HTTP válida. Como esse serviço do Comet gera um thread para cada cliente TCP, bem como para os objetos do servidor, achei mais seguro incluir essas verificações de segurança em intervalos frequentes, para facilitar a depuração.

Depois de verificar que é uma solicitação válida, crio uma mensagem JSON para enviar à equipe de resposta HTTP. Obseve que apenas criei a mensagem JSON, a serializei e utilizei para criar uma mensagem de resposta 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 reunir tudo, primeiro crio as instâncias do objeto de loop da mensagem e o objeto de loop do servidor durante o evento Start do serviço. Observe que esses objetos devem ser membros protegidos da classe de serviço, para que os métodos neles possam ser chamados durante outros eventos do serviço. Agora, o loop da mensagem que envia o evento da mensagem deve ser manipulado pelo método BroadcastMessage do objeto de 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 envia a mesma mensagem a todos os clientes. Se desejar, você pode modificá-lo para enviar a mensagem apenas para os clientes desejados; dessa maneira, você pode usar esse serviço para manipular, por exemplo, várias salas de chat online.

O método OnStop é chamado quando o serviço é interrompido. Posteriormente, ele chama o método Shutdown do objeto de servidor, que passa pela lista de objetos de cliente que ainda são válidos e os desliga.

Neste ponto, tenho um serviço do Comet que funciona razoavelmente bem, que posso instalar no miniaplicativo de serviços no prompt de comando usando o comando installutil (para obter mais informações, consulte bit.ly/OtQCB7). Você também pode criar seu próprio instalador do Windows para implantá-lo, pois já adicionou os componentes do instalador do serviço ao projeto de serviço.

Por que isso não funciona? O problema com o CORS

Agora, tente configurar a URL na chamada $.ajax do cliente de navegador para apontar para a URL do serviço do Comet. Inicie o serviço do Comet e abra o cliente de navegador no Firefox. Verifique se tem a extensão Firebug instalada no navegador Firefox. Inicie o Firebug e atualize a página. Você perceberá que recebeu um erro na área de saída do console, informando “Acesso negado”. Isso ocorre devido ao CORS, onde, por motivos de segurança, o JavaScript não consegue acessar recursos fora do mesmo aplicativo Web e diretório virtual nos quais a página de hospedagem reside. Por exemplo, se a página de seu cliente de navegador estiver em http://www.algumdominio.com/algumdir1/algumdir2/cliente.aspx, qualquer chamada AJAX feita nessa página poderá ir apenas para recursos no mesmo diretório ou subdiretório virtual. Isso é ótimo se você estiver chamando outra página ou manipulador HTTP no aplicativo Web, mas não desejar que páginas ou manipuladores bloqueiem uma fila de mensagens ao transmitir a mesma mensagem a todos os clientes. Para isso, você precisa usar o serviço do Comet do Windows e encontrar uma maneira de contornar a restrição do CORS.

Para fazer isso, recomendo criar uma página proxy no mesmo diretório virtual, cuja única função é interceptar a mensagem HTTP do cliente de navegador, extrair todos os cabeçalhos e conteúdos relevantes, e criar outro objeto de solicitação HTTP que se conecta com o serviço do Comet. Como essa conexão é feita no servidor, não é afetada pelo CORS. Portanto, por meio de um proxy, você pode manter uma conexão de vida longa entre o cliente de navegador e o serviço do Comet. Além disso, você pode transmitir simultaneamente uma única mensagem que chegar a uma fila de mensagens a todos os clientes de navegador conectados.

Primeiro, pego a solicitação HTTP e a transmito a uma matriz de bytes, para que possa passá-la a um novo objeto de solicitação HTTP para o qual criarei uma instância em breve:

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

Em seguida, crio um objeto HttpWebRequest e o aponto para o servidor do Comet, cuja URL coloquei no arquivo web.config, para que possa ser facilmente modificada mais tarde:

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

Isso cria uma conexão com o servidor do Comet para cada usuário, mas como a mesma mensagem está sendo difundida para cada usuário, você pode apenas encapsular o objeto cometRequest em um singleton de bloqueio duplo para reduzir a carga de conexão no servidor do Comet e permitir que o IIS faça o balanceamento de carga da conexão para você.

Em seguida, preencho os cabeçalhos do HttpWebRequest com os mesmos valores que recebi do cliente jQuery, configurando especialmente a propriedade KeepAlive como verdadeira para manter uma conexão HTTP de vida longa, que é a técnica fundamental por trás de uma comunicação estilo Comet.

Aqui, verifico um cabeçalho Origin, que é exigido pela especificação do W3C ao lidar com problemas relacionados com o CORS:

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

Em seguida, passo o cabeçalho Origin para o HttpWebRequest, para que o servidor do Comet o receba:

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

Em seguida, pego os bytes do conteúdo da solicitação HTTP do cliente jQuery e os gravo no fluxo de solicitação do HttpWebRequest, que será enviado ao servidor do Comet, conforme mostrado na Figura 12.

Figura 12 Gravando no fluxo do 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));

Após encaminhar a mensagem para o servidor do Comet, chamo o método GetResponse do objeto HttpWebRequest, que fornece um objeto HttpWebResponse que me permite processar a resposta do servidor. Também adiciono os cabeçalhos HTTP necessários que enviarei com a mensagem de volta para o 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.");

Em seguida, aguardo a resposta do servidor:

Stream s = res.GetResponseStream();

Quando recebo a mensagem do servidor do Comet, gravo essa mensagem no fluxo de resposta da solicitação HTTP original para que o cliente possa recebê-la, conforme mostrado na Figura 13.

Figura 13 Gravando a mensagem do servidor no fluxo de resposta 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(); }

 

Testar o aplicativo

Para testar o aplicativo, crie um site para armazenar as páginas do exemplo de aplicativo. Verifique se a URL para seu serviço do Windows está correta e se a fila de mensagens está configurada adequadamente e pode ser utilizada. Inicie o serviço e abra a página do cliente do Comet em um navegador e a página para enviar mensagens em outro. Digite uma mensagem e pressione o botão para enviá-la. Depois de aproximadamente 10 minutos, você deve ver a mensagem aparecer na janela do outro navegador. Faça esse teste com vários navegadores — principalmente em alguns dos antigos. Contanto que eles ofereçam suporte ao objeto xmlHttpRequest, deve funcionar. Isso apresenta quase o comportamento da Web em tempo real (en.wikipedia.org/wiki/Real-time_web), em que o conteúdo é enviado por push para o navegador quase que instantaneamente sem exigir ações do usuário.

Antes que qualquer novo aplicativo seja implantado, é preciso realizar o teste de carga e desempenho. Para fazer isso, primeiro você deve identificar as métricas que deseja coletar. Sugiro medir a carga de uso em relação aos tempos de resposta e ao tamanho da transferência de dados. Além disso, você deve testar os cenários de uso que são relevantes para o Comet, particularmente a difusão de uma única mensagem para vários clientes com postback.

Para realizar o teste, construí um utilitário que abre vários threads, cada um com uma conexão com o servidor do Comet, e aguarda até que o servidor dispare uma resposta. Esse utilitário de teste me permite definir alguns parâmetros, como o número total de usuários que se conectarão ao meu servidor do Comet e o número de vezes que eles reabrem a conexão (atualmente, a conexão é fechada depois que a resposta do servidor é enviada).

Em seguida, criei um utilitário que despeja uma mensagem de x número de bytes na fila de mensagens, com o número de bytes definido por um campo de texto na tela principal, e um campo de texto para definir o número de milissegundos a aguardar entre as mensagens enviadas do servidor. Irei utilizá-lo para enviar a mensagem de teste de volta para o cliente. Em seguida, iniciei o cliente de teste, especifiquei o número de usuários mais o número de vezes que o cliente reabrirá a conexão com o Comet, e os threads abriram as conexões com meu servidor. Esperei alguns segundos até que todas as conexões fossem abertas e fui até o utilitário de envio de mensagens e enviei um determinado número de bytes. Repeti esse procedimento para várias combinações de total de usuários, total de repetições e tamanhos de mensagem.

A primeira amostragem de dados que fiz foi para um único usuário com repetições crescentes, mas com a mensagem de resposta com um tamanho (pequeno) consistente durante o teste. Como você pode ver na Figura 14, o número de repetições não parece afetar o desempenho ou a confiabilidade do sistema.

Figura 14 Variando o número de usuários

Usuários Repetições Tamanho da mensagem (em bytes) Tempo de reposta (em milissegundos)
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

Os tempos aumentam gradualmente de maneira linear/constante, o que significa que o código no servidor do Comet é robusto, em geral. A Figura 15 mostra o gráfico do número de usuários em relação ao tempo de resposta para uma mensagem de 512 bytes. A Figura 16 mostra algumas estatísticas para um tamanho de mensagem de 1.024 bytes. Por fim, a Figura 17 mostra o gráfico da Figura 16 em formato gráfico. Todos esses testes foram realizados em um único laptop com 8 GB de RAM e um processador Intel Core i3 de 2,4 GHz.


Figura 15 Tempos de resposta para números variados de usuários para uma mensagem de 512 bytes

Figura 16 Testando com um tamanho de mensagem de 1.024 bytes

Usuários Repetições Tempo de reposta (em milissegundos)
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 usuários vs. tempo de resposta para uma mensagem de 1 KB

Os números não mostram uma tendência particular, exceto que os tempos de resposta são razoáveis, permanecendo abaixo de um segundo para tamanhos de mensagem de até 1 KB. Não me preocupei em acompanhar o uso da largura de banda, pois isso é afetado pelo formato da mensagem. Além disso, como todos os testes foram realizados em um único computador, a latência da rede foi eliminada como um fator. Poderia ter testado em minha rede doméstica, mas não achei que valeria a pena porque a Internet pública é muito mais complexa do que a configuração de meu roteador sem fio e modem a cabo. No entanto, como o ponto principal das técnicas de comunicação do Comet é reduzir as idas e voltas ao servidor ao enviar conteúdo por push do servidor à medida que ele é atualizado, teoricamente metade do uso da largura de banda da rede deveria ser reduzida por meio das técnicas do Comet.

Conclusão

Agora, espero que você consiga implementar com êxito seus próprios aplicativos estilo Comet e usá-los com eficiência para reduzir a largura de banda da rede e aumentar o desempenho do aplicativo de site. É claro que você deveria conhecer as novas tecnologias incluídas com o HTML5, que podem substituir o Comet, como WebSockets (bit.ly/UVMcBg) e Server-Sent Events (SSE) (bit.ly/UVMhoD). Essas tecnologias prometem fornecer uma maneira mais simples de publicar conteúdo no navegador, mas elas exigem que o usuário tenha um navegador com suporte para HTML5. Se você ainda precisar oferecer suporte a usuários em navegadores mais antigos, a comunicação estilo Comet continua sendo a melhor opção.

Derrick Lau é um líder de equipe de desenvolvimento de software experiente com aproximadamente 15 anos de experiência relevante. Trabalhou nos departamentos de TI de empresas financeiras e do governo, bem como em divisões de desenvolvimento de software de empresas com foco em tecnologia. Ganhou o prestigiado prêmio em um concurso de desenvolvimento de EMC em 2010 e foi um dos finalistas em 2011. Também é um MCSD e um desenvolvedor certificado de gerenciamento de conteúdo de EMC.

Agradecemos ao seguinte especialista técnico pela revisão deste artigo: Francis Cheung