1

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

2
  • 1
    It has been a while for me in .NET, but have you looked at groups. This would mean you would need an identifier of some type. A login perhaps? Commented Feb 11, 2023 at 21:32
  • 1
    Did the answer below inspire you? Commented Feb 21, 2023 at 1:29

1 Answer 1

3

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

    }
}
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.