Featured image of post Gráficos em tempo real com Blazor, SignalR e ApexCharts

Gráficos em tempo real com Blazor, SignalR e ApexCharts

Streaming de preços de moedas com SignalR

Introdução Link to this section

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 Link to this section

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 Link to this section

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 Link to this section

 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 Link to this section

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 Link to this section

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 Link to this section

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 Link to this section

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 Link to this section

 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 Link to this section

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 Link to this section

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 Link to this section

Entrando nas ferramentas de desenvolvedor do navegador, podemos ver as mensagens na conexão WebSocket:

Código fonte completo Link to this section

Repositório GitHub

💬 Like or have something to add? Leave a comment below.
Ko-fi
GitHub Sponsor
Licensed under CC BY-NC-SA 4.0
Criado com Hugo
Tema Stack desenvolvido por Jimmy