I am working on a Blazor Server app over a SignalR connection with an ASP.NET Core API to send real-time updates from the server to clients. However, I am having a problem with managing user connections.
The uniqueness of the problem is that each tab opened in a browser by a user represents an individual connection on the SignalR server. This becomes a problem when a user has multiple tabs open with the application and each of them is generating a unique connection. This is because each tab is considered a unique session and therefore the SignalR server creates a new connection for each tab. For example:
The desired environment, regardless of the number of tabs open, instead of duplicating connections:
My question is how can I effectively manage user connections so that there is only one connection per user/client/session instead of as many as opened tabs. Has anyone had a similar problem and knows how to solve it? I'm sorry if there is an usual easy know fix for this, but I'm looking around and I can't find something that fits exactly my behaviour; and I need some orientation on here instead of copy-paste some code, since conections managment it's a core feature and I'm not much familiar with these.
To clarifly some solutions I've tried are:
Sol. A) In the client: AddSingleton
instead of AddScoped
Sol. B) In the client: Set the ConnectionId
after hubConn.StartAsync()
Sol. B) In the server: Clients.Client(Context.ConnectionId).SendAsync()
instead of Clients.All.SendAsync()
And to mention I didn't used services.AddHttpClient()
w/
IClientFactory
, but I dont know if it's needed at all or if it's involved in the problem.
Thank you for your time and help!
I provide code used in the connections below:
ASP.NET Core API - SERVER:
Program.cs
using ChartServer.DataProvider;
using ChartServer.RHub;
using SharedModels;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Add CORS Policy
builder.Services.AddCors(option => {
option.AddPolicy("cors", policy => {
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyHeader();
});
});
builder.Services.AddSignalR();
// Register the Watcher
builder.Services.AddScoped<TimeWatcher>();
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseCors("cors");
app.UseAuthorization();
app.MapControllers();
// Add the SignalR Hub
app.MapHub<MarketHub>("/marketdata");
app.Run();
MarketController.cs
namespace ChartServer.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class MarketController : ControllerBase
{
private IHubContext<MarketHub> marketHub;
private TimeWatcher watcher;
public MarketController(IHubContext<MarketHub> mktHub, TimeWatcher watch)
{
marketHub = mktHub;
watcher = watch;
}
[HttpGet]
public IActionResult Get()
{
if(!watcher.IsWatcherStarted)
{
watcher.Watcher(()=>marketHub.Clients.All.SendAsync("SendMarketStatusData",MarketDataProvider.GetMarketData()));
}
return Ok(new { Message = "Request Completed" });
}
}
}
MarketHub.cs
namespace ChartServer.RHub
{
public class MarketHub : Hub
{
public async Task AcceptData(List<Market> data) =>
await Clients.All.SendAsync("CommunicateMarketData", data);
}
}
TimeWatcher.cs
namespace ChartServer.DataProvider
{
/// <summary>
/// This call will be used to send the data after each second to the client
/// </summary>
public class TimeWatcher
{
private Action? Executor;
private Timer? timer;
// we need to auto-reset the event before the execution
private AutoResetEvent? autoResetEvent;
public DateTime WatcherStarted { get; set; }
public bool IsWatcherStarted { get; set; }
/// <summary>
/// Method for the Timer Watcher
/// This will be invoked when the Controller receives the request
/// </summary>
public void Watcher(Action execute)
{
int callBackDelayBeforeInvokeCallback = 1000;
int timeIntervalBetweenInvokeCallback = 2000;
Executor = execute;
autoResetEvent = new AutoResetEvent(false);
timer = new Timer((object? obj) => {
Executor();
}, autoResetEvent, callBackDelayBeforeInvokeCallback, timeIntervalBetweenInvokeCallback);
WatcherStarted = DateTime.Now;
IsWatcherStarted = true;
}
}
}
Blazor Server app - CLIENT:
Program.cs
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using SignalsServer.Data;
using SignalsServer.HttpCaller;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddSingleton<WeatherForecastService>();
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri("https://localhost:7084/") });
builder.Services.AddScoped<MarketDataCaller>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.MapBlazorHub();
app.MapFallbackToPage("/_Host");
app.Run();
MarketDataCaller.cs
namespace SignalsServer.HttpCaller
{
public class MarketDataCaller
{
private HttpClient httpClient;
public MarketDataCaller(HttpClient http)
{
httpClient = http;
}
public async Task GetMarketDataAsync()
{
try
{
var response = await httpClient.GetAsync("marketdata");
if (!response.IsSuccessStatusCode)
throw new Exception("Something is wrong with the connection make sure that the server is running.");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
throw ex;
}
}
public async Task GetMarketEndpoint()
{
try
{
var response = await httpClient.GetAsync("https://localhost:7193/api/Market");
if (!response.IsSuccessStatusCode)
throw new Exception("Something is wrong with the connection so get call is not executing.");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
throw ex;
}
}
}
}
ChartComponent.razor
@page "/chartui"
@using Microsoft.AspNetCore.SignalR.Client;
@using SharedModels
@using System.Text.Json
@inject IJSRuntime js
@inject SignalsServer.HttpCaller.MarketDataCaller service;
<h3>Chart Component</h3>
<div>
<div class="container">
<table class="table table-bordered table-striped">
<tbody>
<tr>
<td>
<button class="btn btn-success"
@onclick="@generateLineChartTask">Line Chart</button>
</td>
<td>
<button class="btn btn-danger"
@onclick="@generateBarChartTask">Bar Chart</button>
</td>
</tr>
</tbody>
</table>
<div id="market"></div>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>Company Name</th>
<th>Volume</th>
</tr>
</thead>
<tbody>
@foreach (var item in MarketData)
{
<tr>
<td>@item.CompanyName</td>
<td>@item.Volume</td>
</tr>
}
</tbody>
</table>
<hr/>
<div class="container">
@ConnectionStatusMessage
</div>
</div>
</div>
@code {
private HubConnection? hubConn;
private string? ConnectionStatusMessage;
public List<Market> MarketData = new List<Market>();
public List<Market> MarketReceivedData = new List<Market>();
private List<string> xSource;
private List<int> ySource;
private List<object> source;
protected override async Task OnInitializedAsync()
{
xSource = new List<string>();
ySource = new List<int>();
source = new List<object>();
await service.GetMarketEndpoint();
hubConn = new HubConnectionBuilder().WithUrl("https://localhost:7193/marketdata").Build();
await hubConn.StartAsync();
if(hubConn.State == HubConnectionState.Connected )
ConnectionStatusMessage = "Connection is established Successfully...";
else
ConnectionStatusMessage = "Connection is not established...";
}
private int contador = 0;
private void MarketDataListener(string chartType)
{
hubConn.On<List<Market>>("SendMarketStatusData", async (data) =>
{
MarketData = new List<Market>();
foreach (var item in data)
{
Console.WriteLine($"Company Name: {item.CompanyName}, Volumn: {item.Volume}");
xSource.Add(item.CompanyName);
ySource.Add(item.Volume);
}
source.Add(ySource);
source.Add(xSource);
MarketData = data;
contador++;
Console.WriteLine($"CONTADOR: {contador}");
InvokeAsync(StateHasChanged);
await js.InvokeAsync<object>(chartType, source.ToArray());
xSource.Clear();
ySource.Clear();
});
}
private void ReceivedMarketDataListener()
{
hubConn.On<List<Market>>("CommunicateMarketData", (data) =>
{
MarketReceivedData = data;
InvokeAsync(StateHasChanged);
});
}
public async Task Dispose()
{
await hubConn.DisposeAsync();
}
async Task generateLineChartTask()
{
MarketDataListener("marketLineChart");
ReceivedMarketDataListener();
await service.GetMarketDataAsync();
}
async Task generateBarChartTask()
{
MarketDataListener("marketBarChart");
ReceivedMarketDataListener();
await service.GetMarketDataAsync();
}
}
FULL CODE: https://github.com/maheshsabnis/SignalRChartBlazor
In the signalr application, opening the page in the browser will generate a new connectionId, which is the default behavior.
We can maintain the ConnectionIds of each user through the following sample code.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Connections.Features;
using Microsoft.AspNetCore.SignalR;
using System.Collections.Concurrent;
using System.Diagnostics;
namespace SignalRMiddleawre.Hubs
{
/// <summary>
/// </summary>
[Authorize]
public partial class MainHub : Hub
{
#region Connection
/// <summary>
/// Manage Connected Users
/// </summary>
private static ConcurrentDictionary<string?, List<string>>? ConnectedUsers = new ConcurrentDictionary<string?, List<string>>();
/// <summary>
/// OnConnect Event
/// </summary>
/// <param name="userid"></param>
/// <returns></returns>
///
public override async Task OnConnectedAsync()
{
// Get HttpContext In asp.net core signalr
//IHttpContextFeature hcf = (IHttpContextFeature)this.Context.Features[typeof(IHttpContextFeature)];
//HttpContext hc = hcf.HttpContext;
//string uid = hc.Request.Path.Value.Split(new string[] { "=", "" }, StringSplitOptions.RemoveEmptyEntries)[1].ToString();
string? userid = Context.User?.Identity?.Name;
if (userid == null || userid.Equals(string.Empty))
{
Trace.TraceInformation("user not loged in, can't connect signalr service");
return;
}
Trace.TraceInformation(userid + "connected");
// save connection
List<string>? existUserConnectionIds;
ConnectedUsers.TryGetValue(userid, out existUserConnectionIds);
if (existUserConnectionIds == null)
{
existUserConnectionIds = new List<string>();
}
existUserConnectionIds.Add(Context.ConnectionId);
ConnectedUsers.TryAdd(userid, existUserConnectionIds);
await Clients.All.SendAsync("ServerInfo", userid, userid + " connected, connectionId = " + Context.ConnectionId);
await base.OnConnectedAsync();
}
/// <summary>
/// OnDisconnected event
/// </summary>
/// <param name="userid"></param>
/// <returns></returns>
public override async Task OnDisconnectedAsync(Exception? exception)
{
string? userid = Context.User?.Identity?.Name;
// save connection
List<string>? existUserConnectionIds;
ConnectedUsers.TryGetValue(userid, out existUserConnectionIds);
existUserConnectionIds.Remove(Context.ConnectionId);
if (existUserConnectionIds.Count == 0)
{
List<string> garbage;
ConnectedUsers.TryRemove(userid, out garbage);
}
await base.OnDisconnectedAsync(exception);
}
#endregion
#region Message
/// <summary>
/// Send msg to all user
/// </summary>
/// <param name="userid"></param>
/// <param name="message"></param>
/// <returns></returns>
public async Task SendMessage(string msgType, string message)
{
await Clients.All.SendAsync("ReceiveMessage", msgType, message);
}
/// <summary>
/// Send msg to user by userid
/// </summary>
/// <param name="connectionId"></param>
/// <param name="message">message format : type-message </param>
/// <returns></returns>
public async Task SendToSingleUser(string userid, string message)
{
List<string>? existUserConnectionIds;
// find all the connectionids by userid
ConnectedUsers.TryGetValue(userid, out existUserConnectionIds);
if (existUserConnectionIds == null)
{
existUserConnectionIds = new List<string>();
}
existUserConnectionIds.Add(Context.ConnectionId);
ConnectedUsers.TryAdd(userid, existUserConnectionIds);
await Clients.Clients(existUserConnectionIds).SendAsync("ReceiveMessage", message);
}
#endregion
}
}