Introdução
SignalR é uma biblioteca gratuita de código aberto para ASP.NET Core que permite que o servidor envie mensagens assíncronas em tempo real para clientes conectados. É uma camada de abstração sobre WebSockets, tornando-o mais fácil de usar e fornecendo fallback para outras formas de comunicação quando necessário (eventos enviados pelo servidor e long polling).
Neste post, mostrarei como construir um aplicativo Blazor WebAssembly que exibe gráficos em tempo real de um servidor SignalR.

A estrutura do projeto
O projeto terá 4 projetos, criados usando 2 modelos de projeto:
- Projeto ASP.NET Core (para o servidor SignalR)
- BlazorWasmSignalR.SignalRServer
- Aplicativo Blazor WebAssembly (ASP.NET Core Hosted)
- BlazorWasmSignalR.Wasm.Client (Blazor WASM)
- BlazorWasmSignalR.Wasm.Server (ASP.NET Core Host)
- BlazorWasmSignalR.Wasm.Shared (Componentes comuns)

O Backend - Servidor SignalR
SignalR faz parte do ASP.NET Core. Para usá-lo, basta configurá-lo em nosso Startup.cs
ou Program.cs
(se estiver usando instruções de nível superior):
1
2
3
4
5
6
7
8
9
10
11
12
13
| //Adicionar serviços SignalR
builder.Services.AddSignalR();
...
var app = builder.Build();
...
//Mapear nosso Hub
app.MapHub<RealTimeDataHub>("/realtimedata");
app.Run();
|
Os clientes SignalR se conectam a Hubs, que são componentes que definem métodos que podem ser chamados pelos clientes para enviar mensagens ou para se inscrever em mensagens do servidor.
Nesta demonstração, criei um Hub com dois métodos que retornam, cada um, um fluxo de dados simulando uma mudança no preço da moeda. Quando o cliente chama os métodos, ele adicionará o ConnectionId
do cliente a uma lista de ouvintes e retornará o fluxo de dados para o cliente. Depois disso, o serviço RealTimeDataStreamWriter
gravará cada mudança no preço da moeda nos fluxos dos ouvintes.
O OnDisconnectedAsync
é chamado quando um cliente se desconecta e remove o cliente da lista de ouvintes.
⚠️ Esta é uma implementação ingênua que não é horizontalmente escalável. É apenas para fins de demonstração. Além disso, para escalar o SignalR horizontalmente, um plano de dados Redis deve ser configurado.
Implementação do RealTimeDataHub
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| using BlazorWasmSignalR.SignalRServer.BackgroundServices;
using BlazorWasmSignalR.Wasm.Shared;
using Microsoft.AspNetCore.SignalR;
using System.Threading.Channels;
namespace BlazorWasmSignalR.SignalRServer.Hubs;
public class RealTimeDataHub : Hub
{
private readonly RealTimeDataStreamWriter _realTimeDataStreamWriter;
public RealTimeDataHub(RealTimeDataStreamWriter realTimeDataStreamWriter)
{
_realTimeDataStreamWriter = realTimeDataStreamWriter;
}
public ChannelReader<CurrencyStreamItem> CurrencyValues(CancellationToken cancellationToken)
{
var channel = Channel.CreateUnbounded<CurrencyStreamItem>();
_realTimeDataStreamWriter.AddCurrencyListener(Context.ConnectionId, channel.Writer);
return channel.Reader;
}
public ChannelReader<DataItem> Variation(CancellationToken cancellationToken)
{
var channel = Channel.CreateUnbounded<DataItem>();
_realTimeDataStreamWriter.AddVariationListener(Context.ConnectionId, channel.Writer);
return channel.Reader;
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_realTimeDataStreamWriter.RemoveListeners(Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}
|
Implementação do RealTimeDataStreamWriter
Este serviço mantém uma lista de clientes que se inscreveram para receber mudanças de preço e simula uma mudança de preço a cada segundo.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
| using BlazorWasmSignalR.Wasm.Shared;
using System.Security.Cryptography;
using System.Threading.Channels;
namespace BlazorWasmSignalR.SignalRServer.BackgroundServices;
public class RealTimeDataStreamWriter
{
private readonly Dictionary<string, ChannelWriter<CurrencyStreamItem>> _currencyWriters;
private readonly Dictionary<string, ChannelWriter<DataItem>> _variationWriters;
private readonly Timer _timer = default!;
private int _currentVariationValue = 50;
private decimal _currentYenValue = RandomNumberGenerator.GetInt32(1, 3);
private decimal _currentEuroValue = RandomNumberGenerator.GetInt32(1, 3);
public RealTimeDataStreamWriter()
{
_timer = new(OnElapsedTime, null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
_currencyWriters = new();
_variationWriters = new();
}
public void AddCurrencyListener(string connectionId, ChannelWriter<CurrencyStreamItem> channelWriter)
{
_currencyWriters[connectionId] = channelWriter;
}
public void AddVariationListener(string connectionId, ChannelWriter<DataItem> channelWriter)
{
_variationWriters[connectionId] = channelWriter;
}
public void RemoveListeners(string connectionId)
{
_currencyWriters.Remove(connectionId);
_variationWriters.Remove(connectionId);
}
public void Dispose()
{
_timer?.Dispose();
}
private void OnElapsedTime(object? state)
{
SendCurrencyData();
SendVariationData();
}
private void SendCurrencyData()
{
var date = DateTime.Now;
var yenDecimals = RandomNumberGenerator.GetInt32(-20, 20) / 100M;
var euroDecimals = RandomNumberGenerator.GetInt32(-20, 20) / 100M;
_currentYenValue = Math.Max(0.5M, _currentYenValue + yenDecimals);
_currentEuroValue = Math.Max(0.5M, _currentEuroValue + euroDecimals);
var currencyStreamItem = new CurrencyStreamItem()
{
Minute = date.ToString("hh:mm:ss"),
YenValue = _currentYenValue,
EuroValue = _currentEuroValue
};
foreach(var listener in _currencyWriters)
{
_ = listener.Value.WriteAsync(currencyStreamItem);
}
}
private void SendVariationData()
{
var min = Math.Max(0, _currentVariationValue - 20);
var max = Math.Min(100, _currentVariationValue + 20);
var variationValue = new DataItem(DateTime.Now.ToString("hh:mm:ss"),
RandomNumberGenerator.GetInt32(min, max));
_currentVariationValue = (int)variationValue.Value;
foreach (var listener in _variationWriters)
{
_ = listener.Value.WriteAsync(variationValue);
}
}
}
|
ApexCharts para Blazor
ApexCharts é uma biblioteca JavaScript gratuita de código aberto para gerar gráficos interativos e responsivos. Ele tem uma ampla gama de tipos de gráficos. É a melhor biblioteca gratuita que encontrei para trabalhar com gráficos em tempo real, com animações fluidas.
ApexCharts para Blazor é uma biblioteca wrapper para trabalhar com ApexCharts em Blazor. Ele fornece um conjunto de componentes Blazor que tornam mais fácil usar os gráficos em aplicativos Blazor.

O Frontend - Blazor WebAssembly
No projeto Blazor WebAssembly, precisamos instalar os pacotes NuGet Blazor-ApexCharts
e Microsoft.AspNetCore.SignalR.Client
:
dotnet add package Blazor-ApexCharts
dotnet add package Microsoft.AspNetCore.SignalR.Client
Adicionando os gráficos
Na página principal, teremos dois gráficos:
- Um gráfico de linha com atualizações de preço de Yen e Euro;
- Um gráfico de velocímetro com um valor de variação.
Para renderizar um gráfico usando ApexCharts para Blazor, usamos o componente ApexChart
e um ApexPointSeries
para cada série.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| <ApexChart TItem="DataItem"
Title="Currency Exchange Rates in USD"
Options="@_lineChartOptions"
@ref="_lineChart">
<ApexPointSeries TItem="DataItem"
Items="_yenSeries"
Name="Yen"
SeriesType="SeriesType.Line"
XValue="@(e => e.Minute)"
YAggregate="@(e => e.Sum(e => e.Value))" />
<ApexPointSeries TItem="DataItem"
Items="_euroSeries"
Name="Euro"
SeriesType="SeriesType.Line"
XValue="@(e => e.Minute)"
YAggregate="@(e => e.Sum(e => e.Value))" />
</ApexChart>
|
ℹ️ O atributo @ref
define uma variável para acessar o objeto ApexChart
. Ele será usado para atualizar o gráfico quando novos valores chegarem.
A propriedade Options
recebe um ApexChartOptions<DataItem>
onde podemos personalizar nosso gráfico. Nesta amostra, estou:
- Habilitando animações e definindo sua velocidade para 1 segundo;
- Desabilitando a barra de ferramentas do gráfico e o zoom;
- Travando o eixo X em 12 elementos. Os valores mais antigos são empurrados para fora do gráfico;
- Fixando o eixo Y na faixa de 0 a 5.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| private ApexChartOptions<DataItem> _lineChartOptions = new ApexChartOptions<DataItem>
{
Chart = new Chart
{
Animations = new()
{
Enabled = true,
Easing = Easing.Linear,
DynamicAnimation = new()
{
Speed = 1000
}
},
Toolbar = new()
{
Show = false
},
Zoom = new()
{
Enabled = false
}
},
Stroke = new Stroke { Curve = Curve.Straight },
Xaxis = new()
{
Range = 12
},
Yaxis = new()
{
new()
{
DecimalsInFloat = 2,
TickAmount = 5,
Min = 0,
Max = 5
}
}
};
|
💡 A documentação das opções está nos documentos do ApexCharts.
Implementação do RealtimeCharts.razor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| @page "/"
@using BlazorWasmSignalR.Wasm.Shared
@using System.Security.Cryptography;
<PageTitle>Gráficos em tempo real no Blazor WebAssembly</PageTitle>
<h1>Gráficos em tempo real no Blazor WebAssembly</h1>
<div class="chart-container">
<div class="radial-chart">
<ApexChart TItem="DataItem"
Title="Transactions"
Options="@_radialChartOptions"
@ref="_radialChart">
<ApexPointSeries TItem="DataItem"
Items="_radialData"
SeriesType="SeriesType.RadialBar"
Name="Variation"
XValue="@(e => "Variation")"
YAggregate="@(e => e.Average(e => e.Value))" />
</ApexChart>
</div>
<div class="line-chart">
<ApexChart TItem="DataItem"
Title="Currency Exchange Rates in USD"
Options="@_lineChartOptions"
@ref="_lineChart">
<ApexPointSeries TItem="DataItem"
Items="_yenSeries"
Name="Yen"
SeriesType="SeriesType.Line"
XValue="@(e => e.Minute)"
YAggregate="@(e => e.Sum(e => e.Value))" />
<ApexPointSeries TItem="DataItem"
Items="_euroSeries"
Name="Euro"
SeriesType="SeriesType.Line"
XValue="@(e => e.Minute)"
YAggregate="@(e => e.Sum(e => e.Value))" />
</ApexChart>
</div>
</div>
|
Conectando ao fluxo SignalR
Para conectar a um fluxo SignalR, criamos uma conexão com o Hub usando a classe HubConnectionBuilder
e abrimos a conexão usando o método StartAsync
da classe HubConnection
.
Em seguida, nos inscrevemos no fluxo usando o método StreamAsChannelAsync
, passando o nome do fluxo.
1
2
3
4
5
6
7
8
9
10
11
| var connection = new HubConnectionBuilder()
.WithUrl(_configuration["RealtimeDataUrl"]!) //https://localhost:7086/realtimedata
.Build();
await connection.StartAsync();
var channelCurrencyStreamItem = await connection
.StreamAsChannelAsync<CurrencyStreamItem>("CurrencyValues");
var channelVariation = await connection
.StreamAsChannelAsync<DataItem>("Variation");
|
ℹ️ Observe que uma conexão pode ser usada para se inscrever em muitos fluxos.
Atualizando os valores do gráfico em tempo real
Para ler os dados do fluxo, uso o método WaitToReadAsync
da classe ChannelReader
para esperar por novas mensagens e, em seguida, percorro-as com o método TryRead
.
Em seguida, adiciono os valores à série e chamo o método UpdateSeriesAsync
do gráfico para forçar uma nova renderização.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| private async Task ReadCurrencyStreamAsync(ChannelReader<CurrencyStreamItem> channelCurrencyStreamItem)
{
// Aguarde assincronamente que os dados se tornem disponíveis
while (await channelCurrencyStreamItem.WaitToReadAsync())
{
// Leia todos os dados atualmente disponíveis de forma síncrona, antes de esperar por mais dados
while (channelCurrencyStreamItem.TryRead(out var currencyStreamItem))
{
_yenSeries.Add(new(currencyStreamItem.Minute, currencyStreamItem.YenValue));
_euroSeries.Add(new(currencyStreamItem.Minute, currencyStreamItem.EuroValue));
await _lineChart.UpdateSeriesAsync();
}
}
}
|
⚠️ Como quero que as atualizações sejam assíncronas, não await
nos métodos ReadCurrencyStreamAsync
e ReadVariationStreamAsync
.
Aqui está o código completo:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
| private readonly IList<DataItem> _yenSeries = new List<DataItem>();
private readonly IList<DataItem> _euroSeries = new List<DataItem>();
private ApexChart<DataItem> _lineChart = default!;
private ApexChart<DataItem> _radialChart = default!;
private ApexChart<DataItem> _lineChart = default!;
protected override async Task OnInitializedAsync()
{
_radialData = new DataItem[1] {
new(DateTime.Now.ToString("mm:ss"), 0)
}; //Inicialize os dados para o gráfico radial
var connection = new HubConnectionBuilder()
.WithUrl(_configuration["RealtimeDataUrl"]!)
.Build();
await connection.StartAsync();
var channelCurrencyStreamItem = await connection
.StreamAsChannelAsync<CurrencyStreamItem>("CurrencyValues");
var channelVariation = await connection
.StreamAsChannelAsync<DataItem>("Variation");
_ = ReadCurrencyStreamAsync(channelCurrencyStreamItem);
_ = ReadVariationStreamAsync(channelVariation);
}
private async Task ReadCurrencyStreamAsync(ChannelReader<CurrencyStreamItem> channelCurrencyStreamItem)
{
// Aguarde assincronamente que os dados se tornem disponíveis
while (await channelCurrencyStreamItem.WaitToReadAsync())
{
// Leia todos os dados atualmente disponíveis de forma síncrona, antes de esperar por mais dados
while (channelCurrencyStreamItem.TryRead(out var currencyStreamItem))
{
_yenSeries.Add(new(currencyStreamItem.Minute, currencyStreamItem.YenValue));
_euroSeries.Add(new(currencyStreamItem.Minute, currencyStreamItem.EuroValue));
await _lineChart.UpdateSeriesAsync();
}
}
}
private async Task ReadVariationStreamAsync(ChannelReader<DataItem> channelVariation)
{
// Aguarde assincronamente que os dados se tornem disponíveis
while (await channelVariation.WaitToReadAsync())
{
// Leia todos os dados atualmente disponíveis de forma síncrona, antes de esperar por mais dados
while (channelVariation.TryRead(out var variation))
{
_radialData[0] = variation;
await _radialChart.UpdateSeriesAsync();
}
}
}
|
Inspecionando as mensagens
Entrando nas ferramentas de desenvolvedor do navegador, podemos ver as mensagens na conexão WebSocket:

Código fonte completo
Repositório GitHub
Referências e links