I have a hub that works great. There are 3 events at the moment:
ReceiveMessage
(taken directly from the Chat app examples)RefreshPage
(used when one page invalidates another page)NotificationsUpdated
(an icon with a number of how many Notifications you currently have)Right now, the Chat page works as expected and I am catching each of the 3 events on it to prove to me they all work (and they do.) However, I know that some pages in the site will not need to connect to this hub while others will connect and only need to handle 1 or 2 of the events. Chances are I will delete the Chat's ReceiveMessage event eventually.
At the moment, I am using the HubConnectionBuilder
like so:
protected override async Task OnInitializedAsync()
{
hubConnection = new HubConnectionBuilder()
.WithUrl(NavigationManager.ToAbsoluteUri("/hub/ping"), options =>
{
options.AccessTokenProvider = async () => await GetAccessTokenValueAsync();
})
.WithAutomaticReconnect()
.Build();
hubRegistrations.Add(hubConnection.OnMessageReceived(OnMessageReceivedAsync));
hubRegistrations.Add(hubConnection.OnRefreshPage(OnRefreshPageAsync));
hubRegistrations.Add(hubConnection.OnNotificationsUpdate(OnNotificationsUpdateAsync));
await hubConnection.StartAsync();
}
And as an example of one of the events:
public async Task OnMessageReceivedAsync(string user, string message) =>
await InvokeAsync(() =>
{
var encodedMessage = $"{user}: {message}";
Messages.Add(encodedMessage);
StateHasChanged();
});
And there is a lot of duplicated code if I have to implement this on another page (sure, if I do not have to implement all 3 events, there is less, but not much less.)
[Inject]
public required NavigationManager NavigationManager { get; set; }
[Inject]
public required IAccessTokenProvider TokenProvider { get; set; }
[Inject]
public required StateManager State { get; set; }
private HubConnection hubConnection;
private readonly HashSet<IDisposable> hubRegistrations = new();
public async ValueTask DisposeAsync()
{
await PHST.DisposeAsync();
if (hubRegistrations is { Count: > 0 })
{
foreach (var disposable in hubRegistrations)
disposable.Dispose();
}
if (hubConnection is not null)
{
await hubConnection.DisposeAsync();
}
}
So, as a coder, I want to make this as simple as possible for all future coders and pages. I'd like to make it a service or a singlton or something. Is this something I "can" do or is there a better way to have a single hub on any number of pages?
I have toyed around with making a base class but inheritance would be a bit of a pain on other pages where there is already inheritance. I have only tried a bit of a service that I can inject, but wanted to stop and ask the question before I go down a rabbit hole.
I'd also like to make it rather dynamic so the events can do what they want with the given data. At the moment, each of them are dynamic using a few extension methods.
public static IDisposable OnMessageReceived(this HubConnection connection, Func<string, string, Task> handler) =>
connection.On("ReceiveMessage", handler);
public static IDisposable OnRefreshPage(this HubConnection connection, Func<string, Task> handler) =>
connection.On("RefreshPage", handler);
public static IDisposable OnNotificationsUpdate(this HubConnection connection, Func<int, Task> handler) =>
connection.On("NotificationsUpdate", handler);
Seeing that I received no help I continued forward. I am not sure if I did it perfectly, but my requirement of less redundancy on each page, it fits that! I wanted to even make group names as idiot proof as possible and I implemented an interface to do that (commented below - see the HubGroupXXX
methods and HubGroupAdders
class.)
This will be a full view of everything, not just the abstraction into a helper class, in case it helps anyone in the future.
Let's start with the Helper side.
builder.Services.AddScoped<IPingActionsHubHelper, PingActionsHubHelper>();
public interface IPingActionsHubHelper
{
/// <summary>
/// Use this method to do all of the heavy lifting to connect to the hub. It should be run within the OnInitializedAsync method.
/// It should be followed up by at least one of the AddOnXXXEvents and locally handled.
/// </summary>
/// <param name="groupNames">Chances are your page would need to let the hub know what groups to be in, so they may be signaled later.</param>
/// <returns></returns>
Task HubSetup(List<IHubGroupAdder> groupNames = null);
/// <summary>
/// After the HubSetup and each of the AddOnXXXEvents, call this to start the hub.
/// </summary>
/// <returns></returns>
Task HubStart();
HubConnection HubConnection { get; }
ValueTask DisposeAsync();
void AddOnReceiveMessageEvent(Action<string, string> action);
void AddOnRefreshPageEvent(Action<SignalRRefreshTypes> action);
void AddOnNotificationsUpdateEvent(Action<int, bool> action);
}
public class PingActionsHubHelper : IPingActionsHubHelper
{
private readonly NavigationManager NavigationManager;
private readonly IAccessTokenProvider TokenProvider;
private readonly HashSet<IDisposable> HubRegistrations = new();
public HubConnection HubConnection { get; private set; }
public PingActionsHubHelper(IAccessTokenProvider tokenProvider, NavigationManager navigationManager)
{
NavigationManager = navigationManager;
TokenProvider = tokenProvider;
}
public async Task HubSetup(List<IHubGroupAdder> groupNames = null) // Run this within the OnInitializedAsync event
{
var groupNamesParsed = string.Empty;
if (groupNames is not null && groupNames.Any())
{
groupNamesParsed = string.Join("|", groupNames.Select(x => x.ToString()));
}
HubConnection = new HubConnectionBuilder()
.WithUrl(NavigationManager.ToAbsoluteUri($"/hub/ping?{(string.IsNullOrWhiteSpace(groupNamesParsed) ? "" : groupNamesParsed)}"), options =>
{
options.AccessTokenProvider = async () => await GetAccessTokenValueAsync();
})
.WithAutomaticReconnect()
.Build();
}
public async Task HubStart()
{
await HubConnection.StartAsync();
}
#region Events to consume
public void AddOnReceiveMessageEvent(Action<string, string> action)
{
HubRegistrations.Add(HubConnection.On(Core.Constants.PingActionsHub.MethodNames.ReceiveMessage, action));
}
public void AddOnRefreshPageEvent(Action<SignalRRefreshTypes> action)
{
HubRegistrations.Add(HubConnection.On(Core.Constants.PingActionsHub.MethodNames.RefreshPage, action));
}
public void AddOnNotificationsUpdateEvent(Action<int, bool> action)
{
HubRegistrations.Add(HubConnection.On(Core.Constants.PingActionsHub.MethodNames.NotificationsUpdate, action));
}
#endregion Events to consume
private async ValueTask<string> GetAccessTokenValueAsync()
{
var result = await TokenProvider.RequestAccessToken();
return result.TryGetToken(out var accessToken) ? accessToken.Value : null;
}
public async ValueTask DisposeAsync()
{
if (HubRegistrations is { Count: > 0 })
{
foreach (var disposable in HubRegistrations)
disposable.Dispose();
}
if (HubConnection is not null)
{
await HubConnection.DisposeAsync();
}
}
}
[Inject]
public IPingActionsHubHelper HubHelper { get; set; }
public bool IsConnected => HubHelper.HubConnection?.State == HubConnectionState.Connected;
protected override async Task OnInitializedAsync()
{
// Here is an example of adding group(s) to whatever page you are on that is logical
// You should be adding at least one group so it can be signaled
// By default, any instance of the PingActionsHub (using the HubHelper) will have the User's Id group
await HubHelper.HubSetup(new List<IHubGroupAdder>()
{
new HubGroupProject(333333),
new HubGroupPage(Core.SignalRPageNames.FGV, 444444),
new HubGroupFormulation(999999),
});
// This event will most likely never be used, it is really just for the chat page
HubHelper.AddOnReceiveMessageEvent((string user, string message) =>
{
var encodedMessage = $"{user}: {message}";
Messages.Add(encodedMessage);
StateHasChanged();
});
// Implement this on your page when another page could alater the Project or Formulation and should refresh the page where the hub is instantiated
HubHelper.AddOnRefreshPageEvent((SignalRRefreshTypes refreshType) =>
{
System.Console.WriteLine($"We have caught a Page Refresh of type: {refreshType}");
// This is where the page would refresh or LoadData or something based perhaps on the refreshType
});
// This should really only need to be implemented on the Navbar component (which it is), unless some other location should show
// how many notifications a user has
HubHelper.AddOnNotificationsUpdateEvent((int totalNotifications, bool inError) =>
{
State.UsersTotalNotifications.SetState(totalNotifications);
StateHasChanged();
});
await HubHelper.HubStart();
}
I have comments in the code above. But it is just 3 steps:
HubSetup
method and pass any groups you want to catchHubHelper.AddOnXXXEvent
to listen for and what to do with themHubStart
methodThat is it from the abstraction. The rest of the code below is my complete implementation where I have a Client-side Service that calls Server-side Controller that allows the Hub to be called from anywhere.
The following code shows how to interact with the page where there is a Send button in this example Chat.razor page. It calls the local Service which calls the servers Controller which in turn calls the Hubs method.
[Inject]
public IPingActionsService PingActionsService { get; set; }
public async Task SendMessage()
{
await PingActionsService.SendMessage(new PingSendMessageAction
{
ToAll = true,
Message = Message,
SentFrom = UsersName,
});
Message = string.Empty;
await MessageRef.FocusAsync();
}
public interface IPingActionsService
{
Task<bool> SendMessage(PingSendMessageAction notificationPing);
Task<bool> SendRefresh(PingSendRefreshAction notificationPing);
Task<bool> UsersNotifications(PingUsersNotificationsAction notificationPing);
}
The next file just implements the above interface to just call the Server-side Controller.
// Client - PingActionsService.cs
public class PingActionsService : IPingActionsService
{
private const string BaseUri = "api/ping-actions";
private readonly IHttpService HttpService;
public PingActionsService(IHttpService httpService)
{
HttpService = httpService;
}
public async Task<bool> SendMessage(PingSendMessageAction notificationPing)
{
return await HttpService.PostAsJsonAsync<PingSendMessageAction, bool>($"{BaseUri}/send-message", notificationPing);
}
public async Task<bool> SendRefresh(PingSendRefreshAction notificationPing)
{
return await HttpService.PostAsJsonAsync<PingSendRefreshAction, bool>($"{BaseUri}/refresh-page", notificationPing);
}
public async Task<bool> UsersNotifications(PingUsersNotificationsAction notificationPing)
{
return await HttpService.PostAsJsonAsync<PingUsersNotificationsAction, bool>($"{BaseUri}/users-notifications", notificationPing);
}
}
public interface IPingActionsService
{
// This is the generic call to determine which one of the more specific calls to make
Task<bool> HandlePing(PingActionBase ping);
// These are the calls that can be used within any other service to pass to the UI
Task<bool> SendMessageToGroups(PingSendMessageAction ping);
Task<bool> SendRefreshSignalToGroups(PingSendRefreshAction ping);
Task<bool> UpdateUsersNotifications(PingUsersNotificationsAction ping);
}
[ApiController]
[Route("api/ping-actions")]
public class PingActionsController : ControllerBase
{
private readonly IPingActionsService PingActionsService;
public PingActionsController(IPingActionsService pingActionsService)
{
PingActionsService = pingActionsService;
}
[HttpPost]
[Route("ping")]
public async Task<bool> Ping(PingActionBase notificationPing)
{
return await PingActionsService.HandlePing(notificationPing);
}
[HttpPost]
[Route("send-message")]
public async Task<bool> SendMessage(PingSendMessageAction notificationPing)
{
return await PingActionsService.HandlePing(notificationPing);
}
[HttpPost]
[Route("refresh-page")]
public async Task<bool> SendRefresh(PingSendRefreshAction notificationPing)
{
return await PingActionsService.HandlePing(notificationPing);
}
[HttpPost]
[Route("users-notifications")]
public async Task<bool> UsersNotifications(PingUsersNotificationsAction notificationPing)
{
return await PingActionsService.HandlePing(notificationPing);
}
}
public class PingActionsService : IPingActionsService
{
private readonly IHubContext<PingActionsHub, IPingActionsHub> Context;
private readonly IProjectUsersService ProjectUsersService;
public PingActionsService(IHubContext<PingActionsHub, IPingActionsHub> context, IProjectUsersService projectUsersService)
{
Context = context;
ProjectUsersService = projectUsersService;
}
public async Task<bool> HandlePing(PingActionBase ping)
{
bool result;
if (ping is PingSendMessageAction sendAction)
result = await SendMessageToGroups(sendAction);
else if (ping is PingSendRefreshAction refreshAction)
result = await SendRefreshSignalToGroups(refreshAction);
else if (ping is PingUsersNotificationsAction notificationAction)
result = await UpdateUsersNotifications(notificationAction);
else
throw new ArgumentOutOfRangeException("PingType selection not valid");
return result;
}
public async Task<bool> SendMessageToGroups(PingSendMessageAction ping)
{
if (ping.ToAll)
{
await Context.Clients.All.ReceiveMessage(ping.SentFrom, ping.Message);
}
else
{
var toGroups = ParseGroups(ping);
foreach (var group in toGroups)
await Context.Clients.Group(group).ReceiveMessage(ping.SentFrom, ping.Message);
}
return true;
}
public async Task<bool> SendRefreshSignalToGroups(PingSendRefreshAction ping)
{
var toGroups = ParseGroups(ping);
foreach (var group in toGroups)
await Context.Clients.Group(group).RefreshPage(ping.RefreshType);
return true;
}
public async Task<bool> UpdateUsersNotifications(PingUsersNotificationsAction ping)
{
if ((!ping.UserId.HasValue || ping.UserId.Value <= 0) && (!ping.ProjectId.HasValue || ping.ProjectId <= 0))
throw new ArgumentOutOfRangeException($"You must specify a UserId or ProjectId when sending an UpdateUsersNotification");
var groupNames = new List<string>();
if (ping.UserId.HasValue)
groupNames.Add($"{Core.Constants.PingActionsHub.GroupNames.User}:{ping.UserId}");
if (ping.ProjectId.HasValue)
{
var projectUsers = await ProjectUsersService.GetProjectUsersAsync(ping.ProjectId.Value);
foreach (var projectUser in projectUsers)
groupNames.Add($"{Core.Constants.PingActionsHub.GroupNames.User}:{projectUser.UserId}");
}
foreach (var group in groupNames)
await Context.Clients.Group(group).NotificationsUpdate(ping.TotalNotificadtions, ping.InError);
return true;
}
private static HashSet<string> ParseGroups(PingActionReceivers ping)
{
var result = new HashSet<string>();
foreach (var projectId in ping.ProjectsToSignal.Where(x => x > 0))
result.Add($"{Core.Constants.PingActionsHub.GroupNames.Project}:{projectId}");
foreach (var formulationId in ping.FormulationsToSignal.Where(x => x > 0))
result.Add($"{Core.Constants.PingActionsHub.GroupNames.Formulation}:{formulationId}");
foreach (var userId in ping.UsersToSignal.Where(x => x > 0))
result.Add($"{Core.Constants.PingActionsHub.GroupNames.User}:{userId}");
foreach (var page in ping.PagesToSignal)
result.Add(page.ToString());
return result;
}
}
public interface IHubGroupAdder
{
abstract string ToString();
}
public record HubGroupProject : IHubGroupAdder
{
public int ProjectId { get; init; }
public HubGroupProject(int projectId) { ProjectId = projectId; }
public override string ToString() => $"{Core.Constants.PingActionsHub.GroupNames.Project}:{ProjectId}";
}
public record HubGroupFormulation : IHubGroupAdder
{
public int Formulation { get; init; }
public HubGroupFormulation(int formulationId) { Formulation = formulationId; }
public override string ToString() => $"{Core.Constants.PingActionsHub.GroupNames.Formulation}:{Formulation}";
}
public record HubGroupPage : IHubGroupAdder
{
public SignalRPageNames Page { get; init; }
public int ProjectId { get; init; }
public HubGroupPage(SignalRPageNames page, int projectId)
{
Page = page;
ProjectId = projectId;
}
public override string ToString() => $"{Page}:{ProjectId}";
}
public readonly struct PingActionsHub
{
public readonly struct GroupNames
{
public static readonly string Project = "ProjectId";
public static readonly string Formulation = "FormulationId";
public static readonly string User = "UserId";
}
public readonly struct MethodNames
{
public static readonly string ReceiveMessage = "ReceiveMessage";
public static readonly string RefreshPage = "RefreshPage";
public static readonly string NotificationsUpdate = "NotificationsUpdate";
}
}
public enum SignalRPingTypes
{
Unknown = 0,
SendMessage = 1,
Refresh = 2,
UserNotifications = 3,
}
public enum SignalRPageNames
{
FGV = 1,
PAndC,
PerfTests,
PM,
}
public enum SignalRRefreshTypes
{
RefreshOnly = 0,
SomethingElse = 1, // I think in the future I will need refreshes that only update 1 thing or possibly get a dataset to be used programmatically
}