WebSurfer's Home

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

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

.NET 6.0 ASP.NET Identity に MySQL 使用 (CORE)

by WebSurfer 2021年11月27日 13:53

先の記事「ASP.NET Identity で MySQL 利用 (CORE)」に、ASP.NET Core 3.1 MVC アプリで ASP.NET Identity のユーザー情報のストアに MySQL を利用するにはどうするかということを書きましたが、その Visual Studio 2022 + .NET 6.0 版です。

先の記事と違うのは、Visual Studio Community 2022 のテンプレートを使って .NET 6.0 でプロジェクトを作ったところと、非推奨になった NuGet パッケージ MySql.Data.EntityFrameworkCore に代えて Pomelo.EntityFrameworkCore.MySql を使ったところです。

先の記事で使った MySql.Data.EntityFrameworkCore は非推奨になったので、まずその代替えの Oracle 製 MySql.EntityFrameworkCore を使おうと思いましたが、この記事を書いた時点での最新バージョンが 5.0.8 で .NET 6.0 には対応してなさそうです。6.0.0-preview3.1 というのがありましたがプレビュー版ですし、依存関係が net5.0 と書いてあったので使うのは止めました。(一応 5.0.8 を試してみましたが Add-Migration に失敗します。さらに後日 6.0.1 も試しましたがやはり Add-Migration に失敗します)

Oracle は Entity Framework 対応は積極的ではなさそうな感じです。一方、Pomelo.EntityFrameworkCore.MySql はこの記事を書いた 2021/11/27 時点でバージョン 6.0.0 がすでにリリースされていましたので、この記事ではそれを使ってみました。

結果、Pomelo.EntityFrameworkCore.MySql バージョン 6.0.0 で一切支障なく ASP.NET Identity 用の MySQL データベースを構築できました。Oracle にはあまり期待しない方が良いのかも。

(1) プロジェクトの作成

Visual Studio 2022 のテンプレートを利用して .NET 6.0 の ASP.NET MVC アプリを作成します。「追加情報」の設定で[認証の種類(a)]は必ず「なし」としてください。

新しいプロジェクトの作成

(2) ASP.NET Core Identity の実装

ASP.NET Core Identity はスキャフォールディング機能を使って実装しますが、その前に、NuGet で Microsoft.VisualStudio.Web.CodeGeneration.Design をインストールします。

Web.CodeGeneration.Design

その後で Microsoft のドキュメント「ASP.NET Core プロジェクトでの Identity のスキャフォールディング」を参考に ASP.NET Core Identity を実装します。

ID の追加

レイアウトページはステップ (1) で生成したプロジェクトのレイアウトページ ~/Views/Shared/_Layout.cshtml を設定します。

ASP.NET Core Identity 関係のすべてのファイルを取り込むため[すべてのファイルをオーバーライド]にチェックを入れます。

コンテキストクラス名、エンティティクラス名は任意ですが、この記事では ApplicationDbContext, ApplicationUser としました。 何故かデザイナの + ボタンをクリックすると現れるダイアログのテキストボックス内で名前を設定しないとダメなので注意してください。また、エンティティクラス名を設定する際、コンテキストクラス名と同じ名前空間を追加しないと、コンテキストクラスと異なる名前空間になり、後で面倒なことになるので注意してください。

その後、追加した ASP.NET Core Identity 関係の Razor ページが働くよう、以下の追加・修正を行います。Visual Studio 2022 + .NET 6.0 で作ったプロジェクトでは Stratup.cs は無くなっていますので注意してください。サービス、ミドルウェア追加のためのコードは Program.cs に移すことにしたらしいです。

  1. Program.cs に builder.Services.AddRazorPages(); を追記。
  2. Program.cs の app.MapControllerRoute(name: "default", ... ); を書き換えて endpoints.MapRazorPages(); を追加。(これが無いと Razor ページのルーティングが働かないので必須)
  3. Areas/Identity/Page/Account/Manage/ の _Layout.cshtml で Layout = "/Views/Shared/_Layout.cshtml"; に変更。
  4. Views/Shared/_Layout.cshtml に <partial name="_LoginPartial" /> を追加。

(3) Pomelo.EntityFrameworkCore.MySql

NuGet で Pomelo.EntityFrameworkCore.MySql バージョン 6.0.0 をインストールします。

Pomelo.EntityFrameworkCore.MySql

先の記事で使った MySql.Data.EntityFrameworkCore は非推奨になってました。 代替えパッケージが MySql.EntityFrameworkCore とのことですので、まずそれの最新リリース版 5.0.8 を試してみたのですが、Add-Migration で以下のエラーとなります。

"Method 'AppendIdentityWhereCondition' in type 'MySql.EntityFrameworkCore.MySQLUpdateSqlGenerator' from assembly 'MySql.EntityFrameworkCore, Version=5.0.8.0, Culture=neutral, PublicKeyToken=c5687fc88969c44d' does not have an implementation."

Pomelo.EntityFrameworkCore.MySql には上の画像の通りバージョン 6.0.0 (.NET 6.0 用) がありますのでそれを使ってみたところ、Add-Migration, Update-Database で問題なくデーターベースを生成できました。

 

(4) 接続文字列の変更

テンプレートで自動生成された接続文字列は appsetteins.json にありますが、それは LocalDB を利用するように設定されていますので、MySQL に接続するように変更します。以下の例を見てください。

接続文字列の変更

例えば、上の画像のように database=coreidentity2 とデータベース名を指定すると、Entity Framework Code First の機能を使って coreidentity2 という名前のデータベースを新たに生成し、そこに必要なテーブルを生成してくれます。

(5) Program.cs の修正

自動生成されれた Program.cs ファイルで、サービス登録のコードが SQL Server を使うように設定されていますが、これを MySQL を使うように変更します。以下のような感じです。

サービス登録の変更

Pomelo.EntityFrameworkCore.MySql を使う場合、UseMySQL ではなくて UseMySql であること、引数が異なることに注意してください。詳しくは Pomelo.EntityFrameworkCore.MySql の「2. Services Configuration」のコードを見てください。

(6) プロジェクトのリビルド

ここで一旦プロジェクトをリビルドします。リビルドには成功するはずですが、以下の通り警告が 6 つ出ると思います (現時点での話で将来改善されるかも)。

プロジェクトのビルド

上の 2 つは LoginWith2fa.cshtml.cs での using 句のダブり、残り 4 つは NULL 許容参照型がプロジェクト全体で有効化されているものの一部の .cshtml ファイルのソースコードがそれに対応してないことによります。そこを直してもう一度リビルドします。

(7) Add-Migration の実行

パッケージマネージャーコンソールから Add-Migration CreateIdentitySchema を実行します (CreateIdentitySchema という名前は任意です)。

Add-Migration を実行

上の画像で赤色で反転された文は MySql.EntityFrameworkCore バージョン 5.0.8 を使って失敗した結果のメッセージです。その下が Pomelo.EntityFrameworkCore.MySql バージョン 6.0.0 を使って成功した結果です。

Migrations と言う名前のフォルダとその中に xxxxx_CreateIdentitySchema.cs というファイルが生成されているはずですので確認してください。ファイル名の xxxxx は作成時のタイムスタンプ、CreateIdentitySchema は Add-Migration コマンドで指定した名前です。

(8) Update-Database の実行

次に Update-Database を実行し、Entity Framework Code First の機能を利用して MySQL にデータベース / テーブルを作成します。成功すると、以下のようにデータベースと、必要なテーブルが一式生成されます。coreidentity2 というデータベース名は、上のステップ (4) で接続文字列に指定したものになります。

MySQL データベース

先の記事で問題となった"Specified key was too long; max key length is 3072 bytes" という制約のためエラーになるということはなかったです。

データベース / テーブルは CreateIdentitySchema.cs ファイルのコードに基づいて生成されるのですが、先の記事では主キーの長さが指定されてなかったところが、以下のように varchar(255) に指定されています。

CreateIdentitySchema.cs

utf8mb4 を使用していますので、主キーに設定された varchar(255) は 255 x 4 = 1,020 バイトになります。連結主キーでも制限の 3,072 より小さいので問題ないということのようです。

その後で Visual Studio から MVC アプリを起動しユーザー登録できます。登録したユーザーは上の手順で作成した MySQL データーベースに反映され、登録した ID とパスワードでアプリにログインできるようになります。

Tags: , , ,

CORE

画像をアップロードして SQL Server に保存 (CORE)

by WebSurfer 2021年11月24日 14:51

画像ファイルをタイトルと説明の文字列と共にアップロードし、サーバ側でサムネイル画像を作成してタイトル・説明・サムネイル画像・オリジナル画像を一式 SQL Server データベースに保存するサンプルを書きます。

一覧の表示

上の画像はアップロードして SQL Server に保存されたタイトル、説明、サムネイル画像を取得して一覧にして表示したものです。(オリジナル画像を表示してないのは一覧表に表示するのは大きすぎるからという理由だけです)

以下に、Entity Framework Code First の機能を使っての SQL Server データベースの作り方、一覧の表示、アップロード、編集、削除機能を実装した ASP.NET Core MVC アプリの作り方を述べます。

(保存先をデータベースではなく Web サーバーの特定のフォルダにファイルとして保存する場合は別の記事「ASP.NET Core MVC でファイルアップロード」に書きましたので、興味がありましたらそちらを見てください)

ベースに使ったプロジェクトは、先の記事「Visual Studio 2022 の ASP.NET MVC アプリ」に書いた Visual Studio 2022 で作成した .NET 6.0 の ASP.NET Core MVC アプリです。それに以下のように機能を追加します。

(1) Model

アップロードの際に Controller と View の間でデータのやり取りをするための View Model と、Entity Framework を使って SQL Server データベースとのデータのやり取りをするエンティティクラスを作ります。この記事では以下のようにしました (ファイル名とか MIME タイプ情報なども保持したいかもしれませんが、それはまた別の機会に)。

#nullable disable

using System.ComponentModel.DataAnnotations;

namespace MvcCore6App.Models
{
    // View Model
    // アップロードの際 Controller/View 間でデータのやり取りをする
    public class FileUploadViewModel
    {
        [Display(Name = "タイトル")]
        [Required(ErrorMessage = "{0} は必須")]
        [StringLength(25, ErrorMessage = "{0} は {1} 文字以内")]
        public string Title { get; set; }

        [Display(Name = "説明")]
        [StringLength(250, ErrorMessage = "{0} は {1} 文字以内")]
        public string Description { get; set; }

        [Display(Name = "ファイル")]
        [Required(ErrorMessage = "{0} は必須")]
        public IFormFile PostedFile { get; set; }
    }

    // エンティティクラス
    // SQL Server データベースとのデータのやり取りをする。また、
    // これをベースに EF Code First でデータベースを生成する
    public class FileEntity
    {
        public int Id { get; set; }

        [Display(Name = "タイトル")]
        [Required]
        [StringLength(25)]
        public string FileName { get; set; }

        [Display(Name = "説明")]
        [StringLength(250)]
        public string Description { get; set; }

        [Display(Name = "サムネイル画像")]
        [Required]
        public byte[] ThumbImage { get; set; }

        [Required]
        public byte[] OriginalImage { get; set; }
    }
}

Visual Studio 2022 で作成した .NET 6.0 の ASP.NET Core MVC アプリでは NULL 許容参照型がプロジェクト全体で有効化されていますので、警告を抑制するため #nullable disable を付与しています。下のコードでも必要に応じてそのようにしています。

(2) コンテキストクラス

Entity Framework を使って Controller と SQL Server データベースとの間でデータをやり取りするためのコンテキストクラスを定義します。この記事では以下のようにしました。

#nullable disable

using MvcCore6App.Models;
using Microsoft.EntityFrameworkCore;

namespace MvcCore6App.Data
{
    public class FileContext : DbContext
    {
        public FileContext(DbContextOptions<FileContext> options) : base(options)
        {
        }

        public DbSet<FileEntity> Files { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<FileEntity>().ToTable("File");
        }
    }

(3) Program.cs にサービスの追加

上記 (2) で定義したコンテクストクラスのインスタンスを Controller のコンストラクタ経由で DI できるようにするため、Program.cs に AddDbContext メソッドを使ってサービスの追加を行います。以下のコードで「// これを追加」とコメントした部分です。

using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using MvcCore6App.Data;
using MvcCore6App.Areas.Identity.Data;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration
    .GetConnectionString("ApplicationDbContextConnection");

builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));

builder.Services.AddDefaultIdentity<ApplicationUser>(options => 
    options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();

// これを追加
builder.Services.AddDbContext<FileContext>(options =>
    options.UseSqlServer(connectionString));

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

// ・・・後略・・・

(4) SQL Server データベース作成

上に定義したエンティティクラスとコンテクストクラスをベースに Migration 操作を行って SQL Server データベースを生成します。

SQL Server データベース

具体的には、Visual Studio のパッケージマネージャーコンソールで Add-Migration, Update-Database コマンドを実行すれば、Entity Framework Code First の機能によって、上の画像のようなデータベースが生成されます。

なお、上の (3) で接続文字列を appsettings.json に既存の ASP.NET Identity 用の "ApplicationDbContextConnection" にしていますので、ASP.NET Identity のユーザー情報のストア用のデータベースに File テーブルが追加されます。別のデータベースとして作成したい場合は接続文字列を変更してください。

(5) サムネイル作成用ユーティリティ

オリジナル画像を指定したサイズに縮小したサムネイル画像を作成するユーティリティクラスを定義します。

今回は以前に作成した .NET Framework の Windows アプリ用のコードを流用したのですが、それは Windows OS の GDI+ に依存する System.Drawing 名前空間のグラフィックス機能を利用しています。

.NET Core では特定の OS に依存する機能は Visual Studio のテンプレートで作るプロジェクトには含まれてないようで、利用するには NuGet パッケージ System.Drawing.Common をインストールする必要があります。

System.Drawing.Common

Windows OS の GDI+ に依存するということで Linux 上で動かすと例外が出て動かないとのことですが、それを解決するために libgdiplus というライブラリがあるそうです。(未検証・未確認です)

NuGet パッケージ System.Drawing.Common をインストールしても CA1416 警告が出ますが、#pragma warning disable CA1416 を追記して警告を抑制しました。コードは以下の通りです。

#pragma warning disable CA1416

using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;

namespace MvcCore6App.Utils
{
    public class ImageUtils
    {
        const int sizeThumb = 69;   // thumbimage のサイズ(縦横同じ)
        const int sizeLarge = 400;  // largerimage のサイズ(横幅)

        // sizeThumb で指定されたサイズのサムネイルを作る。
        // オリジナルの縦横比は保たれる(高さ or 幅の大きい方が sizeThumb になる)
        public static byte[] MakeThumb(byte[] fullsize)
        {
            // ・・・省略・・・
        }

        // 引数 newWidth, newHeight で指定されたサイズのサムネイルを作る。
        // 縦横で縮小率が異なる場合変形されないよう大きい方をトリミングして縮小
        public static byte[] MakeThumb(byte[] fullsize, int newWidth, int newHeight)
        {
            using (MemoryStream ms1 = new MemoryStream(fullsize))
            using (Image iOriginal = Image.FromStream(ms1))
            {                                
                // オリジナル/サムネイルの縦横のサイズ比
                double scaleW = (double)iOriginal.Width / (double)newWidth;
                double scaleH = (double)iOriginal.Height / (double)newHeight;

                // オリジナル画像をトリミングするための Rectangle 作成
                Rectangle srcRect = new Rectangle();

                if (scaleH == scaleW)  // 縦横同じ⇒トリミングなし
                {
                    srcRect.Width = iOriginal.Width;
                    srcRect.Height = iOriginal.Height;
                    srcRect.X = 0;
                    srcRect.Y = 0;
                }
                else if (scaleH > scaleW) // 縦 > 横 ⇒ 縦のみトリミング
                {
                    srcRect.Width = iOriginal.Width;
                    srcRect.Height = Convert.ToInt32((double)newHeight * scaleW);
                    srcRect.X = 0;
                    srcRect.Y = (iOriginal.Height - srcRect.Height) / 2;
                }
                else   // 縦 < 横 ⇒ 横のみトリミング
                {
                    srcRect.Width = Convert.ToInt32((double)newWidth * scaleH);
                    srcRect.Height = iOriginal.Height;
                    srcRect.X = (iOriginal.Width - srcRect.Width) / 2;
                    srcRect.Y = 0;
                }

                using (Image iThumb = new Bitmap(newWidth, newHeight))
                using (Graphics g = Graphics.FromImage(iThumb))
                {
                    g.InterpolationMode = InterpolationMode.HighQualityBicubic;
                    Rectangle destRect = new Rectangle(0, 0, newWidth, newHeight);
                    g.DrawImage(iOriginal, destRect, srcRect, GraphicsUnit.Pixel);

                    using (MemoryStream ms2 = new MemoryStream())
                    {
                        iThumb.Save(ms2, ImageFormat.Jpeg);
                        return ms2.GetBuffer();
                    }
                }
            }
        }

        // 幅のみ指定してサムネイルを作る。高さは幅と同じ縮小率で縮小。
        public static byte[] MakeThumb(byte[] fullsize, int maxWidth)
        {
            // ・・・省略・・・
        }
    }
}

引数 fullsize のバイト列が有効なイメージ形式でないと Image.FromStream で例外がスローされます。一応 png と jpeg 形式は問題ないのは確認しましたが、その他はチェックはしてないので注意してください。戻り値のサムネイル画像のバイト列は jpeg 形式になります。

(6) Controller / Action Method

SQL Server のレコード一覧の表示、アップロード、編集、削除を行うための Controller のコードは以下のようにしました。

using Microsoft.AspNetCore.Mvc;
using MvcCore6App.Data;
using MvcCore6App.Models;
using MvcCore6App.Utils;
using Microsoft.EntityFrameworkCore;

namespace MvcCore6App.Controllers
{
    public class FileController : Controller
    {
        private readonly FileContext _context;

        public FileController(FileContext context)
        {
            _context = context;
        }

        public async Task<IActionResult> Index()
        {
            var fileContext = _context.Files;
            return View(await fileContext.ToListAsync());
        }

        public async Task<IActionResult> GetThumb(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var file = await _context.Files
                .FirstOrDefaultAsync(m => m.Id == id);

            if (file == null)
            {
                return NotFound();
            }

            return File(file.ThumbImage, "image/jpeg");
        }


        public IActionResult Upload()
        {
            return View();
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Upload(FileUploadViewModel model)
        {
            if (ModelState.IsValid)
            {
                using (var memoryStream = new MemoryStream())
                {
                    await model.PostedFile.CopyToAsync(memoryStream);

                    // Upload the file if less than 2 MB
                    if (memoryStream.Length < 2097152)
                    {
                        var byteArray = memoryStream.ToArray();
                        var file = new FileEntity()
                        {
                            FileName = model.Title,
                            Description = model.Description,
                            ThumbImage = ImageUtils.MakeThumb(byteArray, 70, 70),
                            OriginalImage = byteArray
                        };

                        _context.Files.Add(file);

                        await _context.SaveChangesAsync();
                    }
                    else
                    {
                        ModelState.AddModelError("PostedFile", "サイズは 2MB 以下");
                        return View(model);
                    }
                }

                return RedirectToAction("Index");
            }

            return View(model);
        }

        public async Task<IActionResult> Edit(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var file = await _context.Files.FindAsync(id);
            if (file == null)
            {
                return NotFound();
            }
            return View(file);
        }

        [HttpPost, ActionName("Edit")]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> EditPost(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var fileToUpdate = await _context
                                     .Files
                                     .FirstOrDefaultAsync(f => f.Id == id);

            if (fileToUpdate != null)
            {

                if (await TryUpdateModelAsync<FileEntity>(
                    fileToUpdate,
                    "",
                    f => f.FileName, f => f.Description))
                {
                    await _context.SaveChangesAsync();
                    return RedirectToAction("Index");
                }

            }
            return View(fileToUpdate);
        }

        public async Task<IActionResult> Delete(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var file = await _context.Files
                .AsNoTracking()
                .FirstOrDefaultAsync(f => f.Id == id);

            if (file == null)
            {
                return NotFound();
            }

            return View(file);
        }

        // POST: Students/Delete/5
        [HttpPost, ActionName("Delete")]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> DeleteConfirmed(int id)
        {
            var file = await _context.Files.FindAsync(id);

            if (file == null)
            {
                return RedirectToAction("Index");
            }

            _context.Files.Remove(file);
            await _context.SaveChangesAsync();
            return RedirectToAction("Index");
            
            
        }
    }
}

Upload メソッドは Microsoft のドキュメント「バッファー モデル バインドを使用して小さいファイルをデータベースにアップロードする」を参考にしました。Action Method と View の間のデータのやり取りにはビューモデル FileUploadViewModel を使用し、それをエンティティクラス FileEntity に移し替えるとともにサムネイル画像を追加し SQL Server データベースに保存しています。

アップロードするファイルの検証については、アップロードする時点でのファイル選択の有無とサーバー側でのサイズのチェックしか行っていません。Microsoft のドキュメント「ASP.NET Core でファイルをアップロードする」の「セキュリティに関する考慮事項」のセクションに書かれたセキュリティ関する配慮はしていませんので注意してください。

先の記事「ファイルアップロード時の検証 (CORE)」に、ASP.NET Core. 3.1 MVC アプリでファイルをアップロードする際に、カスタム検証属性を利用してファイルのサイズとタイプをクライアント側とサーバー側の両方で検証し、検証結果 NG の場合はエラーメッセージを表示する方法を書きましたので、興味があれば見てください。

Edit / EditPost メソッドではタイトルと説明のみ編集して結果を SQL Server データベースに反映するようにしました。画像の差し替えは上のコードではできません (画像を差し替えるなら、削除してから新たに Upload し直した方が良いと思いましたので)。

タイトルと説明のみ変更するため TryUpdateModelAsync メソッドを使っているところに注目してください。よくあるパターンとしては、上の EditPost メソッドの引数に ビューモデル FileUploadViewModel を使ってそれにモデルバインドということをすると思いますが、画像データはアップロードされてこないところが問題です。

上の EditPost メソッドのコードでは、既存のエンティティを読み取り、TryUpdateModel を呼び出してポストされたタイトルと説明からフィールドを更新しています。既存のエンティティの読み取りによって画像データも取得されるので、その上でタイトルと説明だけを変更して SaveChanges を適用するので画像データが消えることはないです。

TryUpdateModelAsync メソッドについては Microsoft のチュートリアルの「HttpPost Edit メソッドの更新」が参考になると思いますので興味があれば見てください。

GetThumb メソッドは、DB からサムネイル画像を取得してダウンロードするためのメソッドです。この記事の一番上の一覧表示の画像ようにサムネイルを表示するために使います。View に img 要素を配置し、その src 属性に GetThumb メソッドを設定することによりサムネイル画像が表示されます。

src 属性に GetThumb メソッドを設定するということは、ブラウザはそこで GetThumb メソッドを呼んでデータベースからデータを取得してくるという動きになることに注意してください。特にこの記事の一番上の画像のように一覧表示する場合はレコードの数だけ GetThumb メソッドが呼ばれることになります。

それを避けるのは、アクションメソッドですべてのデータを Model として View に渡し済みですので、View で Model に含まれる画像のバイト列を Data URL 形式に変換して src 属性に設定することにより可能です。その例は下の「(7) View」のセクションの Index.cshtml に書きます。ただし、そのようにした場合はブラウザはその画像をキャッシュできないことに注意してください。

(7) View

Index.cshtml, Upload.cshtml, Edit.cshtml, Delete.cshtml のコードをその順に以下に記載しておきます。Index.cshtml と Delete.cshtml には上のコントローラーのコードの GetThumb メソッドを使ってサムネイル画像を表示するようにしています。

Index.cshtml

@model IEnumerable<MvcCore6App.Models.FileEntity>

@{
    ViewData["Title"] = "Index";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<p>
    <a asp-action="Upload">Uoload New</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.FileName)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Description)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.ThumbImage)
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
@{
#nullable disable
    foreach (var item in Model) {
        <tr>
            <td>
                @Html.DisplayFor(modelItem => item.FileName)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Description)
            </td>
            <td>
                @*以下はコメントアウトして代わりのコードを書いた                
                <img src="/File/GetThumb/@item.Id" 
                    alt="@item.FileName" title="@item.FileName" />*@

                @*アクションメソッドですべてのデータを Model として受け取って
                いるので、Model に含まれる画像のバイト列を Data URL 形式に変
                換して src 属性に設定することにより画像を表示できる。*@
                <img src="data:image/jpeg;base64,@Convert.ToBase64String(item.ThumbImage)" 
                    alt="@item.FileName" title="@item.FileName" />
            </td>
            
            <td>
                <a asp-action="Edit" asp-route-id="@item.Id">Edit</a> |
                <a asp-action="Delete" asp-route-id="@item.Id">Delete</a>
            </td>
        </tr>
    }
}
    </tbody>
</table>

Upload.cshtml

@model MvcCore6App.Models.FileUploadViewModel

@{
    ViewData["Title"] = "Upload";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h1>Upload</h1>

<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="Upload" enctype="multipart/form-data" method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="Title" class="control-label"></label>
                <input asp-for="Title" class="form-control" />
                <span asp-validation-for="Title" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Description" class="control-label"></label>
                <input asp-for="Description" class="form-control" />
                <span asp-validation-for="Description" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="PostedFile" class="control-label"></label>
                <input asp-for="PostedFile" type="file" class="form-control" />
                <span asp-validation-for="PostedFile" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

<div>
    <a asp-action="Index">Back to List</a>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

Edit.cshtml

@model MvcCore6App.Models.FileEntity

@{
    ViewData["Title"] = "Edit";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h1>Edit</h1>

<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="Edit">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Id" />
            <div class="form-group">
                <label asp-for="FileName" class="control-label"></label>
                <input asp-for="FileName" class="form-control" />
                <span asp-validation-for="FileName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Description" class="control-label"></label>
                <input asp-for="Description" class="form-control" />
                <span asp-validation-for="Description" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

<div>
    <a asp-action="Index">Back to List</a>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

Delete.cshtml

@model MvcCore6App.Models.FileEntity

@{
    ViewData["Title"] = "Delete";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h1>Delete</h1>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>FileEntity</h4>
    <hr />
    <dl class="row">
        <dt class = "col-sm-2">
            @Html.DisplayNameFor(model => model.FileName)
        </dt>
        <dd class = "col-sm-10">
            @Html.DisplayFor(model => model.FileName)
        </dd>
        <dt class = "col-sm-2">
            @Html.DisplayNameFor(model => model.Description)
        </dt>
        <dd class = "col-sm-10">
            @Html.DisplayFor(model => model.Description)
        </dd>
        <dt class = "col-sm-2">
            @Html.DisplayNameFor(model => model.ThumbImage)
        </dt>
        <dd class = "col-sm-10">
            @{
                if (Model != null)
                {
                    <img src="/File/GetThumb/@Model.Id" 
                        alt="@Model.FileName" 
                        title="@Model.FileName" />
                }
            }
        </dd>
    </dl>
    
    <form asp-action="Delete">
        <input type="hidden" asp-for="Id" />
        <input type="submit" value="Delete"
            class="btn btn-danger" /> |
        <a asp-action="Index">Back to List</a>
    </form>
</div>

Tags: , , , ,

CORE

About this blog

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

Calendar

<<  2024年3月  >>
252627282912
3456789
10111213141516
17181920212223
24252627282930
31123456

View posts in large calendar