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

RESTful SignalR 服务

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (160投票s)

2015 年 4 月 21 日

CPOL

9分钟阅读

viewsIcon

184363

downloadIcon

6391

一篇解释如何通过 ASP.NET Web API 公开 SignalR 功能的文章,这有助于可以使用 REST 服务的应用程序向其客户端广播实时消息。

背景

本文是关于 ASP.Net SignalR 与 ASP.NET Web API 技术协同工作的又一次探讨。您可能之前已经阅读过或正在使用该技术。本文将是对 CodeProject 上已有的关于该技术的文章的补充(ASP.Net SignalR)。本文主要介绍如何通过 ASP.NET Web API 公开 SignalR 功能,这有助于可以使用 REST 服务的应用程序向其客户端广播实时消息。

文章将涵盖的内容

  • SignalR 简介
  • 控制反转/依赖注入,包括 SignalR 注入
  • 自定义配置节读取器
  • SQL Server (表、存储过程和触发器)
  • 轻触 WPF、INotifyPropertyChanged、ObservableCollection、DataGrid
  • JQuery、AJAX
  • 设计原则和技术

引言

自从我们在 Twitter、FaceBook、多人在线游戏以及其他一些现代 Web 应用程序中看到用户之间令人惊叹的交互式实时消息交换以来,已经有一段时间了。用户可以实时异步地交换消息,而不会相互阻塞。SignalR 使这种交互变得如此简单,您只需付出很少的努力即可在任何需要的地方实现它。消息交互以及与两个或多个用户/应用程序的持久连接并不是 SignalR 的新概念。它也通过不同的通信技术/协议进行了尝试。提及它们:长轮询、服务器发送事件、WebSockets 和 Forever Frame。每种技术解释如下。

  • 长轮询 - 由客户端发起,并保持其持久连接,直到服务器将数据发送到客户端或连接超时。在这里,连接专门用于从服务器接收数据。如果客户端要发送数据,它将并行打开另一个 HTTP 连接。其优点是,一旦服务器发送请求数据,轮询就会断开。
  • 服务器发送事件 - 顾名思义,服务器在消息更新完成后,会尽快以事件的形式将消息发送/响应给客户端。该技术依赖于名为Event Source的 HTML5 API。通信也是由客户端发起的。https://caniuse.cn/Server-Sent events
  • WebSockets - 这是另一个 API,有助于在客户端/服务器之间建立同步的持久双向通信,随时可用。https://caniuse.cn/WebSockets
  • Forever Frame/Comet - 该技术依赖于一个隐藏的 iframe HTML 标签,以一种长连接的方式,将服务器的消息分块发送到客户端,直到所有消息内容传输完成。

那么 SignalR 有何不同之处?SignalR 将所有这些通信协议包装成一个单一的(统一的)框架。这使得开发人员能够轻松地专注于解决需要实时消息传递的问题,而无需担心客户端和服务器之间的底层通信。它也是客户端和服务器在连接初始化时在通信协议中的最佳选择。选择过程是根据通信双方可用协议的可用性决定的。SignalR 还使用持久连接,该连接提供了一种调用/侦听事件的机制,以检查连接是否已关闭/打开,或者消息是否已发送/接收到/来自客户端。一旦建立连接,消息就可以同步/异步发送和接收。

在 Web API 中使用 SignalR 的好处

如我前面所述,本文的主要目的是通过 RESTful 服务公开 SignalR 功能,以便依赖 REST 的客户端/服务器有机会实时广播/发送消息。拥有此实现的好处是:

  1. 数据库服务器可以通过 REST 服务实时广播/发送任何更改(插入/更新/删除/其他)。
  2. 使用其他编程语言开发并能够消耗 REST 服务的应用程序将有机会实时广播/发送消息。
  3. IoT 硬件,如 NetdunioPlus2、Arduino、Raspberry PI,有机会发送关于其状态的实时消息状态。
  4. 使用内存缓存机制的 Web 应用程序/服务将有机会在缓存过期之前或不重新启动应用程序/服务的情况下监听/接收缓存的实时更改。
  5. 最后但并非最不重要的是,它有助于设计高度解耦的系统,这些系统几乎不知道彼此,但又可以同时利用 REST 和 SignalR 技术。
RESTful SignalR Service and usage

设计与实现

解决方案的总体思想是定义一个 ASP.NET Web API 服务,该服务封装了 SignalR 实时消息广播事件。通过使用依赖注入(包括 SignalR DP),SignalR hub 上下文和消息广播器事件 REST API 将在服务初始化时绑定。此外,REST 服务使用者将获得一个现成可用的 SignalR 消息广播器事件,而无需显式调用 SignalR hub 连接。代码主要分为两个部分:RESTful SignalR 服务SignalR 广播监听器,后者是一个包装了 SignalR 客户端库的库。

定义RESTful SignalR 服务从定义IBroadCast接口及其实现者BroadCaster类开始,如下所示:

/// <summary>
/// IBroadCast interface
/// </summary>
public interface IBroadCast
{
    /// <summary>
    /// BroadCast messsage
    /// </summary>
    /// <param name="messageRequest">MessageRequest value</param>
    void BroadCast(MessageRequest messageRequest);

    /// <summary>
    /// Message Listener event handler
    /// </summary>
    event EventHandler<BroadCastEventArgs> MessageListened;
}
   
/// <summary>
/// BroadCaster class
/// </summary>
public class BroadCaster : IBroadCast
{
    // .........................................
    // Full code is available in the source code
    // .........................................

    /// <summary>
    /// BroadCaster class
    /// </summary>
    /// <param name="messageRequest">MessageRequest value</param>
    public void BroadCast(MessageRequest messageRequest)
    {
        EventHandler<BroadCastEventArgs> handler;
        lock (eventLocker)
        {
            handler = messageListenedHandler;
            if (handler != null)
            {
                handler(this, new BroadCastEventArgs(messageRequest));
            }
        }
    }
}
    

然后定义 API 控制器类MessageBroadCastController,它将广播的消息传递给 SignalR Hub。

/// <summary>
/// Message broadcaster ApiController class
/// </summary>
public class MessageBroadCastController : ApiController
{
    private IBroadCast _broadCast;

    // .........................................
    // Full code is available in the source code
    // .........................................

    /// <summary>
    /// Message broadcaster ApiController class
    /// </summary>
    public MessageBroadCastController(IBroadCast broadCast)
    {
        _broadCast = broadCast;
    }

    /// <summary>
    /// BroadCast message
    /// </summary>
    /// <param name="messageRequest">MessageRequested value</param>
    /// <returns>string message</returns>
    [HttpPost]
    public string BroadCast(MessageRequest messageRequest)
    {
       string response = string.Empty;
        try
        {
            _broadCast.BroadCast(messageRequest);
            response = "Message successfully broadcasted !";
        }
        catch (Exception exception)
        {
            response = "Opps got error. ";
            response = string.Concat(response, "Excepion, Message : ", exception.Message);
        }
        return response;
    }
}
    

然后定义BroadCastHub类,该类在客户端调用时注册消息事件。

/// <summary>
/// BroadCastHub class
/// </summary>
public class BroadCastHub : Hub
{
    // .........................................
    // Full code is available in the source code
    // .........................................

    /// <summary>
    /// BroadCastHub class
    /// </summary>
    public BroadCastHub(IBroadCast broadCast)
    {
        if (broadCast == null)
            throw new ArgumentNullException("BroadCast object is null !");

        BeginBroadCast(broadCast); // This will avoid calling to initialize a hub in message broadcaster client
    }

    /// <summary>
    /// Begin broadCast message
    /// </summary>
    /// <param name="broadCast">IBroadCast value</param>
    private void BeginBroadCast(IBroadCast broadCast)
    {
        // Register/Attach broadcast listener event
        broadCast.MessageListened += (sender, broadCastArgs)
            =>
            {
                RegisterMessageEvents(broadCastArgs);
            }; 
        
        // .........................................
        // Full code is available in the source code
        // .........................................
    }

    /// <summary>
    /// Register broadcasted message to SignalR events
    /// </summary>
    /// <param name="broadCastArgs">BroadCastEventArgs value</param>
    private void RegisterMessageEvents(BroadCastEventArgs broadCastArgs)
    {
        if (broadCastArgs != null)
        {
            MessageRequest messageRequest = broadCastArgs.MessageRequest;
            
            IClientProxy clientProxy = Clients.Caller;
            if (messageRequest.EventName != EventNameEnum.UNKNOWN)
            {
                clientProxy.Invoke(messageRequest.EventName.EnumDescription(), messageRequest.Message);
            }
            else
            {
                string errorMessage = "Unknown or empty event name is requested!";
                clientProxy.Invoke(EventNameEnum.ON_EXCEPTION.EnumDescription(), errorMessage); // Goes to the listener
                throw new Exception(errorMessage); // Goes to the broadcaster
            }
        }
    }
}
    

定义了所有必需的类和接口后,将其注册到全局配置中,以便通过服务提供一个准备好的消息监听器事件。但在那之前,让我们定义我们的依赖解析器,它将协助注册过程。

/// <summary>
/// NInject dependency resolver class 
/// </summary>
public class NInjectDependencyResolver : NInjectScope, IDependencyResolver
{
    private readonly IKernel _kernel;

    // .........................................
    // Full code is available in the source code
    // .........................................

    /// <summary>
    /// NInject dependency resolver class
    /// </summary>
    /// <param name="container">IKernel value</param>
    public NInjectDependencyResolver(IKernel kernel)
        : base(container)
    {
        _kernel = kernel;
    }
}

/// <summary>
/// SignalR NInject dependency resolver class 
/// </summary>
public class NInjectSignalRDependencyResolver : DefaultDependencyResolver
{
    private readonly IKernel _kernel;

    // .........................................
    // Full code is available in the source code
    // .........................................

    /// <summary>
    /// SignalR NInject dependency resolver class
    /// </summary>
    /// <param name="container">IKernel value</param>
    public NInjectSignalRDependencyResolver(IKernel kernel)
    {
        _kernel = kernel;
    }
}

最后,在 OWIN Startup类中绑定消息广播器和 SignalR hub 连接上下文。这里重要的部分是为两个依赖解析器注册相同的 NInject 内核实例,并将它们连接到全局配置。

[assembly: OwinStartup(typeof(RESTfulSignalRService.Startup))]
namespace RESTfulSignalRService
{
    /// <summary>
    /// OWIN startup class
    /// </summary>
    public class Startup
    {      
        /// <summary>
        /// Configuration value
        /// </summary>
        /// <param name="app">IAppBuilder value</param>
        public void Configuration(IAppBuilder app)
        {
            var kernel = new StandardKernel();
            
            // SignalR Hub DP resolver
            var resolver = new NInjectSignalRDependencyResolver(kernel);

            kernel.Bind(typeof(IHubConnectionContext<dynamic>)).
                    ToMethod(context =>
                    resolver.Resolve<IConnectionManager>().
                    GetHubContext<BroadCastHub>().Clients).
                    WhenInjectedInto<IBroadCast>();

            kernel.Bind<IBroadCast>().
                ToConstant<BroadCaster>(new BroadCaster());

            // IBroadcast DP resolver
            GlobalConfiguration.Configuration.DependencyResolver = new NInjectDependencyResolver(kernel);

            GlobalHost.Configuration.MaxIncomingWebSocketMessageSize = null; // Unlimited incoming message size
            
            app.Map("/signalr", map =>
            {
                map.UseCors(CorsOptions.AllowAll);
                map.RunSignalR(new HubConfiguration()
                {
                    EnableDetailedErrors = true,
                    Resolver = resolver
                });
            });
        }
    }
}

SignalR 广播监听器库只不过是 SignalR .NET 客户端库之上的 SignalR Hub 事件监听器包装器。该库简化了客户端监听 RESTful SignalR 服务引发的 hub 事件的方式。在这里实现的一个主要功能是,将客户端事件附加到接收 RESTful SignalR 服务广播的消息。配置该库所需的输入(URL、hubName 和 event name)后,广播器消息将传输到客户端代码中指定的节。为了提供这些必需的输入,我们定义了一个自定义 hub 配置读取器类(HubConfigurationSection),它可以从配置(app.config/web.config)中读取这些输入,或者在初始化类时分配这些输入。自定义 hub 配置读取器的输入定义如下:
 

配置名称 描述 示例
hubURL 消息广播的 URL。在此情况下,该值为消息广播器 REST 服务地址。 <hubUrl url="https:///RESTfulSignalRService/"/>
hubName 定义消息事件的实际 hub 名称。 <hubName name="BroadcastHub"/>
hubEventName 将要监听的实际事件名称。 <hubEventName eventName="onMessageListened" />
hubListeningIndicator 一个启用/禁用事件监听的指示器。 <hubListeningIndicator isEnabled="false"/>


除了这些可配置值之外,客户端还将传递一个操作(Action<object, BroadCastEventArgs>),该操作捕获广播的消息并将其回传给客户端代码。那么该库是如何实现的呢?首先,让我们看看该库的整体类图。

SignalR message listener class diagram

从类图可以看出,有几个类和接口有助于收集我之前解释过的必要配置。IBroadCastListener为监听广播消息提供必要的操作。IHubConfiguration接口有助于从app.config/web.config文件读取自定义 hub 配置,或通过其实现者HubConfigurationSection类进行实例化。自定义配置如下所示:

<configSections>
<sectionGroup name="hubConfigurations">
    <section name="messageListenerConfiguration" 
                type="SignalRBroadCastListener.HubConfiguration.HubConfigurationSection, 
                      SignalRBroadCastListener" />
    <section name="insertListenerConfiguration" 
                type="SignalRBroadCastListener.HubConfiguration.HubConfigurationSection, 
                      SignalRBroadCastListener" />
    <!-- 
        Define more hub sections
    -->
</sectionGroup>
</configSections>
<hubConfigurations>
    <messageListenerConfiguration>
        <hubUrl url="https:///RESTfulSignalRService/" />
        <hubName name="BroadcastHub" />
        <hubEventName eventName="onMessageListened" />
        <!-- <hubListeningIndicator isEnabled="false" /> --> <!-- Default is enabled -->

    </messageListenerConfiguration>
    <insertListenerConfiguration>
        <hubUrl url="https:///RESTfulSignalRService/" />
        <hubName name="BroadcastHub" />
        <hubEventName eventName="onInserted" />
    </insertListenerConfiguration>
    <!-- 
    Configure more hub section 
    -->
</hubConfigurations>

一旦传递了这些配置以及事件监听器委托,BroadCastListener类将在调用ListenHubEvent方法后初始化 SignalR .NET 客户端相关类。

/// <summary>
/// BroadCastListener class
/// </summary>
public class BroadCastListener : IBroadCastListener, IDisposable
{
    // .........................................
    // Full code is available in the source code
    // .........................................

    ///  <summary>
    /// BroadCast listener class
    ///  </summary>
    ///  <param name="hubConfiguration">IHubConfiguration value </param>
    public BroadCastListener(IHubConfiguration hubConfiguration)
    {
           
    }

    /// <summary>
    /// Listen hub event and attach the message to the client event
    /// </summary>
    /// <param name="hubEvent">Client hub event</param>
    /// <returns>string value that shows status</returns>
    public string ListenHubEvent(Action<object, BroadCastEventArgs> hubEvent)
    {
        // .........................................
        // Full code is available in the source code
        // .........................................

        try
        {
            hubConnection.Start().
                ContinueWith(task
                    =>
                    {
                        if (task.IsFaulted)
                        {
                            throw task.Exception;
                        }
                        else
                        {
                            // Register broadcast events
                            if (_hubConfiguration.HubEventName != EventNameEnum.UNKNOWN)
                            {
                                 // Register/attach broadcast event
                                 lock (eventLocker)
                                 {
                                     BroadCastListenerEventHandler += (sender, broadCastArgs) 
                                                                    => hubEvent.Invoke(sender, broadCastArgs);
                                 }
                            }
                        }

                    }, TaskContinuationOptions.OnlyOnRanToCompletion).Wait();
        }
        catch (AggregateException aggregateException)
        {
            throw aggregateException;
        }

        if (hubConnection.State == ConnectionState.Connected)
            IsConnected = true;

        proxyHub.On<string>(_hubConfiguration.HubEventName.EnumDescription(),
                message =>
                {
                    _broadCastListenerEventArgs = new BroadCastEventArgs(
                        new MessageRequest()
                        {
                            Message = message,
                            EventName = _hubConfiguration.HubEventName
                        });
                    OnMessageListened(_broadCastListenerEventArgs);
                });           
    
       // .........................................
       // Full code is available in the source code
       // .........................................
    
    }

    private void OnMessageListened(BroadCastEventArgs broadCastArgs)
    {
        if (BroadCastListenerEventHandler != null)
            BroadCastListenerEventHandler(this, broadCastArgs);
    }
}
    

请注意ListenHubEvent方法周围的代码。我使用了TaskContinuationOptions.OnlyOnRanToCompletion来确保在将内容回传给客户端代码之前,所有与消息广播相关的先前任务都已完成,并且已收到适当的消息。

客户端应用程序

1. 消息广播器

  • 一个 SQL Server 数据库表,以实时方式将数据更改广播给 respective 客户端。下面的存储过程负责调用 RESTful SignalR 服务。
    CREATE PROCEDURE [dbo].[USP_INVOKE_REST_SERVICE]
    	        @message NVARCHAR(MAX),
    	        @eventName NVARCHAR(20),
    	        @response NVARCHAR(1000) OUTPUT
    AS
    
    -- .........................................
    -- Full code is available in the source code
    -- .........................................
         
    -- Make sure the URL pointed to the right SignalR enabled service
    SET @url = CONCAT('https:///restfulsignalrservice/messagebroadcast/broadcast?message=',
            @message,'&eventName=', @eventName)
    
    EXEC sp_OACreate 'MSXML2.XMLHTTP', @object OUT;
    EXEC sp_OAMethod @object, 'open', NULL, 'post', @url,'false'
    EXEC sp_OAMethod @object, 'send'
    EXEC sp_OAMethod @object, 'responseText', @response OUTPUT
     
    SELECT @response AS 'Response Text'
     
    EXEC sp_OADestroy @object
    
    基本上,存储过程使用XMLHttpRequest对象以及内置的 SQL Server 存储过程,如sp_OACreatesp_OAMethod来调用服务。然后,在任何适用的地方使用此存储过程。假设需要广播/发送数据库表(ConfigurationLookUp)的更改,那么通过定义一个插入触发器,我们可以实现所需的功能,如下所示:
    CREATE TRIGGER [dbo].[TRG_INSERTED_CONFIGURATION_LOOKUP] ON [dbo].[ConfigurationLookUp] 
    FOR INSERT
    AS
    
    -- .........................................
    -- Full code is available in the source code
    -- .........................................
           
    -- Complex query can be applied 
    SELECT
        @id = i.ID,
        @name = i.Name,
        @value = i.Value
    FROM
        inserted i
            
    -- JSONify the message
    SET @message = CONCAT('{"ID":',CAST(@id AS NVARCHAR(20)),',"Name":"',@name,'","Value":"',@value,'"}')
    SET @eventName = 'onInserted'
    
    EXEC [dbo].[USP_INVOKE_REST_SERVICE]  @message, @eventName, @response OUTPUT
    
    更新删除触发器也可以以类似的方式实现。需要注意的两个重要事项:
    1. 使用触发器可以告诉你精确的*已修改/更改*记录(行),而不是整个表记录(行),它会将此修改/更改的记录(行)广播给 respective 广播监听器客户端。这使得监听器只需处理*已修改/更改*的记录(行)。
    2. 一个简单的 ADO.NET CRUD 操作可以通过数据库间接调用服务并广播更改。这也是虚拟消息广播场景的一个例子。
  • 注意:为了使用sp_OACreatesp_OAMethod,它们应该通过名为sp_configure的全局配置设置存储过程进行配置。有关如何启用它们,请参阅https://msdn.microsoft.com/en-us/library/ms191188.aspx
  • 像 Netdunio Plus 2 或 Arduino 这样的 IoT 硬件可以调用该服务,从而允许任何外部应用程序监听广播消息。源代码中提供了一个使用 Netdunio Plus 2 的简单示例。
  • 一个简单的 HTML 客户端应用程序,它使用 ajax post 来广播消息。代码也包含在源代码中。

2. 消息监听器

  • 一个缓存服务,它通过监听更改源来更新其缓存数据。在这种情况下,源是数据库。
    /// <summary>
    /// Memory cache manager
    /// </summary>>
    public class MemoryCacheManager
    {
        // .........................................
        // Full code is available in the source code
        // .........................................
    
        /// <summary>
        /// Get ConfigurationLookUps caches
        /// </summary>
        public static List<ConfigurationLookup> ConfigurationLookUpCaches
        {
            get
            {
                cache = MemoryCache.Default;
                _configurationLookUpCaches = cache[CONFIGURATION_LOOKUP_CACHE_KEY] as List<ConfigurationLookup>
                if (_configurationLookUpCaches == null)
                {
                    _configurationLookUpCaches = ConfigurationCacheDataAcces.GetConfigurationLookUps();
                    cache.Add(CONFIGURATION_LOOKUP_CACHE_KEY, _configurationLookUpCaches, policy);
                }
                return _configurationLookUpCaches ?? (_configurationLookUpCaches = new List<ConfigurationLookup>());
            }
        }
    
        /// <summary>
        /// IDBListener initializer method 
        /// </summary>
        /// <param name="dbListener">IDBListener value</param>
        public static void DBListener(IDBListener dbListener)
        {
            policy = new CacheItemPolicy() { AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(20) }; // A 20 min expiration policy
            HookDBListeners(dbListener);
        }
    
        /// <summary>
        /// ListenerEvent Hooker method 
        /// </summary>
        /// <param name="dbListener">IDBListener value</param>
        private static void HookDBListeners(IDBListener dbListener)
        {
            if (dbListener != null)
            {
                dbListener.InsertListener.ListenHubEvent(InsertListenerEvent);
                dbListener.UpdateListener.ListenHubEvent(UpdateListenerEvent);
                dbListener.DeleteListener.ListenHubEvent(DeleteListenerEvent);
            }
        }
    
        /// <summary>
        /// Insert event listener
        /// </summary>>
        /// <param name="sender">Sender value</param>
        /// <param name="broadCastEventArgs">BroadCastEventArgs value</param>
        void static InsertListenerEvent(object sender, BroadCastEventArgs broadCastEventArgs)
        {
            lock (_locker)
            {   
                ConfigurationLookup configurationLookUp;
                if (ConverterHelper.TryDeserialize<ConfigurationLookup>(broadCastEventArgs.MessageRequest.Message, 
                    out configurationLookUp))
                {
                    _configurationLookUpCaches.Add(configurationLookUp);
                    _configurationLookUpCaches.OrderByDescending(cl => cl.ID);
                    cache.Add(CONFIGURATION_LOOKUP_CACHE_KEY, _configurationLookUpCaches, policy);
                }         
            }
        }
    }
    
    注意:这种实现避免了不必要的往返整个数据库以及服务重启操作来反映数据更改。
     
  • 同样,一个 WPF 应用程序监听数据库更改并将更改反映到可观察集合和动画数据网格控件。完整的代码可在源代码中找到。
  • 一个简单的 HTML 客户端应用程序,它使用 SignalR JS 客户端来监听广播器发送的广播消息。代码也包含在源代码中。

结论

在过去的几年里,Microsoft Visual Studio 团队为 .NET 生态系统创建了如此重要的技术。让客户端/服务器应用程序实时交互并不容易。ActiveX、Flash、Silverlight 等嵌入式插件能够完成工作。但由于客户端需要启用插件,它们并不优雅。此外,它们并未完全支持移动设备和其他不同环境。

自从 SignalR 推出以来,许多需要实时通信的困难业务场景都在几行代码中得到了解决。还值得一提的是,最新和更新的浏览器版本对这种革命产生了很大影响。

参考文献

历史

  • 2015 年 4 月 21 日:第一个版本
  • 更新于 2015 年 4 月 22 日 - 文章格式问题
  • 更新于 2015 年 4 月 26 日 - 文章格式问题
  • 更新于 2015 年 5 月 19 日 - 文章格式问题
  • 更新于 2015 年 5 月 28 日 - 文章格式问题
  • 更新于 2015 年 5 月 29 日 - 文章格式问题
  • 更新于 2015 年 10 月 27 日 - 下载链接问题
© . All rights reserved.