65.9K
CodeProject 正在变化。 阅读更多。
Home

在同一应用程序中选择性地将 Hub 分配给不同 Web 应用程序的一种可能方式

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (4投票s)

2018 年 4 月 17 日

CPOL

9分钟阅读

viewsIcon

14243

downloadIcon

294

一个应用程序,多个具有不同 Hub 的 SignalR 端点

引言

让我们设想一个并非虚构的场景,即您需要协调两个通信端点之间的某种交互。这种协调可能很简单,只是桥接,或者包含任何逻辑。这两个端点是不同的,不像在聊天场景中,所有端点都非常相似,通过相同的接口进行交互。让我们设想,一端是现场的 HMI,运行着兼容 HTML5 的 Android 浏览器;另一端是 Windows 工作站和中间的 Windows 服务器。现场端需要使用基本身份验证,而 Windows 客户端端需要 NTLM/Kerberos。最重要的是,由于某种 ISA95 方法,服务器可以从不同的端点通过不同的 IP 地址访问。

SignalR 是一个很棒的工具,但要适应这个场景并不那么直接。

很明显,您不能为两者使用相同的端点和相同的 HttpListener。因此,让两者访问同一个 Hub 是不可行的——无论如何都不是一个好的方法。

不久前我遇到了这个场景。我无法弄清楚如何满足这些要求。我在所有可能的论坛上都发帖提问,但都没有结果。这时,我决定将 SignalR Hub 替换为面向 Windows 客户端的 ApiController——因为 Hub 和控制器可以在同一个应用程序中共存而不互相干扰。

但现在,我再次遇到了同样的问题,这次这个解决方法真的不可行了。

背景

这次,我开始查阅官方的 https://github.com/SignalR/SignalR/wiki/Extensibility 页面,并深入研究了 SignalR 的代码。页面上提到了一个看起来很有希望的扩展点:用自定义的 IAssemblyLocator 替换默认的 IAssemblyLocator,它将为每个端点返回包含所需 Hub 的程序集。

方法

如果仔细观察,您会发现上面页面上的示例看起来是这样的

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

毫不奇怪,GlobalHost 是许多事物的全局注册表,并且在底层被整个解决方案使用。不难看出,全局替换 IAssemblyLocator 无法解决任何问题,因为它们的实现并不知道它们被使用的上下文(请参阅 源代码),它们只能盲目地返回一个 Assembly 集合。

幸运的是,IAppBuilder.MapSignalR 的一些重载接受 HubConfiguration 类型参数,它是 ConnectionConfiguration 的子类,而 ConnectionConfiguration 又有一个 IDependencyResolver 属性。如果我们不想拥有自己的 IDependencyResolver 实现,我们可以为每个端点使用不同的 DefaultDependencyResolver 实例。如果未指定其他任何内容,GlobalHost.DependencyResolver 也只是这个类的另一个实例。

那么,最简单的实现方法在实践中看起来会是怎样的?就像这样

string url = "https://: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();
}

哇,没什么大不了的。而且它确实有效。

好的,这确实是一个简单的实现,因为这样的端点可以轻松地共存在一个应用程序中,但它们彼此之间没有感知,因此,如引言中所述的桥接功能尚未实现。请注意,您需要将不同的 Hub 放在不同的程序集中,因此它们不能相互引用。

所以,实际上,这就是我们目前可以实现的

(旁注:可能有一种更优雅的方法。通过替换 IHubDescriptorProvider 服务,我们也可以设法将所有 Hub 放在同一个程序集中,或者仅仅微调映射。但目前,SignalR 的代码过于封闭(在可见性和可重写方法方面),因此我们不得不复制 HubTypeExtensions 的代码,以及 ReflectedHubDescriptorProvider 的大部分代码。这并不优雅。我已经提交了一个拉取请求,以打开这些进行扩展。如果并何时将其合并到发布版本中,我打算更新本文。)

那么,让我们来看看这个是如何工作的。

“简单场景”演示项目

您可以下载的演示项目实现了上图所示的场景(仅限于本地主机绑定)。启动了两个端点,每个端点都有一个 Hub。两个端点之间没有交互。还包括一个简单的客户端,作为资源中的静态文件提供。您会注意到,此时项目中的 Hub 在“功能”上实际上是相同的:有一个方法,在收到请求时,会向该 Hub 的所有连接的客户端发送一条问候消息。JavaScript 代码经过定制,可以与这两个 Hub 一起使用。

“桥接场景”演示项目

正如昨天承诺的,我将介绍高级方法,更确切地说,是一个 Hub 使用桥接将消息发送到另一个 Hub 的客户端的场景。

这是开始时的消息流

为了实现这一点,我们需要做一些事情。

桥接

正如您已经知道的,这两个 Hub 位于不同的程序集中。它们不能相互引用,否则会导致循环引用;出于同样的原因,它们也不能引用应用程序项目。这就是为什么我们必须引入第四个库项目来容纳一个接口(但您也可以将所有对大多数项目通用的内容都放在其中)。

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

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

正如您在上图中所看到的,后两种方法将由一个 Hub 调用,以请求向另一个 Hub 发送消息。(这也可以通过其他形式实现,但由于 Hub 并不了解对方的类型,我暂时没有想到类型安全的方法)。

您可能想知道 RegisterParty 方法的目的是什么。稍后我将多次回到这个问题。

Hubs

现在,两个 Hub 都需要知道这个接口,所以在添加引用后,我们按以下方式声明 Hub(另一个 Hub 类似)

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);
	}
}

该 Hub 向客户端公开两个方法。一个方法将消息发布到同一 Hub 的客户端,另一个方法调用桥接。

构造函数将通过其接口获取桥接实例。由于交叉引用限制,我们不能直接使用实现(使其成为 static 会遇到同样的问题)。构造函数调用 RegisterParty 方法,该方法将 Hub 的实例注册到 Hub 的类型(稍后会详细介绍,正如我所承诺的)。

您可能想知道构造函数如何获得桥接实例。在基本场景中,Hub 没有构造函数。好吧,为了使其正常工作,我们需要替换我们在每个端点配置中所做的 IHubActivator 服务,正如我们使用 IAssemblyLocator 一样(我们不能使用 GlobalHost)。我们可以自己实现一个独立的实现,但这正是 DI 容器的作用。

利用 DI

在此演示中,我使用了 Unity。

首先,我们需要一个 IHubActivator 实现类,它将包装一个 IUnityContainer,该容器将用于解析 Hub 及其依赖项 IHubBridge

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);
	}
} 

现在,在开始时,我们实例化一个 container,一个 activator,并注册所有依赖项

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

注意:默认的 Hub 激活器不使用单例模式,因此会创建许多 Hub 实例。但要使此代码正常工作,我们需要 Hub 是单例的。让我们看看为什么。

访问 Hub

如果您曾尝试从 Hub 外部访问 Hub 客户端,您需要访问其 Clients 属性,该属性的类型为 IHubCallerConnectionContext。在 Hub 方法内部,您可以作为继承成员访问它。从外部,您可以使用以下调用获取特定 Hub 的 IHubContext,它具有相同的属性

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

好吧,出于我尚未弄清楚的原因,这在这种情况下行不通。这样获得的上下文虽然有效但无用。

解决方法是确保每个 Hub 只存在一个实例(这就是单例注册的原因),并让桥接对象(它也是单例)知道这些实例。只要您能确保 Hub 方法是线程安全的,您就不必担心任何问题。

桥接再谈

现在,我们可以在应用程序中连接 IHubBridge 实现

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}");
	}
}

该类包含一个 dictionary,它将存储 Hub 类型及其对应的实例,这些实例是由 Hub 在构造函数通过 RegisterParty 方法报告的。

两个 SayHelloTo... 方法将使用此注册表来访问每个 Hub 的客户端。

连接所有

现在我们已经准备好了一切,让我们更新启动顺序。由于两个 Hub 的连接方式相同,我添加了一个小的辅助方法来遵守 DRY 原则:WireUpHub 方法。

有了这个,连接两个 Hub 就很简单了

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("https://:8080", WireUpHub<HubAlpha>))
			using (WebApp.Start("https://: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" } } });
		}
	}

下面是一张截图,显示了两个 Hub 各有两个客户端,以及它们在按下按钮后收到的消息,顺序如编号所示。

黑客之道

还记得上面的旁注吗?如果我们想将所有 Hub 放在同一个程序集中,或者以某种方式分布在多个程序集中呢?那么,我们就需要一些别的东西。如旁注所述,有一个 IHubDescriptorProvider 服务接口,它负责从注册的 IAssemblyLocator 服务找到的程序集中提取 Hub。它的默认实现是 ReflectedHubDescriptorProvider(请参阅 源代码)。如果 protected IDictionary<string, HubDescriptor> BuildHubsCache 是虚拟的,我们就可以简单地继承这个类

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);
    }
}

但它不是虚拟的。但幸运的是,该类也不是内部的,因此我们可以将其包装起来。虽然不太优雅,而且会增加一些额外的开销,但仍然可用

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);
}

我们可以将它与我们的程序集定位器服务结合使用,但意义不大。所以,让我们再次连接它

using (WebApp.Start("https://: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();
}

注意:由于此服务接口未被列为可扩展点,因此可能不被视为可扩展点,并且在后续版本中可能会在未经通知的情况下发生重大更改。因此,请谨慎使用此后续方法。

就是这样!现在我们拥有了最大的灵活性来将特定的 Hub 绑定到特定的 WebApps。祝您编码愉快!

历史

  • 2018年4月17日 - 首次发布,包含简单场景演示
  • 2018年4月18日 - 添加了桥接场景演示项目
  • 2018年4月19日 - 添加了黑客方法
© . All rights reserved.