Tempo real com SignalR: quando o servidor toma a iniciativa

Tempo real com SignalR: quando o servidor toma a iniciativa

A Livraria passa a avisar quem está conectado: polling, SSE e WebSockets, hubs tipados do SignalR com grupos e usuários, avisos só depois do commit, notificação fina com cache, reconexão que refaz grupos, token na query string só em /hubs e backplane Redis.
Linguagem C#

• • 17 min de leitura

Até aqui a Livraria só fala quando alguém pergunta. Neste artigo ela passa a avisar os navegadores e apps conectados quando um livro muda de preço ou fica sem estoque, sem esperar que eles recarreguem a página.

O problema do HTTP clássico

O HTTP é um protocolo de requisição e resposta: o cliente pergunta, o servidor responde e a conversa acaba. Não existe, no modelo original, um jeito de o servidor dizer "o livro que você está olhando acabou de esgotar". Ao longo dos anos surgiram quatro técnicas para contornar isso:

Técnica Como funciona Custo
Polling o cliente pergunta a cada N segundos atraso de até N segundos e requisições inúteis
Long polling o cliente pergunta e o servidor segura a resposta até ter novidade ou estourar o tempo uma requisição pendurada por cliente, reaberta a cada mensagem
Server-Sent Events (SSE) uma resposta HTTP que nunca termina, pela qual o servidor envia eventos em texto só do servidor para o cliente, só texto
WebSockets (RFC 6455) a conexão HTTP é "promovida" a um canal bidirecional permanente exige suporte de proxies e balanceadores e mantém estado por conexão

WebSockets é a melhor opção técnica, mas nem todo ambiente permite: proxies corporativos antigos, alguns balanceadores e redes móveis instáveis derrubam a conexão. Uma aplicação robusta precisa tentar WebSockets e cair para as alternativas quando não der, além de reconectar, serializar mensagens, identificar usuários e agrupar conexões. É esse trabalho que o SignalR faz.

O que o SignalR oferece

O SignalR é a biblioteca de tempo real do ASP.NET Core. Ele entrega:

  • Negociação de transporte: tenta WebSockets, depois SSE, depois long polling, sem mudança no código.
  • Hubs: chamadas de método nos dois sentidos. O cliente chama métodos do servidor, e o servidor chama métodos registrados no cliente.
  • Endereçamento: enviar para uma conexão, para todas as conexões de um usuário, para um grupo ou para todos.
  • Clientes oficiais para JavaScript/TypeScript, .NET (inclusive Unity e MAUI) e Java.
  • Protocolos: JSON por padrão e MessagePack, binário e mais compacto, como opção.

O servidor já faz parte do framework compartilhado do ASP.NET Core, então não há pacote a instalar na API.

Se a necessidade for só empurrar eventos do servidor para o navegador, sem chamadas no sentido inverso, o .NET 10 trouxe um resultado nativo para SSE nas Minimal APIs (TypedResults.ServerSentEvents), que recebe um IAsyncEnumerable<T>. É bem mais simples que um hub. O SignalR compensa quando há chamadas nos dois sentidos, grupos, usuários, clientes que não são navegadores e várias réplicas.

O hub da Livraria

Um hub é uma classe cujos métodos públicos podem ser chamados pelos clientes. Usamos a versão fortemente tipada, em que uma interface descreve o que o servidor pode chamar no cliente. Assim o compilador pega erros de digitação que, com strings, só apareceriam no navegador:

namespace Livraria.Api.TempoReal;

public interface IClienteDaLivraria
{
    Task ProdutoAlterado(int produtoId);
    Task PedidoRegistrado(int pedidoId, decimal total);
}

public sealed class HubDaLivraria : Hub<IClienteDaLivraria>
{
    public Task AcompanharProduto(int produtoId) =>
        Groups.AddToGroupAsync(Context.ConnectionId, Grupo(produtoId));

    public Task DeixarDeAcompanhar(int produtoId) =>
        Groups.RemoveFromGroupAsync(Context.ConnectionId, Grupo(produtoId));

    public static string Grupo(int produtoId) => $"produto:{produtoId}";
}
builder.Services.AddSignalR();

// ...

app.MapHub<HubDaLivraria>("/hubs/livraria").AllowAnonymous();

O AllowAnonymous é necessário por causa da política de fallback de "Autenticação e autorização com JWT", que fecha tudo por padrão. Visitantes anônimos também querem saber que o livro esgotou.

Há uma regra de tempo de vida que surpreende quem vem de outros frameworks: um hub é transitório. O framework cria uma instância nova a cada chamada de método e a descarta logo depois. Campos na classe do hub não guardam nada entre chamadas. Estado por conexão fica em Context.Items, e estado compartilhado fica em um serviço singleton ou fora do processo.

Os grupos são o mecanismo central aqui. Quem abre a página do livro 7 entra no grupo produto:7, e o servidor avisa só esse grupo quando o livro 7 muda, em vez de mandar a notícia a todos os visitantes da loja. Grupos são criados sob demanda e somem quando ficam vazios, e não há como listar seus membros, de propósito. Eles são um endereço de entrega, não uma estrutura de dados.

Avisando a partir de fora do hub

As alterações não acontecem dentro do hub. Acontecem nos endpoints de produtos e de pedidos. Para enviar mensagens de qualquer lugar, injeta-se IHubContext<THub, TCliente>.

Em "Cache com Redis, HybridCache e output caching" os endpoints ganharam chamadas de invalidação espalhadas. Agora que toda alteração também precisa gerar um aviso, é hora de juntar esses efeitos colaterais numa classe só:

public sealed class EfeitosDeAlteracao(
    CatalogoEmCache catalogo,
    IOutputCacheStore saida,
    IHubContext<HubDaLivraria, IClienteDaLivraria> hub,
    ILogger<EfeitosDeAlteracao> log)
{
    public async Task ProdutosAlteradosAsync(IEnumerable<int> ids)
    {
        var ct = CancellationToken.None;   // veja o texto
        await saida.EvictByTagAsync("catalogo", ct);

        foreach (var id in ids.Distinct())
        {
            await catalogo.InvalidarAsync(id, ct);
            try
            {
                await hub.Clients.Group(HubDaLivraria.Grupo(id)).ProdutoAlterado(id);
            }
            catch (Exception e)
            {
                log.LogWarning(e, "Falha ao notificar alteração do produto {ProdutoId}", id);
            }
        }
    }

    public async Task PedidoRegistradoAsync(Pedido pedido)
    {
        await ProdutosAlteradosAsync(pedido.Itens.Select(i => i.ProdutoId));
        await hub.Clients.User(pedido.ClienteId).PedidoRegistrado(pedido.Numero, pedido.Total);
    }
}
// POST /pedidos, depois que o ServicoDeVendas confirmou a transação
var pedido = await vendas.RegistrarAsync(clienteId, itens, ct);
await efeitos.PedidoRegistradoAsync(pedido);

Três decisões estão embutidas aí.

Avisar só depois do commit. Um aviso enviado antes da confirmação no banco pode anunciar uma venda que acabou desfeita por conflito de concorrência. Os efeitos rodam depois que o ServicoDeVendas devolve o pedido, quando a transação já foi confirmada.

Não usar o CancellationToken da requisição. Se o cliente fechar a aba um milissegundo depois do commit, o token da requisição é cancelado. A venda aconteceu, mas a invalidação do cache e os avisos seriam abortados, e todos os outros visitantes veriam estoque errado. Trabalho que precisa acontecer porque algo foi gravado não pode depender de o cliente continuar conectado. É um dos raros casos em que CancellationToken.None é a escolha certa.

Falha no aviso não desfaz a venda. Notificar é um detalhe de conveniência, enquanto a venda é a garantia. Se o envio falhar (por exemplo, se o backplane que veremos adiante estiver fora do ar), registramos um aviso no log e seguimos. É o mesmo princípio do último exercício do artigo anterior.

Clients.User(id) envia a mensagem para todas as conexões de um usuário, como o celular e o notebook. O SignalR descobre o id do usuário pela claim ClaimTypes.NameIdentifier, que é justamente o sub do nosso JWT. Conexões anônimas não têm usuário e nunca recebem essas mensagens.

Notificação fina ou estado embutido

A mensagem ProdutoAlterado(int produtoId) não diz o que mudou. É uma notificação fina: o cliente sabe que algo mudou e busca a versão atual com GET /produtos/7. Poderíamos enviar o produto inteiro na mensagem, o que se chama event-carried state. Cada abordagem tem seu custo:

  • Notificação fina. A mensagem é pequena, não vaza dados para quem não pediria e o cliente sempre obtém o estado consistente da fonte. O preço é uma requisição a mais por cliente. Quinhentos visitantes olhando um lançamento geram quinhentos GETs quase simultâneos. Aqui o trabalho dos artigos anteriores rende: a invalidação acabou de esvaziar o cache, e o HybridCache com proteção contra estouro faz essas quinhentas requisições resultarem em uma consulta ao banco. As outras aguardam o mesmo resultado. Quem já tinha a versão atual recebe um 304 pelo ETag.
  • Estado embutido. Não há requisição extra, mas o formato da mensagem vira um segundo contrato público para manter, e mensagens fora de ordem podem sobrescrever um estado novo com um velho.

Para a Livraria, a notificação fina com cache é a escolha mais simples de manter correta.

O cliente JavaScript

npm install @microsoft/signalr
import * as signalR from "@microsoft/signalr";

const produtoId = 7;

const conexao = new signalR.HubConnectionBuilder()
  .withUrl("/hubs/livraria", { accessTokenFactory: () => obterTokenAtual() })
  .withAutomaticReconnect()           // tenta em 0, 2, 10 e 30 segundos
  .build();

conexao.on("ProdutoAlterado", async (id) => {
  if (id === produtoId) await recarregarProduto();   // GET com If-None-Match
});

conexao.on("PedidoRegistrado", (pedidoId, total) =>
  mostrarAviso(`Pedido ${pedidoId} confirmado: R$ ${total.toFixed(2)}`));

conexao.onreconnected(async () => {
  await conexao.invoke("AcompanharProduto", produtoId);  // grupos não sobrevivem
  await recarregarProduto();                             // mensagens perdidas
});

await conexao.start();
await conexao.invoke("AcompanharProduto", produtoId);

Os dois callbacks do onreconnected são o que separa um tempo real que funciona de um que parece funcionar.

Grupos pertencem à conexão, não ao usuário. Quando a rede cai e o cliente reconecta, ele recebe um ConnectionId novo e não está em grupo nenhum. Sem voltar a entrar no grupo, a página continua aberta, parecendo viva, e nunca mais recebe avisos.

O SignalR não guarda mensagens. A entrega é no máximo uma vez: o que for enviado enquanto o cliente estava desconectado se perde. A mensagem é uma dica de que vale olhar de novo, não a fonte da verdade. Depois de qualquer reconexão, o cliente relê o estado. Graças ao ETag, isso custa um 304 quando nada mudou.

O cliente .NET

O mesmo hub atende clientes .NET: um app MAUI, um painel de estoque em outro serviço ou, no capstone, o jogo em Godot.

dotnet add package Microsoft.AspNetCore.SignalR.Client
await using var conexao = new HubConnectionBuilder()
    .WithUrl("https://livraria.exemplo/hubs/livraria", o =>
        o.AccessTokenProvider = () => Task.FromResult<string?>(token))
    .WithAutomaticReconnect()
    .Build();

conexao.On<int>("ProdutoAlterado", id =>
    Console.WriteLine($"Produto {id} mudou"));

conexao.Reconnected += async _ =>
    await conexao.InvokeAsync("AcompanharProduto", 7);

await conexao.StartAsync();
await conexao.InvokeAsync("AcompanharProduto", 7);

Autenticação: a exceção da query string

Em "Autenticação e autorização com JWT" a regra foi clara: token nunca vai na query string. O SignalR obriga a uma exceção documentada. A API de WebSocket dos navegadores não permite cabeçalhos personalizados, e o EventSource do SSE também não. O cliente JavaScript envia então o token no parâmetro access_token da URL, e o servidor precisa ser instruído a procurá-lo ali:

builder.Services.AddAuthentication().AddJwtBearer(o =>
{
    o.Events = new JwtBearerEvents
    {
        OnMessageReceived = contexto =>
        {
            var token = contexto.Request.Query["access_token"];
            if (!string.IsNullOrEmpty(token) &&
                contexto.HttpContext.Request.Path.StartsWithSegments("/hubs"))
            {
                contexto.Token = token;
            }
            return Task.CompletedTask;
        }
    };
});

A restrição a /hubs é essencial: sem ela, qualquer endpoint passaria a aceitar token na URL. Para reduzir o risco que a regra original evitava, use tokens de vida curta (o accessTokenFactory é chamado a cada reconexão, então pode entregar um token renovado) e configure o proxy reverso e o UseHttpLogging para não registrar a query string nessas rotas.

O token só é validado na abertura da conexão. Uma conexão WebSocket pode durar horas depois de o token expirar. Para dados sensíveis, o .NET oferece a opção CloseOnAuthenticationExpiration no mapeamento do hub, que encerra a conexão quando a autenticação expira e força o cliente a reconectar com um token novo.

Métodos de hub aceitam [Authorize] e [Authorize(Policy = "...")] como os endpoints. No nosso hub não foi necessário: os métodos públicos só entram e saem de grupos de produtos públicos, e as mensagens pessoais saem por Clients.User, que só alcança conexões autenticadas.

Várias réplicas: o backplane

O SignalR guarda as conexões na memória do processo. Com três réplicas atrás de um balanceador, um cliente conectado à réplica B não recebe a mensagem que a réplica A enviou ao grupo produto:7, porque A nem sabe que esse cliente existe.

A solução é um backplane: cada réplica publica suas mensagens num barramento compartilhado, e todas repassam às conexões locais que correspondem ao destino. Já temos o Redis:

dotnet add src/Livraria.Api package Microsoft.AspNetCore.SignalR.StackExchangeRedis
builder.Services.AddSignalR()
    .AddStackExchangeRedis(
        builder.Configuration.GetConnectionString("Redis")!,
        o => o.Configuration.ChannelPrefix = RedisChannel.Literal("livraria"));

O prefixo de canal isola a Livraria de outras aplicações que usem o mesmo Redis. O backplane usa o pub/sub do Redis, que também é no máximo uma vez. Isso não muda o contrato que já estabelecemos: as mensagens podem se perder, e o cliente relê o estado ao reconectar.

Há um segundo requisito de infraestrutura. A negociação (a requisição inicial que escolhe o transporte) e os transportes SSE e long polling fazem várias requisições HTTP que precisam cair na mesma réplica. O balanceador precisa de sticky sessions (afinidade por cookie ou por IP). A exceção é quando o cliente usa só WebSockets com SkipNegotiation, em que tudo acontece numa única conexão. Em nuvem, serviços gerenciados como o Azure SignalR Service assumem as conexões e dispensam backplane e afinidade.

Cuidados de operação

  • Cada conexão ocupa memória e um socket. Dezenas de milhares por servidor é algo normal, mas o número precisa entrar no planejamento de capacidade, e o timeout ocioso do balanceador precisa ser maior que o intervalo de keep-alive do SignalR (15 segundos por padrão).
  • Mensagens pequenas. O limite padrão de mensagem recebida é 32 KB. Envie identificadores e deixe os detalhes para o HTTP com cache.
  • Limite o que o cliente pode pedir. Nada impede um cliente malicioso de chamar AcompanharProduto dez mil vezes. Um contador em Context.Items com um teto razoável resolve.
  • Nada de lógica de negócio no hub. Ele é uma fronteira de transporte, como os endpoints. Regras continuam na camada de aplicação.

A Livraria agora conversa nos dois sentidos: o catálogo se atualiza sozinho na tela de quem está olhando, o cliente vê a confirmação do pedido em todos os seus dispositivos, e tudo continua correto com várias réplicas e com conexões instáveis, porque as mensagens são tratadas como dicas e o estado sempre vem da fonte. Os efeitos da venda, porém, ainda rodam dentro da requisição do cliente: invalidar cache, avisar conexões e, em breve, enviar e-mail de confirmação e atualizar o ranking. Se o servidor cair entre o commit e esses efeitos, eles se perdem sem deixar rastro.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um desenvolvedor guarda no hub um campo private readonly HashSet<int> _acompanhados = new(); e acrescenta o id a cada chamada de AcompanharProduto, para depois mostrar "você está acompanhando N produtos". O recurso nunca funciona. Explique por quê e proponha duas correções.

Ver resposta

✓ Resposta: O hub é transitório: cada chamada de método cria uma instância nova, com um HashSet vazio, que é descartada ao fim da chamada. O conjunto nunca passa de um elemento e se perde em seguida. Correção 1, estado por conexão: guardar o conjunto em Context.Items["acompanhados"], que vive enquanto a conexão viver. Correção 2, estado que sobrevive a reconexões e réplicas: guardar no Redis, numa chave por usuário ou por conexão com expiração. Como os grupos também se perdem na reconexão, a lista costuma ficar no próprio cliente, que a reaplica no onreconnected, e o servidor não precisa guardar nada.

Exercício 2

Por que os efeitos pós-venda usam CancellationToken.None, e em que situação essa escolha seria errada?

Ver resposta

✓ Resposta: Os efeitos (invalidar cache, notificar outros clientes) são consequência de algo que já foi gravado. Se dependessem do token da requisição, um cliente que desconecta logo depois do commit cancelaria efeitos que interessam a todos os outros usuários, e o cache ficaria servindo estoque errado até expirar. Seria errado usar None em trabalho que só interessa ao próprio cliente e que pode ser longo, como montar um relatório para a resposta: aí o cancelamento economiza recursos. Também seria arriscado em operações que podem travar indefinidamente. Nesse caso o certo é um token com timeout próprio (new CancellationTokenSource(TimeSpan.FromSeconds(5))) no lugar de None, para não prender a requisição.

Exercício 3

Escreva um teste de integração que conecta um cliente SignalR à FabricaDaApi, entra no grupo do produto 1, faz um PUT como admin e afirma que ProdutoAlterado(1) chegou. Que detalhe de transporte o TestServer exige?

Ver resposta

✓ Resposta: var conexao = new HubConnectionBuilder().WithUrl(new Uri(Fabrica.Server.BaseAddress, "/hubs/livraria"), o => { o.HttpMessageHandlerFactory = _ => Fabrica.Server.CreateHandler(); o.Transports = HttpTransportType.LongPolling; }).Build();. Registre var recebido = new TaskCompletionSource<int>(); conexao.On<int>("ProdutoAlterado", id => recebido.TrySetResult(id));, depois StartAsync, InvokeAsync("AcompanharProduto", 1), o PUT com o cliente admin e Assert.Equal(1, await recebido.Task.WaitAsync(TimeSpan.FromSeconds(5))). O detalhe é que o TestServer não abre sockets de verdade. O handler em memória atende requisições HTTP, então força-se long polling. Para testar WebSockets, Fabrica.Server.CreateWebSocketClient() oferece um cliente em memória, mas a configuração é mais trabalhosa. O WaitAsync com prazo evita que um aviso que nunca chega trave a suíte.

Exercício 4

Com o backplane Redis e três réplicas, a réplica A envia para Clients.User("ana"). Ana tem o celular conectado na réplica B e o notebook na C. Descreva o caminho da mensagem e o que acontece se a conexão de A com o Redis estiver caída no momento do envio.

Ver resposta

✓ Resposta: A publica a mensagem num canal do Redis associado ao usuário "ana" (com o prefixo livraria). B e C, inscritas nesse canal, recebem a publicação, procuram suas conexões locais cujo UserIdentifier é "ana" e enviam a mensagem por elas: celular via B, notebook via C. A mesma lógica vale para grupos. Se A não alcança o Redis, a publicação falha e ninguém em outras réplicas recebe a mensagem. Dependendo da versão, A entrega só às conexões locais ou lança exceção. Por isso o envio fica num try/catch com log em EfeitosDeAlteracao. Como os clientes já tratam mensagens como dicas e relêem o estado ao reconectar, a perda degrada a experiência mas não gera dado incorreto.

Exercício 5

Troque a notificação fina por event-carried state: EstoqueAlterado(int produtoId, int estoque, long versao). Por que o campo versao é necessário e como o cliente deve usá-lo?

Ver resposta

✓ Resposta: Mensagens podem chegar fora de ordem entre réplicas e reconexões. Sem versão, um EstoqueAlterado(7, 3) atrasado que chega depois de EstoqueAlterado(7, 2) faria a tela voltar a mostrar 3 exemplares. Com a versão (a coluna de versão do exercício 4 de "A API da Livraria", ou um contador monotônico), o cliente guarda a última versão aplicada por produto e ignora mensagens com versão menor ou igual. Mensagens de versão maior substituem o estado. Mesmo assim, depois de uma reconexão o cliente relê o estado pelo HTTP, porque mensagens perdidas não são reenviadas. A versão resolve a ordem, não a perda. O custo dessa abordagem é que o formato da mensagem vira um contrato público, que precisa ser mantido com o mesmo cuidado do OpenAPI.

Comentários

Mais em Linguagem C#

Concorrência: Threads, Travas e Dados Compartilhados Sem Corrupção
Concorrência: Threads, Travas e Dados Compartilhados Sem Corrupção

Concorrência no C#: condição de corrida e por que contador++ perde…

C# e IA: Microsoft.Extensions.AI, busca semântica e um assistente com ferramentas
C# e IA: Microsoft.Extensions.AI, busca semântica e um assistente com ferramentas

Modelos de linguagem como infraestrutura na Livraria: Microsoft.Extensions.AI…

async e await: Esperar Sem Bloquear
async e await: Esperar Sem Bloquear

async e await no C#: por que threads paradas custam caro, Task como promessa…