首頁 > 軟體

ASP.NET使用SignalR2實現伺服器廣播

2022-05-27 18:04:27

一、概述

這篇教學通過實現一個股票報價的小程式來講解如何使用SignalR進行伺服器端的推播,伺服器會模擬股票價格的波動,並把最新的股票價格推播給所有連線的使用者端,最終的執行效果如下圖所示。

教學:使用 SignalR 2 廣播的伺服器

可以通過Install-Package Microsoft.AspNet.SignalR.Sample來安裝並檢視完整的程式碼。

二、伺服器端程式碼

新建一個名為Stock.cs的實體類,用來作為伺服器端推播訊息的載體,具體程式碼如下。

這個實體類只有Symbol和Price這兩個屬性需要設定,其它屬性將會依據Price自動進行計算。

using System;

namespace Microsoft.AspNet.SignalR.StockTicker
{
    public class Stock
    {
        private decimal _price;

        public string Symbol { get; set; }

        public decimal DayOpen { get; private set; }

        public decimal DayLow { get; private set; }

        public decimal DayHigh { get; private set; }

        public decimal LastChange { get; private set; }

        public decimal Price
        {
            get
            {
                return _price;
            }
            set
            {
                if (_price == value)
                {
                    return;
                }

                LastChange = value - _price;
                _price = value;

                if (DayOpen == 0)
                {
                    DayOpen = _price;
                }
                if (_price < DayLow || DayLow == 0)
                {
                    DayLow = _price;
                }
                if (_price > DayHigh)
                {
                    DayHigh = _price;
                }
            }
        }

        public decimal Change
        {
            get
            {
                return Price - DayOpen;
            }
        }

        public double PercentChange
        {
            get
            {
                return (double)Math.Round(Change / Price, 4);
            }
        }
    }
}

1、建立StockTicker和StockTickerHub類

我們將使用SignalR Hub API來處理伺服器到使用者端的互動,所以新建一個繼承自SignalR Hub類的StockTickerHub類來處理使用者端的連線及呼叫。除此之外,我們還需要維護股票的價格資料以及新建一個Timer物件來定期的更新價格,而這些都與使用者端連線無關的。由於Hub範例的生命週期很短暫,只有在比如使用者端連線和呼叫的時候,Hub類範例是為Hub上的每個這樣的操作才會建立新的範例,所以不要把與使用者端連線及呼叫無關的程式碼放置到SignalR Hub類中。在這裡,我們將維護股票資料、模擬更新股票價格以及向用戶端推播股票價格的程式碼放置到一個名為StockTicker的類中。

我們只需要在伺服器端執行一個StockTicker類的範例(單例模式),由於這個StockTicker類維護著股票的價格,所以它也要能夠將最新的股票價格推播給所有的使用者端。為了達到這個目的,我們需要在這單個範例中參照所有的StockTickerHub範例,而這可以通過SignalR Hub的Context物件來獲得,然後它可以使用SignalR連線Context物件向用戶端廣播。

2、StockTickerHub類

這個Hub類用來定義使用者端可以呼叫的伺服器端方法,當用戶端與伺服器建立連線後,將會呼叫GetAllStocks()方法來獲得股票資料以及當前的價格,因為這個方法是直接從記憶體中讀取資料的,所以會立即返回IEnumerable資料。如果這個方法是通過其它可能會有延時的方式來呼叫最新的股票資料的話,比如從資料庫查詢,或者呼叫第三方的Web Service,那麼就需要指定Task>來作為返回值,從而實現非同步通訊,更多資訊請參考ASP.NET SignalR Hubs API Guide - Server - When to execute asynchronously

HubName屬性指定了該Hub的別名,即使用者端指令碼呼叫的Hub名,如果不使用HubName屬性指定別名的話,預設將會使用駱駝命名法,那麼它在使用者端呼叫的名稱將會是stockTickerHub。

using Microsoft.AspNet.SignalR.Hubs;
using System.Collections.Generic;

namespace Microsoft.AspNet.SignalR.StockTicker
{
    [HubName("stockTicker")]
    public class StockTickerHub : Hub
    {
        private readonly StockTicker _stockTicker;

        public StockTickerHub()

            : this(StockTicker.Instance)
        {
        }

        public StockTickerHub(StockTicker stockTicker)
        {
            _stockTicker = stockTicker;
        }

        public IEnumerable GetAllStocks()
        {
            return _stockTicker.GetAllStocks();
        }

    }
}

接下來我們將會建立StockTicker類,並且建立一個靜態範例屬性。這樣不管有多少個使用者端連線或者斷開,記憶體中都只有一個StockTicker類的範例,並且還可以通過該範例的GetAllStocks方法來獲得當前的股票資料。

3、StockTicker類

由於所有執行緒都執行 StockTicker 程式碼的同一個範例,StockTicker 類必須要是執行緒安全的。

1、將單個範例儲存在一個靜態欄位中

在這個類中,我們新建了一個名為_instance的欄位用來存放該類的範例,並且將建構函式的存取許可權設定成私有狀態,這樣其它的類就只能通過Instance這個靜態屬性來獲得該類的範例,而無法通過關鍵字new來建立一個新的範例。在這個_instance欄位上面,我們使用了Lazy特性,雖然會損失一點兒效能,但是它卻可以保證以執行緒安全的方式來建立範例。

每次使用者端連線到伺服器時,執行在單獨執行緒中的StockTickerHub類的新範例都會從StockTicker獲取StockTicker單例範例。範例靜態屬性,如前面在StockTickerHub類中看到的。

2、使用ConcurrentDictionary來存放股票資料

這個類定義了一個_stocks欄位來存放測試用的股票資料,並且通過GetAllStocks這個方法來進行獲取。我們前面講過使用者端會通過StockTickerHub.GetAllStocks來獲取當前的股票資料,其實就是這裡的股票資料。

在這個測試程式中,我們將資料存直接存放在記憶體中,這樣做並沒有什麼問題,但在實際的應用場景中,則需要將資料存放在資料庫之類的檔案中以便長久的儲存。

3、定期的更新股票價格

在這個類中,我們定義了一個Timer物件來定期的更新股票的價格。

在實際的場景中,TryUpdateStockPrice方法通常會通過呼叫第三方的Web Service來獲取最新的股票價格,而在這個程式中,我們則是通過亂數來進行模擬該實現。

4、通過SignalR Hub的Context物件來實現伺服器端的推播

因為股票價格變動是在StockTicker物件中,所以這個物件需要呼叫使用者端的updateStockPrice回撥方法來推播資料。在Hub類中,我們可以直接使用API來呼叫使用者端的方法,但是這個StockTicker類並沒有繼承自Hub,所以無法直接使用這些物件。為了能夠向用戶端廣播資料,StockTicker類需要使用SignalR Hub的Context物件來獲得StokTickerHub類的範例,並用它來呼叫使用者端的方法。

using Microsoft.AspNet.SignalR.Hubs;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;

namespace Microsoft.AspNet.SignalR.StockTicker
{
    public class StockTicker
    {
        #region 欄位

        // 初始化StockTicker為單例範例
        private readonly static Lazy _instance = new Lazy(() => new StockTicker(  GlobalHost.ConnectionManager.GetHubContext<StockTickerHub>().Clients) );
         //在建立StockTicker類靜態範例的時候,把SignalR的Context參照通過建構函式傳遞給Clients這個屬性。
         //在這裡只需要獲取一次SignalR.Context,這樣做有2個好處,首先是因為獲取SignalR.Context很耗費資源,其次是獲取一次SignalR.Context可以保留訊息傳送到使用者端的預定義順序。

        private readonly object _marketStateLock = new object();
        private readonly object _updateStockPricesLock = new object();

        //為了執行緒安全,我們使用了ConcurrentDictionary來存放股票資料,當然你也可以使用Dictionary物件來進行儲存,但是在更新資料之前需要進行鎖定。
        private readonly ConcurrentDictionary<string, Stock> _stocks = new ConcurrentDictionary<string, Stock>();

        // 控制股票價格波動的百分比
        private readonly double _rangePercent = 0.002;

        private readonly TimeSpan _updateInterval = TimeSpan.FromMilliseconds(250);
        private readonly Random _updateOrNotRandom = new Random();

        private Timer _timer;

        //使用volatile修飾符來標記_updatingStockPrices變數,該修飾符指示一個欄位可以由多個同時執行的執行緒修改,宣告為volatile的欄位不受編譯器優化(假定由單個執行緒存取)的限制,這樣可以確保該欄位在任何時間呈現的都是最新的值。
        //該修飾符通常用於由多個執行緒存取但不使用lock語句對存取進行序列化的欄位。
        private volatile bool _updatingStockPrices;

        #endregion 欄位

        #region 建構函式

        private StockTicker(IHubConnectionContext<dynamic> clients)
        {
            Clients = clients;

            _stocks.Clear();

            var stocks = new List
            {
                new Stock { Symbol = "MSFT", Price = 41.68m },
                new Stock { Symbol = "AAPL", Price = 92.08m },
                new Stock { Symbol = "GOOG", Price = 543.01m }
            };

            stocks.ForEach(stock => _stocks.TryAdd(stock.Symbol, stock));

            _timer = new Timer(UpdateStockPrices, null, _updateInterval, _updateInterval);//定時更新股價
        }

        #endregion 建構函式

        #region 屬性

        private IHubConnectionContext<dynamic> Clients { get; set; } //使用Clients屬性,可以使您和在Hub類中一樣,通過它來呼叫使用者端的方法。

        public static StockTicker Instance
        {
            get
            {
                return _instance.Value;
            }
        }

        #endregion 屬性

        #region 方法

        //獲取所有股價
        public IEnumerable GetAllStocks()
        {
            return _stocks.Values;
        }

        //在更新之前,我們使用了_updateStockPricesLock物件將需要更新的部份進行鎖定,並通過_updatingStockPrices變數來確定是否有其它執行緒已經更新了股票的價格。
        //然後通過對每一個股票程式碼執行TryUpdateStockPrice方法來確定是否更新股票價格以及股票價格的波動幅度。如果檢測到股票價格變動,將會通過BroadcastStockPrice方法將最新的股票價格推播給每一個連線的使用者端。
        private void UpdateStockPrices(object state)
        {
            // 此函數必須可重入re-entrant,因為它是作為計時器間隔處理程式執行的。
            lock (_updateStockPricesLock)
            {
                if (!_updatingStockPrices)
                {
                    _updatingStockPrices = true;

                    foreach (var stock in _stocks.Values)
                    {
                        if (TryUpdateStockPrice(stock))
                        {
                            BroadcastStockPrice(stock);
                        }
                    }

                    _updatingStockPrices = false;
                }
            }
        }

        private bool TryUpdateStockPrice(Stock stock)
        {
            // 隨機選擇是否更新該股票
            var r = _updateOrNotRandom.NextDouble();
            if (r > 0.1)
            {
                return false;
            }

            // 以範圍百分比的隨機因子更新股票價格
            var random = new Random((int)Math.Floor(stock.Price));
            var percentChange = random.NextDouble() * _rangePercent;
            var pos = random.NextDouble() > 0.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); //Clients.All是dynamic型別的,意味著傳送給所有的使用者端,同時SignalR還提供了用來指定具體的使用者端或組的屬性,具體資訊可以參考HubConnectionContext。
        }

        #endregion 方法
    }
}

4、註冊SignalR路由

伺服器需要知道把哪些請求交由SignalR進行操作,為了實現這個功能,我們需要在OWIN的Startup檔案中進行相應的設定。

using Owin;

namespace Microsoft.AspNet.SignalR.StockTicker
{
    public static class Startup
    {
        public static void ConfigureSignalR(IAppBuilder app)
        {
            // For more information on how to configure your application using OWIN startup, visit http://go.microsoft.com/fwlink/?LinkID=316888

            app.MapSignalR();
        }
    }
}

三、使用者端程式碼

StockTicker.html

頁面分別引入了jQuery、SignalR、SignalR代理,以及StockTicker指令碼檔案。SignalR代理檔案(/signalr/hubs)將會根據伺服器端編寫的Hub檔案動態的生成相應的指令碼(生成關於StockTickerHub.GetAllStocks的相關程式碼),如果你願意,你還可以通過SignalR Utilities來手動生成指令碼檔案,但是需要在MapHubs方法中禁用動態檔案建立的功能。

注意:請確保StockTicker.html檔案中引入的指令碼檔案在你的專案中是實際存在的。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>ASP.NET SignalR Stock Ticker</title>
    <link href="StockTicker.css" rel="external nofollow"  rel="stylesheet" />
</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>High</th><th>Low</th><th>Change</th><th>%</th></tr>
            </thead>
            <tbody>
                <tr class="loading"><td colspan="7">loading...</td></tr>
            </tbody>
        </table>
    </div>

    <h2>Live Stock Ticker</h2>
    <div id="stockTicker">
        <div class="inner">
            <ul>
                <li class="loading">loading...</li>
            </ul> 
        </div>
    </div>
    <script src="jquery-1.10.2.min.js"></script>
    <script src="jquery.color-2.1.2.min.js"></script>
    <script src="../Scripts/jquery.signalR-2.4.1.js"></script>
    <script src="../signalr/hubs"></script>
    <script src="SignalR.StockTicker.js"></script>
</body>
</html>

StockTicker.js

// Crockford's supplant method (poor man's templating)自定義的模板方法
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;
            }
        );
    };
}

// A simple background color flash effect that uses jQuery Color plugin
jQuery.fn.flash = function (color, duration) {
    var current = this.css('backgroundColor');
    this.animate({ backgroundColor: 'rgb(' + color + ')' }, duration / 2)
        .animate({ backgroundColor: current }, duration / 2);
};

$(function () {

    var ticker = $.connection.stockTicker, //$.connection即是指SignalR代理,這行程式碼錶示將StockTickerHub類的代理的參照儲存在變數ticker中,代理的名稱即為伺服器端通過[HubName]屬性設定的名稱。
        up = '▲',
        down = '▼',
        $stockTable = $('#stockTable'),
        $stockTableBody = $stockTable.find('tbody'),
        rowTemplate = '{Symbol}{Price}{DayOpen}{DayHigh}{DayLow}{Direction} {Change}{PercentChange}',
        $stockTicker = $('#stockTicker'),
        $stockTickerUl = $stockTicker.find('ul'),
        liTemplate = '
{Symbol} {Price} {Direction} {Change} ({PercentChange})
';

    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,
            DirectionClass: stock.Change === 0 ? 'even' : stock.Change >= 0 ? 'up' : 'down'
        });
    }

    function scrollTicker() {
        var w = $stockTickerUl.width();
        $stockTickerUl.css({ marginLeft: w });
        $stockTickerUl.animate({ marginLeft: -w }, 15000, 'linear', scrollTicker);
    }

    function stopTicker() {
        $stockTickerUl.stop();
    }

    //init方法呼叫伺服器端的getAllStocks方法,並將返回的資料顯示在Table中。伺服器端我們預設會使用帕斯卡命名法,而SignalR會在生成使用者端的代理類時,自動將伺服器端的方法改成駱駝命名法,不過該規則只對方法名及Hub名稱有效。
    //而對於物件的屬性名,則仍然和伺服器端的一樣,比如stock.Symbol、stock.Price,而不是stock.symbol、stock.price。
    //如果你想在使用者端使用與伺服器商相同的名稱(包括大小寫),或者想自己定義其它的名稱,那麼你可以通過給Hub方法加上HubMethodName標籤來實現這個功能,而HubName標籤則可以實現自定義的Hub名稱。
    function init() {
        return ticker.server.getAllStocks().done(function (stocks) {
            $stockTableBody.empty();
            $stockTickerUl.empty();
            //遍歷伺服器端返回的股票資料,然後通過呼叫formatStock來格式化成我們想要的格式,接著通過supplant方法(在StockTicker.js的最頂端)來生成一條新行,並把這個新行插入到表格裡面。
            $.each(stocks, function () {
                var stock = formatStock(this);
                $stockTableBody.append(rowTemplate.supplant(stock));                 
                $stockTickerUl.append(liTemplate.supplant(stock));
            });
        });
    }

    //為了讓伺服器能夠呼叫客戶的程式碼,我們需要把updateStockPrice新增到stockTicker代理的client物件中
    $.extend(ticker.client, {
        updateStockPrice: function (stock) {
            var displayStock = formatStock(stock),
                $row = $(rowTemplate.supplant(displayStock)),
                $li = $(liTemplate.supplant(displayStock)),
                bg = stock.LastChange < 0
                        ? '255,148,148' // red
                        : '154,240,117'; // green
            //該updateStockPrice方法和init方法一樣,通過呼叫formatStock來格式化成我們想要的格式,接著通過supplant方法(在StockTicker.js的最頂端)來生成一條新行,不過它並不是將該新行追加到Table中,而是找到Table中現有的行,然後使用新行替換它。
            $stockTableBody.find('tr[data-symbol=' + stock.Symbol + ']')
                .replaceWith($row);
            $stockTickerUl.find('li[data-symbol=' + stock.Symbol + ']')
                .replaceWith($li);

            $row.flash(bg, 1000);
            $li.flash(bg, 1000);
            scrollTicker();
        }
    });

    // 使用者端的程式碼編寫好之後,就可以通過最後的這行程式碼來與伺服器建立連線,由於這個start方法執行的是非同步操作,並會返回一個jQuery延時物件,所以我們要使用jQuery.done函數來處理連線成功之後的操作。
    //這個init方法其實是在start方法完成非同步操作後作為回撥函數執行的,如果你把init作為一個獨立的JavaScript語句放在start方法之後的話,那麼程式將會出錯,因為這樣會導致伺服器端的方法在使用者端還沒有與伺服器建立連線之前就被呼叫。
    $.connection.hub.start().done(init);

});

四、輸出紀錄檔

SignalR內建了紀錄檔功能,你可以在使用者端選擇開啟該功能來幫助你偵錯程式,接下來我們將會通過開啟SignalR的紀錄檔功能來展示一下在不同的環境下SignalR所使用的傳輸技術,大至總結如下:

  • WebSocket,至少需要IIS8及以上版本的支援。
  • Server-sent events,支援IE以外的瀏覽器。
  • Forever frame,支援IE瀏覽器。
  • Ajax long polling,支援所有瀏覽器。

在伺服器端及使用者端都支援的情況下,SignalR預設會選擇最佳的傳輸方式。

1.開啟StockTicker.js,然後在使用者端與伺服器端建立連線之前加上下面這段程式碼。

// Start the connection
$.connection.hub.logging = true;
$.connection.hub.start().done(init);

2.重新執行程式,並開啟瀏覽器的開發者工具,選擇控制檯標籤,就可以看到SignalR輸出的紀錄檔(如果想看到全部的紀錄檔,請重新整理頁面)。

如果你是在Windows 8(IIS 8)上用IE10開啟的話,將會看到WebSocket的連線方式。

如果你是在Windows 7(IIS 7.5)上用IE10開啟的話,將會看到使用iframe的連線方式。

Windows 8(IIS 8)上用Firefox的話,將會看到WebSocket的連線方式。

在Windows 7(IIS 7.5)上用Firefox開啟的話,將會看到使用Server-sent events的連線方式。

到此這篇關於ASP.NET使用 SignalR2實現伺服器廣播的文章就介紹到這了。希望對大家的學習有所幫助,也希望大家多多支援it145.com。


IT145.com E-mail:sddin#qq.com