WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

ASP.NET Core SignalR

by WebSurfer 2021年12月29日 20:12

ASP.NET Core Web アプリでの Core 版 SignalR の使い方を勉強しています。その過程で分かった .NET Framework 版との違いなど、覚えておいた方が良さそうと思ったことを備忘録として書いておきます。

ASP.NET Core SignalR

まず、「チュートリアル: ASP.NET Core SignalR の概要」に従ってのアプリの作成と、.NET Framework 版 ASP.NET Web アプリ用の「チュートリアル: SignalR 2 を使用した高頻度のリアルタイムアプリの作成」と「チュートリアル: SignalR 2 を使用したサーバーブロードキャスト」を .NET 6.0 で作るということをやってみました。

まだその段階ですので、自分が知らない重要なことも多々あると思いますが、とりあえずこれだけ覚えておけば基本はできそうと思ったことを書いておきます。

(1) SignalR ライブラリ

サーバーライブラリは ASP.NET Core フレームワークに含まれています。なので、Hub の作成は .NET Framework 版のように新しい項目 [SignalR Hub クラス (v2)] の追加で行うのではなく (その際必要な NuGet パッケージが多々インストールされる)、普通のクラスファイルを追加してその中に Hub のコードを記述します。

クライアントライブラリ signalr.js, signalr.min.js は npm から取得してプロジェクトにインストールする必要があります。LibMan でインストールする方法が Microsoft のドキュメント「SignalR クライアント ライブラリを追加する」に書いてあります。

(2) Hub 接続コンテキストの取得

下の画像(上に紹介したチュートリアルから借用)のように Hub の外部からメッセージを送信する場合、SignalR Hub 接続コンテキスト(下の画像では SignalR context)への参照を取得する必要があります。

SignalR アプリ

ASP.NET Core SignalR アプリではフレームワークに備わっている DI 機能を使って取得できます。詳しくは Microsoft のドキュメント「ハブの外部からメッセージを送信する 」を見てください。

ちなみに、.NET Framework 版 ASP.NET SignalR アプリでは IConnectionManager.GetHubContext<T> メソッドを使って取得していました。

(3) シングルトンインスタンスの作成と取得

上の画像の StockTicker Instance のように、データの取得と保持を行うクラスのシングルトンインスタンスを生成し、Hub からそれを参照して使いたいというケースがあります。

それは ASP.NET Core アプリのフレームワークに備わっている DI 機能を使って実現できます。

まず、Program.cs(.NET 6.0 の場合。.NET Core 3.1, 5.0 の場合は Startup.cs)で AddSingleton<T> メソッドを使ってサービスに DI するクラスを登録します。下のステップ (4) のコード例を見てください。

AddSingleton<T> メソッドを使って登録すると、一番最初に DI される時点でインスタンスが生成され、アプリケーションが終了するまで、以降の DI ではそのインスタンスを使いまわす、即ちシングルトンインスタンスになります。

それへの参照は Hub のコンストラクタの引数経由で DI できます。具体例は下の方に掲示した StockTickerHub.cs のサンプルコードを見てください。

ちなみに、.NET Framework 版 ASP.NET SignalR アプリでは、Lazy<T> クラスを使って Value プロパティで参照を取得してシングルトンインスタンスになるようにしています。詳しくは上に紹介したチュートリアルを見てください。

(4) SignalR サーバーの構成

要求が SignalR に渡されるように SignalR サーバーを構成します。.NET 6.0 の場合は Program.cs(.NET Core 3.1, .NET 5.0 の場合は Startup.cs)で、以下のコードで「*** 追加 ***」「*** 書き換え ***」とコメントしたコードのように設定します。

using SignalR.Hubs;
using SignalR.SignalRBroadcasters;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllersWithViews();

// *** 追加 ***
builder.Services.AddSignalR();

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


var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthorization();

// *** 書き換え ***
//app.MapControllerRoute(
//    name: "default",
//    pattern: "{controller=Home}/{action=Index}/{id?}");
app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute(
        name: "default",
        pattern: "{controller=Home}/{action=Index}/{id?}");

    endpoints.MapHub<StockTickerHub>("/stockTickerHub");
});

app.Run();

上のコードの builder.Services.AddSignalR(); は、.NET Framework 版では上に紹介したチュートリアルの「アプリの起動時にハブにマップする」と同様なことを行うためのもののようです。

(5) クライアントスクリプト

上のステップ (1) でも書きましたが、クライアントライブラリ signalr.js, signalr.min.js を npm から取得してプロジェクトにインストールします。

そのクライアントライブラリを使って、Hub への接続、Hub のメソッド呼び出し、Hub からのリモートプロシージャコール (RPC) を行います。

以下に簡単に説明を書いておきます。詳しくは Microsoft のドキュメント「ASP.NET Core SignalR JavaScript クライアント」を見てください。

なお、.NET Framework 版ではフレームワークが追加のスクリプトを自動生成するので、事前にそれを ~/signalr/hubs という URL から script タグを使って取得するという操作が必要でしたが、Core 版ではそれは不要になっています。

Hub への接続

まず以下のようなスクリプトで接続 HubConnection を作成します。 "/stockTickerHub" は上のステップ (4) で設定したエンドポイントのパス名です。

var connection = new signalR.HubConnectionBuilder()
                            .withUrl("/stockTickerHub")
                            .build();

Hub への接続の開始は HubConnection.start メソッドで行います。接続された後で何らかの操作を行いたい場合は、続けて .then(function () { ... }) のようにして function にその操作を記述します。下の例では init メソッドを呼び出しています。

// Hub への接続を開始、接続されるとinit メソッドを呼び出す
connection.start().then(init);

Hub のメソッド呼び出し

クライアントスクリプトから Hub のメソッド呼び出しは HubConnection.invoke メソッドで行います。以下のコード例では invoke メソッドで引数の "GetAllStocks" と同名の Hub のメソッド GetAllStocks を呼び出しています。

// Hub の GetAllStocks メソッドを呼び出す。戻り値は stocks 
// に JavaScript の連想配列として渡される(キー名が camel 
// case になるので注意)。そのデータで初期画面を描画
function init() { 
    connection.invoke("GetAllStocks").then(function (stocks) {
        $stockTableBody.empty();
        $.each(stocks, function () {
            var stock = formatStock(this);
            $stockTableBody.append(rowTemplate.supplant(stock));
        });                      
    })
};

invoke メソッドの引数には "GetAllStocks" の他に Hub の GetAllStocks メソッドの引数も設定できますが、GetAllStocks メソッドは引数を取らないので上のコード例では設定していません。

Hub の GetAllStocks メソッドには戻り値がありますが、それは .then(function (stocks) { ... の stocks に渡されます。上の例では Hub のメソッド GetAllStocks が株価情報を返すので、それを初期画面に表示するようにしています。

サーバーからのリモートプロシージャコール

サーバーからリモートプロシージャコールを行うには以下の Hub のコ��ド例のように SendAsync メソッドを使います。

// 株価情報をクライアントへ送信する
private void BroadcastStockPrice(Stock stock)
{
    _hubContext.Clients.All
               .SendAsync("UpdateStockPrice", stock);
}

_hubContext は上のステップ (2) で書いた「Hub 接続コンテキスト」です。SendAsync メソッドの第 1 引数はクライアント側でどのメソッドを呼ぶかの識別用の文字列、第 2 引数はクライアントスクリプトに渡す情報です。

上の Hub コードで送信されたメッセージをクライアント側で受け取って操作を行うには、HubConnection.on メソッドを使います。第 1 引数には上の SendAsync メソッドの第 1 引数に指定した文字列、第 2 引数には操作を行う JavaScript メソッドを設定します。

// StockTicker クラスの BroadcastStockPrice メソッドの 
// SendAsync("UpdateStockPrice", stock); で SignalR コ
// ンテキストを通じて下の function (stock) { ... } が起
// 動される。引数の stock に含まれる情報により株価情報
// の表示を更新する。
connection.on("UpdateStockPrice", function (stock) {              
    var displayStock = formatStock(stock);
    var $row = $(rowTemplate.supplant(displayStock));
    $stockTableBody.find('tr[data-symbol=' + stock.symbol + ']')
                   .replaceWith($row);
});

Hub のコードの SendAsync の第 2 引数 stock は、クライアント側の HubConnection.on メソッドの第 2 引数 function (stock) の stock に JavaScript の連想配列として渡されます。サーバーとクライアントの間のシリアライズ / デシリアライズはフレームワークがやってくれるようです。

Camel Casing

上に「サーバーとクライアントの間のシリアライズ / デシリアライズはフレームワークがやってくれるようです」と書いた件ですが、.NET オブジェクトを JSON 文字列にシリアライズすると {"name":"value"} の "name" の先頭の文字が小文字になるという点に要注意です。

具体的に言うと以下の通りです。まず、上の SendAsync メソッドの第 2 引数ですが、下のような C# の Stock クラスに情報を設定したオブジェクトになります。

public class Stock
{
    public string Symbol { get; set; }
    // ・・・略・・・
}

これをサーバー側で JSON 文字列にシリアライズしてクライアントに送信するのですが、その JSON 文字列は {"symbol":"MSFT"} となります。"Symbol" ではなくて "symbol" と先頭が小文字になるところに注意してください。これを Camel Casing と言うらしいです。

クライアント側ではその JSON 文字列を JavaScript の連想配列にデシリアライズして function (stock) の stock に渡しますが、連想配列のキー名は symbol になります。キー名は大文字・小文字が区別されますので、stock.Symbol では "MSFT" は取得できません (undefined になります)。stock.symbol としなければなりません。

.NET Framework 版の SignalR では Camel Casing になることはなく、JSON 文字列は {"Symbol":"MSFT"} となります。.NET Framework 版と同じつもりでスクリプトを書くとここにハマると思います。

詳しくは先の記事「JsonSerializer の Camel Casing (CORE)」に書きましたので、興味がありましたら見てください。


以下に「チュートリアル: SignalR 2 を使用したサーバーブロードキャスト」を .NET 6.0 で作った場合のコードを載せておます。

StockTickerHub.cs

using Microsoft.AspNetCore.SignalR;
using SignalR.SignalRBroadcasters;
using SignalR.Models;

namespace SignalR.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();
        }
    }
}

StockTicker.cs

using System.Collections.Concurrent;
using SignalR.Models;
using Microsoft.AspNetCore.SignalR;
using SignalR.Hubs;

namespace SignalR.SignalRBroadcasters
{
    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(250);

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

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

        // 株価情報の初期値を設定するヘルパメソッド
        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;
        }

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

クライアントコード StockTicker.cshtml

MVC の View を使いました。静的な .html を使っても良いのですが、ブラウザにキャッシュされるので、修正のたびブラウザのキャッシュを削除するのが面倒だったためです。

@{
    Layout = null;
}

<!DOCTYPE html>

<html>
<head>
    <meta charset="utf-8" />
    <title>ASP.NET SignalR Stock Ticker</title>
    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script src="~/js/signalr/dist/browser/signalr.js"></script>
    <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 type="text/javascript">
        // 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;
                    }
                );
            };
        }

        // チュートリアルのコード。連想配列のキー名が camel case に
        // なるので {Symbol} ⇒ {symbol} 他それに対応して直した
        var 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>';

        // フォーマットを行うヘルパメソッド。チュートリアルのコード
        // と同じだが、連想配列のキー名が camel case になるので、そ
        // こは直した
        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
            });
        }

        // 接続を作成。"/stockTickerHub" は Program.cs で
        // endpoints.MapHub<StockTickerHub>("/stockTickerHub");
        // としてマップしたエンドポイントらしい
        var connection = new signalR.HubConnectionBuilder()
                                    .withUrl("/stockTickerHub")
                                    .build();

        // Hub への接続を開始、接続されるとinit メソッドを呼び出す
        connection.start().then(init);

        // Hub の GetAllStocks メソッドを呼び出す。戻り値は引数 stocks 
        // に JavaScript の連想配列として渡される(キー名が camel case
        // になるので注意)。そのデータで初期画面を描画する
        function init() { 
            connection.invoke("GetAllStocks").then(function (stocks) {
                $stockTableBody.empty();
                $.each(stocks, function () {
                    var stock = formatStock(this);
                    $stockTableBody.append(rowTemplate.supplant(stock));
                });                      
            })
        };
        
        // StockTicker クラスの BroadcastStockPrice メソッドの 
        // SendAsync("UpdateStockPrice", stock); で SignalR コ
        // ンテキストを通じて下の function (stock) { ... } が起
        // 動される。引数の stock に含まれる情報により株価情報
        // の表示を更新する。
        connection.on("UpdateStockPrice", function (stock) {              
            var displayStock = formatStock(stock);
            var $row = $(rowTemplate.supplant(displayStock));
            $stockTableBody.find('tr[data-symbol=' + stock.symbol + ']')
                           .replaceWith($row);
        });

    </script>
</body>
</html>

Stock.cs のコードはチュートリアルのものと同じです(NULL 許容参照型の警告は対応しましたが)。 Program.cs のコードは上のステップ (4) に載せましたのでそちらを見てください。

Tags: ,

CORE

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2021年12月  >>
2829301234
567891011
12131415161718
19202122232425
2627282930311
2345678

View posts in large calendar