Blazor Server SignalR でリアルタイムプッシュ更新（株価ボード）

# WebSurfer's Home さんのサンプルをBlazor Server で動かしてみる
 - [WebSurfer's Home - ASP.NET Core SignalR](http://surferonwww.info/BlogEngine/post/2021/12/29/aspnet-core-signalr.aspx) を参考に進める。

[サンプルコード](https://github.com/beginnnnner/BlazorStockTicker)
 
# アプリ作成
- Visual Studio 2022 v17.1.4
- Blazor Server アプリ .NET6 認証なし 'BlazorStockTicker' 作成
- NuGet でインストール 
`Microsoft.AspNetCore.SignalR.Client`
`Syncfusion.Blazor.Grid`
`Syncfusion.Blazor.Themes`

# 疑似株価発生クラス作成
```Stocks/Stock.cs
namespace BlazorStockTicker.Stocks
{
    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; set; }

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

        public double PercentChange
        {
            get {
                return (double)Math.Round(Change / Price, 4);
            }
        }
    }
}
```
```Stocks/StockTicker.cs
using System.Collections.Concurrent;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Client;
using BlazorStockTicker.Hubs;

namespace BlazorStockTicker.Stocks
{
    public class StockTicker
    {
        private readonly IHubContext<StockTickerHub> _hubContext;
        private readonly ConcurrentDictionary<string, Stock> _stocks;
        private readonly double _rangePercent;
        private readonly TimeSpan _updateInterval;
        private readonly Timer _timer;
        private volatile bool _updatingStockPrices;

        // コンストラクタ
        public StockTicker(IHubContext<StockTickerHub> hubContext)
        {
            // SignalR コンテキストを DI により取得して設定
            _hubContext = hubContext;

            // 株価情報を保持する
            _stocks = new ConcurrentDictionary<string, Stock>();

            // 株価情報の初期値を設定
            InitializeStockPrices(_stocks);

            // 株価は下の TryUpdateStockPrice メソッドでランダムに
            // 変更されるが、その上限・下限を 2% に設定している
            _rangePercent = .002;

            // Timer を使って UpdateStockPrices メソッドを呼ぶ間隔
            _updateInterval = TimeSpan.FromMilliseconds(100);

#nullable disable
            // _updateInterval (250 ミリ秒間隔) で UpdateStockPrices
            // メソッドが呼ばれるよう Timer を設定
            _timer = new Timer(UpdateStockPrices,
                               null,
                               _updateInterval,
                               _updateInterval);
#nullable enable

            // 株価情報が更新中であることを示すフラグ
            _updatingStockPrices = false;
        }

        private void _timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e)
        {
            UpdateStockPrices(new object());

        }

        // 株価情報の初期値を設定するヘルパメソッド
        private void InitializeStockPrices(
            ConcurrentDictionary<string, Stock> stocks)
        {
            var init = 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.Clear();
            // bool TryAdd (TKey key, TValue value) メソッドで
            // キー/値ペアを追加する。成功すると true を返す。
            // キーが既に存在する場合は false を返す
            init.ForEach(init => _stocks.TryAdd(init.Symbol, init));
        }

        // ConcurrentDictionary<string, Stock> のインスタンス　
        // から Value プロパティで IEnumerable<Stock> を取得
        public IEnumerable<Stock> GetAllStocks()
        {
            return _stocks.Values;
        }
        public async Task<IEnumerable<Stock>> GetAllStocksAsync()
        {
            return _stocks.Values;
        }

        private readonly object _updateStockPricesLock = new object();

        // Timer を使って 250ms 毎に以下のメソッドが呼ばれる。
        // 株価に変更があった場合は BroadcastStockPrice メソッ
        // ドで株価情報をクライアントへ送信する
        private void UpdateStockPrices(object state)
        {
            lock (_updateStockPricesLock)
            {
                if (!_updatingStockPrices)
                {
                    _updatingStockPrices = true;

                    // _stocks.Values は IEnumerable<Stock>
                    foreach (var stock in _stocks.Values)
                    {
                        if (TryUpdateStockPrice(stock))
                        {
                            // 株価に変更があった場合は株価
                            // 情報をクライアントへ送信
                            BroadcastStockPrice(stock);
                        }
                    }

                    _updatingStockPrices = false;
                }
            }
        }

        private readonly Random _updateOrNotRandom = new Random();

        private bool TryUpdateStockPrice(Stock stock)
        {
            // 0.0 以上 1.0 未満のランダム値
            var r = _updateOrNotRandom.NextDouble();
            if (r > .1)
            {
                return false;
            }

            // 株価をランダムに変更する
            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)
        {
            _hubContext.Clients.All.SendAsync("UpdateStockPrice", stock);
        }
    }
}
```

# HUB 作成
```Hubs/StockTickerHub.cs
using Microsoft.AspNetCore.SignalR;
using BlazorStockTicker.Stocks;

namespace BlazorStockTicker.Hubs
{
    public class StockTickerHub : Hub
    {
        private readonly StockTicker _stockTicker;

        public StockTickerHub(StockTicker stockTicker)
        {
            // Broadcaster インスタンスを DI により取得して設定。
            // Program.cs で AddSingleton メソッドを使ってシング
            // ルトンになるようにしている
            _stockTicker = stockTicker;
        }

        // StockTicker.GetAllStock メソッドは株価情報を
        // IEnumerable<Stock> として返す
        public IEnumerable<Stock> GetAllStocks()
        {
            return _stockTicker.GetAllStocks();
        }
        public async Task<IEnumerable<Stock>> GetAllStocksAsync()
        {
            return await _stockTicker.GetAllStocksAsync();
        }
    }
}
```

# 株価ボード作成
```Pages/StockTicker.razor@.html
@page "/stockticker"
@using Microsoft.AspNetCore.SignalR.Client
@using Syncfusion.Blazor.Grids
@using BlazorStockTicker.Stocks
@inject NavigationManager NavigationManager
@implements IAsyncDisposable

<h1>ASP.NET SignalR Stock Ticker Sample</h1>
 
<h2>Live Stock Table</h2>

<div>
    <SfGrid DataSource="@stocks.Values.ToList()" AllowPaging="true" >
        <GridEvents QueryCellInfo="CellInfoHandler" TValue="Stock"></GridEvents> 
        <GridColumns>
            <GridColumn Field=@nameof(Stock.Symbol) HeaderText="銘柄" TextAlign="TextAlign.Center"  Width="70" ></GridColumn>
            <GridColumn Field=@nameof(Stock.DayOpen) HeaderText="始値" Type="ColumnType.Number" TextAlign="TextAlign.Right"  Width="70"></GridColumn>
            <GridColumn Field=@nameof(Stock.Price) HeaderText="価格" Type="ColumnType.Number" TextAlign="TextAlign.Right"  Width="70"></GridColumn>
            <GridColumn Field=@nameof(Stock.Change) HeaderText="増減"  Type="ColumnType.Number" Format="▲0.00;▼0.00" TextAlign="TextAlign.Right"  Width="70"></GridColumn>                   
            <GridColumn Field=@nameof(Stock.PercentChange) HeaderText="増減率" Type="ColumnType.Number" Format="p2" TextAlign="TextAlign.Right" Width="70"></GridColumn>
        </GridColumns>
    </SfGrid>
</div>

<style> 
    .negative-color{ 
        color: red; 
    } 
    .positive-color{ 
        color: lime; 
    } 
    .e-grid .e-spinner-pane{ /** Grid スピナーを非表示*/
        display:none; 
    } 
</style> 

@code {
    private HubConnection? connection;
    private Dictionary<string, Stock> stocks = new Dictionary<string, Stock>();

    protected override async Task OnInitializedAsync()
    {
        // 接続を作成。"/stockTickerHub" は Program.cs で
        // app.MapHub<StockTickerHub>("/stockTickerHub");
        // としてマップしたエンドポイントらしい
        connection = new HubConnectionBuilder()
            .WithUrl(NavigationManager.ToAbsoluteUri("/stockTickerHub"))
            .Build();

        // StockTicker クラスの BroadcastStockPrice メソッドの 
        // SendAsync("UpdateStockPrice", stock); で SignalR コ
        // ンテキストを通じて下の On<Stock>() { ... } が起
        // 動される。引数の stock に含まれる情報により株価情報
        // の表示を更新する。
        connection.On<Stock>("UpdateStockPrice", (stock) =>
        {
            if (stocks.ContainsKey(stock.Symbol))
            {
                stocks[stock.Symbol] = stock;
            } else {
                stocks.Add(stock.Symbol, stock);
            }

            InvokeAsync(StateHasChanged);
        });

        await connection.StartAsync();
    }

    // セル変更イベント、マイナスなら赤にする
    private void CellInfoHandler(QueryCellInfoEventArgs<Stock> e)
    {
        bool? isPositive = e.Column.Field == nameof(Stock.Change)        ? 0 <= e.Data.Change
                         : e.Column.Field == nameof(Stock.PercentChange) ? 0 <= e.Data.PercentChange : null;

        e.Cell.AddStyle(new string[] { isPositive == true  ? "color:lime"
                                     : isPositive == false ? "color:red" : "" });
    }

    public async ValueTask DisposeAsync()
    {
        if (connection is not null)
        {
            await connection.DisposeAsync();
        }
    }
}

```

# ナビに「株価ボード」メニュー追加 及び SyncFusion
```Shared/NavNenu.razor@.html
・・・
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="stockticker">
                <span class="oi oi-list-rich" aria-hidden="true"></span> 株価ボード
            </NavLink>
        </div>
    </nav>
</div>
```
- SyncFusion の Script と css を追加
```Pages/_Layout.csthml@.html
・・・
    <link id="theme" href="_content/Syncfusion.Blazor.Themes/bootstrap4.css" rel="stylesheet" />
    <script src="_content/Syncfusion.Blazor.Core/scripts/syncfusion-blazor.min.js" type="text/javascript"></script>
</head>
・・・
```

# サービスの追加
```Programs.cs
// 追加
using BlazorStockTicker.Stocks;
using BlazorStockTicker.Hubs;

・・・

// *** 追加 ***
builder.Services.AddSignalR();
// StockTickerHub でコンストラクタ経由 DI によりシングル
// トンインスタンスを取得できるよう以下の設定を行う
builder.Services.AddSingleton<StockTicker>();

// SyncFusion Service を追加
builder.Services.AddSyncfusionBlazor(options => { options.IgnoreScriptIsolation = true; });

var app = builder.Build();

・・・

app.MapBlazorHub();
app.MapHub<StockTickerHub>("/stockTickerHub");  // 追加

・・・
```

# 引用
- [ASP.NET Core SignalR 実装](http://surferonwww.info/BlogEngine/post/2021/12/29/aspnet-core-signalr.aspx)
- [旧.Net4.x用チュートリアル: SignalR 2 を使用したサーバーブロードキャスト](https://docs.microsoft.com/ja-jp/aspnet/signalr/overview/getting-started/tutorial-server-broadcast-with-signalr)
- [ASP.NET Core SignalR を Blazor と共に使用する](https://docs.microsoft.com/ja-jp/aspnet/core/blazor/tutorials/signalr-blazor?view=aspnetcore-6.0&tabs=visual-studio&pivots=server)
- [ASP.NET Core BlazorSignalR ガイダンス](https://docs.microsoft.com/ja-jp/aspnet/core/blazor/fundamentals/signalr?view=aspnetcore-6.0)