Tempo Real com SignalR

Programação Aplicada em .NET • Prof. Me. William Tenório

O problema do tempo real

Dois funcionários estão vendo a lista de pagamentos de um sistema. Um deles aprova um pagamento. Como o outro fica sabendo?

Na comunicação HTTP tradicional, quem começa a conversa é sempre o cliente. O servidor só responde. Então o navegador precisa ficar perguntando de tempos em tempos "mudou alguma coisa?". Isso se chama polling. Se a consulta é feita a cada 5 segundos, a notícia pode chegar com até 5 segundos de atraso, e a maior parte das consultas volta com a resposta "nada mudou".

Com o SignalR, a conexão fica aberta e o servidor envia a mensagem assim que o evento acontece. Esse modelo é usado em chats, notificações, painéis operacionais, placares ao vivo e no acompanhamento de processamentos demorados.

SignalR e arquitetura

O SignalR não substitui a API REST: ela continua sendo o caminho natural para consultar e alterar dados. O SignalR complementa a API quando o servidor precisa avisar os clientes. No projeto da aula, os dois convivem: GET /api/pagamentos carrega a lista e o Hub avisa quando algo muda.

Como funciona a aula prática

Até agora, cada um rodava seu programa isolado. Hoje precisamos de um servidor e de vários clientes em máquinas diferentes. A dinâmica será assim:

Computador do professor ASP.NET Core + Hub SignalR Aluno 1 Aluno 2 Aluno 3 ... rede do laboratório · http://IP-DO-PROFESSOR:5000
O servidor também entrega a página HTML do cliente. Basta digitar no navegador o endereço mostrado no projetor; não é preciso instalar nada.

Login: para entrar, selecione o seu nome na lista e digite o seu RA. Todas as mensagens enviadas ficam registradas no servidor com nome, RA e horário.

ParteO que aconteceOnde roda
1. PollingA tela de pagamentos sem tempo real: o navegador fica perguntandoServidor do professor
2. Tempo realA mesma tela com SignalR, lado a lado com a de pollingServidor do professor
3. Quem recebe?All, Others, Caller e Group na práticaServidor do professor
4. StreamingImportação com barra de progresso e filaServidor do professor
5. Exercício AVocê implementa um Hub de chatSua máquina (várias abas)

Parte 1 · Polling: o navegador fica perguntando

Com o endereço do projetor, abra no navegador:

http://IP-DO-PROFESSOR:5000/polling.html

Essa tela não usa tempo real. A cada 5 segundos ela faz um GET /api/pagamentos e redesenha a lista. O professor vai aprovar alguns pagamentos. Observe:

O polling tem um dilema sem saída: intervalo curto deixa a tela mais atual, mas sobrecarrega o servidor com perguntas inúteis; intervalo longo alivia o servidor, mas a informação chega atrasada. O problema é que só o cliente pode começar a conversa.

Parte 2 · Tempo real: o servidor avisa

Mantenha a aba de polling aberta e abra, ao lado, a versão com SignalR:

http://IP-DO-PROFESSOR:5000/

Faça login (seu nome + RA) e aprove um pagamento. Compare as duas abas: na de SignalR a mudança aparece na hora, para todos; na de polling, só na próxima consulta. A aba de SignalR não fica perguntando nada: a conexão permanece aberta e o servidor empurra o evento quando ele acontece.

Parte 3 · Notificações: quem recebe a mensagem?

Um método do Hub é chamado pelo cliente. Dentro dele, o servidor escolhe para quem enviar um evento. Cada destinatário é um botão na página da aula:

DestinatárioQuem recebeNa página
Clients.AllTodos os conectados, inclusive quem chamouAprovar pagamento
Clients.OthersTodos, menos quem chamouAvisar os outros
Clients.CallerSó quem chamouEco
Clients.Group("financeiro")Só quem está no grupoGrupos

No servidor (trecho de PagamentosHub.cs):

public class PagamentosHub(RepositorioPagamentos pagamentos, Participantes participantes) : Hub
{
    public async Task AprovarPagamento(int id)
    {
        Pagamento? aprovado = pagamentos.Aprovar(id, NomeAtual);
        if (aprovado is null)
            throw new HubException($"O pagamento {id} não existe.");

        await Clients.All.SendAsync("PagamentoAprovado", aprovado);
    }

    public Task AvisarOutros(string texto) =>
        Clients.Others.SendAsync("Aviso", NomeAtual, texto);

    public Task Eco(string texto) =>
        Clients.Caller.SendAsync("Eco", texto);

    public async Task EntrarNoGrupo(string grupo)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, grupo);
        await Clients.Group(grupo).SendAsync("AvisoGrupo", grupo, "Sistema", $"{NomeAtual} entrou no grupo");
    }
}

No cliente (JavaScript), cada evento precisa de um "ouvinte", e os métodos do Hub são chamados com invoke:

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hub")
    .withAutomaticReconnect()
    .build();

// servidor → cliente: o nome do evento é o 1º argumento de SendAsync
connection.on("PagamentoAprovado", pagamento => {
    console.log(`Pagamento ${pagamento.id} aprovado por ${pagamento.aprovadoPor}`);
});

await connection.start();

// cliente → servidor: chama o método AprovarPagamento do Hub
await connection.invoke("AprovarPagamento", 3);

Teste: combine com um colega e cliquem em "Aprovar" no mesmo pagamento ao mesmo tempo. Só um consegue. O repositório protege a lista com lock, como na aula de Concorrência, e o outro recebe uma HubException, que é a única exceção cuja mensagem chega até o cliente.

Um Hub é criado a cada chamada. Por isso, nada de guardar estado em campos do Hub: o estado compartilhado fica em serviços singleton injetados no construtor. E só guardamos estado quando existe uma necessidade de negócio (o nome do participante, o grupo dele). Conexões, grupos e reconexões o SignalR já gerencia sozinho.

Parte 4 · Streaming

Uma notificação informa um acontecimento ("o pagamento 3 foi aprovado"). Já o streaming envia uma sequência de valores ao longo do tempo: 0%, 5%, 10%... 100%. No SignalR, basta o método do Hub retornar IAsyncEnumerable<T>: cada yield return chega ao cliente assim que é produzido.

Para deixar mais real, o servidor só processa 3 importações ao mesmo tempo, controladas por um SemaphoreSlim. Quando a turma inteira clicar em "Importar" junto, o projetor mostra quem está processando e quem está na fila.

public class PainelImportacoes
{
    private readonly SemaphoreSlim _vagas = new(3, 3);

    public async IAsyncEnumerable<Progresso> Executar(
        string nome, int registros, [EnumeratorCancellation] CancellationToken cancelamento)
    {
        bool conseguiuVaga = false;
        try
        {
            yield return new Progresso("Aguardando", 0, "Na fila...");

            await _vagas.WaitAsync(cancelamento);
            conseguiuVaga = true;

            for (int percentual = 0; percentual <= 100; percentual += 5)
            {
                yield return new Progresso("Processando", percentual, $"{registros * percentual / 100} de {registros}");
                await Task.Delay(400, cancelamento);
            }
        }
        finally
        {
            // roda no fim normal E quando o cliente cancela (fechou a aba, clicou em Cancelar...)
            if (conseguiuVaga)
                _vagas.Release();
        }
    }
}

No cliente, em vez de invoke, usamos stream:

const assinatura = connection.stream("Importar", 10000).subscribe({
    next: progresso => atualizarBarra(progresso.percentual),
    complete: () => console.log("Concluído"),
    error: erro => console.error(erro)
});

// Cancelar: o CancellationToken do método no servidor é cancelado
assinatura.dispose();

Para discutir: uma tela precisa mostrar o andamento da importação de 10 mil registros. É melhor enviar uma notificação isolada ou transmitir o progresso? Depende do comportamento desejado. A notificação informa que algo aconteceu; o streaming permite acompanhar uma sequência de resultados.

Parte 5 · Exercício A: chat em tempo real (na sua máquina)

Baixe tempo-real-exercicios.zip, descompacte e abra a pasta ExercicioA no VS Code. Funciona com .NET 9 ou 10.

cd ExercicioA
dotnet run

Abra http://localhost:5050 em 2 ou 3 abas e use um nome diferente em cada uma: cada aba é um cliente. A página já está pronta. Seu trabalho é implementar os métodos de Hubs/ChatHub.cs. Enquanto um TODO não estiver feito, o erro aparece em vermelho na página.

  1. TODO 1: EnviarParaTodos: envie o evento "MensagemRecebida" (nome, texto, "todos") para todos.
  2. TODO 2: EnviarParaOutros: o mesmo, mas para todos menos quem enviou. Confira que a aba que enviou não recebe.
  3. TODO 3: EntrarNaSala: adicione a conexão ao grupo e avise a sala que alguém entrou. Dica: Context.ConnectionId.
  4. TODO 4: EnviarParaSala: só quem está na sala recebe. Teste com uma aba fora da sala.
  5. TODO 5: em OnConnectedAsync/OnDisconnectedAsync, mantenha o total de conectados e envie "UsuariosOnline" para todos. Feche uma aba e veja o número cair. Atenção: várias conexões chegam ao mesmo tempo. Use Interlocked.
  6. TODO 6: Contagem: um stream que conta de de até 0, um número por segundo. Clique em "Cancelar" no meio e confirme que o servidor para.

Desafios:

Se algo não funcionar

Para refletir

Projeto completo para estudo

Todos os projetos da aula: o servidor da demonstração (página do aluno, painel do projetor e versão com polling) e a resolução do Exercício A. Veja o LEIA-ME.md dentro do ZIP.

Para estudar sozinho, rode o servidor (cd Servidor e dotnet run) e abra várias abas em http://localhost:5000. O painel fica em /painel.html.