Introduction
SignalR is a free open-source library for ASP.NET Core that allows the server to push real-time asynchronous messages to connected clients. It is an abstraction layer on top of WebSockets, making it easier to use and providing fallback to other forms of communication when necessary (server-sent events and long polling).
In this post, I’ll show how to build a Blazor WebAssembly app that displays real-time charts from a SignalR server.
The project structure
The project will have 4 projects, created using 2 project templates:
- ASP.NET Core Project (for the SignalR server)
- BlazorWasmSignalR.SignalRServer
- Blazor WebAssembly App (ASP.NET Core Hosted)
- BlazorWasmSignalR.Wasm.Client (Blazor WASM)
- BlazorWasmSignalR.Wasm.Server (ASP.NET Core Host)
- BlazorWasmSignalR.Wasm.Shared (Common components)
The Backend - SignalR Server
SignalR is part of ASP.NET Core. To use it, we just need to configure it in our Startup.cs
or Program.cs
(if using top-level statements):
1
2
3
4
5
6
7
8
9
10
11
12
13
| //Add SignalR services
builder.Services.AddSignalR();
...
var app = builder.Build();
...
//Map our Hub
app.MapHub<RealTimeDataHub>("/realtimedata");
app.Run();
|
SignalR clients connect to Hubs, which are components that define methods that can be called by the clients to send messages or to subscribe to messages from the server.
In this demo, I’ve created a Hub with two methods that return, each, a stream of data simulating a currency price change. When the client calls the methods, it will add the client’s ConnectionId
to a list of listeners and return the stream of data to the client. After that, the RealTimeDataStreamWriter
service will write every currency price change to the listeners streams.
The OnDisconnectedAsync
is called when a client disconnects and removes the client from the listeners list.
⚠️ This is a naive implementation that is not horizontally scalable. It is for demo purposes only. Also, for scaling SignalR horizontally, a Redis dataplane must be configured.
RealTimeDataHub implementation
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);
}
}
|
RealTimeDataStreamWriter implementation
This service keeps a list of clients that subscribed to receive price changes and simulates a price change every second.
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 for Blazor
ApexCharts is a free open-source JavaScript library to generate interactive and responsive charts. It has a wide range of chart types. It is the best free library that I found for working with real-time charts, with fluent animations.
ApexCharts for Blazor is a wrapper library for working with ApexCharts in Blazor. It provides a set of Blazor components that makes it easier to use the charts within Blazor applications.
The Frontend - Blazor WebAssembly
In the Blazor WebAssembly project, we need to install the Blazor-ApexCharts
and Microsoft.AspNetCore.SignalR.Client
nuget packages:
dotnet add package Blazor-ApexCharts
dotnet add package Microsoft.AspNetCore.SignalR.Client
Adding the charts
In the main page, we’ll have two charts:
- One line chart with Yen and Euro price updates;
- One gauge chart with a variation value.
To render a chart using ApexCharts for Blazor, we use the ApexChart
component and one ApexPointSeries
for each series.
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>
|
ℹ️ The @ref
attribute defines a variable to access the ApexChart
object. It will be used to update the chart when new values arrive.
The Options
property receives a ApexChartOptions<DataItem>
where we can customize our chart. In this sample, I’m:
- Enabling animations and setting their speed to 1 second;
- Disabling the chart toolbar and zoom;
- Locking the X axis in 12 elements. Older values are pushed out of the chart;
- Fixing the Y axis in the range of 0 to 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
}
}
};
|
💡 The options documentations is in the ApexCharts docs.
RealtimeCharts.razor implementation
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>Real-time charts in Blazor WebAssembly</PageTitle>
<h1>Real-time charts in 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>
|
Connecting to the SignalR stream
To connect to a SignalR stream, we create a connection to the Hub using the HubConnectionBuilder
class and open the connection using the StartAsync
method of the HubConnection
class.
Then, we subscribe to the stream using the StreamAsChannelAsync
method, passing the stream name.
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");
|
ℹ️ Note that one connection can be used to subscribe to many streams.
Updating the chart values in real-time
To read the data from the stream, I use the method WaitToReadAsync
of the ChannelReader
class to wait for new messages and then loop through them with the TryRead
method.
Then, I add the values to the series and call the UpdateSeriesAsync
method of the chart to force a re-render.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| private async Task ReadCurrencyStreamAsync(ChannelReader<CurrencyStreamItem> channelCurrencyStreamItem)
{
// Wait asynchronously for data to become available
while (await channelCurrencyStreamItem.WaitToReadAsync())
{
// Read all currently available data synchronously, before waiting for more data
while (channelCurrencyStreamItem.TryRead(out var currencyStreamItem))
{
_yenSeries.Add(new(currencyStreamItem.Minute, currencyStreamItem.YenValue));
_euroSeries.Add(new(currencyStreamItem.Minute, currencyStreamItem.EuroValue));
await _lineChart.UpdateSeriesAsync();
}
}
}
|
⚠️ Because I want the updates to be asynchronous, I do not await
on the ReadCurrencyStreamAsync
and ReadVariationStreamAsync
methods.
Here is the complete code:
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)
}; //Initialize the data for the radial chart
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)
{
// Wait asynchronously for data to become available
while (await channelCurrencyStreamItem.WaitToReadAsync())
{
// Read all currently available data synchronously, before waiting for more data
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)
{
// Wait asynchronously for data to become available
while (await channelVariation.WaitToReadAsync())
{
// Read all currently available data synchronously, before waiting for more data
while (channelVariation.TryRead(out var variation))
{
_radialData[0] = variation;
await _radialChart.UpdateSeriesAsync();
}
}
}
|
Inspecting the messages
Going into the browser’s developer tools, we can see the messages in the WebSocket connection:
Full source code
GitHub repository
References and Links