garotosopa

Comet – Server Push com XHR Multipart

Publicado em Javascript, PHP por garotosopa em Setembro 21, 2008

Em 1995 a Netscape[1] teve a idéia de utilizar respostas HTTP multipart como implementação de server push, onde o servidor envia múltiplas respostas a uma mesma requisição. Treze anos depois eu me dei conta que esse streaming de dados realmente torna aplicações web muito mais dinâmicas, caracterizando o que alguns chamam de Comet[2].

Outras opções para aplicações dinâmicas incluem deixar um iframe carregando tags Javascript eternamente; abrir uma conexão XMLHttpRequest a cada intervalo de tempo; ou implementar Long Polling, onde o servidor segura a conexão enquanto não houver conteúdo novo e, quando a resposta for enviada, um novo XMLHttpRequest é criado pelo navegador.

O grande conceito de server push é o servidor poder se comunicar com o cliente em tempo real sem que o navegador tenha que ficar pedindo atualizações. Nenhuma das soluções acima consegue atingir o estado da arte neste ponto.

Multipart

O conceito de multipart é o mesmo que utilizamos em e-mails MIME[3], como quando enviamos e-mails com anexo. Um cabeçalho deve ser enviado definindo que o tipo da resposta é com várias partes separadas por um delimitador (boundary):

Content-type: multipart/mixed; boundary="delimitador"

E então cada uma das partes fica entre este delimitador, acompanhada do seu próprio content-type:

--delimitador
Content-type: text/plain
 
Primeira mensagem
--delimitador
Content-type: text/plain
 
Segunda mensagem
--delimitador
Content-type: text/plain
 
Mensagem final
--delimitador--

O delimitador é iniciado por dois hífens e o último deles também termina com dois hífens, indicando o final do conteúdo. Sem este último delimitador, o cliente vai ficar esperando por outras partes até que os dois hífens depois do delimitador indiquem o final do conteúdo.

 

Javascript

A diferença no Javascript para receber respostas multipart é apenas um parâmetro extra no objeto XMLHttpRequest:

var xhr = new XMLHttpRequest();
xhr.multipart = true;
xhr.open('GET', 'script-multipart.php', true);
xhr.onreadystatechange = function(){
    if( this.readyState == 4 && this.status == 200 ){
        document.body.innerHTML = this.responseText;
    }
};
window.onload = function(){ xhr.send(null); }

Quando configurado para respostas multipart, a requisição é dada como concluída pra cada uma das partes recebidas. A propriedade readyState recebe o valor 4 (Loaded) e o evento onReadyStateChange é disparado. A parte carregada vai estar disponível em responseText, da mesma forma que em requisições normais.

Se o XMLHttpRequest estiver configurado para multipart, é importante que o servidor responda como multipart e vice-versa.

 

Servidor

No servidor é necessário enviar o cabeçalho HTTP para definir o tipo de resposta como multipart. Mas diferente das mensagens MIME, o tipo de conteúdo multipart no HTTP é multipart/x-mixed-replace. A partir daí basta respeitarmos a saída com os delimitadores.

<?php
set_time_limit(0);
    
header('Content-type: multipart/x-mixed-replace;boundary="delimitador"');
echo "--delimitador\n";
    
while( true ){
    echo "Content-type: text/plain\n\n";
    
    echo date("d/m/Y H:i:s"), "\n";
    
    echo "--delimitador\n";
    flush();
    
    sleep(1);
}

Ao acessar o script acima pelo navegador já é possível ver as partes sendo trocadas na tela (daí o tipo replace).

O time limit foi desabilitado para que o script fique em execução enquanto o cliente estiver conectado. Sem ele, pela configuração normal do PHP, o script daria timeout em 30 segundos.

O content-type principal (multipart/x-mixed-replace) foi enviado com a função header por fazer parte dos response headers da requisição HTTP. Já os content-type’s de cada mensagem são enviados como saída normal, pois fazem parte do conteúdo como um todo, e não dos cabeçalhos HTTP. Para diferenciar o cabeçalho do conteúdo em si, ele deve ser terminado com uma linha em branco (\n\n). Caso não seja necessário o envio de nenhum cabeçalho, é importante ter uma linha vazia entre o delimitador e o conteúdo.

Assim que cada parte é montada, é preciso ter certeza que ela será enviada ao cliente ao invés de ficar no buffer do PHP. Dependendo do seu ambiente, pode ser necessário o uso da função ob_flush() em conjunto com flush()[4].

 

Problemas

Somente Gecko

Até o momento, a interpretação de multipart pelo XMLHttpRequest está disponível apenas em navegadores que utilizam a engine Gecko, como o Firefox, por exemplo. Para manter o código compatível com outros browsers é possível checar o suporte com if( xhr.multipart != null ) e implementar outra solução como contingência. Uma boa saída é utilizar multipart nos navegadores que o suportarem e long polling nos demais.

if( window.XMLHttpRequest ){
    var xhr = new XMLHttpRequest();
}else if( window.ActiveXObject ){
    var xhr = new ActiveXObject('Microsoft.XMLHTTP');
}
function iniciar(){
    xhr.abort();
    if( xhr.multipart != null ){
        xhr.multipart = true;
        xhr.open('GET', 'script-multipart.php?multipart', true);
    }else{
        xhr.open('GET', 'script-multipart.php', true);
    }
    xhr.onreadystatechange = ler;
    xhr.send(null);
}
function ler(){
    if( xhr.readyState == 4 ){
        if( xhr.status == 200 ){
            document.body.innerHTML = xhr.responseText;
        }
        if( ! xhr.multipart ){
            iniciar();
        }
    }
}
window.onload = iniciar;

<?php
set_time_limit(0);
if( isset($_GET['multipart']) ){
    header('Content-type: multipart/x-mixed-replace;boundary="delimitador"');
    echo "--delimitador\n";
}
while( true ){
    if( isset($_GET['multipart']) ){
        echo "Content-type: text/plain\n\n";
    }
 
    echo date("d/m/Y H:i:s"), "\n";
 
    if( isset($_GET['multipart']) ){
        echo "--delimitador\n";
        flush();
        sleep(1);
    }else{
        sleep(1);
        exit();
    }
}

Controle de falha

Nestes exemplos, o script nunca terminará a requisição e o navegador vai ficar esperando novas partes enquanto a página estiver aberta. Essa é realmente a idéia. Contudo, é possível que a execução do script ou a conexão com o servidor seja interrompida inesperadamente.

Como o navegador não recebeu a indicação do fim da resposta multipart (delimitador seguido de dois hífens), o XMLHttpRequest vai ficar esperando novas partes sem que haja qualquer aviso de erro ou timeout. Uma solução razoável é implementar o controle de timeout manualmente:

var xhr = new XMLHttpRequest();
var t;
function iniciar(){
    xhr.abort();
    xhr.multipart = true;
    xhr.open('GET', 'script-multipart.php', true);
    xhr.onreadystatechange = ler;
    xhr.send(null);
    contarTimeout();
}
function ler(){
    if( xhr.readyState == 4 && xhr.status == 200 ){
        contarTimeout();
        document.body.innerHTML = xhr.responseText;
    }
}
function contarTimeout(){
    clearTimeout(t);
    t = setTimeout(iniciar, 5000);
}
window.onload = iniciar();

FastCGI buffer

Um problema que não consegui resolver foi o buffer do FastCGI. Por mais que o script faça flush e ob_flush, parece que o FastCGI mantém um buffer intocável. Neste caso essa solução não funcionaria.

No meu host, eu tenho a alternativa de executar scripts diretamente como CGI. Ao invés de ter um arquivo com extensão .php, criei o script com extensão .cgi e coloquei #!/usr/bin/php na primeira linha. Esta solução funcionou para testes, mas em produção é melhor procurar outra saída, como utilizar mod_php ou até mesmo uma forma de desabilitar o buffer do FastCGI.

 

Chat em PHP

Eu já tinha publicado um Chat em PHP utilizando Long Polling[5] e acabei reescrevendo parte dele para utilizar XMLHttpRequest com requisição multipart.

Assim como no último o exemplo, o Javascript faz o controle manual de timeout. Para evitar reconexões quando ninguém mandar mensagem por muito tempo, o servidor envia um ping de tempos em tempos para manter a conexão ativa. Em navegadores que não utilizam a engine Gecko, a solução de Long Polling é utilizada.

Código fonte:
http://code.google.com/p/chatildis/source/browse/trunk/chat-multipart.php?r=10

Script para download:
http://chatildis.googlecode.com/files/chat-multipart.php

Da mesma forma que o chat anterior, esta implementação ainda utiliza o MySQL com tabela na memória para repassar as mensagens para todos os clientes. Este método está longe do ideal. Talvez em uma próxima ocasião eu tente alguma coisa com threads e Observer Pattern.

 

Referências

  1. An exploration of dynamic documents
  2. Comet (programming)
  3. Multipurpose Internet Mail Extensions (MIME), Multipart messages
  4. PHP: flush – manual
  5. Chat PHP com XHR Long Polling
Etiquetado como:, , ,

2 Respostas

Subscreva aos comentários comRSS.

  1. Hamilcar said, on Março 30, 2009 at 12:06 pm

    Será que dá pra arrumar os links de demo e códigos.

  2. Fernando said, on Agosto 28, 2009 at 11:21 am

    Muito bom seu conteúdo sobre Comet, tanto este como o post com Long Polling. Para quem está querendo se aprofundar ainda mais no assunto, existe um livro da Apress, entitulado: “Comet and Reverse Ajax: The Next-Generation Ajax 2.0″.
    Ele é bem curto, mas contém tópicos interessantes. O problema é que não abordam o PHP, mas acredito que ao lê-lo seria possível adaptar ao PHP também. Para quem está sem idéias além do chat para usar comet, imagine um sistema de vendas/controle de estoque, onde a tela de vendas do vendedor sempre mantém os dados mais atualizados de seu estoque para permitir ou não a venda dos produtos, mantendo atualizados os dados do banco, sem dar refreshs na página. Algo bem interessante, não? Parabéns novamente, até mais.


Deixe uma resposta