使用SignalR 2进行服务器广播
概述
在本教程中,您将创建一个股票代码应用程序,该应用程序代表您希望定期“推送”或广播从服务器到所有连接客户端的通知的实时应用程序。在本教程的第一部分中,您将从头开始创建该应用程序的简化版本。在本教程的其余部分中,您将安装包含其他功能的NuGet包,并查看这些功能的代码。
您将在本教程的第一部分中构建的应用程序显示包含库存数据的网格。
服务器会定期随机更新股票价格并将更新推送到所有连接的客户端。在浏览器中,Change和%列中的数字和符号会根据来自服务器的通知动态更改。如果您打开其他浏览器到同一个URL,它们都会同时显示相同的数据和相同的数据更改。
该Microsoft.AspNet.SignalR.Sample NuGet包安装在Visual Studio项目的样本模拟股票行情应用。
设置服务器代码
在本节中,您将设置在服务器上运行的代码。
创建Stock类
首先创建将用于存储和传输股票信息的Stock模型类。
在项目文件夹中创建一个新的类文件,将其命名为Stock.cs,然后使用以下代码替换模板代码:
using System; namespace SignalR.StockTicker { public class Stock { private decimal _price; public string Symbol { get; set; } public decimal Price { get { return _price; } set { if (_price == value) { return; } _price = value; if (DayOpen == 0) { DayOpen = _price; } } } public decimal DayOpen { get; private set; } public decimal Change { get { return Price - DayOpen; } } public double PercentChange { get { return (double)Math.Round(Change / Price, 4); } } } }
创建股票时要设置的两个属性是Symbol(例如,Microsoft的MSFT)和Price。其他属性取决于您设置Price的方式和时间。第一次设置Price时,该值会传播到DayOpen。设置Price时的后续时间,Change和PercentChange属性值将根据Price和DayOpen之间的差异计算。
创建StockTicker和StockTickerHub类
您将使用SignalR Hub API来处理服务器到客户端的交互。从SignalR Hub类派生的StockTickerHub类将处理从客户端接收连接和方法调用。您还需要维护库存数据并运行Timer对象以定期触发价格更新,而与客户端连接无关。您不能将这些函数放在Hub类中,因为Hub实例是瞬态的。为集线器上的每个操作创建一个Hub类实例,例如从客户端到服务器的连接和调用。因此,保存库存数据,更新价格和广播价格更新的机制必须在一个单独的类中运行,您可以将其命名为StockTicker。
您只需要在服务器上运行StockTicker类的一个实例,因此您需要将每个StockTickerHub实例的引用设置为singleton StockTicker实例。StockTicker类必须能够向客户端广播,因为它具有库存数据并触发更新,但StockTicker不是Hub类。因此,StockTicker类必须获取对SignalR Hub连接上下文对象的引用。然后,它可以使用SignalR连接上下文对象向客户端广播。
在解决方案资源管理器中,右键单击该项目,然后单击添加| SignalR Hub Class(v2)。
将新集线器命名为StockTickerHub.cs,然后单击“ 添加”。SignalR NuGet包将添加到您的项目中。
使用以下代码替换模板代码:
该中心类是用来定义方法的客户端可以调用服务器上。您正在定义一种方法:GetAllStocks()。当客户端最初连接到服务器时,它将调用此方法以获取所有具有当前价格的股票的列表。该方法可以同步执行并返回,IEnumerable因为它从内存返回数据。如果方法必须通过执行涉及等待的操作(例如数据库查找或Web服务调用)来获取数据,则应指定Task<ienumerable>为返回值以启用异步处理。
HubName属性指定如何在客户端的JavaScript代码中引用Hub。如果不使用此属性,则客户端上的默认名称是类名称的驼峰版本,在本例中为stockTickerHub。
正如您稍后在创建StockTicker类时所看到的那样,在其静态Instance属性中创建该类的单例实例。无论有多少客户端连接或断开连接,StockTicker的单例实例都会保留在内存中,并且该实例是GetAllStocks方法用于返回当前股票信息的实例。
在项目文件夹中创建一个新的类文件,将其命名为StockTicker.cs,然后使用以下代码替换模板代码:
using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading; using Microsoft.AspNet.SignalR; using Microsoft.AspNet.SignalR.Hubs; namespace SignalR.StockTicker { public class StockTicker { // Singleton instance private readonly static Lazy<StockTicker> _instance = new Lazy<StockTicker>(() => new StockTicker(GlobalHost.ConnectionManager.GetHubContext<StockTickerHub>().Clients)); private readonly ConcurrentDictionary<string, Stock> _stocks = new ConcurrentDictionary<string, Stock>(); private readonly object _updateStockPricesLock = new object(); //stock can go up or down by a percentage of this factor on each change private readonly double _rangePercent = .002; private readonly TimeSpan _updateInterval = TimeSpan.FromMilliseconds(250); private readonly Random _updateOrNotRandom = new Random(); private readonly Timer _timer; private volatile bool _updatingStockPrices = false; private StockTicker(IHubConnectionContext<dynamic> clients) { Clients = clients; _stocks.Clear(); var stocks = new List<Stock> { new Stock { Symbol = "MSFT", Price = 30.31m }, new Stock { Symbol = "APPL", Price = 578.18m }, new Stock { Symbol = "GOOG", Price = 570.30m } }; stocks.ForEach(stock => _stocks.TryAdd(stock.Symbol, stock)); _timer = new Timer(UpdateStockPrices, null, _updateInterval, _updateInterval); } public static StockTicker Instance { get { return _instance.Value; } } private IHubConnectionContext<dynamic> Clients { get; set; } public IEnumerable<Stock> GetAllStocks() { return _stocks.Values; } private void UpdateStockPrices(object state) { lock (_updateStockPricesLock) { if (!_updatingStockPrices) { _updatingStockPrices = true; foreach (var stock in _stocks.Values) { if (TryUpdateStockPrice(stock)) { BroadcastStockPrice(stock); } } _updatingStockPrices = false; } } } private bool TryUpdateStockPrice(Stock stock) { // Randomly choose whether to update this stock or not var r = _updateOrNotRandom.NextDouble(); if (r > .1) { return false; } // Update the stock price by a random factor of the range percent var random = new Random((int)Math.Floor(stock.Price)); var percentChange = random.NextDouble() * _rangePercent; var pos = random.NextDouble() > .51; var change = Math.Round(stock.Price * (decimal)percentChange, 2); change = pos ? change : -change; stock.Price += change; return true; } private void BroadcastStockPrice(Stock stock) { Clients.All.updateStockPrice(stock); } } }
由于多个线程将运行相同的StockTicker代码实例,因此StockTicker类必须是线程安全的。
将单例实例存储在静态字段中
该代码初始化使用类的实例支持Instance属性的static _instance字段,这是可以创建的类的唯一实例,因为构造函数被标记为private。延迟初始化用于_instance字段,不是出于性能原因,而是为了确保实例创建是线程安全的。
private readonly static Lazy<StockTicker> _instance = new Lazy<StockTicker>(() => new StockTicker(GlobalHost.ConnectionManager.GetHubContext<StockTickerHub>().Clients)); public static StockTicker Instance { get { return _instance.Value; } }
每次客户端连接到服务器时,在单独的线程中运行的StockTickerHub类的新实例从StockTicker.Instance静态属性获取StockTicker单例实例,如前面在StockTickerHub类中看到的那样。
将库存数据存储在ConcurrentDictionary中
构造函数使用一些样本库存数据初始化_stocks集合,GetAllStocks返回库存。如前所述,StockTickerHub.GetAllStocks将返回此股票集合,这是Hub类中客户端可以调用的服务器方法。
private readonly ConcurrentDictionary<string, Stock> _stocks = new ConcurrentDictionary<string, Stock>(); private StockTicker(IHubConnectionContext<dynamic> clients) { Clients = clients; _stocks.Clear(); var stocks = new List<Stock> { new Stock { Symbol = "MSFT", Price = 30.31m }, new Stock { Symbol = "APPL", Price = 578.18m }, new Stock { Symbol = "GOOG", Price = 570.30m } }; stocks.ForEach(stock => _stocks.TryAdd(stock.Symbol, stock)); _timer = new Timer(UpdateStockPrices, null, _updateInterval, _updateInterval); } public IEnumerable<Stock> GetAllStocks() { return _stocks.Values; }
库存集合被定义为用于线程安全的ConcurrentDictionary类型。作为替代方案,您可以使用Dictionary对象并在对其进行更改时显式锁定字典。
对于此示例应用程序,可以将应用程序数据存储在内存中,并在处理StockTicker实例时丢失数据。在实际应用程序中,您将使用后端数据存储(如数据库)。
定期更新股票价格
构造函数启动一个Timer对象,该对象定期调用随机更新股票价格的方法。
_timer = new Timer(UpdateStockPrices, null, _updateInterval, _updateInterval); private void UpdateStockPrices(object state) { lock (_updateStockPricesLock) { if (!_updatingStockPrices) { _updatingStockPrices = true; foreach (var stock in _stocks.Values) { if (TryUpdateStockPrice(stock)) { BroadcastStockPrice(stock); } } _updatingStockPrices = false; } } } private bool TryUpdateStockPrice(Stock stock) { // Randomly choose whether to update this stock or not var r = _updateOrNotRandom.NextDouble(); if (r > .1) { return false; } // Update the stock price by a random factor of the range percent var random = new Random((int)Math.Floor(stock.Price)); var percentChange = random.NextDouble() * _rangePercent; var pos = random.NextDouble() > .51; var change = Math.Round(stock.Price * (decimal)percentChange, 2); change = pos ? change : -change; stock.Price += change; return true; }
UpdateStockPrices由Timer调用,它在state参数中传入null。在更新价格之前,会对_updateStockPricesLock对象进行锁定。代码检查另一个线程是否已在更新价格,然后在列表中的每个库存上调用TryUpdateStockPrice。TryUpdateStockPrice方法决定是否更改股票价格以及更改它的价格。如果股票价格发生变化,则调用BroadcastStockPrice将股票价格变动广播给所有连接的客户。
_updatingStockPrices标志被标记为volatile,以确保对它的访问是线程安全的。
private volatile bool _updatingStockPrices = false;
在实际应用程序中,TryUpdateStockPrice方法会调用Web服务来查询价格; 在此代码中,它使用随机数生成器随机进行更改。
获取SignalR上下文,以便StockTicker类可以向客户端广播
由于价格变化源自StockTicker对象,因此需要在所有连接的客户端上调用updateStockPrice方法。在Hub类中,您有一个用于调用客户端方法的API,但是StockTicker不是从Hub类派生的,也没有对任何Hub对象的引用。因此,为了向连接的客户端广播,StockTicker类必须获取StockTickerHub类的SignalR上下文实例,并使用它来调用客户端上的方法。
代码在创建单例类实例时获取对SignalR上下文的引用,将该引用传递给构造函数,构造函数将其放入Clients属性中。
您只想获取上下文一次有两个原因:获取上下文是一项昂贵的操作,获取上下文可确保保留发送给客户端的预期消息顺序。
private readonly static Lazy<StockTicker> _instance = new Lazy<StockTicker>(() => new StockTicker(GlobalHost.ConnectionManager.GetHubContext<StockTickerHub>().Clients)); private StockTicker(IHubConnectionContext<dynamic> clients) { Clients = clients; // Remainder of constructor ... } private IHubConnectionContext<dynamic> Clients { get; set; } private void BroadcastStockPrice(Stock stock) { Clients.All.updateStockPrice(stock); }
获取上下文的Clients属性并将其放在StockTickerClient属性中可以编写代码来调用与Hub类中相同的客户端方法。例如,要向所有客户端广播,您可以编写Clients.All.updateStockPrice(stock)。
您在BroadcastStockPrice中调用的updateStockPrice方法尚不存在; 稍后当您编写在客户端上运行的代码时,您将添加它。您可以在此处参考updateStockPrice,因为Clients.All是动态的,这意味着将在运行时评估表达式。当执行此方法调用时,SignalR将方法名称和参数值发送到客户端,如果客户端有一个名为updateStockPrice的方法,则将调用该方法并将参数值传递给它。
Clients.All意味着发送给所有客户。SignalR为您提供了其他选项来指定要发送到的客户端或客户端组。有关更多信息,请参阅HubConnectionContext。
注册SignalR路线
服务器需要知道拦截哪个URL并指向SignalR。为此,添加一个OWIN启动类:
在解决方案资源管理器中,右键单击该项目,然后单击“ 添加”| OWIN启动课程。将类命名为Startup.cs。
用以下内容替换Startup.cs中的代码。
using System; using System.Threading.Tasks; using Microsoft.Owin; using Owin; [assembly: OwinStartup(typeof(SignalR.StockTicker.Startup))] namespace SignalR.StockTicker { public class Startup { public void Configuration(IAppBuilder app) { // Any connection or hub wire up and configuration should go here app.MapSignalR(); } } }
您现在已完成设置服务器代码。在下一节中,您将设置客户端。
设置客户端代码
在项目文件夹中创建一个新的HTML文件,并将其命名为StockTicker.html。
使用以下代码替换模板代码。
<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <title>ASP.NET SignalR Stock Ticker</title> <style> body { font-family: 'Segoe UI', Arial, Helvetica, sans-serif; font-size: 16px; } #stockTable table { border-collapse: collapse; } #stockTable table th, #stockTable table td { padding: 2px 6px; } #stockTable table td { text-align: right; } #stockTable .loading td { text-align: left; } </style> </head> <body> <h1>ASP.NET SignalR Stock Ticker Sample</h1> <h2>Live Stock Table</h2> <div id="stockTable"> <table border="1"> <thead> <tr><th>Symbol</th><th>Price</th><th>Open</th><th>Change</th><th>%</th></tr> </thead> <tbody> <tr class="loading"><td colspan="5">loading...</td></tr> </tbody> </table> </div> <!--Script references. --> <!--Reference the jQuery library. --> <script src="/Scripts/jquery-1.10.2.min.js" ></script> <!--Reference the SignalR library. --> <script src="/Scripts/jquery.signalR-2.1.0.js"></script> <!--Reference the autogenerated SignalR hub script. --> <script src="/signalr/hubs"></script> <!--Reference the StockTicker script. --> <script src="StockTicker.js"></script> </body> </html>
HTML创建一个包含5列的表,一个标题行和一个数据行,其中一个单元格跨越所有5列。数据行显示“正在加载…”,并且仅在应用程序启动时立即显示。JavaScript代码将删除该行,并在其位置添加从服务器检索的库存数据。
脚本标记指定jQuery脚本文件,SignalR核心脚本文件,SignalR代理脚本文件以及稍后将创建的StockTicker脚本文件。SignalR代理脚本文件(指定“/ signalr / hubs”URL)是动态生成的,它定义了Hub类上方法的代理方法,在本例中为StockTickerHub.GetAllStocks。如果您愿意,可以使用SignalR Utilities手动生成此JavaScript文件,并在MapHubs方法调用中禁用动态文件创建。
重要
确保StockTicker.html中的JavaScript文件引用正确无误。也就是说,确保脚本标记中的jQuery版本(示例中为1.10.2)与项目的Scripts文件夹中的jQuery版本相同,并确保脚本标记中的SignalR版本与SignalR版本在项目的Scripts文件夹中。如有必要,请更改脚本标记中的文件名。
在“ 解决方案资源管理器”中,右键单击“ StockTicker.html”,然后单击“ 设置为起始页”。
在项目文件夹中创建一个新的JavaScript文件,并将其命名为StockTicker.js ..
使用以下代码替换模板代码:
// A simple templating method for replacing placeholders enclosed in curly braces. if (!String.prototype.supplant) { String.prototype.supplant = function (o) { return this.replace(/{([^{}]*)}/g, function (a, b) { var r = o[b]; return typeof r === 'string' || typeof r === 'number' ? r : a; } ); }; } $(function () { var ticker = $.connection.stockTickerMini, // the generated client-side hub proxy up = '▲', down = '▼', $stockTable = $('#stockTable'), $stockTableBody = $stockTable.find('tbody'), rowTemplate = '<tr data-symbol="{Symbol}"><td>{Symbol}</td><td>{Price}</td><td>{DayOpen}</td><td>{Direction} {Change}</td><td>{PercentChange}</td></tr>'; function formatStock(stock) { return $.extend(stock, { Price: stock.Price.toFixed(2), PercentChange: (stock.PercentChange * 100).toFixed(2) + '%', Direction: stock.Change === 0 ? '' : stock.Change >= 0 ? up : down }); } function init() { ticker.server.getAllStocks().done(function (stocks) { $stockTableBody.empty(); $.each(stocks, function () { var stock = formatStock(this); $stockTableBody.append(rowTemplate.supplant(stock)); }); }); } // Add a client-side hub method that the server will call ticker.client.updateStockPrice = function (stock) { var displayStock = formatStock(stock), $row = $(rowTemplate.supplant(displayStock)); $stockTableBody.find('tr[data-symbol=' + stock.Symbol + ']') .replaceWith($row); } // Start the connection $.connection.hub.start().done(init); });
$ .connection指的是SignalR代理。代码获取对StockTickerHub类的代理的引用,并将其放在ticker变量中。代理名称是[HubName]属性设置的名称:
var ticker = $.connection.stockTickerMini
[HubName("stockTickerMini")] public class StockTickerHub : Hub
在定义了所有变量和函数之后,文件中的最后一行代码通过调用SignalR启动函数来初始化SignalR连接。start函数异步执行并返回一个jQuery Deferred对象,这意味着你可以调用done函数来指定异步操作完成时要调用的函数。
$.connection.hub.start().done(init);
init函数调用服务器上的getAllStocks函数,并使用服务器返回的信息来更新stock表。请注意,默认情况下,您必须在客户端上使用camel大小写,尽管方法名称是pascal-cased在服务器上。camel-casing规则仅适用于方法,而不适用于对象。例如,您指的是stock.Symbol和stock.Price,而不是stock.symbol或stock.price。
function init() { ticker.server.getAllStocks().done(function (stocks) { $stockTableBody.empty(); $.each(stocks, function () { var stock = formatStock(this); $stockTableBody.append(rowTemplate.supplant(stock)); }); }); } public IEnumerable<Stock> GetAllStocks() { return _stockTicker.GetAllStocks(); }
如果要在客户端上使用pascal大小写,或者如果要使用完全不同的方法名称,则可以使用HubMethodName属性修饰Hub方法,就像使用HubName属性修饰Hub类本身一样。
在init方法中,通过调用formatStock来格式化stock对象的属性,然后通过调用supplant(在StockTicker.js的顶部定义)来替换从服务器接收的每个stock对象的表行的HTML。rowTemplate变量中的占位符与stock对象属性值。然后将生成的HTML附加到stock表。
通过将其作为在异步启动函数完成后执行的回调函数传入来调用init。如果在调用start之后将init作为单独的JavaScript语句调用,则该函数将失败,因为它将立即执行而无需等待start函数完成建立连接。在这种情况下,init函数将尝试在建立服务器连接之前调用getAllStocks函数。
当服务器更改股票的价格时,它会在连接的客户端上调用updateStockPrice。该函数将添加到stockTicker代理的client属性中,以使其可用于来自服务器的调用。
ticker.client.updateStockPrice = function (stock) { var displayStock = formatStock(stock), $row = $(rowTemplate.supplant(displayStock)); $stockTableBody.find('tr[data-symbol=' + stock.Symbol + ']') .replaceWith($row); }
updateStockPrice函数将从服务器接收的stock对象格式化为表行,与init函数中的方式相同。但是,它不是将行附加到表中,而是在表中查找库存的当前行,并用新行替换该行。
测试应用程序
按F5以在调试模式下运行应用程序。
库存表最初显示“loading …”行,然后在短暂延迟后显示初始库存数据,然后股票价格开始变化。
从浏览器地址栏复制URL并将其粘贴到一个或多个新的浏览器窗口中。
初始库存显示与第一个浏览器相同,并且同时发生更改。
关闭所有浏览器并打开新浏览器,然后转到相同的URL。
StockTicker单例对象继续在服务器中运行,因此库存表显示库存继续变化。(您没有看到初始表的零变化数字。)
关闭浏览器。
启用日志记录
SignalR具有内置日志记录功能,您可以在客户端上启用该功能以帮助进行故障排除。在本节中,您将启用日志记录并查看示例,以显示日志如何告诉您SignalR正在使用以下哪种传输方法:
WebSockets,受IIS 8和当前浏览器支持。
服务器发送的事件,由Internet Explorer以外的浏览器支持。
永远的框架,由Internet Explorer支持。
Ajax长轮询,受所有浏览器支持。
对于任何给定的连接,SignalR选择服务器和客户端都支持的最佳传输方法。
打开StockTicker.js并添加一行代码,以便在初始化文件末尾连接的代码之前立即启用日志记录:
// Start the connection $.connection.hub.logging = true; $.connection.hub.start().done(init);
按F5运行该项目。
打开浏览器的开发人员工具窗口,然后选择控制台以查看日志。您可能必须刷新页面以查看Signalr协商新连接的传输方法的日志。
如果在Windows 8(IIS 8)上运行Internet Explorer 10,则传输方法为WebSockets。