WebSurfer's Home

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

ASP.NET Core で SqlDependency

by WebSurfer 2022年1月1日 17:50

.NET 6.0 アプリでも NuGet で System.Data.SqlClient をインストールすれば SqlDependency は使えるようです (コンソールアプリで確認しました)。

それが分かったので、ASP.NET Core Web アプリで、SqlDependency クラスを使って SQL Server のデータが更新されたときのクエリ通知を受け取れるようにし、通知を受け取ったら更新後のデータを SQL Server から取得して、接続されている全クライアントに ASP.NET Core SignalR を使ってリアルタイムに配信するサンプルを作ってみました。

SignalR と SqlDependency

先の記事「SignalR と SqlDependency」とほぼ同じことをしていますが、そちらはターゲットフレームワークが .NET Framework 4.8 だったものを、.NET 6.0 に変えて書き直したものです。

以下にその作成手順を書きます。

(1) サンプルデータベースとテーブルの作成

先の記事「SignalR と SqlDependency」と同じデータベースとテーブルを使います。作成手順は先の記事を見てください。

参考に作成したテーブルを CREATE TABLE スクリプト化したものを下に再掲しておきます。

USE [SqlDependency]
GO

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].[Products](
	[ProductID] [int] IDENTITY(1,1) NOT NULL,
	[Name] [nvarchar](100) NOT NULL,
	[UnitPrice] [decimal](18, 2) NOT NULL,
	[Quantity] [int] NOT NULL,
 CONSTRAINT [PK_Products] PRIMARY KEY CLUSTERED 
(
	[ProductID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, 
       IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, 
       ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]

GO

クエリ通知はサービスブローカを使用するため、データベースに対して要件がありますので注意してください。それも以下に再掲しておきます。

  1. 通知クエリが実行されるデータベースでサービスブローカが有効になっている必要があります。
  2. クエリを送信するユーザーには、クエリ通知にサブスクライブするための権限が必要です。

(2) ASP.NET プロジェクトの作成

Visual Studio 2022 のテンプレートを使って、ターゲットフレームワークを .NET 6.0 として ASP.NET Core Web アプリのプロジェクトを作成します。この記事では アプリは MVC を選んで認証は「なし」としておきました (MVC である必要はなく Razor Pages でも OK です)。

(3) Product クラスの作成

Models フォルダに Product.cs という名前のクラスファイルを作成し、自動生成されたコードを以下のように書き換えます。

#nullable disable

namespace SqlDependency.Models
{
    public class Product
    {
        public int ProductID { get; set; }
        public string Name { get; set; }
        public decimal UnitPrice { get; set; }
        public int Quantity { get; set; }
    }
}

内容は先の記事「SignalR と SqlDependency」と同じですが、フレームワーク .NET 6.0 でプロジェクトを作成するとデフォルトで NULL 許容参照型が有効になるので、警告を消すため #nullable disable を追加しています (警告を無視しないでちゃんと対応した方が良いかも)。

先の記事の場合と同様に、Product クラスは Model-View-Controller (MVC) の Model とは役割が異なり、サーバーで SQL Server のデータを保持するのと Hub からクライアントへデータを渡すために使います。

具体的には、クエリ通知のサブスクリプションを設定するのと同時に SQL Server からデータを取得し List<Product> オブジェクトとしてサーバー側に保持しておきます。それを Hub からクライアントに送信します。その際、サーバー側での List<Product> オブジェクトの JSON 文字列へのシリアライズ、クライアント側で受け取った JSON 文字列の JavaScript オブジェクト(連想配列)へのデシリアライズは SignalR のフレームワークがやってくれます。

(4) 接続文字列の設定

appsettings.json に接続文字列の設定を追加します。下のコードの "AllowedHosts": "*" までは自動生成されたもので、その後カンマ , に続けて接続文字列の設定を追加します。JSON 文字列なので \ はエスケープして \\ にする必要があることに注意してください。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "ProductConnection": 
      "data source=lpc:(local)\\sqlexpress;initial catalog=SqlDependency;integrated security=True"
  }
}

ADO.NET の SqlConnection, SqlCommand, SqlDataReader を使ってデータを取得しますので、そのために有効な接続文字列としてください。また、上のステップ (1) に書いた要件 2 の「クエリを送信するユーザーには、クエリ通知にサブスクライブするための権限が必要です」に注意してください。

この記事では Visual Studio 2022 を管理者権限で立ち上げて IIS Express のインプロセスホスティングモデルで実行して検証しています。その管理者は SQL Server のログインに設定してありサーバーロールは sysadmin を持っています。上の接続文字列の例では Windows 認証を設定していますので SQL Server には sysadmin サーバーロールでログインしますので権限の問題が避けられていますが、実環境ではそうはできない点に注意してください。

(5) クライアントライブラリの取得

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

サーバーライブラリは ASP.NET Core フレームワークに含まれていますので、それを取得するための作業は不要です。

(6) SignalR Hub を追加

ソリューションエクスプローラーでプロジェクトルート直下に Hubs というフォルダを作成し、その中に ProductHub.cs という名前でクラスファイルを追加します。

自動生成されたコードの内容を以下のように書き換えます。下のコードで参照している Notifier クラスは下のステップ (8) で定義します。

using Microsoft.AspNetCore.SignalR;
using SqlDependency.Broadcasters;
using SqlDependency.Models;

namespace SqlDependency.Hubs
{
    public class ProductHub : Hub
    {
        private readonly Notifier _notifier;

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

        // 初期画面のデータをクライアントが取得する時に
        // クライアントスクリプトでこのメソッドを呼び出す
        public IEnumerable<Product> GetAllProducts()
        {
            return _notifier.GetAllProducts();
        }
    }
}

(7) NuGet パッケージのインストール

クエリ通知のサブスクリプションの設定に必要な SqlConnection, SqlCommand 等は .NET 6.0 には含まれておらず NuGet で System.Data.SqlClient をインストールする必要があります。(そこは .NET Core 3.1、.NET 5.0 でも同じです)

NuGet パッケージのインストール

上の画像の System.Data.Common には System.DBNull とか System.Data.DbType 等が含まれているとのことなので念のため追加しておきました。

(8) Notifier クラスの実装

プロジェクトルート直下に Broadcasters という名前のフォルダを設けて、その中に上のステップ (6) で作成した Hub が使う Notifier ク��スを作成します。

Notifier クラスは、SQL Server からデータの取得してキャッシュするのと同時にクエリ通知のサブスクリプションの設定し、キャッシュしたデータをクライアントからの要求に応じて配信します。

クエリ通知のサブスクリプションを設定しているのが RegisterForNotifications メソッドです。ADO.NET の SqlConnection, SqlCommand, SqlDataReader を使って SELECT クエリを発行してデータを取得するのと同時に、通知のサブスクリプションの設定と、通知によって発生する SqlDependency.OnChange イベントで必要な処理を行うためイベントハンドラを設定しています。

Microsoft のドキュメント「クエリ通知を使用するときの特別な注意事項 (ADO.NET)」に書いてありますようにいろいろ制約があるので注意してください。

SQL Server のインスタンスから依存関係の変更通知を受け取るリスナの開始 / 停止を設定する SqlDependency.Start / Stop は、.NET Framework 版 ASP.NET Web アプリでは Global.asax に実装しましたが、ASP.NET Core Web アプリでは Global.asax は存在しないので Notifier クラスで行うようにしました。

SqlDependency.Start は Nortifier クラスのコンストラクタで、SqlDependency.Stop は Nortifier クラスに Dispose パターンを実装し Dispose(bool disposing) メソッドに含めるようにしました。Stop の方が期待通り動いているか不安ではありますが。

using System.Data;
using System.Data.SqlClient;
using SqlDependency.Models;
using SqlDependency.Hubs;
using Microsoft.AspNetCore.SignalR;

namespace SqlDependency.Broadcasters
{
    public class Notifier : IDisposable
    {
        private readonly IHubContext<ProductHub> _hubContext;
        private readonly IConfiguration _configuration;
        private readonly string _connString;
        private readonly string _sqlQuery;
        private List<Product> _products;

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

            // IConfiguration を DI により取得して設定            
            _configuration = configuration;

            // appsettings.json の接続文字列を取得
            _connString = _configuration
                          .GetConnectionString("ProductConnection");

            // SELECT クエリ。テーブル名は dbo.Products とすること。
            // SqlDependency.dbo.Products でも Products でもダメで、
            // 通知のサブスクリプションに失敗する
            _sqlQuery = "SELECT ProductID,Name,UnitPrice,Quantity" +
                        " FROM dbo.Products";

            System.Data.SqlClient.SqlDependency.Start(_connString);

            // クエリ通知のサブスクリプションを設定するのと同時に SQL
            // Server からデータを取得し List<Product> オブジェクトと
            // してサーバーに保持しておく
            _products = RegisterForNotifications();
        }

        // Hub の GetAllProducts メソッドから呼ばれる。保持している
        // List<Product> オブジェクトを返す
        public IEnumerable<Product> GetAllProducts()
        {
            return _products;
        }

        // クエリ通知のサブスクリプションを設定するのと同時に SQL
        // Server からデータを取得し List<Product> として返す
        private List<Product> RegisterForNotifications()
        {
            var products = new List<Product>();
            using (var connection = new SqlConnection(_connString))
            using (var command = new SqlCommand(_sqlQuery, connection))
            {
                var sqlDependency = new System.Data.SqlClient.SqlDependency(command);

                // イベントハンドラの設定
                sqlDependency.OnChange += OnSqlDependencyChange;

                if (connection.State == ConnectionState.Closed)
                {
                    connection.Open();
                }

                // ExecuteReader でクエリ通知のサブスクリプションが設定
                // される。同時に SqlDataReader でデータを取得できる
                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        var product = new Product
                        {
                            ProductID = reader.GetInt32(0),
                            Name = reader.GetString(1),
                            UnitPrice = reader.GetDecimal(2),
                            Quantity = reader.GetInt32(3)
                        };
                        products.Add(product);
                    }
                }
            }
            return products;
        }

        // Products テーブルが更新されるとこのイベントハンドラに制御が
        // 飛んでくる
        private void OnSqlDependencyChange(object sender,
                                           SqlNotificationEventArgs e)
        {
            // 引数 e が期待する結果と違っていたら何もしない
            if ((e.Info == SqlNotificationInfo.Insert ||
                e.Info == SqlNotificationInfo.Update ||
                e.Info == SqlNotificationInfo.Delete) &&
                e.Source == SqlNotificationSource.Data &&
                e.Type == SqlNotificationType.Change)
            {
                // 一度通知が行われるとサブスクリプションが解除されてしま
                // うので、以下のメソッドで再度設定するとともに更新後の
                // データを _products に取得する
                _products = RegisterForNotifications();

                // 更新後のデータを接続されている全クライアントに送信
                _hubContext.Clients.All
                           .SendAsync("UpdateProductInfo", _products);
            }
        }

        private bool disposedValue;

        protected virtual void Dispose(bool disposing)
        {
            if (!disposedValue)
            {
                if (disposing)
                {
                    System.Data.SqlClient.SqlDependency.Stop(_connString);
                }

                disposedValue = true;
            }
        }

        public void Dispose()
        {
            Dispose(disposing: true);
            GC.SuppressFinalize(this);
        }
    }
}

Notifier クラスは ASP.NET Core アプリのフレームワークに備わっている DI 機能を使ってシングルトンインスタンスを作成し、それへの参照を Hub のコンストラクタ経由で DI します。

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

(9) Program.cs の修正

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

上のステップ (8) で述べた AddSingleton<T> メソッドを使っての Notifier クラスのサービスへの登録も同時に行います。

// *** 追加 ***
using SqlDependency.Hubs;
using SqlDependency.Broadcasters;

var builder = WebApplication.CreateBuilder(args);

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

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

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

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<ProductHub>("/productHub");
});

app.Run();

(10) 表示画面の作成

表示画面は、この記事では Controller / View を使いましたが、静的な html ページで作っても良いです。

自動生成されている HomeController に Product という名前のアクションメソッドを追加します。Product アクションメソッドを右クリックしてスキャフォールディング機能により View を生成します。自動生成された View のコードを以下のように書き換えます。

@{
    Layout = null;
}

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>ASP.NET Core SqlDependency</title>
    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script src="~/js/signalr/dist/browser/signalr.js"></script>
</head>
<body>
    <h1>ASP.NET Core SqlDependency</h1>
    <div id="pruductTable">
    <table border="1">
        <thead>
            <tr><th>ProductID</th><th>Name</th><th>UnitPrice</th><th>Quantity</th></tr>
        </thead>
        <tbody id="productbody">
            <tr class="loading"><td colspan="4">loading...</td></tr>
        </tbody>
    </table>
</div>

<script type="text/javascript">
        var signalRHubInitialized = false;
 
        $(function () {
            InitializeSignalRHubStore();
        });
 
        function InitializeSignalRHubStore() {
 
            if (signalRHubInitialized) {
                return;
            }
 
            try {
                // 接続を作成。"/productHub" は Program.cs で
                // endpoints.MapHub("/productHub");
                // としてマップしたエンドポイントらしい
                var connection = new signalR.HubConnectionBuilder()
                                    .withUrl("/productHub")
                                    .build();

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

                // Hub の GetAllProducts メソッドを呼び出す。戻り値は products 
                // に JavaScript の連想配列として渡される(キー名が camel 
                // case になるので注意)。そのデータで初期画面を描画
                function init() { 
                    connection.invoke("GetAllProducts").then(function (products) {
                        $('#productbody').empty();
                        $.each(products, function (index, product) {
                            $('#productbody').append(
                                '<tr><td>' + product.productID +
                                '</td><td>' + product.name +
                                '</td><td>' + product.unitPrice +
                                '</td ><td>' + product.quantity +
                                '</td></tr >');
                        });
                    });
                    signalRHubInitialized = true;
                };

                // Notifier クラスの OnSqlDependencyChange イベントハンドラの 
                // SendAsync("UpdateProductInfo", _products); で SignalR コ
                // ンテキストを通じて下の function (products) { ... } が起
                // 動される。引数の products に含まれる情報により table の
                // 表示を更新する。
                connection.on("UpdateProductInfo", function (products) {              
                    $('#productbody').empty();
                    $.each(products, function (index, product) {
                        $('#productbody').append(
                            '<tr><td>' + product.productID +
                            '</td><td>' + product.name +
                            '</td><td>' + product.unitPrice +
                            '</td ><td>' + product.quantity +
                            '</td></tr >');
                    });
                });                
            } catch (err) {
                signalRHubInitialized = false;
            }
        };
    </script>
</body>
</html>

上の html コードの table 要素内の tbody 要素をサーバーから送られてきたデータで書き換えるようにしています。

まず、初期画面が表示されると connection.start メソッドで Hub への接続が開始され、接続が完了すると init メソッドが呼び出されます。

init メソッド内の connection.invoke メソッドにより Hub の GetAllProducts が呼び出され、サーバ側で保持されている List<Product> が JSON 文字列にシリアライザされてクライアントに送信されます。クライアントでは受け取った JSON 文字列を JavaScript オブジェクト(連想配列)にデシリアライズして function (products) の引数 products に渡します。その products を使って tbody 要素を書き換えて初期データを表示します。

その後、SQL Server の Products テーブルが更新されるとサーバー側で SqlDependency.OnChange イベントが発生し、上の connection.on メソッドの引数に設定された function (products) が呼び出されます。その際、引数 products には更新後のデータを含む JavaScript オブジェクトが渡され、それを使って tbody 要素を書き換えて更新後のデータを表示します。

アプリを実行して複数のブラウザでアクセスし、SQL Sever Management Studio などを使って Products テーブルを更新すると、更新結果がリアルタイムで接続されているすべてのブラウザに反映されます。それを表示したのがこの記事の一番上の画像です。

Tags: , , ,

CORE

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

SignalR と SqlDependency

by WebSurfer 2021年12月26日 13:38

.NET Framework 4.8 の ASP.NET Web アプリで、SqlDependency クラスを使って SQL Server のデータが更新されたときのクエリ通知を受け取れるように設定し、通知を受け取ったら更新後のデータを SQL Server から取得して、接続されている全クライアントに ASP.NET SignalR を使ってリアルタイムに配信する方法を書きます。

SignalR と SqlDependency

参考にしたのは Microsoft の「チュートリアル: SignalR 2 を使用したサーバーブロードキャスト」です。SqlDependecy 関係については Code Project の ASP.NET MVC 5 SignalR, SqlDependency and EntityFramework 6 も参考にしました。

Code Project からダウンロードできるサンプルコードは、サンプルデータベースとテーブルを作成後、接続文字列を自分の環境に合わせて変更すれば動きます。しかし、(1) 全クライアントが Entity Framework を使って直接 SQL Server にデータを取得に行く、(2) クエリ通知のサブスクリプションを設定するのに必要な情報を DbContext から取得している・・・という点が冗長だと思いました。

なので、それらを変更して (1) クエリ通知のサブスクリプションを設定するのと同時に SQL Server からデータを取得できるので (SELECT クエリを投げるのはその一回で済みます)、それをサーバーに保持しておいて Hub からクライアントに配信する、(2) サブスクリプション設定に必要な情報は接続文字列と SELECT クエリだけなので DbContext から取得という面倒なことは止めて直接コードに記述する・・・というように変更しました。

以下にアプリの作成手順を書きます。

(1) サンプルデータベースとテーブルの作成

サンプルデータベースとテーブルを SQL Server に作成します。この記事で使用した SQL Server は開発マシンの Windows 10 Pro 64-bit にインストールした SQL Server 2012 Express です。

クエリ通知はサービスブローカを使用するため、データベースに対して以下の要件がありますので注意してください。(詳しくは Microsoft のドキュメント「ADO.NET 2.0 のクエリ通知」参照)

  1. 通知クエリが実行されるデータベースでサービスブローカが有効になっている必要があります。
  2. クエリを送信するユーザーには、クエリ通知にサブスクライブするための権限が必要です。

まず SQL Server Management Studio を使って SqlDependency という名前 (名前は任意) でデータベースを作成します。上に書いた要件 1 に従ってオプションの[Broker が有効]を True に設定しましたが、それ以外はデフォルトのままです。

新しいデータベースの作成

次に Products という名前 (名前は任意) でテーブルを作成します。これも下の画像の通り SQL Server Management Studio で行いました。

Products テーブルの作成

作成したテーブルをスクリプト化すると以下の通りとなります。

USE [SqlDependency]
GO

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].[Products](
	[ProductID] [int] IDENTITY(1,1) NOT NULL,
	[Name] [nvarchar](100) NOT NULL,
	[UnitPrice] [decimal](18, 2) NOT NULL,
	[Quantity] [int] NOT NULL,
 CONSTRAINT [PK_Products] PRIMARY KEY CLUSTERED 
(
	[ProductID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, 
       IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, 
       ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]

GO

(2) ASP.NET プロジェクトの作成

Visual Studio 2022 のテンプレートを使ってフレームワーク .NET Framework 4.8 で ASP.NET プロジェクトを作成します。この記事では アプリは MVC を選んで認証は「なし」としておきました (MVC である必要はなく SignalR v2 が動けば OK です)。

ASP.NET プロジェクトの作成

(3) Product クラスの作成

Models フォルダに Product.cs という名前のクラスファイルを作成し、自動生成されたコードを以下のように書き換えます。

namespace SqlDependencySignalR2.Models
{
    public class Product
    {
        public int ProductID { get; set; }
        public string Name { get; set; }
        public decimal UnitPrice { get; set; }
        public int Quantity { get; set; }
    }
}

Product クラスは Model-View-Controller (MVC) の Model とは異なり、サーバーで SQL Server のデータを保持するのと Hub からクライアントへデータを渡すために使います。

具体的には、クエリ通知のサブスクリプションを設定するのと同時に SQL Server からデータを取得し List<Product> オブジェクトとしてサーバー側に保持しておきます。それを Hub からクライアントに送信します。その際、サーバー側での List<Product> オブジェクトの JSON 文字列へのシリアライズ、クライアント側で受け取った JSON 文字列の JavaScript オブジェクトへのデシリアライズは SignalR のフレームワークがやってくれます。

(4) 接続文字列の設定

web.config に接続文字列の設定を追加します。ADO.NET の SqlConnection, SqlCommand, SqlDataReader を使ってデータを取得しますので、そのために有効な接続文字列としてください。また、上に書いた要件 2 の「クエリを送信するユーザーには、クエリ通知にサブスクライブするための権限が必要です」に注意してください。

<connectionStrings>
  <add name="ProductConnection" 
     connectionString="data source=(local)\sqlexpress;initial catalog=SqlDependency;integrated security=True" 
     providerName="System.Data.SqlClient" />
</connectionStrings>

この記事では Visual Studio 2022 を管理者権限で立ち上げて IIS Express 上で実行して検証しています。その管理者は SQL Server のログインに設定してありサーバーロールは sysadmin を持っています。上の接続文字列の例では Windows 認証を設定していますので SQL Server には sysadmin サーバーロールでログインしますので権限の問題が避け られていますが、実環境ではそうはできない点に注意してください。

(5) SignalR Hub を追加

ソリューションエクスプローラーでプロジェクトルートを右クリックして[追加(D)]⇒[新しい項目(W)...]で「SignalR Hub クラス (v2)」を選んで ProductHub.cs という名前 で追加します。

SignalR Hub クラスの作成

自動生成されたコードの内容を以下のように書き換えます。下のコードで参照している Notifier クラスは下のステップ (7) で定義します。

using Microsoft.AspNet.SignalR;
using System.Collections.Generic;
using SqlDependencySignalR2.SqlDependencyNotifier;
using SqlDependencySignalR2.Models;

namespace SqlDependencySignalR2
{
    public class ProductHub : Hub
    {
        // Notifier インスタンスへの参照を保持する
        private readonly Notifier _notifier;

        public ProductHub() : this(Notifier.Instance) { }

        public ProductHub(Notifier notifier)
        {
            _notifier = notifier;
        }

        // 初期画面のデータをクライアントが取得する時に
        // JavaScript でこのメソッドを呼び出す
        public IEnumerable<Product> GetAllProducts()
        {
            return _notifier.GetAllProducts();
        }
    }
}

(6) Startup クラスの追加

ソリューションエクスプローラーでプロジェクトルートを右クリックして[追加(D)]⇒[新しい項目(W)...]で Startup.cs という名前のクラスファイルを追加します。 内容は以下のようにします。

using Owin;
using Microsoft.Owin;

// アプリの起動時にハブにマップする。SignalR 2 では、OWIN startup
// クラスを追加するとマッピングが作成される
[assembly: OwinStartup(typeof(SqlDependencySignalR2.Startup))]
namespace SqlDependencySignalR2
{
    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            // OWIN startup クラスは、アプリが Configuration メソッド
            // を実行するときに MapSignalR を呼び出す。OwinStartup
            // assembly 属性を使用して OWIN のスタートアッププロセス
            // にクラスを追加する
            app.MapSignalR();
        }
    }
}

(7) Notifier クラスの実装

プロジェクトルート直下に SqlDependencyNotifier という名前のフォルダを設けて、その中に上のステップ (5) の Hub が使用する Notifier クラスを作成します。

クエリ通知のサブスクリプションを設定しているのが RegisterForNotifications メソッドです。ADO.NET の SqlConnection, SqlCommand, SqlDataReader を使って SELECT クエリを発行しデータを取得してキャッシュするのと同時に、通知のサブスクリプションの設定と、通知によって発生する SqlDependency.OnChange イベントで必要な処理を行うためイベントハンドラを設定しています。

Microsoft のドキュメント「クエリ通知を使用するときの特別な注意事項 (ADO.NET)」に書いてありますようにいろいろ制約があるので注意してください。

特に自分がハマったのが SELECT クエリのテーブル名です。ドキュメントには "テーブル名は 2 つの部分から構成される名前で修飾する必要があります" と書いてありますが、それは dbo.Products のようにする必要があると言うことです。SqlDependency.dbo.Products でも Products でもダメで、通知のサブスクリプションに失敗します。

ちなみに、失敗すると即 SqlDependency.OnChange イベントが発生し、RegisterForNotifications メソッドが実行されるので無限ループに陥ってしまいます。それを避けるためイベントハンドラの引数 SqlNotificationEventArgs をチェックして期待する結果と違っていたら何もしないようにしました。

using System;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using Microsoft.AspNet.SignalR;
using System.Web.Configuration;
using SqlDependencySignalR2.Models;

namespace SqlDependencySignalR2.SqlDependencyNotifier
{
    public class Notifier
    {
        // シングルトンとなるよう Lazy<T> クラスを使用
        private static readonly Lazy<Notifier> _instance
            = new Lazy<Notifier>(() => new Notifier());

        private readonly IHubContext _hubContext;
        private readonly string _connString;
        private readonly string _sqlQuery;
        private List<Product> _products;

        // コンストラクタ
        private Notifier()
        {
            // SignalR コンテキストを保持
            _hubContext = GlobalHost
                          .ConnectionManager
                          .GetHubContext<ProductHub>();

            // 接続文字列
            _connString = WebConfigurationManager
                          .ConnectionStrings["ProductConnection"]
                          .ConnectionString;
            
            // SELECT クエリ。テーブル名は dbo.Products とすること。
            // SqlDependency.dbo.Products でも Products でもダメで、
            // 通知のサブスクリプションに失敗する
            _sqlQuery = "SELECT ProductID,Name,UnitPrice,Quantity" +
                        " FROM dbo.Products";

            // クエリ通知のサブスクリプションを設定するのと同時に SQL
            // Server からデータを取得し List<Product> オブジェクトと
            // してサーバーに保持しておく
            _products = RegisterForNotifications();
        }

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

        // Hub の GetAllProducts メソッドから呼ばれる。保持している
        // List<Product> オブジェクトを返す
        public IEnumerable<Product> GetAllProducts()
        {
            return _products;
        }

        // クエリ通知のサブスクリプションを設定するのと同時に SQL
        // Server からデータを取得し List<Product> として返す
        private List<Product> RegisterForNotifications()
        {
            var products = new List<Product>();
            using (var connection = new SqlConnection(_connString))
            using (var command = new SqlCommand(_sqlQuery, connection))
            {                
                var sqlDependency = new SqlDependency(command);

                // イベントハンドラの設定
                sqlDependency.OnChange += OnSqlDependencyChange;

                if (connection.State == ConnectionState.Closed)
                {
                    connection.Open();
                }

                // ExecuteReader でクエリ通知のサブスクリプションが設定
                // される。同時に SqlDataReader でデータを取得できる
                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        var product = new Product
                        {
                            ProductID = reader.GetInt32(0),
                            Name = reader.GetString(1),
                            UnitPrice = reader.GetDecimal(2),
                            Quantity = reader.GetInt32(3)
                        };
                        products.Add(product);
                    }
                }
            }
            return products;
        }

        // Products テーブルが更新されるとこのイベントハンドラに制御が
        // 飛んでくる
        private void OnSqlDependencyChange(object sender, 
                                           SqlNotificationEventArgs e)
        {
            // 引数 e が期待する結果と違っていたら何もしない
            if ((e.Info == SqlNotificationInfo.Insert ||
                e.Info == SqlNotificationInfo.Update ||
                e.Info == SqlNotificationInfo.Delete) &&
                e.Source == SqlNotificationSource.Data &&
                e.Type == SqlNotificationType.Change)
            {
                // 一度通知が行われるとサブスクリプションが解除されてしま
                // うので、以下のメソッドで再度設定するとともに更新後の
                // データを _products に取得する
                _products = RegisterForNotifications();

                // 更新後のデータを接続されている全クライアントに送信
                _hubContext.Clients.All.broadcastMessage(_products);
            }
        }
    }
}

(8) SqlDependency.Start / Stop の設定

Global.asax.cs に、接続文字列で指定される SQL Server のインスタンスから依存関係の変更通知を受け取るリスナの開始 / 停止を設定します。

using System;
using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;
using System.Web.Configuration;
using System.Data.SqlClient;

namespace SqlDependencySignalR2
{
    public class MvcApplication : System.Web.HttpApplication
    {
        protected String SqlConnectionString { get; set; }

        protected void Application_Start()
        {
            // この下の 4 行は自動生成された既存のコード
            AreaRegistration.RegisterAllAreas();
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);

            SqlConnectionString = WebConfigurationManager
                                  .ConnectionStrings["ProductConnection"]
                                  .ConnectionString;

            if (!String.IsNullOrEmpty(SqlConnectionString))
            {
                SqlDependency.Start(SqlConnectionString);
            }
        }

        protected void Application_End()
        {
            if (!String.IsNullOrEmpty(SqlConnectionString))
            {
                SqlDependency.Stop(SqlConnectionString);
            }
        }
    }

(9) 表示画面の作成

表示画面は、この記事では Controller / View を使いましたが、静的な html ページで作っても良いです。ただし、ブラウザにキャッシュされるので内容を変更するたびキャッシュを削除するのが面倒ですが。

自動生成されている HomeController に Product という名前のアクションメソッドを追加します。

public ActionResult Product()
{
    return View();
}

上の Product アクションメソッドを右クリックして表示されるダイアログで以下のように設定し、アクションメソッドに対応するView を自動生成させます。

View の作成

自動生成されたコードを以下のように書き換えます。

@{
    ViewBag.Title = "Product";
}

<h2>SignalR and SqlDependency Sample</h2>

<div id="pruductTable">
    <table border="1">
        <thead>
            <tr><th>ProductID</th><th>Name</th><th>UnitPrice</th><th>Quantity</th></tr>
        </thead>
        <tbody id="productbody">
            <tr class="loading"><td colspan="4">loading...</td></tr>
        </tbody>
    </table>
</div>

@section scripts {
    <!--jQuery.js は _Layout.cshtml で参照済み -->

    <!--SignalR ライブラリの参照 -->
    <script src="~/Scripts/jquery.signalR-2.2.2.min.js"></script>

    <!--サーバー側で自動生成されるプロキシの JavaScript コードを取得する -->
    <script src="~/signalr/hubs"></script>

    <script type="text/javascript">
        var signalRHubInitialized = false;

        $(function () {
            InitializeSignalRHubStore();
        });

        function InitializeSignalRHubStore() {

            if (signalRHubInitialized) {
                return;
            }

            try {
                // ハブ用に自動生成されたプロキシへの参照を作成
                var productHub = $.connection.productHub;

                // SqlDependency.OnChange イベントが発生すると呼び出される
                productHub.client.broadcastMessage = function (products) {
                    $('#productbody').empty();
                    $.each(products, function (index, product) {
                        $('#productbody').append(
                            '<tr><td>' + product.ProductID +
                            '</td><td>' + product.Name +
                            '</td><td>' + product.UnitPrice +
                            '</td ><td>' + product.Quantity +
                            '</td></tr >');
                    });
                };

                // start() で Hub への接続を開始。.done でクライアントから Hub の
                // パブリックメソッド GetAllProducts を呼び出す
                $.connection.hub.start().done(function () {
                    productHub.server.getAllProducts().done(function (products) {
                        $('#productbody').empty();
                        $.each(products, function (index, product) {
                            $('#productbody').append(
                                '<tr><td>' + product.ProductID +
                                '</td><td>' + product.Name +
                                '</td><td>' + product.UnitPrice +
                                '</td ><td>' + product.Quantity +
                                '</td></tr >');
                        });
                    });
                    signalRHubInitialized = true;
                });

            } catch (err) {
                signalRHubInitialized = false;
            }
        };
    </script>
}

上の html コードの table 要素内の tbody 要素をサーバーから送られてきたデータで書き換えるようにしています。

まず、初期画面が表示されると Hub への接続が開始され、接続が完了すると Hub の GetAllProducts が呼び出されサーバ側で保持されている List<Product> がクライアントに送信され、それが上の function (products) の引数に JavaScript オブジェクトとして渡されます。その products を使って tbody 要素を書き換えて初期データを表示します。

その後、SQL Server の Products テーブルが更新されるとサーバー側で SqlDependency.OnChange イベントが発生し、上の productHub.client.broadcastMessage に設定された function (products) が呼び出されます。その際、引数 products には更新後のデータを含 む JavaScript オブジェクトが渡され、それを使って tbody 要素を書き換えて更新後のデータを表示します。

アプリを実行して複数のブラウザでアクセスし、SQL Sever Management Studio などを使って Products テーブルを更新すると、更新結果がリアルタイムで接 続されているすべてのブラウザに反映されます。それを表示したのがこの記事の一番上の画像です。

Tags: , , ,

ASP.NET

About this blog

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

Calendar

<<  2024年4月  >>
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar