Skip to main content

SignalR | Dia a Dia | Ambiente .NET

  • SignalR | Dia a Dia | Ambiente .NET

1. Modelo mental correto do SignalR

Pensa em SignalR assim:

  • Hub = endpoint lógico RPC bidirecional
  • Cliente = conexão persistente que registra handlers locais e invoca métodos remotos
  • Transporte = WebSockets, SSE ou Long Polling
  • Hub protocol = JSON ou MessagePack
  • Escala = instância única, Redis backplane ou Azure SignalR Service

O erro mais comum é tratar o Hub como se fosse um singleton stateful. Não é. Cada chamada de método do hub roda em uma nova instância, então você não deve guardar estado em propriedade do hub. A própria Microsoft reforça isso, junto com duas outras regras críticas: não instanciar hub via DI diretamente e sempre usar await em chamadas assíncronas dependentes da vida útil do hub. (Microsoft Learn)


2. Pacotes principais

Server side

Em app ASP.NET Core moderno, o lado servidor já vem dentro do stack web. O ponto de entrada é:

using Microsoft.AspNetCore.SignalR;

Cliente .NET

Para cliente C# externo, console, desktop, worker etc.:

dotnet add package Microsoft.AspNetCore.SignalR.Client

3. Estrutura mínima do servidor

Program.cs

using Microsoft.AspNetCore.SignalR;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR();

WebApplication app = builder.Build();

app.MapHub<ChatHub>("/hub/chat");

app.Run();

Hub básico

using Microsoft.AspNetCore.SignalR;

public sealed class ChatHub : Hub
{
public async Task EnviarMensagem(string usuario, string mensagem)
{
await Clients.All.SendAsync("ReceberMensagem", usuario, mensagem);
}

public override async Task OnConnectedAsync()
{
await base.OnConnectedAsync();
}

public override async Task OnDisconnectedAsync(Exception? exception)
{
await base.OnDisconnectedAsync(exception);
}
}

Esse é o núcleo: o cliente chama métodos públicos do hub, e o hub chama métodos registrados no cliente.


4. Hub fortemente tipado

Esse é um dos recursos mais importantes para C#. Em vez de string solta em SendAsync, você expõe uma interface do lado cliente e o hub passa a chamar métodos com tipagem forte. Isso reduz erro de nome, melhora refactor e IntelliSense. O material que você tem aí destaca isso claramente.

public interface IChatClient
{
Task ReceberMensagem(string usuario, string mensagem);
Task AtualizarStatus(string status);
}

public sealed class ChatHub : Hub<IChatClient>
{
public async Task EnviarMensagem(string usuario, string mensagem)
{
await Clients.All.ReceberMensagem(usuario, mensagem);
}
}

Quando você usa Hub<TClient>, também pode injetar IHubContext<ChatHub, IChatClient> fora do hub. A documentação oficial recomenda exatamente isso para HubContext fortemente tipado. (Microsoft Learn)


5. Objetos principais dentro do Hub

Dentro de Hub, você trabalha principalmente com:

  • Clients
  • Context
  • Groups

Clients

Permite enviar mensagem para:

  • todos: Clients.All
  • caller: Clients.Caller
  • outros exceto caller: Clients.Others
  • connectionId específico: Clients.Client(connectionId)
  • múltiplos clientes: Clients.Clients(ids)
  • grupo: Clients.Group("grupo")
  • múltiplos grupos: Clients.Groups(...)
  • usuário: Clients.User(userId)
  • múltiplos usuários: Clients.Users(...)

Context

Traz metadados da conexão:

  • Context.ConnectionId
  • Context.User
  • Context.UserIdentifier
  • Context.Items
  • Context.GetHttpContext()
  • Context.ConnectionAborted

Groups

Gerencia associação dinâmica:

await Groups.AddToGroupAsync(Context.ConnectionId, "contrato-123");
await Groups.RemoveFromGroupAsync(Context.ConnectionId, "contrato-123");

Grupo é conceito lógico em memória distribuída do SignalR. Ele é ótimo para fan-out por tenant, contrato, sala, canal, CNPJ etc.


6. Envio fora do Hub com IHubContext

Essa é a forma correta quando quem dispara a mensagem não está dentro do hub: controller, minimal API, background service, consumer RabbitMQ, job, listener de evento etc. A Microsoft orienta usar IHubContext para isso. (Microsoft Learn)

using Microsoft.AspNetCore.SignalR;

public sealed class NotificacaoService
{
private readonly IHubContext<ChatHub, IChatClient> _hubContext;

public NotificacaoService(IHubContext<ChatHub, IChatClient> hubContext)
{
_hubContext = hubContext;
}

public Task NotificarGrupoAsync(string grupo, string mensagem)
{
return _hubContext.Clients.Group(grupo)
.ReceberMensagem("sistema", mensagem);
}
}

Esse modelo encaixa muito bem no teu cenário de API recebendo comando e empurrando para clients desktop.


7. Cliente .NET em C#

Esse é o ponto que muita gente conhece mal.

Conexão básica

using Microsoft.AspNetCore.SignalR.Client;

HubConnection connection = new HubConnectionBuilder()
.WithUrl("https://localhost:5001/hub/chat")
.Build();

Registrar handlers antes do StartAsync

connection.On<string, string>("ReceberMensagem", (usuario, mensagem) =>
{
Console.WriteLine($"{usuario}: {mensagem}");
});

Abrir conexão

await connection.StartAsync();

Invocar método do servidor

Use InvokeAsync quando o método do hub retorna algo:

string resposta = await connection.InvokeAsync<string>("Ping");

Use SendAsync quando você só quer disparar e não precisa de retorno:

await connection.SendAsync("EnviarMensagem", "adolfo", "olá");

8. Eventos de ciclo de vida do cliente .NET

Você precisa dominar estes três:

connection.Reconnecting += error =>
{
Console.WriteLine("Reconectando...");
return Task.CompletedTask;
};

connection.Reconnected += connectionId =>
{
Console.WriteLine($"Reconectado. Novo ConnectionId: {connectionId}");
return Task.CompletedTask;
};

connection.Closed += error =>
{
Console.WriteLine("Conexão fechada.");
return Task.CompletedTask;
};

Por padrão, o cliente não reconecta automaticamente. Para isso, você precisa configurar WithAutomaticReconnect. A documentação oficial deixa isso explícito. (Microsoft Learn)

Reconexão automática

HubConnection connection = new HubConnectionBuilder()
.WithUrl("https://localhost:5001/hub/chat")
.WithAutomaticReconnect()
.Build();

O retry padrão é: 0, 2, 10 e 30 segundos, até quatro tentativas. Isso vem da API oficial. (Microsoft Learn)

Você também pode customizar:

.WithAutomaticReconnect(new[]
{
TimeSpan.Zero,
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(15)
})

9. Streaming

SignalR suporta dois sentidos:

  • server-to-client streaming
  • client-to-server streaming

Isso é peça central quando você quer mandar muitos itens progressivamente, sem esperar o lote inteiro.

A documentação oficial informa que StreamAsync e StreamAsChannelAsync no cliente .NET são usados para invocar métodos de streaming do servidor para o cliente. (Microsoft Learn)

9.1 Server-to-client com IAsyncEnumerable

Hub

public sealed class StreamHub : Hub
{
public async IAsyncEnumerable<int> Contar(
int inicio,
int quantidade,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
for (int i = 0; i < quantidade; i++)
{
cancellationToken.ThrowIfCancellationRequested();
yield return inicio + i;
await Task.Delay(500, cancellationToken);
}
}
}

Cliente

await foreach (int valor in connection.StreamAsync<int>("Contar", 10, 5))
{
Console.WriteLine(valor);
}

9.2 Client-to-server com ChannelReader<T>

Hub

using System.Threading.Channels;

public sealed class UploadHub : Hub
{
public async Task ReceberLote(ChannelReader<string> stream)
{
await foreach (string item in stream.ReadAllAsync())
{
Console.WriteLine($"Recebido: {item}");
}
}
}

Cliente

Channel<string> channel = Channel.CreateUnbounded<string>();

_ = Task.Run(async () =>
{
await channel.Writer.WriteAsync("A");
await channel.Writer.WriteAsync("B");
channel.Writer.Complete();
});

await connection.SendAsync("ReceberLote", channel.Reader);

Na documentação atual, o cliente .NET também pode enviar IAsyncEnumerable<T> para métodos client-to-server streaming. (Microsoft Learn)


10. Configurações importantes do servidor

Aqui está o bloco que separa projeto hobby de projeto sério.

Configuração base

builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = false;
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
options.HandshakeTimeout = TimeSpan.FromSeconds(15);
options.MaximumReceiveMessageSize = 32 * 1024;
options.StreamBufferCapacity = 10;
options.MaximumParallelInvocationsPerClient = 1;
});

O que cada uma faz

  • EnableDetailedErrors: padrão false; se true, devolve erro detalhado ao cliente. Útil em dev, perigoso em prod porque pode expor informação sensível. (Microsoft Learn)
  • HandshakeTimeout: padrão 15s; se o handshake inicial não chegar nesse intervalo, a conexão fecha. Só altere se houver latência severa. (Microsoft Learn)
  • KeepAliveInterval: padrão 15s; envia ping automático para manter a conexão aberta. Ao alterar isso, o ServerTimeout do cliente deve ser pelo menos o dobro. (Microsoft Learn)
  • MaximumReceiveMessageSize: padrão 32 KB; aumenta o limite da mensagem de entrada, mas também aumenta risco de DoS. (Microsoft Learn)
  • StreamBufferCapacity: padrão 10; quantidade máxima de itens bufferizados para upload stream do cliente. (Microsoft Learn)
  • MaximumParallelInvocationsPerClient: padrão 1; controla quantas invocações paralelas cada cliente pode fazer antes de enfileirar. (Microsoft Learn)

Configuração por Hub específico

builder.Services.AddSignalR()
.AddHubOptions<ChatHub>(options =>
{
options.EnableDetailedErrors = true;
});

Isso é útil quando um hub é “interno” e outro é exposto para internet.


11. Configurações do transporte HTTP

No despacho HTTP do hub, você também pode afinar o transporte.

app.MapHub<ChatHub>("/hub/chat", options =>
{
options.Transports =
HttpTransportType.WebSockets |
HttpTransportType.LongPolling;

options.LongPolling.PollTimeout = TimeSpan.FromSeconds(60);
options.ApplicationMaxBufferSize = 1024 * 1024;
options.TransportMaxBufferSize = 1024 * 1024;
});

Pontos principais

  • PollTimeout: padrão 90s; controla quanto tempo o servidor segura uma requisição de long polling antes de responder. (Microsoft Learn)
  • ApplicationMaxBufferSize: controla quantos bytes recebidos do servidor o cliente bufferiza antes de backpressure. (Microsoft Learn)
  • TransportMaxBufferSize: controla quantos bytes enviados pelo app o servidor bufferiza antes de backpressure. (Microsoft Learn)

12. JSON x MessagePack

SignalR suporta os dois oficialmente. A documentação atual afirma isso de forma direta. (Microsoft Learn)

JSON

Mais simples, legível, melhor para debug, integração e compatibilidade.

builder.Services.AddSignalR()
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.PropertyNamingPolicy = null;
options.PayloadSerializerOptions.WriteIndented = false;
});

MessagePack

Menor payload e melhor eficiência, mas mais rígido. O material do teu acervo destaca a desvantagem prática: sensibilidade a nomes/casing e necessidade de pacote adicional.

dotnet add package Microsoft.AspNetCore.SignalR.Protocols.MessagePack
builder.Services.AddSignalR()
.AddMessagePackProtocol();

Regra prática:

  • usa JSON por padrão
  • usa MessagePack quando payload, frequência e custo de banda realmente importam

13. Autenticação e autorização

SignalR integra com a autenticação do ASP.NET Core. Cada conexão pode ser associada a um usuário, acessível por Context.User, e um mesmo usuário pode ter várias conexões simultâneas. (Microsoft Learn)

Proteger o hub

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;

[Authorize]
public sealed class SecureHub : Hub
{
}

Ou por policy:

[Authorize(Policy = "DesktopClientsOnly")]
public sealed class SecureHub : Hub
{
}

Cliente .NET com bearer token

A documentação oficial mostra AccessTokenProvider como mecanismo para fornecer token. Ela também observa que essa função é chamada antes de cada requisição HTTP feita pelo SignalR, o que é importante para renovação do token. (Microsoft Learn)

HubConnection connection = new HubConnectionBuilder()
.WithUrl("https://localhost:5001/hub/secure", options =>
{
options.AccessTokenProvider = () => Task.FromResult("SEU_JWT");
})
.Build();

Cuidado com query string e navegador

Quando o cliente usa WebSockets ou SSE no navegador, o token pode ir na query string. A Microsoft recomenda sempre usar HTTPS e alerta para risco de logar URL com token. (Microsoft Learn)


14. Usuários, múltiplas conexões e IUserIdProvider

Quando você chama:

await Clients.User("123").SendAsync("ReceberMensagem", "oi");

o SignalR envia para todas as conexões daquele usuário. Isso é ótimo para cenários tipo:

  • usuário logado em desktop + browser
  • múltiplas janelas
  • vários dispositivos

Se o identificador padrão não servir, você pode customizar com IUserIdProvider, por exemplo para usar cnpj, sub, nameidentifier ou outra claim.


15. Escala horizontal

Redis backplane

Serve para múltiplas instâncias trocarem mensagens e parecerem um único hub lógico. O teu material também cobre isso.

Configuração oficial:

dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis
builder.Services.AddSignalR()
.AddStackExchangeRedis("host:6379", options =>
{
options.Configuration.ChannelPrefix = RedisChannel.Literal("meu-app");
});

A Microsoft recomenda Redis backplane em produção somente se Redis e app estiverem no mesmo datacenter; caso contrário, latência degrada desempenho. Se estiver em Azure, a recomendação é Azure SignalR Service. (Microsoft Learn)

Sticky session

Com Redis backplane, normalmente você precisa de afinidade de sessão, exceto em um caso específico: todos os clientes usando apenas WebSockets e SkipNegotiation. A documentação oficial fala isso claramente. (Microsoft Learn)

Azure SignalR Service

Quando o volume de conexões é alto, ele costuma ser a opção mais limpa. A própria Microsoft destaca que, com Azure SignalR, sticky session não é necessária e os servidores da aplicação deixam de sustentar todas as conexões cliente diretamente. (Microsoft Learn)


16. Armadilhas clássicas

1. Guardar estado no hub

Errado. Cada invocação cria nova instância. (Microsoft Learn)

2. Não usar await em Clients.*

Pode falhar porque o método do hub termina antes do envio concluir. (Microsoft Learn)

3. Aumentar MaximumReceiveMessageSize sem critério

Isso aumenta superfície para abuso e consumo de memória. (Microsoft Learn)

4. Usar EnableDetailedErrors = true em produção

Pode vazar detalhes internos. (Microsoft Learn)

5. Não casar KeepAliveInterval do servidor com ServerTimeout do cliente

Se você mexe em um e esquece o outro, cria desconexão falsa. (Microsoft Learn)

6. Achar que grupo é banco de dados

Grupo é mecanismo de roteamento, não camada de persistência.

7. Confundir SendAsync com InvokeAsync

  • SendAsync: sem retorno
  • InvokeAsync<T>: espera retorno tipado

17. Exemplo completo e limpo: servidor + cliente .NET

Servidor

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization();

builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = false;
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
options.HandshakeTimeout = TimeSpan.FromSeconds(15);
options.MaximumReceiveMessageSize = 32 * 1024;
options.StreamBufferCapacity = 10;
});

WebApplication app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapHub<ComandoHub>("/hub/comandos");

app.Run();

public interface IComandoClient
{
Task ReceberComando(string requestId, string payload);
Task ReceberStatus(string status);
}

[Authorize]
public sealed class ComandoHub : Hub<IComandoClient>
{
public override async Task OnConnectedAsync()
{
string? contrato = Context.User?.FindFirst("cnpj")?.Value;

if (!string.IsNullOrWhiteSpace(contrato))
await Groups.AddToGroupAsync(Context.ConnectionId, contrato);

await base.OnConnectedAsync();
}

public Task Ping() => Clients.Caller.ReceberStatus("pong");

public Task EnviarParaGrupo(string grupo, string requestId, string payload)
=> Clients.Group(grupo).ReceberComando(requestId, payload);
}

Cliente .NET

using Microsoft.AspNetCore.SignalR.Client;

HubConnection connection = new HubConnectionBuilder()
.WithUrl("https://localhost:5001/hub/comandos", options =>
{
options.AccessTokenProvider = () => Task.FromResult("SEU_JWT");
})
.WithAutomaticReconnect()
.Build();

connection.On<string, string>("ReceberComando", async (requestId, payload) =>
{
Console.WriteLine($"Comando recebido: {requestId} - {payload}");
await Task.CompletedTask;
});

connection.On<string>("ReceberStatus", status =>
{
Console.WriteLine($"Status: {status}");
});

connection.Reconnecting += error =>
{
Console.WriteLine("Reconectando...");
return Task.CompletedTask;
};

connection.Reconnected += id =>
{
Console.WriteLine($"Reconectado com connectionId {id}");
return Task.CompletedTask;
};

connection.Closed += error =>
{
Console.WriteLine("Conexão fechada.");
return Task.CompletedTask;
};

await connection.StartAsync();
Console.WriteLine("Conectado.");

await connection.InvokeAsync("Ping");

Console.ReadLine();
await connection.DisposeAsync();

18. O que eu considero o conjunto mínimo que você realmente precisa dominar

Se você quiser ficar forte de verdade em SignalR com C#, foque em dominar estes blocos:

  • ciclo de vida da conexão
  • diferenças entre hub, IHubContext, grupo e usuário
  • JSON vs MessagePack
  • keep-alive, timeout e handshake
  • autenticação JWT com AccessTokenProvider
  • reconexão automática
  • streaming com IAsyncEnumerable e ChannelReader
  • scale-out com Redis ou Azure SignalR
  • restrições de produção: sticky session, afinidade, proxy, HTTPS e buffers

19. Testes de Unidade | SignalR

using Microsoft.AspNetCore.SignalR;
using Moq;
using Xunit;

public class ChatHubTests
{
[Fact]
public async Task EnviarMensagem_DeveEnviarParaTodos()
{
var clientProxy = new Mock<IClientProxy>();
var clients = new Mock<IHubCallerClients>();
clients.Setup(x => x.All).Returns(clientProxy.Object);

var hub = new ChatHub
{
Clients = clients.Object
};

await hub.EnviarMensagem("adolfo", "oi");

clientProxy.Verify(
x => x.SendCoreAsync(
"ReceberMensagem",
It.Is<object[]>(o =>
(string)o[0] == "adolfo" &&
(string)o[1] == "oi"),
default),
Times.Once);
}
}

public class ChatHub : Hub
{
public Task EnviarMensagem(string usuario, string mensagem)
=> Clients.All.SendAsync("ReceberMensagem", usuario, mensagem);
}


Referências