Click here to Skip to main content
15,884,388 members
Articles / Programming Languages / C#

One Possible Way of Selectively Assigning Hubs to Different WebApps in the Same Application

Rate me:
Please Sign up or sign in to vote.
4.92/5 (4 votes)
16 Jun 2023CPOL9 min read 13.3K   230   8  
One application, multiple SignalR endpoints with different hubs
The article discusses how to use SignalR in a scenario where two sides of communication have different requirements, such as different authentication methods and different platforms.

Introduction

Let's imagine the not so imaginary situation where you have to coordinate some kind of interaction between two sides of a communication. This coordination might be simple bridging or comprise any kind of logic. The two sides are different, not like in a chat situation when all sides are much the same, interacting with each other over the same interface. Let's imagine that you have one side, an HMI on the field running HTML5 compatible browser on Android and on the other side, you have a Windows workstation and a Windows server in the middle. The field side has to use basic authentication, while the Windows client side needs NTLM/Kerberos. And on top of all, because of some ISA95 approach, the server can be accessed over different IPs from the different sides.

SignalR is just a great tool, but not so straightforward to adapt to this scenario.

It is clear that you can't use the same endpoint and the same HttpListener for both. Thus having one single hub accessed by both sides is not an option - wouldn't be a good approach anyway.

I had this scenario not so long time ago. And I was unable to figure out how to satisfy these requirements. I have posted a question on all possible forums, without any result. At that point, I decided to replace the SignalR hub with an ApiController for the Windows client facing side - as hubs and controllers can live in the same application without interfering.

But now, I have run into the same problem again, and this workaround is not really an option this time.

Background

This time, I have looked around starting from the official https://github.com/SignalR/SignalR/wiki/Extensibility page, and dug myself into SignalR's code. There is one extension point mentioned that looks promising: replacing the default IAssemblyLocator with a custom one, that would return the assembly containing the desired hub for each of the endpoints.

The Approach

If you look closer, you will notice that the examples on the page from above look like this:

C#
GlobalHost.DependencyResolver.Register(typeof(IWhateverService), () => service_instance);

Not surprisingly, GlobalHost is a global register of many things, and it is used solution-wide under the hood. It is not hard to figure out, that replacing IAssemblyLocator globally won't resolve anything as their implementations have no idea of the context they are used in (see source), they can only blindly return a collection of Assemblys.

Luckily for us, some overloads of IAppBuilder.MapSignalR accept a HubConfiguration type parameter, which is a descendant of ConnectionConfiguration, which in turn has an IDependencyResolver property. If we don't want to have our own implementation of IDependencyResolver, we can use different instances of DefaultDependencyResolver for each of our endpoints. If nothing else is specified, GlobalHost.DependencyResolver is also just another instance of this class.

So how would the simplistic approach look like in practice? Like this:

C#
string url = "http://localhost:8080";
using (WebApp.Start(url, (app) => {
     var resolver = new DefaultDependencyResolver();
     var locator = new SingleAssemblyLocator(typeof(MyFirstHub).Assembly);
     
     resolver.Register(typeof(IAssemblyLocator), () => locator);
     app.MapSignalR(new HubConfiguration { Resolver = resolver });
   }))
{
    Console.WriteLine("Server running on {0}", url);
    Console.ReadLine();
}

Wow, not a big deal. And it really works.

Ok, it is really a simplistic approach as such endpoints will live together in an application without problem, but they are unaware of each other, thus the bridging as described in the introduction is not yet possible. Please note that one will need to put the different hubs in different assemblies, thus they can't have reference to the other.

So practically, this is what we can achieve for now:

Image 1

(Sidenote: There could a more elegant approach. By replacing the IHubDescriptorProvider service, we can also manage to have all hubs in the same assembly, or to simply fine-tune the mapping. But for now, the code of SignalR is too closed (in terms of visibility and overridable methods), thus we would have to copy the code of HubTypeExtensions, and much of the code of ReflectedHubDescriptorProvider. This is not elegant. I have issued pull request that opens these for extensibility. If and when this is merged into a release, I intend to update this article.)

So, let's see how this one works.

The "Simple Scenario" Demo Project

The demo project you can download implements the scenario from the above image (with localhost binding only). There are two endpoints started, with one hub in each. There is no interaction between the two. A simple client is included too, served as static file from resource. As you will notice, at this point the hubs in the project are actually identical in terms of "functionality": there is a single method sending on request a hello message to all connected clients of the hub. The JavaScript code is tailored to work with both hubs.

Image 2

The "Bridge Scenario" Demo Project

As promised yesterday, I am presenting the advanced approach, more precisely a scenario where one hub is using a bridge to send messages to the clients of the other hub.

Here is the message flow for the start:

Image 3

There are some things we have to put in place for this to work.

The Bridge

As you already know, the two hubs sit in different assemblies. They can't have reference to each other as this would result in circular reference; they can't have reference to the application project either for the same reason. This is why we have to introduce a fourth library project that will hold an interface (but one could put everything inside that is common for most projects):

C#
public interface IHubBridge
{
	void RegisterParty<HubType>(Hub intance) where HubType: Hub;

	void SayHelloToAlpha(Type sender, string from);
	void SayHelloToBeta(Type sender, string from);
}

As you can see in the image from above, the latter two methods will be called by one hub to request sending out a message to the other hub. (This could be achieved in other forms too, but as the hubs are not aware of the other's type, no type-safe approach came to me in mind.)

You might be wondering what is the purpose of the RegisterParty method. I will come back to this several times later on.

The Hubs

Now, both hubs need to know about this interface, so after adding the reference, we declare the hubs in the following way (the other hub is similar):

C#
public class HubAlpha : Hub
{
	private readonly IHubBridge bridge;

	public HubAlpha(IHubBridge bridge)
	{
		this.bridge = bridge;

		bridge.RegisterParty<HubAlpha>(this);
	}

	public void GetHello()
	{
		Clients.All.AddMessage($"Hello from 
               '{GetType()}' hub at {DateTime.Now} from { Context.ConnectionId }");
	}

	public void SayHello()
	{
		bridge.SayHelloToBeta(GetType(), Context.ConnectionId);
	}
}

The hub is exposing two methods to the client. One that posts a message to the clients in the same hub, and one that calls the bridge.

The constructor will get the instance of the bridge via its interface. Because of the cross-reference constraint, we cannot use the implementation directly (making it static would have the same issue). The constructor is calling the RegisterParty method, which will register the instance of the hub to the type of the hub (more later, as I've promised).

You might be wondering how the constructor will get the bridge instance. In basic scenarios, the hubs don't have constructors. Well, for this to work, we need to replace the IHubActivator service in each of the endpoint configurations we make as we did with the IAssemblyLocator (we can't use GlobalHost). We could make our own independent implementation but that is what DI Containers are for.

Making Use of DI

In this demo, I used Unity.

First of all, we need an IHubActivator implementation class that will wrap around an IUnityContainer that will be used to resolve the hubs and also their dependency, the IHubBridge.

C#
public class UnityHubActivator : IHubActivator
{
	private readonly IUnityContainer _container;

	public UnityHubActivator(IUnityContainer container)
	{
		_container = container;
	}

	public IHub Create(HubDescriptor descriptor)
	{
		return (IHub)_container.Resolve(descriptor.HubType);
	}
} 

And now, at the start, we instantiate a container, an activator and we register all dependencies:

C#
container = new UnityContainer();
 
activator = new UnityHubActivator(container);
 
container.RegisterSingleton<HubAlpha>();
container.RegisterSingleton<HubBeta>();
container.RegisterSingleton<IHubBridge, HubBridge>();

Note: The default hub activator is not using the singleton pattern, thus many instances of the hub are created. But for this code to work, we need the hubs to be singleton. Let's see why.

Reaching Hubs

If you have ever tried to reach the hub clients from outside the hub, you need access to its Clients property, which is of type IHubCallerConnectionContext you have it as an inherited member inside a hub method. From outside, you can use the following call to get an IHubContext for a specific hub, that has the same property:

C#
var ontextHubAlpha = GlobalHost.ConnectionManager.GetHubContext<HubAlpha>();

Well, for reasons I haven't figured out yet, this won't work in this scenario. The context you get this way is valid but useless.

The workaround is to assure that only one instance of each hub exists (this is the reason for the singleton registration) and to have the bridge object (which is also singleton) knowing each of these. As long as you can assure that the hub methods are thread-safe, you should not have anything to fear.

The Bridge Again

Now, we can wire up the IHubBridge implementation in the application:

C#
internal class HubBridge : IHubBridge
{
	private Dictionary<Type, Hub> registry = new Dictionary<Type, Hub>();
 
	public void RegisterParty<HubType>(Hub intance) where HubType : Hub
	{
		lock (registry)
		{
			registry[typeof(HubType)] = intance;
		}
	}
 
	public void SayHelloToAlpha(Type sender, string from)
    {
		var context = GlobalHost.ConnectionManager.GetHubContext<HubAlpha>();

		registry[typeof(HubAlpha)]?.Clients.All.AddMessage
                         ($"Hello from {from}@{sender} at {DateTime.Now}");
	}

	public void SayHelloToBeta(Type sender, string from)
	{
		registry[typeof(HubBeta)]?.Clients.All.AddMessage
                         ($"Hello from {from}@{sender} at {DateTime.Now}");
	}
}

The class contains a dictionary that will hold the hub types and their corresponding intances as reported by the hubs themselves during construction via the RegisterParty method.

The two SayHelloTo... methods will use this registry to get access to the clients of each hub.

Wiring All Up

Now that we have everything in place, let us update the launch sequence. As both hubs are wired up identically, I have added a small helper to respect the DRY principle: WireUpHub method.

With this at hand, wiring up the two hubs is simple as this:

C#
public static class Program
{
	private static IUnityContainer container = new UnityContainer();
	private static IHubActivator activator;
 
	public static void Main()
	{
			activator = new UnityHubActivator(container);
 
			container.RegisterSingleton<HubAlpha>();
			container.RegisterSingleton<HubBeta>();
			container.RegisterSingleton<IHubBridge, HubBridge>();
 
			using (WebApp.Start("http://localhost:8080", WireUpHub<HubAlpha>))
			using (WebApp.Start("http://localhost:8081", WireUpHub<HubBeta>))
			{
				Console.WriteLine("Server running...");
				Console.ReadLine();
			}
		}
 
		private static void WireUpHub<HubType>(IAppBuilder app) where HubType: Hub
		{
			var resolver = new DefaultDependencyResolver();
			var locator = new SingleAssemblyLocator(typeof(HubType).Assembly);
 
			resolver.Register(typeof(IAssemblyLocator), () => locator);
			resolver.Register(typeof(IHubActivator), () => activator);
 
			app.MapSignalR(new HubConfiguration { Resolver = resolver });
 
			app.UseFileServer(new FileServerOptions 
                { FileSystem = new EmbeddedResourceFileSystem("BridgeApproach"), 
                  DefaultFilesOptions = { DefaultFileNames = { "index.html" } } });
		}
	}

Below is a screenshot showing two clients of each of the two hubs, and the messages they got after pressing the buttons in the order of the numbering.

Image 4

Hacking Around

Remember the sidenote from above? What if we would like to have all hubs in the same assembly, or spread somehow between multiple ones? Well, then we need something else. As mentioned in the sidenote, there is a IHubDescriptorProvider service interface that is responsible for extracting the hubs from the assemblies found by the registered IAssemblyLocator service. The default implementation of it is ReflectedHubDescriptorProvider (see source). If the protected IDictionary<string, HubDescriptor> BuildHubsCache would be virtual, we could simply derive from this class:

C#
public class FilteredHubDescriptorProvider : ReflectedHubDescriptorProvider
{
    private readonly Predicate<HubDescriptor> _filter;

    public FilteredHubDescriptorProvider
        (IDependencyResolver resolver, Predicate<HubDescriptor> filter): base(resolver)
    {
        _filter = filter;
    }

    public FilteredHubDescriptorProvider
        (IDependencyResolver resolver, params Type[] hubTypes) : base(resolver)
    {
        _filter = (d) => hubTypes.Contains(d.HubType);
    }

    public FilteredHubDescriptorProvider
        (IDependencyResolver resolver, params string[] names) : base(resolver)
    {
        _filter = (d) => names.Any(x => x == d.Name);
    }

    protected override IDictionary<string, HubDescriptor> BuildHubsCache()
    {
        return base.BuildHubsCache().Where
              (d => _filter(d.Value)).ToDictionary(x => x.Key, x=> x.Value);
    }
}

But it is not virtual. But fortunately, the class is not internal either, thus we can wrap around it. Not so elegant, adds some more overhead, but still usable:

C#
public class FilteredHubDescriptorProvider : IHubDescriptorProvider
{
	private readonly Func<HubDescriptor, bool> _filter;
	private readonly ReflectedHubDescriptorProvider _provider;

	public FilteredHubDescriptorProvider(IDependencyResolver resolver, 
                                             Func<HubDescriptor, bool> filter)
	{
		_filter = filter;
		_provider = new ReflectedHubDescriptorProvider(resolver);
	}

	public FilteredHubDescriptorProvider(IDependencyResolver resolver, 
               params Type[] hubTypes) : this(resolver, 
                      (d) => hubTypes.Contains(d.HubType))
	{
	}

	public FilteredHubDescriptorProvider(IDependencyResolver resolver, 
               params string[] names) : this(resolver, 
               (d) => names.Any(x => x == d.Name))
	{
	}
 
	IList<HubDescriptor> IHubDescriptorProvider.GetHubs()
	{
		return _provider.GetHubs().Where(_filter).ToList();
	}
 
	bool IHubDescriptorProvider.TryGetHub(string hubName, 
                out HubDescriptor descriptor) => 
                _provider.TryGetHub(hubName, out descriptor);
}

We could combine this with our assembly locator service but makes little sense. So let's wire it up again:

C#
using (WebApp.Start("http://localhost:8081", (app) =>
	{
		var resolver = new DefaultDependencyResolver();
		var provider = new FilteredHubDescriptorProvider(resolver, typeof(HubBeta));
 		resolver.Register(typeof(IHubDescriptorProvider), () => provider);
		app.MapSignalR(new HubConfiguration { Resolver = resolver });
 		app.UseFileServer(new FileServerOptions 
                      { FileSystem = new EmbeddedResourceFileSystem("HackerApproach"), 
                        DefaultFilesOptions = { DefaultFileNames = { "index.html" } } });
	}))
{
	Console.WriteLine("Server running...");
	Console.ReadLine();
}

Note: As this service interface is not mentioned as an extensibility point, thus might not be treated as such and might suffer breaking changes in later releases without notice. So use this later approach with precaution.

That's it! Now we have some tools with maximum flexibility to bind specific hubs to specific WebApps. Happy coding!

History

  • 17th April, 2018 - First publication, simple scenario demo included
  • 18th April, 2018 - Added bridge scenario demo project
  • 19th April, 2018 - Hacker approach added

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Technical Lead
Hungary Hungary
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
-- There are no messages in this forum --