in ASP.NET

Usando Long Polling com AsyncControllers

Long Polling é uma técnica extremamente utilizada em cenários de aplicações que exigem atualizações em tempo real com a menor latência possível, ela garante que assim que uma nova informação esteja disponível para o cliente ele seja enviada de volta, em uma conexão que já está aberta entre o cliente e o servidor.

Polling (Tradicional)

Posso sintetizar e exemplificar o Polling (tradicional) como uma requisição de atualização feita ao servidor a cada intervalo fixo de tempo, que recebe uma resposta imediata e fecha esta conexão, ex:

A aplicação envia um XMLHttpRequest para o servidor a cada 2 segundos, recebe uma resposta imediata, e fecha a conexão.

Long Polling

Já a abordagem do Long Polling funciona com a aplicação enviando uma solicitação para o servidor, ao chegar no servidor essa solicitação não é devolvida até que uma nova resposta (atualizada) esteja disponível, ou então, até que a requisição tenha esgotado seu tempo limite, após uma dessas situações ocorrerem, uma nova conexão é iniciada, começando novamente o ciclo, ex:

A aplicação envia um XMLHttpRequest para o servidor, com um tempo de timeout de 50 segundos, ao receber essa solicitação o servidor fica aguardando uma nova resposta para retornar o pedido, se isso acontece dentro do tempo, a solicitação é respondida com os novos dados, se não, a solicitação é encerrada por timeout, quando o retorno acontece por qualquer um destes motivos, uma nova requisição com as mesmas características é aberta.

Na imagem abaixo, eu utilizei o Firebug para debugar as conexões que o Facebook fazia, como é possível ver,

ele sempre mantem uma conexão aberta com um longo timeout (55 segundos), assim que uma mensagem nova chega, é enviada ou esgota o tempo limite da requisição, ele abre outra requisição com as mesmas características.

Resultado

O resultado do uso do Long Polling é uma redução significativa na latência, pois o servidor geralmente tem uma conexão estabelecida com o cliente quando novas informações chegam, e ele já está pronto ( com uma conexão aberta) para retornar informações para o cliente.

Uso do AsyncControllers com Long Polling

O .NET Framework oferece excelentes recursos para implementarmos o Long Polling, um deles é o oferecido pelo ASP.NET MVC, o AsyncController. Com ele é possível criarmos requisições que podem ficar aguardando uma resposta nova, sem que isso represente um custo muito grande para o web server.

Começando

Como exemplo eu criarei um serviço de chat, utilizando jQuery para as requisições Ajax e manipulação da interface, e ASP.NET MVC 3 com controllers assíncronos para o serviço do chat, também  manterei as mensagens em memória, em um cenário real nos manteríamos essas conversas em algum servidor de cache, ou, em alguma base de dados.

Para isso vou criar um projeto do tipo ASP.NET MVC 3.

 Após isso vou selecionar um projeto com o template: Internet Application, que nos traz um exemplo de uma estrutura de projeto

Que nos trás a seguinte estrutura e projeto

 

Para começarmos, vou criar 4 classes, que vamos utilizar como base para esse projeto.

A primeira será a classe  Usuario

public class Usuario
{
    public long Id { get; set; }
    public string Nome { get; set; }
}

A segunda classe será a de Mensagem

public class Mensagem
{
    public long Id { get; set; }
    public DateTime Data { get; set; }
    public string Conteudo { get; set; }
    public Usuario Usuario { get; set; }
}

Para facilitar nossas respostas, vou criar a terceira classe:

public class ChatResposta
{
    public List<Mensagem> mensagens { get; set; }
}

Agora, nossa principal classe, será a ChatServer, ela é responsável pelo engine de funcionamento do chat, consistindo em uma classe com algumas propriedades de controle, e alguns métodos, que guardará as mensagens em memória, com os dados dos usuários e oferecerá a manipulação destes dados ( pegar historico, adicionar mensagens, e aguardar nova mensagem ) .

Para ela vou utilizar uma classe chamada Subject , que podemos encontrar na biblioteca do .NET Reactive. Ao utilizar eu precisei referenciar duas DLL’s, (System.CoreEx.dll e System.Reactive.dll)

public class ChatServer
{
    public const int MaxMensagemCount = 100;
    public const int MaxTimetoutSegundos = 60;

    private static object _msgLock = new object();
    private static Subject<Mensagem> _mensagens = new Subject<Mensagem>();

    private static object _historicoLock = new object();
    private static Queue<Mensagem> _historico = new Queue<Mensagem>(MaxMensagemCount + 5);

    static ChatServer(){...}

    public static void CheckForMensagensAsync(Action<List<Mensagem>> onMensagens){...}

    private static long currMsgId = 0;
    private static long currUserId = 0;

    public static void AddMensagem(string nome, string mensagem){...}

    public static List<Mensagem> GetHistorico(){...}
}

Agora vamos implementar os métodos responsáveis pelo funcionamento, o primeiro será o inicializador da classe, ele iniciará o padrão de enfileiramento e observação das mensagens.

static ChatServer()
{
    _mensagens
        .Subscribe(msg =>
                        {
                            lock (_historicoLock)
                            {
                                while (_historico.Count > MaxMensagemCount)
                                    _historico.Dequeue();

                                _historico.Enqueue(msg);
                            }
                        });
}

O próximo método é o que busca as novas mensagens de forma assíncrona.

public static void CheckForMensagensAsync(Action<List<Mensagem>> onMensagens)
{
    var queued = ThreadPool.QueueUserWorkItem(
        new WaitCallback(parm =>
                    {
                        var msgs = new List<Mensagem>();
                        var wait = new AutoResetEvent(false);
                        using (var subscriber = _mensagens.Subscribe(msg =>
                                                                        {
                                                                            msgs.Add(msg);
                                                                            wait.Set();
                                                                        }))
                        {
                            // espera maxima para uma nova mensagem
                            wait.WaitOne(TimeSpan.FromSeconds(MaxTimetoutSegundos));
                        }

                        ((Action<List<Mensagem>>)parm)(msgs);
                    }), onMensagens
    );

    if (!queued)
        onMensagens(new List<Mensagem>());
}

Agora o método que adiciona uma nova mensagem.

public static void AddMensagem(string nome, string mensagem)
{
    _mensagens
        .OnNext(new Mensagem
                    {
                        Id = currMsgId++;
                        Conteudo = mensagem,
                        Data = DateTime.Now,
                        Usuario = new Usuario
                                        {
                                            Id = currUserId++,
                                            Nome = nome
                                        }
                    });
}

E por fim um método que busca o historico das mensagens que estão em memória

public static List<Mensagem> GetHistorico()
{
    var msgs = new List<Mensagem>();
    lock (_historicoLock)
        msgs = _historico.ToList();

    return msgs;
}

Terminamos as estruturas responsáveis pelo funcionamento do chat, a partir de agora vamos montar o Controller que responderá pelo Chat, eu dividi essa parte em dois Controllers diferentes, um que será responsável por carregar a pagina do Chat, e outro que trabalha com as chamadas assíncronas, via Ajax.

Primeiro criei uma Model para a Home, que contem uma lista de Mensagens.

namespace DemoChat.Models
{
    public class Home
    {
        public List<Mensagem> Mensagens { get; set; }
    }
}

Agora no HomeController, eu carrego as mensagens existentes e retorno a View.

public class HomeController : Controller
{
    public ActionResult Index()
    {
        var viewHome = new Home()
        {
            Mensagens = ChatServer.GetHistorico()
        };

        return View(viewHome);
    }
}

Criei um segundo controller que será utilizado para trabalhar com as requisições assíncronas do chat. Como é possível ver, este controller herda de AsyncController, e tem dois métodos, um chamado Index e outro chamado New, um retorna as novas mensagens e o outro adiciona uma nova mensagem.

public class ChatController : AsyncController
{
    [AsyncTimeout(ChatServer.MaxTimetoutSegundos * 1000)]
    public void IndexAsync()
    {
        AsyncManager.OutstandingOperations.Increment();
        ChatServer.CheckForMensagensAsync(msgs =>
        {
            AsyncManager.Parameters["response"] = new ChatResposta
            {
                mensagens = msgs
            };
            AsyncManager.OutstandingOperations.Decrement();
        });
    }

    public ActionResult IndexCompleted(ChatResposta response)
    {
        return Json(response);
    }

    [HttpPost]
    public ActionResult New(string nome, string msg)
    {
        ChatServer.AddMensagem(nome, msg);
        return Json(new
        {
            d = 1
        });
    }
}

Nosso próximo passo é construir a interface para que nosso chat funcione, primeiro vou tipar a Index.cshtml e criar uma lista de Mensagens e um botão para enviar mensagens, notem que e coloquei um jQuery Templates no final dela, e chamei a Biblioteca do jQuery Templates, vou utilizar ele para formatar as novas mensagens que chegarem.

@model DemoChat.Models.Home
@{
    ViewBag.Title = "Home Chat";
}
<h2>
    Chat</h2>
<p>
    Nome:<input type="text" name="txtNome" id="txtNome" />
</p>
<p>
    Mensagem:<br />
    <input type="text" name="txtMensagem" id="txtMensagem" />
    <input id="btnEnviar" type="submit" value="enviar" /><br />
</p>
<ul id="chatList">
    @foreach (var msg in Model.Mensagens)
    {
        <li>
            <p style="margin-bottom: -2px; color: black;">
                <strong>@msg.Usuario.Nome</strong>
            </p>
            <p>@msg.Conteudo</p>
        </li>
    }
</ul>
<!-- jQuery Template novas mensagens -->
<script id="msgTmpl" type="text/x-jquery-tmpl">
    <li><p style="margin-bottom:-2px; color: black;"><strong>${Usuario.Nome}</strong></p><p>${Conteudo}</p></li>
</script>

<script src="@Url.Content("~/Scripts/jquery.tmpl.min.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/chat.js")" type="text/javascript"></script>

Último item que falta é programar  o chat.js, para  fazer basicamente duas coisas: adicionar novas mensagens e buscar as mensagens novas utilizando Long Polling.

Para isso vamos utilizar jQuery, então precisamos ter certeza que ele esteja adicionado na View, ou na Layout (como é nosso caso). Então na _Layout.cshtml podemos verificar a chamada do jQuery

<!DOCTYPE html>
<html>
<head>
    <title>@ViewBag.Title</title>
    <link href="@Url.Content("~/Content/Site.css")" rel="stylesheet" type="text/css" />
    <script src="@Url.Content("~/Scripts/jquery-1.4.4.min.js")" type="text/javascript"></script>

</head>

Agora vamos programar nosso chat.js

    $('#btnEnviar').bind('click', function () {
        var msgVal = $('#txtMensagem').val();
        $('#txtMensagem').val('');
        $.post("/Chat/New", { nome: $('#txtNome').val(), msg: msgVal }, function (data, s) {
            if (data.d) {
                //mensagem adicionada
            }
            else {
                //erro ao adicionar
            }

        });
    });

    //Envia a mensagem com enter
    $('#txtMensagem').keydown(function (e) {
        if (e.keyCode == 13) {
            $('#btnEnviar').click();
        }
    });

    setTimeout(function () {
        getMensagens();
    }, 100)
});

function getMensagens() {
    $.post("/Chat", null, function (data, s) {
        if (data.mensagens) {
            $('#msgTmpl').tmpl(data.mensagens).appendTo('#chatList');
        }
        setTimeout(function () {
            getMensagens();
        }, 500)
    });
}

Notem que, toda vez que uma chamada getMensagens é fechada, temos a abertura de uma nova chama, isto, em conjunto com as chamadas assíncronas no servidor, faz com que sempre tenhamos uma conexão aberta com o servidor, podendo receber as atualizações o mais rápido possível.

O que nos trás o seguinte resultado:

Projeto para download: DemoChat

Espero que este post seja útil.

Estou a disposição para dúvidas, criticas ou sugestões.

 

Abraços, Rodolfo