Comet – Server Push com XHR Multipart
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.




Será que dá pra arrumar os links de demo e códigos.
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.