TL;DR
In this article, I describe an approach how to build SignalR modules with a shared connection using a C# Source Generator.
The concept is based on the idea of organizing multiple hub methods in partial classes within the same project. But instead of putting everything in one project, SignalR modules can live in separate projects or NuGet packages. The partial classes get then generated using a C# Source Generator.
The following graphic provides an overview of how it works:
The source code with samples is available on GitHub.
Intro
Over the last months, I worked together with the engineers of our customer Söderberg & Partners to build the foundation for their new CRM system. They made the strategic decision to use full-stack .NET and build a new frontend with Blazor for their existing ASP.NET Core micro-service backend.
There are multiple teams inside Söderberg & Partners that will contribute to the CRM system. Therefore we developed a small base framework that allows different teams to build independent modules. Each module can consist of a frontend part that will be executed in the Blazor client application and a backend part that runs as part of the ASP.NET Core backend-for-frontend (BFF).
One requirement is that modules can send real-time updates from the backend to the client using SignalR. SignalR is structured in so-called hubs and each hub is using its own WebSocket connection. The first logical thought was that each module can provide its own hub that can be used by the client application. But there can be multiple modules on one page, and they can subscribe to updates from different hubs. This can lead to many concurrent WebSocket connections. Holding many active WebSocket connections can get expensive on the server-side. For example, Azure SignalR Service is paid per unit and one unit can have 1.000 concurrent connections. And one unit is around 40€ per month.
Let’s say we have a maximum of 1.000 concurrent users per day. It’s a big difference if each user has only 1 or, for example, 5 concurrent WebSocket connections. That’s why we want that multiple modules can share the same WebSocket connection.
Why is it not possible to share one connection between hubs you ask? Well, this was supported in SignalR but isn’t supported anymore in SignalR Core. The Compare SignalR and SignalR Core page has this information:
In ASP.NET Core SignalR, the connection model has been simplified. Connections are made directly to a single hub, rather than a single connection being used to share access to multiple hubs.
Some people asked about this on GitHub too and you can find more insights from the product team here, here, and here.
My first idea was to create a single hub with a single method that parses the messages and calls the methods of the modules. This solution would require a lot of reflection magic to find the right method, pass the parameters with the correct types, and handle the return values. It gets even more complicated if we want to support all SignalR features like filters, streaming, and authentication. I looked at the SignalR source code, and it turned out that I would have to replicate a lot of things that SignalR is already doing. Because with SignalR the messages are sent as text messages, and SignalR maps them to the hub methods and passes the correct parameters and converts them to the right type. The code looked quite complex, and I didn’t want to replicate it because it would require a lot of testing and would be hard to maintain in the long term.
So, I started to look for an easier solution and found a recommendation from David Fowler (the creator of SignalR):
If you’re using multiple hubs just for organization within the same project then I’d recommend using a partial class with more methods.
The CRM we are building is separated into modules, but in the end, all modules get referenced from one single project (for the server part, there is of course another project for the client application). This main server project (the BFF) contains the startup logic, registers the services, and configures also the SignalR hub. Taking the suggestion from David Fowler, we can create one main hub as a partial class. And for each module we can create a partial class with all the methods from this module, get the module service from the DI container and call the method of the module service.
In abstract this could look like this:
public partial class MainHub : Hub { public IServiceProvider Services { get; private set; } }
And for a module with a chat service, we can add another file that extends the MainHub.
public partial class MainHub { public Task SendMessage(string message) { var chatService = Services.GetRequiredService<ChatService>(); return chatService.SendMessage(message); } }
This would work, but it would require us to write a lot of code for all the modules and methods. And it would be hard to maintain because every time we add/remove a module or add/remove/change a method in a module, we must update the code. But what if we could generate this code automatically? This is where C# Source Generators come in.
The idea
Let’s take a look at how the documentation explains C# Source Generators:
A Source Generator is a piece of code that runs during compilation and can inspect your program to produce additional source files that are compiled together with the rest of your code.
Sounds good, this is exactly what we want. We want to inspect the program to find SignalR modules and generate a source file with a partial class for each module.
I decided to use attributes to specify which module hubs should be part of a SignalR hub. This is what the final code looks like.
[SignalRModuleHub(typeof(ChatHub))] [SignalRModuleHub(typeof(WeatherHub))] [SignalRModuleHub(typeof(CounterHub))] public partial class MainHub : ModulesEntryHub { }
Before I show how it works, I want to point out the things I tried to achieve with the solution:
- Independent modules: It should be possible to create independent modules that use SignalR to add real-time communication. Modules can live in their own repository and can be distributed as NuGet packages.
- Shared connection: Multiple modules can share the same WebSocket connection to reduce the number of concurrent connections.
- Avoid naming conflicts: Even though multiple modules are sharing one connection and are part of the same hub, naming conflicts should be avoided. That means if two modules define a method
SendMessage
it should work without a conflict. - Developer friendly: The solution should not be a completely new framework. If someone knows SignalR, he or she should be able to start working with SignalR modules without learning new stuff. And if you want to solve a problem, search how to do it with SignalR and it should work the same here.
- No restrictions: Developers should have access to all SignalR features without limitations.
- Easy to maintain: The solution should fulfill all requirements, but it should be as simple as possible. It should use only documented APIs to make it easy to upgrade to newer SignalR versions.
Let’s take a look at the solution and how I tried to achieve these goals.
The server-side
To create a module hub, a reference to the SignalR.Modules
project is needed. Then create a hub class as you would do with regular SignalR, but instead of Hub
(or Hub<T>
) use ModuleHub
(or ModuleHub<T>
) as the base class.
public class ChatHub : ModuleHub { public async Task SendMessage(string user, string message) { await Clients.All.SendAsync("ReceiveMessage", user, message); } }
The ChatHub sample is used in the SignalR documentation too. And as you can see, there is no difference except the base class. You have access to the Clients
property to send messages to the client. Besides the Clients
property, the ModuleHub
provides also access to the Context
and Groups
properties and you can override the OnConnectedAsync
and OnDisconnectedAsync
methods as well. Everything is the same as with regular SignalR.
To use the module hub, create an ASP.NET Core project and add a reference to SignalR.Modules
and SignalR.Modules.Generator
.
<ProjectReference Include="......srcSignalR.Modules.GeneratorSignalR.Modules.Generator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" /> <ProjectReference Include="......srcSignalR.ModulesSignalR.Modules.csproj" />
Then add an entry hub that will be used by all module hubs. You can add as many SignalRModuleHub
attributes as you like.
[SignalRModuleHub(typeof(ChatHub))] [SignalRModuleHub(typeof(WeatherHub))] [SignalRModuleHub(typeof(CounterHub))] public partial class MainHub : ModulesEntryHub { }
The ModulesEntryHub
derives from Hub
and provides some helper methods to initialize the module hubs. We will see it later in the generated code.
The last step is to register all services and add an endpoint for the hub. I added an extension method that registers all module hubs that are added via the attribute.
services.AddSignalRModules<MainHub>();
app.UseEndpoints(endpoints => { endpoints.MapHub<MainHub>("/hub"); // ... });
And that’s it. There is nothing more to do on the server-side.
Let’s expand the analyzers section and take a look at the generated code of the SignalR.Modules.Generator
:
The Source Generator generates one file per module hub. This is the content of the MainHub_ChatHub.g.cs
file:
public partial class MainHub { public System.Threading.Tasks.Task ChatHub_SendMessage(string user, string message) { var hub = ServiceProvider.GetRequiredService<ChatModule.Server.ChatHub>(); InitModuleHub(hub); return hub.SendMessage(user, message); } }
For each method of the module hub, a new method gets added to the entry hub. To avoid naming conflicts, the name of the module hub type is added to the method name. The generated method doesn’t do much. It gets a new instance of the module hub from the service provider, initializes it, and then calls the related method of the module hub. The InitModuleHub
method is defined in the ModulesEntryHub
base class.
It’s worth mentioning that not only the server methods get prefixed with the module hub name. In this sample we use Clients.All.SendAsync("ReceiveMessage", user, message)
. Behind the scenes, "ReceiveMessage"
gets changed to "ChatHub_ReceiveMessage"
too. To achieve this, I am using a custom ClientProxy
that wraps the default IClientProxy
from SignalR. It gets applied in the InitModuleHub
method of the ModulesEntryHub
class.
Strongly typed module hubs
The previous sample uses the magic string "ReceiveMessage"
to call this method on the client side. SignalR allows us to define strongly typed hubs to avoid using magic strings. And you can do the same with SignalR modules by using ModuleHub<T>
.
The WeatherHub sample uses a strongly typed hub:
public interface IWeatherClient { public Task ReceiveWeatherUpdate(IEnumerable<WeatherForecast> weatherForecast); }
public class WeatherHub : ModuleHub<IWeatherClient> { // ... }
Instead of using the SendAsync
method and passing the client method name as a string, we can now use Clients.All.ReceiveWeatherUpdate(…)
. And the client method name will be prefixed with the module hub name too. So, the name of the client method that will be called is WeatherHub_ReceiveWeatherUpdate
.
The Source Generator adds an implementation of the interface that is used at runtime. For the IWeatherClient
interface the MainHub_IWeatherClientImpl.g.cs
file with the following content gets generated:
public class MainHub_IWeatherClientImpl : ClientProxy<WeatherHub>, IWeatherClient { public MainHub_IWeatherClientImpl(IClientProxy clientProxy) : base(clientProxy) {} public Task ReceiveWeatherUpdate(System.Collections.Generic.IEnumerable<WeatherModule.Shared.WeatherForecast> weatherForecast) { return SendAsync("ReceiveWeatherUpdate", new[] { weatherForecast }); } }
Send messages from outside a hub
As you can do it with regular SignalR hubs, you can send messages from outside a module hub as well. Instead of injecting IHubContext<T>
you can inject IModuleHubContext<T>
.
[ApiController] [Route("api/[controller]")] public class ChatController : ControllerBase { private readonly IModuleHubContext<ChatHub> _hubContext; public ChatController(IModuleHubContext<ChatHub> hubContext) { _hubContext = hubContext; } [HttpGet] public ActionResult SendMessage(string user, string message) { _hubContext.Clients.All.SendAsync("ReceiveMessage", user, message); return Ok(); } }
When you have a strongly typed module hub, you can use IModuleHubContext<WeatherHub, IWeatherClient>
.
You can use the IModuleHubContext
not only in controllers. Take a look at the WeatherHub
sample and the WeatherUpdateHostedService
class to see how to use it in a background service.
Other SignalR features
With this solution, all module hubs get combined into one SignalR hub. This entry hub is a regular SignalR hub and therefore features like authentication and authorization, hub filters, hub protocols, etc. are working the same way as with regular SignalR.
The client-side
You can connect to the hub and start sending messages the same way as with any SignalR hub. The only thing to remember is that all server and client methods must be prefixed with the module hub name.
The following sample is the sample from the documentation but uses the ChatHub
module on the server:
@using Microsoft.AspNetCore.SignalR.Client @page "/chat" @inject NavigationManager NavigationManager @implements IAsyncDisposable <div class="form-group"> <label> User: <input @bind="userInput" /> </label> </div> <div class="form-group"> <label> Message: <input @bind="messageInput" size="50" /> </label> </div> <button @onclick="Send" disabled="@(!IsConnected)">Send</button> <hr> <ul id="messagesList"> @foreach (var message in messages) { <li>@message</li> } </ul> @code { private HubConnection hubConnection; private List<string> messages = new List<string>(); private string userInput; private string messageInput; protected override async Task OnInitializedAsync() { hubConnection = new HubConnectionBuilder() .WithUrl(NavigationManager.ToAbsoluteUri("/hub")) .Build(); hubConnection.On<string, string>("ChatHub_ReceiveMessage", (user, message) => { var encodedMsg = $"{user}: {message}"; messages.Add(encodedMsg); StateHasChanged(); }); await hubConnection.StartAsync(); } async Task Send() => await hubConnection.SendAsync("ChatHub_SendMessage", userInput, messageInput); public bool IsConnected => hubConnection.State == HubConnectionState.Connected; public async ValueTask DisposeAsync() { if (hubConnection is not null) { await hubConnection.DisposeAsync(); } } }
Using the ModuleHubClient
While it’s possible to do it as described in the previous sample, it has some downsides. If you open a new connection on each page or component, it will not be shared, and you may end up with many concurrent WebSocket connections. And you must prefix all your methods with the module hub name.
There is one thing that concerned us when using a shared connection. When we register a handler using the HubConnection.On
method, it returns an IDisposable
that can be used to unsubscribe from the hub method. All samples in the SignalR docs ignore the return value and never unsubscribe. The reason is that when the HubConnection
gets disposed, all handlers get unsubscribed too.
But we want to use a shared connection that stays open for a long time while the user navigates between pages. If a developer registers a handler on a page and forgets to unsubscribe, the page won’t be garbage collected when the user leaves it, and the handler will still be called in the background. It’s a small mistake that can happen easily and such a memory leak may remain undiscovered.
That’s why I introduced the ModuleHubClient
class. It allows sharing the connection, prefixes all method names with the module hub name, and unsubscribes all handlers when it gets disposed. To use it, create a client class per module hub that derives from the ModuleHubClient
. For the ChatHub
it’s just an empty class, but you could put more logic in it (see the WeatherHubClient
).
public class ChatHubClient : ModuleHubClient { }
Then add the clients to the service collection:
builder.Services.AddSignalRModules("MainHub", sp => sp.GetRequiredService<NavigationManager>().ToAbsoluteUri("/hub")) .AddModuleHubClient<ChatHubClient>() .AddModuleHubClient<WeatherHubClient>() .AddModuleHubClient<SignalRCounter>("CounterHub");
The AddSignalRModules
method takes the name of the entry hub as the first parameter. The name is used internally as a key to share the connection. The second parameter can be the URL of the hub or an action to configure the SignalR IHubConnectionBuilder
.
Then use AddModuleHubClient
to add all the module hub clients that use the same entry hub. Per convention, the class name (without the Client suffix) is used to prefix all method names. Alternatively, it’s possible to pass the name as a parameter. By default, all clients get added as scoped services, but it’s possible to specify the ServiceLifetime
.
This is how the chat sample looks like using the ChatHubClient
:
@using Microsoft.AspNetCore.SignalR.Client @using SignalR.Modules.Client; @page "/chat" @inherits OwningComponentBase<ChatHubClient> <div class="form-group"> <label> User: <input @bind="userInput" /> </label> </div> <div class="form-group"> <label> Message: <input @bind="messageInput" size="50" /> </label> </div> <button @onclick="Send" disabled="@(!IsConnected)">Send</button> <hr> <ul id="messagesList"> @foreach (var message in messages) { <li>@message</li> } </ul> @code { private List<string> messages = new List<string>(); private string userInput = string.Empty; private string messageInput = string.Empty; private ChatHubClient ChatHub => Service; protected override async Task OnInitializedAsync() { ChatHub.On<string, string>("ReceiveMessage", (user, message) => { var encodedMsg = $"{user}: {message}"; messages.Add(encodedMsg); StateHasChanged(); }); await ChatHub.EnsureConnectionStartedAsync(); } async Task Send() => await ChatHub.SendAsync("SendMessage", userInput, messageInput); public bool IsConnected => ChatHub.State == HubConnectionState.Connected; }
The code looks very similar to the previous sample with just a few exceptions. First, the base class is OwningComponentBase<ChatHubClient>
. This is a special base component provided by Blazor that creates a new scope just for this component and resolves the specified services within this scope. As I mentioned before, the hub clients are registered as scoped services by default. This means when the component is not used anymore, the created scope with all the resolved scoped services gets disposed and we don’t have to unsubscribe the handlers manually.
Another difference is that instead of the HubConnection
we are using the ChatHubClient
to send messages and to add handlers. I tried to use the same names to make it easy to use. I even wanted to reuse the extension methods from SignalR, but unfortunately, all the SignalR extension methods are extensions for the HubConnection
class and not for an interface. That’s why I had to copy the code from the SignalR repo and replaced the HubConnection
with the ModuleHubClient
.
One notable difference is that instead of hubConnection.StartAsync()
you must use ChatHub.EnsureConnectionStartedAsync()
. The reason is that a shared connection is used and it’s likely that the connection was already started before.
Recap
Let’s look at the goals and compare them with the solution:
- Independent modules: It’s possible to place the module hubs in the same project or put them anywhere else and reference the assemblies directly or via NuGet.
- Shared connection: The modules that are added to one
ModulesEntryHub
are part of the sameSignalR
hub and can share the same connection. TheModuleHubClient
makes it easier to share the connection when using SignalR on multiple pages or in different components. - Avoid naming conflicts: Server and client methods get prefixed with the module hub name. This reduces the chance of a naming conflict.
- Developer friendly: SignalR provides a lot of interfaces and so it was possible to change only some of the implementations while providing the same APIs as regular SignalR. The main differences are the setup (using the attributes), the base classes (
ModuleHub
/ModuleHub<T>
instead ofHub
/Hub<T>
), and the interface name when sending messages outside a hub (IModuleHubContext<T>
instead ofIHubContext<T>
).
On the client-side, there are a few differences when using theModuleHubClient
class. But most of the things are working the same as when using aHubConnection
. - No restrictions: As mentioned before, the APIs are the same as with regular SignalR. So far, I don’t see any technical restrictions. If you find something, let me know and I will try to find a solution.
- Easy to maintain: The final solution has more classes than I expected. But most of them are just thin wrappers with a few lines of code. Some understanding of how SignalR works internally is needed to make major changes. But I am using only public and documented APIs and therefore I hope there won’t be many breaking changes in the future. C# Source Generators require some learning as well. But overall, I think it’s not too complex and everyone who has worked with ASP.NET Core and SignalR before should be able to understand the code and make at least minor changes.
Let me know what you think. The source code with samples is available on GitHub. Please create an issue for any feedback, questions, or suggestions.