Search code examples
razorblazorsignalrsignalr-hubsignalr.client

How to manage client connections in a Blazor Server + ASP.NET Core API + SignalR project


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:

  • If a user "User1" opens 3 tabs in their browser, 3 individual connections will be created for User1 on the server.
  • If another user "User2" opens 2 tabs in their browser, 2 more connections will be created for User2 on the server.
  • And if I'm not logged in, and I open 3 tabs, it will create 3 more connections on the server.

The desired environment, regardless of the number of tabs open, instead of duplicating connections:

  • User1 = 1 connection.
  • User2 = 1 connection.
  • Not logged = 1 connection.

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


Solution

  • 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
    
        }
    }