WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

非同期プログラミング

by WebSurfer 13. September 2020 20:01

表題の「非同期プログラミング」と言うと高度な話のように思えるかもしれませんが、実は大したことは書いてなくて、ある機会にいろいろ調べたことを備忘録的に書いただけです。(汗)

Windows Forms アプリの ManagedThreadId

上の画像はそのいろいろ調べたことの一つで、Windows Forms アプリで非同期にメソッドを実行して ManagedThreadId プロパティの値がどうなるかを調べた結果です。説明は下の (2) に書きました。

(1) コンソールアプリの ManagedThreadId

以下のコードを実行すると結果はどうなるでしょう? ManagedThreadId は 1 ⇒ 3 ⇒ 3 とか、時々、1 ⇒ 3 ⇒ 4 と言うようにすべて違うスレッドになります。

static async Task Main(string[] args)
{
    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    await Task.Run(() =>
    {
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    });
    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
}

ちなみに、上の Task.Run メソッドは何かと言うと "スレッドプール上で実行する指定された作業をキューに配置し、その作業を表す Task オブジェクトを戻します。" というものだそうです。要するに、上のコード例で言うと、Run メソッドの引数にある Console.WriteLine をメインスレッドとは別のスレッドで実行するものです。

何故 1 ⇒ 3 ⇒ 4 と言うようにすべて違うスレッドになるかというと、3 つの Console.WriteLine を実行するスレッドは、OS がその時の状況に応じてスレッドプールから良しなに選んでくるからということらしいです。

Microsoft のドキュメント「非同期プログラミングのベストプラクティス」の「すべて非同期にする」のセクションに以下のように書いてあります。

"コンソールアプリケーションでは、一度に 1 つのチャンクに制限する SynchronizationContext ではなく、スレッドプールを備えた SynchronizationContext を使用するため、await が完了するとき、スレッド プールのスレッドで async メソッドの残り処理のスケジュールが設定されます。"

"They have a thread pool SynchronizationContext instead of a one-chunk-at-a-time SynchronizationContext, so when the await completes, it schedules the remainder of the async method on a thread pool thread."

(注: 上の「一度に 1 つのチャンクに制限する SynchronizationContext」というのは GUI アプリと ASP.NET アプリに使われるものだそうです)

それが実行結果が 1 ⇒ 3 ⇒ 4 と言うようにすべて違うスレッドになることがあるという理由のようです。

(2) Windows Forms アプリの ManagedThreadId

この記事の一番上の画像が Windows Forms アプリで非同期にメソッドを実行して ManagedThreadId の値がどうなるか調べた結果です。

コンソールアプリとの一番大きな違いは。メインスレッド(UI スレッド)の ManagedThreadId の値は同じになる(上の画像の例では 1 で固定)、即ち同じスレッドが使われ続けるということです。

Microsoft のドキュメント「非同期プログラミングのベストプラクティス」の「すべて非同期にする」のセクションに以下のように書いてあり、これがメインスレッドには同じスレッドが使われ続ける理由のようです。

"既定では、未完了の Task を待機するときは、現在のコンテキストがキャプチャされ、Task が完了するときのメソッドの再開に使用されます。このコンテキストは現在の SynchronizationContext で、Null の場合は現在の TaskScheduler になります。GUI アプリケーションと ASP.NET アプリケーションには、一度に実行するコードを 1 つのチャンクに限定する SynchronizationContext があります。await が完了するときは、キャプチャしたコンテキスト内で async メソッドの残りを実行しようとします。"

"By default, when an incomplete Task is awaited, the current context is captured and used to resume the method when the Task completes. This context is the current SynchronizationContext unless it’s null, in which case it’s the current TaskScheduler. GUI and ASP.NET applications have a SynchronizationContext that permits only one chunk of code to run at a time. When the await completes, it attempts to execute the remainder of the async method within the captured context."

メッセージループでマウスのクリックやキーボードのストロークなどのユーザーイベントを処理して UI に反映する必要があることも、Windows アプリの UI スレッドは常に同じ&固定になる理由なのかもしれません(想像です)。

メッセージループに関しては、@IT の記事「第3回 Windowsアプリケーションの正体 (3/4)」や Microsoft のドキュメント「Application.Run メソッド」が自分的には参考になりました。

上の画像の Windows Forms アプリのコードを以下に書いておきます。

using System;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Threading;

// メインスレッドの ManagedThreadId は最後まで同じ ID が保たれる

namespace WinFormsApp1
{
    public partial class Form7 : Form
    {
        public Form7()
        {
            InitializeComponent();

            textBox1.Text = "呼び出されたメソッド内の";
        }

        private async void button2_Click(object sender, EventArgs e)
        {
            label1.Text = 
                $"メイン ID: {Thread.CurrentThread.ManagedThreadId} / ";
            label2.Text = "";

            // await キーワードは TimeCosumingMethod の中で別スレッド
            // で実行される部分が終了するのを待つという意味。
            // その間メッセージループは処理されるのでフリーズはしない
            label2.Text = await TimeCosumingMethod(textBox1.Text);

            label1.Text += Thread.CurrentThread.ManagedThreadId;
        }

        private void button1_Click(object sender, EventArgs e)
        {
            label3.Text = "[処理中にクリック]ボタンがクリックされた " + 
                Thread.CurrentThread.ManagedThreadId;
        }


        private async Task<string> TimeCosumingMethod(string s)
        {
            if (string.IsNullOrEmpty(s))
            {
                throw new ArgumentException("引数が無い");
            }
            s += $" ID: {Thread.CurrentThread.ManagedThreadId} / ";

            s += await MyMethod();

            s += Thread.CurrentThread.ManagedThreadId;

            return s;
        }


        private async Task<string> MyMethod()
        {
            string s1 = $"MyMethod {Thread.CurrentThread.ManagedThreadId} (IN), ";

            string s2 = await MyMethod2();

            s1 += $"{Thread.CurrentThread.ManagedThreadId} (OUT) / " + s2;
            return s1;
        }

        private async Task<string> MyMethod2()
        {
            string s = $"MyMethod2 {Thread.CurrentThread.ManagedThreadId} (IN), ";

            // Task.Delay(3000) で 3000 ms 後に完了するタスクを作成。
            // そのタスクは別スレッドで実行される。await があるので
            // タスクの完了を待つ
            await Task.Delay(3000);

            s += $"{Thread.CurrentThread.ManagedThreadId} (OUT) / ";
            return s;
        }
    }
}

上記のコードでは UI スレッドと別のスレッドで実行されるのは MyMethod2 メソッドの中の Task.Delay(3000) だけになるようです。上の画像で ManagedThreadId がすべて 1 になっているのがそれを裏付けていると思います。

ちなみに、TimeCosumingMethod メソッドの中で s += await MyMethod(); を s += await Task.Run(() => MyMethod()); に変更すると、MyMethod メソッドは UI スレッドとは別のスレッドで実行されます。結果は以下の画像のとおりで、赤枠の部分が上のケースとは異なってきます。

Windows Forms アプリの ManagedThreadId (その2)

(3) 訳が分からなかった話

下の画像はそのいろいろ調べた過程でコンソールアプリで間違った(?)やり方をして訳が分からなくなった結果です。また分からなくなると何なので(今でもホントに分かっているかは疑問ですが)、どういう話だったのかを書いておくことにしました。

コンソー���アプリの ManagedThreadId

下のコードの実行結果が上の画像です。コードの中の (1) の方を実行しています。想像混じりですが、次のように動いていると思われます。

  1. TimeCosumingMethod の中に Task.Delay(5000) が無ければ 100% 単一のスレッドを使用する同期プログラミング。
  2. メインスレッド (ID = 1) で TimeCosumingMethod が呼び出され Task.Delay(5000) まで来る。  
  3. Task.Delay(5000) は 5000 ミリ秒後に完了する別スレッドのタスク。await が付与されているのでその場所でそのタスクの完了を待つ。別スレッドのタスクなのでその時点で ID = 1 のスレッドはフリーになる。(スレッドプールに戻す? それともそのまま同期実行を続ける?・・・そこが不明)
  4. TimeCosumingMethod には await は付与されてないので、すぐ次の行の HeavyMethod を実行しようとする。フリーになった ID = 1 のスレッドで HeavyMethod が実行される。
  5. TimeCosumingMethod 内の Task.Delay(5000) のタスクが完了すると、OS は ID = 4 のスレッドをスレッドプールから取得して、次の行 Console.WriteLine("Task.Delay(5000) の後 ...); から処理を再開する。  
  6. 結果、HeavyMethod は ID = 1 のスレッド、TimeCosumingMethod は ID = 4 のスレッドで並行して実行される形となり、それぞれコードに書かれたとおりのタイミングで文字列をコンソールに書き込んでいく。
  7. HeavyMethod の次の行の Console.WriteLine(task.Result); の引数 task.Result はそこで Wait メソッドを呼び出すことと同じ。それゆえ、そこで TimeCosumingMethod による「4秒経過」~「TimeCosumingMethod おわり」のコンソールへの書き込みを待つ。  
  8. TimeCosumingMethod 完了後、Console.WriteLine(task.Result) により「TimeCosumingMethod の戻り値」、次の Console.WriteLine("Main のおわり ...) により「Main のおわり」がコンソールに書き込まれてアプリは終了。
using System;
using System.Threading.Tasks;
using System.Threading;

namespace ConsoleAppAsync2
{
    class Program
    {
        static async Task Main(string[] args)
        {
            Console.WriteLine("Main のはじまり " + 
                Thread.CurrentThread.ManagedThreadId);

            // (1)
            Task<string> task = TimeCosumingMethod();
            HeavyMethod();

            // Task.Result プロパティの get アクセサーに
            // アクセスすると、非同期操作が完了するまで
            // 呼び出し元のスレッドがブロックされる。こ
            // れは、Wait メソッドを呼び出すことと同じ
            Console.WriteLine(task.Result);

            // (2)
            // 時間のかかる処理は別スレッドで非同期に実行
            // ということであれば以下のようにすると思う         
            //string result = await TimeCosumingMethod();
            //await Task.Run(() => HeavyMethod());
            //Console.WriteLine(result);

            Console.WriteLine("Main のおわり " + 
                Thread.CurrentThread.ManagedThreadId);

            Console.ReadLine();
        }

        static async Task<string> TimeCosumingMethod()
        {
            Console.WriteLine("TimeCosumingMethod はじまり " + 
                Thread.CurrentThread.ManagedThreadId);

            // Task.Delay(5000) で 5 秒後に完了するタスク
            // を作成。そのタスクは別スレッドで実行される
            // await でそのタスクの完了を待つ。await 無し
            // では完了を待たず下の行に進む
            await Task.Delay(5000);

            Console.WriteLine("Task.Delay(5000) の後 " + 
                Thread.CurrentThread.ManagedThreadId);

            for (int i = 0; i < 10; i++)
            {
                Thread.Sleep(1000);
                Console.WriteLine(i + 1 + "秒経過 " + 
                    Thread.CurrentThread.ManagedThreadId);
            }

            Console.WriteLine("TimeCosumingMethod おわり " + 
                Thread.CurrentThread.ManagedThreadId);

            return "TimeCosumingMethod の戻り値 " + 
                Thread.CurrentThread.ManagedThreadId;
        }

        static void HeavyMethod()
        {
            Console.WriteLine("HeavyMethod はじまり " + 
                Thread.CurrentThread.ManagedThreadId);

            Thread.Sleep(9000);

            Console.WriteLine("HeavyMethod おわり " +
                Thread.CurrentThread.ManagedThreadId);
        }
    }
}

時間のかかる処理を別スレッドで非同期に実行ということであれば、上のコードのコメントアウトした (2) のようにするのが普通と思います。その場合の実行結果は以下の画像のようになります。

コンソールアプリの ManagedThreadId (その2)

これなら少なくとも訳が分からないということはないと思うのですが、どうでしょう? まぁ、訳が分からないのは自分が無知だからということは否めませんが。(汗)


await と Task.Result によるデッドロックに続く。

Tags: , , ,

.NET Framework

CSV ファイルを DataGridView に表示

by WebSurfer 11. September 2020 15:05

CSV ファイルからデータを取得して Windows Forms アプリの DataGridView に表示し、それをユーザーが編集して結果を CSV ファイルに書き出すサンプルを書きます。先の記事「XML ファイルを DataGridView に表示」の CSV 版です。

CSV ファイルを DataGridView に表示

CSV ファイルのサンプルは、Microsoft のサンプルデータベース Northwind の Products テーブルから SQL Server Management Studio を使ってエクスポートしたものを使います。CSV ファイルは 1 行目がヘッダであることが条件で、Products テーブルのフィールド名がヘッダになります。

CVS ファイルを DataGridView に表示する基本的な構成は以下の通りです。

CSV ファイル => CSV パーサー => DataTable => BindingSource => DataGridView

編集結果の CSV ファイルへの書き出しは以下の通りです。ユーザーが DataGridView を操作して編集した結果は自動的に DataTable に反映されるので、自作メソッドで DataTable からデータを読んで CSV ファイルに書き出すようにしています。

DataTable => 自作メソッド => CSV ファイル

CSV パーサーには、(1) Microsoft が提供している Visual Basic .NET 用のクラスライブラリ TextFieldParser と、(2) ADO.NET + OleDb + JET (ACE も使用可) を使ったもの 2 種類を試してみました。

そのサンプルコードはこの記事の下の方にアップしておきます。

TextFieldParser 版は、CSV ファイルのカラム数とその型は不定という前提で、各カラムの型指定はせず全カラムを string 型として扱っています。

上にも書きましたが、CSV ファイルは 1 行目がヘッダであることが条件で、ヘッダのフィールドを DataTable の列の名前に設定しています。そうしておけば DataTable を DataGridView にバインドした際、 DataTable の列名が DataGridView のヘッダに自動的に設定されます。

TextFieldParser は CSV ファイルに BOM が付与されていればそれから文字エンコーディングを自動判別し、BOM 無しの場合はコンストラクタに設定した文字エンコーディングの指定に従います。この記事のサンプル CSV ファイルは BOM 無しの UTF-8 で作ったので、文字エンコーディングの指定は UTF-8 としています。

ADO.NET + OelDb + JET 版は、CSV ファイルの各列の型を Schema.ini ファイルを使用して指定し、OleDbDataAdapter を利用して DataTable を生成した際 DataColumn に型が反映されるようにしました。

schema.ini の内容は以下の通りです。CSV ファイルと同じディレクトリに置けば自動的に情報を取得して DataTable を作ってくれます。各列の型だけでなく文字コードも指定できます。

schema.ini

作成された DataTable の各列には schema.ini によって .NET の型 Int32, string, decimal, Int16, bool が設定されます。結果、DataGridView の表示も違ってきて以下のようになります。bool 型の Discontinued 列に表示されているのはチェックボックスになっているのが分かるでしょうか。

CSV ファイルを DataGridView に表示(その2)

上の DataGridView の画像を表示したサンプルコードを以下に書いておきます。TextFieldParser 版と ADO.NET + OelDb + JET 版の両方のコードが含まれています(後者はコメントアウトしてます)。

using System;
using System.ComponentModel;
using System.Data;
using System.IO;
using System.Text;
using System.Windows.Forms;
using Microsoft.VisualBasic.FileIO;
using System.Data.OleDb;

namespace WinFormsApp1
{
    public partial class Form8 : Form
    {
        private BindingSource bindingSource1;
        private DataTable table;

        public Form8()
        {
            InitializeComponent();

            this.components = new Container();
            this.bindingSource1 = 
                new BindingSource(this.components);
            this.dataGridView1.DataSource = this.bindingSource1;
        }

        private void Form8_Load(object sender, EventArgs e)
        {
            // CSV ファイルのあるフォルダ、ファイル名の指定
            // CSV ファイルは 1 行目がヘッダであることが条件
            string filePath = @"CSV ファイルのあるフォルダ";
            string fileName = "products.csv";

            // CSV ファイルを読む際の文字エンコーディングを指定
            Encoding encoding = new UTF8Encoding();

            // CSV ファイルから DataTable を作成し DataGridView
            // に表示

            // TextFieldParser 版
            this.table =
                CreateDataTable(filePath + fileName, encoding);

            // ADO.NET + OelDb + JET 版
            //this.table =
            //    CreateDataTableByJet(filePath, fileName);

            this.bindingSource1.DataSource = this.table;
        }

        // テキストボックス入力により ProductName をあいまい検索
        private void button1_Click(object sender, EventArgs e)
        {
            if (!String.IsNullOrEmpty(this.textBox1.Text))
            {
                this.table.DefaultView.RowFilter =
                    "ProductName LIKE '%" +
                    this.textBox1.Text + "%'";
                this.bindingSource1.DataSource = this.table;
            }
            else
            {
                // 元に戻す
                this.table.DefaultView.RowFilter = "";
            }
        }


        // DataGirdView で選択した行を非表示にする。DELETE 操作
        // はこれにより DataTable の当該行に Deleted マークを付
        // けることにより可能になる
        private void button2_Click(object sender, EventArgs e)
        {
            this.bindingSource1.RemoveCurrent();
        }


        // DataGridView を編集した結果を CSV ファイルに書き出す
        private void button3_Click(object sender, EventArgs e)
        {
            // CSV ファイルの保存先のフォルダ、ファイル名の指定
            string filePath = @"CSV ファイルの保存先のフォルダ";
            string fileName = "productsRevised.csv";

            // 保存する CSV ファイルの文字エンコーディングを指定
            Encoding encoding = new UTF8Encoding();

            // DataTable を CSV ファイルに書き出し
            SaveDataTableAsCsv(this.table,
                               filePath + fileName,
                               encoding);

            // 上のメソッドで書き出した CSV ファイルを読んで
            // DataTable を作成し、それを DataGridView に表示

            // TextFieldParser 版
            this.table =
                CreateDataTable(filePath + fileName, encoding);

            // ADO.NET + OelDb + JET 版
            //this.table =
            //    CreateDataTableByJet(filePath, fileName);

            this.bindingSource1.DataSource = this.table;
        }

        // ================ 以下ヘルパメソッド ================

        // TextFieldParser 版
        // 指定されたパスから CSV ファイルを読んできて DataTable
        // を作成。CSV ファイルのカラム数とその型は不定という前提
        // なので、全カラムを string 型として扱わざるを得ない。
        // TextFieldParser は CSV ファイルに BOM が付与されていれ
        // ばそれからエンコーディングを自動判別。BOM 無しの場合は
        // 引数の encoding の指定に従う
        protected DataTable CreateDataTable(string path, 
                                            Encoding encoding)
        {
            // TextFieldParser は Microsoft が提供している Visual
            // Basic .NET 用のクラスライブラリ。C# のアプリでも
            // Microsoft.VisualBasic.dll を参照に追加すれば利用可
            using (TextFieldParser tfp = 
                            new TextFieldParser(path, encoding))
            {
                //フィールドがデリミタで区切られている
                tfp.TextFieldType = FieldType.Delimited;

                // デリミタを , とする
                tfp.Delimiters = new string[] { "," };

                // フィールドを " で囲み、フィールド内に改行文字、
                // デリミタを含めることができるか
                tfp.HasFieldsEnclosedInQuotes = true;

                // 空白文字をトリム
                tfp.TrimWhiteSpace = true;

                // CSV ファイルは 1 行目がヘッダであることが条件
                string[] headers = tfp.ReadFields();
                int fieldCount = headers.Length;

                DataTable dt = new DataTable();
                DataRow dr;
                DataColumn dc;

                // DataTable の列の設定
                for (int i = 0; i < fieldCount; i++)
                {
                    dc = new DataColumn(headers[i], typeof(String));
                    dt.Columns.Add(dc);
                }

                // CSV のデータから DataRow を作り DataTable
                // に追加していく
                while (!tfp.EndOfData)
                {
                    string[] fields = tfp.ReadFields();

                    dr = dt.NewRow();
                    for (int i = 0; i < fieldCount; i++)
                    {
                        dr[headers[i]] = fields[i];
                    }
                    dt.Rows.Add(dr);
                }

                return dt;
            }
        }

        // ADO.NET + OelDb + JET 版 
        // 指定されたパスから CSV ファイルを読んできて DataTable
        // を作成。
        protected DataTable CreateDataTableByJet(string path, 
                                                 string fileName)
        {
            string conString =
              "Provider=Microsoft.Jet.OLEDB.4.0;Data Source=" +
              path +
              ";Extended Properties=\"text;HDR=Yes;FMT=Delimited\"";

            OleDbConnection con = new OleDbConnection(conString);
            string commText = "SELECT * FROM [" + fileName + "]";
            OleDbDataAdapter da = new OleDbDataAdapter(commText, con);

            DataTable dt = new DataTable();
            da.Fill(dt);

            return dt;
        }

        // DataTable から CSV ファイルを指定したパスに作成。
        // デフォルトで CSV ファイルの 1 行目がヘッダとなる
        private void SaveDataTableAsCsv(DataTable dt, 
                                        string path,
                                        Encoding encoding,
                                        bool isHeaderRequired = true)
        {
            int colCount = dt.Columns.Count;
            int lastColIndex = colCount - 1;

            using (var sr = new StreamWriter(path, false, encoding))
            {
                // デフォルトで true
                if (isHeaderRequired)
                {
                    for (int i = 0; i < colCount; i++)
                    {
                        // DataTable の DataColumn.Caption を CSV の
                        // ヘッダとしている
                        string header = dt.Columns[i].Caption;

                        // フィールド中にカンマ等があった場合の処理
                        header = EncloseByDoubleQuote(header);

                        sr.Write(header);
                        if (lastColIndex > i)
                        {
                            sr.Write(',');
                        }
                    }
                    sr.Write("\r\n");
                }

                foreach (DataRow row in dt.Rows)
                {
                    // OleDb + JET で作った DataTable は DataRow
                    // の RowState が Deleted のものも dt.Rows
                    // に含まれてしまう。結果、row[i].ToString() で
                    // DeletedRowInaccessibleException がスローされる
                    // TextFieldParser で作った DataTable は RowState
                    // が Deleted のものは含まれない。理由不明
                    // 前者は列に int, string, decimal, bool などの
                    // 型が schema.ini に従い指定されている、後者は
                    // 全列が string 型。それぐらいしか違いはないが?

                    // RowState を判定して対応するコードを追加
                    if (row.RowState == DataRowState.Unchanged ||
                        row.RowState == DataRowState.Added ||
                        row.RowState == DataRowState.Modified)
                    {

                        for (int i = 0; i < colCount; i++)
                        {
                            // DataRowVersion.Current のデータを取得
                            // row[i] としても同じはずだが念のため
                            string field = 
                                row[i, DataRowVersion.Current].ToString();

                            // フィールド中にカンマ等があった場合の処理
                            field = EncloseByDoubleQuote(field);

                            sr.Write(field);
                            if (lastColIndex > i)
                            {
                                sr.Write(',');
                            }

                        }
                        sr.Write("\r\n");
                    }
                }
            }
        }

        // フィールドの中にダブルクォーテーション、カンマ、改行が
        // 含まれていた場合の処理。フィールド前後の空白文字はトリム
        // してしまうことにした
        private string EncloseByDoubleQuote(string field)
        {
            field = field.Trim();

            // フィールドに " が含まれている
            if (field.IndexOf('"') > -1)
            {
                // " を "" に置き換え
                field = field.Replace("\"", "\"\"");
                return $"\"{field}\"";
            }

            // フィールドにカンマ、改行が含まれている
            if (field.IndexOf(',') > -1 ||
                field.IndexOf('\r') > -1 ||
                field.IndexOf('\n') > -1)
            {
                return $"\"{field}\"";
            }

            return field;
        }
    }
}

Tags: , , ,

.NET Framework

ASP.NET Core Identity 独自実装(その2)

by WebSurfer 6. September 2020 16:29

先の記事「ASP.NET Core Identity 独自実装(その1)」と「Register, Login, Logout 機能の実装」の続きです。

ロール管理画面

先の記事では ASP.NET Core Identity のユーザー認証に用いるデータソースとカスタムプロバイダを作成し、それを用いてユーザー認証機能を実装しました。今回、ロール機能を追加しましたので、その方法などを備忘録として書いておきます。

先の記事「ASP.NET Core Identity 独自実装(その1)」の一番上に表示した画像を見てください。前回、その画像の Data Source, Data Access Layer レイヤーおよび Identity Store レイヤーの中のカスタムプロバイダ UserStore までは作成し、期待通りの動作をすることは確認しました。

今回、ロール機能を実現するためのカスタムプロバイダ RoleStore を実装し、先に作成したカスタムプロバイダ UserStore にロールによるアクセス制限のための機能を追加します。

(1) RoleStore クラスの実装

先に骨組みだけ作っておきましたが、それに中身を実装します。作成済みの UserStore のメソッドを参考にしました。

上位レイヤーにある RoleInManager がこのクラスに定義したメソッドを使って、先に作成済みの Data Access Layer を介して SQL Server の Role テーブルにアクセスし、ロールデータの作成、取得、削除などの操作を行います。

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using MvcIdCustom.Models;

namespace MvcIdCustom.DAL
{
    public class RoleStore : IRoleStore<Role>, 
                             IQueryableRoleStore<Role>
    {
        private DataContext db;

        public RoleStore(DataContext db)
        {
            this.db = db;
        }

        public void Dispose()
        {
            Dispose(true);
        }

        protected virtual void Dispose(bool disposing)
        {
            if (disposing)
            {
                if (db != null)
                {
                    db.Dispose();
                    db = null;
                }
            }
        }

        // IQueryableRoleStore<Role> のメンバーの Roles プロパティ
        public IQueryable<Role> Roles
        {
            get { return db.Roles.Select(r => r); }
        }

        // 同じロール名の二重登録の防止機能は上位レイヤーに組み込まれている
        public async Task<IdentityResult> CreateAsync(Role role, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (role == null) throw new ArgumentNullException(nameof(role));

            db.Add(role);

            int rows = await db.SaveChangesAsync(cancellationToken);

            if (rows > 0)
            {
                return IdentityResult.Success;
            }

            return IdentityResult.Failed(
                new IdentityError { Description = $"{role.Name} 登録失敗" });
        }

        // 同じロール名の二重登録の防止機能は上位レイヤーに組み込まれている
        public async Task<IdentityResult> UpdateAsync(Role role, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (role == null) throw new ArgumentNullException(nameof(role));

            db.Update(role);

            int rows = await db.SaveChangesAsync(cancellationToken);

            if (rows > 0)
            {
                return IdentityResult.Success;
            }

            return IdentityResult.Failed(
                new IdentityError { Description = $"{role.Name} 更新失敗" });
        }

        public async Task<IdentityResult> DeleteAsync(Role role, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (role == null) throw new ArgumentNullException(nameof(role));

            db.Remove(role);

            int rows = await db.SaveChangesAsync(cancellationToken);

            if (rows > 0)
            {
                return IdentityResult.Success;
            }

            return IdentityResult.Failed(
                new IdentityError { Description = $"{role.Name} 削除失敗" });
        }

        public Task<string> GetRoleIdAsync(Role role, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (role == null) throw new ArgumentNullException(nameof(role));

            return Task.FromResult(role.Id.ToString());
        }

        public Task<string> GetRoleNameAsync(Role role,
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (role == null) throw new ArgumentNullException(nameof(role));

            return Task.FromResult(role.Name);
        }

        public Task SetRoleNameAsync(Role role, 
            string roleName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (role == null) throw new ArgumentNullException(nameof(role));
            if (string.IsNullOrEmpty(roleName)) 
                throw new ArgumentException("roleName");

            role.Name = roleName;

            return Task.CompletedTask;
        }

        public Task<string> GetNormalizedRoleNameAsync(Role role, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (role == null) throw new ArgumentNullException(nameof(role));

            return Task.FromResult(role.Name);
        }

        public Task SetNormalizedRoleNameAsync(Role role, 
            string normalizedName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (role == null) throw new ArgumentNullException(nameof(role));
            if (string.IsNullOrEmpty(normalizedName))
                throw new ArgumentException("normalizedName");

            return Task.CompletedTask;
        }

        public async Task<Role> FindByIdAsync(string roleId, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();

            if (int.TryParse(roleId, out int id))
            {
                return await db.Roles.SingleOrDefaultAsync(r => r.Id == id, 
                    cancellationToken);
            }
            else
            {
                return await Task.FromResult<Role>(null);
            }
        }

        public async Task<Role> FindByNameAsync(string normalizedRoleName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (string.IsNullOrEmpty(normalizedRoleName))
                throw new ArgumentException("normalizedRoleName");

            Role role = await db.Roles.SingleOrDefaultAsync(
                r => r.Name.Equals(normalizedRoleName.ToLower()), 
                cancellationToken);

            return role;
        }
    }
}

(2) UserStore クラスへの追加

ロールを使えるようにするには、先に作成した UserStore クラス に機能の追加を行う必要があります。

具体的には、以下のように IUserRoleStore<User> インターフェースを継承し、AddToRoleAsunc, GetRolesAsync, IsInRoleAsync, RemoveFromRoleAsync メソッドを実装します。

using System.Collections.Generic;
using System.Linq;
using System.Reflection.Metadata.Ecma335;
using System.Threading;
using System.Threading.Tasks;

namespace MvcIdCustom.DAL
{
    public class UserStore : IUserStore<User>, 
                             IUserPasswordStore<User>, 
                             IQueryableUserStore<User>, 
                             IUserRoleStore<User>       // 追加
    {

        // ・・・中略・・・

        // Visual Studio のテンプレートで「個別のユーザーアカウント」を
        // 選んで自動生成される AspNetUserRoles テーブルは UserId と
        // RoleId の 2 つのフィールドのみが含まれ、それが連結主キーにな
        // っているので UserId と RoleId の組み合わせが重複することはない。

        // 一方、このプロジェクトで作った UserRole テーブルは Id, UserId,
        // RoleId の 3 つのフィールドを持ち Id が主キーになっている。なの
        // で UserId と RoleId の組み合わせが重複できてしまう。以下のコード
        // では重複設定できないように考えた(つもり)

        public async Task AddToRoleAsync(User user, string roleName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));
            if (string.IsNullOrEmpty(roleName)) 
                throw new ArgumentException("roleName");

            // roleName に該当する Role オブジェクトを取得
            var role = await db.Roles.SingleOrDefaultAsync(
                             role => role.Name.Equals(roleName), 
                             cancellationToken);

            // roleName という名前のロールは存在しないので何もせず return
            if (role == null) return;

            // UserRole テーブルの UserId と RoleId が user.Id と role.Id
            // と一致する UserRole オブジェクトを取得
            var userRole = await db.UserRoles.SingleOrDefaultAsync(
                                 userRole => userRole.UserId.Equals(user.Id) && 
                                             userRole.RoleId.Equals(role.Id),
                                 cancellationToken);

            // user は roleName という名前のロールには既にアサイン済みなので
            // 何もせず return
            if (userRole != null) return;

            userRole = new UserRole
            {
                UserId = user.Id,
                RoleId = role.Id
            };

            db.UserRoles.Add(userRole);
            await db.SaveChangesAsync(cancellationToken);
        }

        public async Task RemoveFromRoleAsync(User user, string roleName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));
            if (string.IsNullOrEmpty(roleName)) 
                throw new ArgumentException("roleName");

            // roleName に該当する Role オブジェクトを取得
            var role = await db.Roles.SingleOrDefaultAsync(
                             role => role.Name.Equals(roleName), 
                             cancellationToken);

            // roleName という名前のロールは存在しないので何もせず return
            if (role == null) return;

            // UserRole テーブルの UserId と RoleId が user.Id と role.Id
            // と一致する UserRole オブジェクトを取得
            var userRole = await db.UserRoles.SingleOrDefaultAsync(
                                userRole => userRole.UserId.Equals(user.Id) &&
                                            userRole.RoleId.Equals(role.Id), 
                                cancellationToken);

            // user は roleName という名前のロールにはアサインされてないので
            // 何もせず return
            if (userRole == null) return;

            db.UserRoles.Remove(userRole);
            await db.SaveChangesAsync(cancellationToken);
        }

        public async Task<IList<string>> GetRolesAsync(User user, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw 
                new ArgumentNullException(nameof(user));

            // UserRole テーブルの UserId フィールドが user.Id と一致する
            // UserRole オブジェクトを取得。Include を使ってナビゲーション
            // プロパティ経由 Role を紐づけているので Select メソッドで
            // ロール名のリストを取得できる
            var roleNames = await db.UserRoles
                .Include(userRole => userRole.Role)
                .Where(userRole => userRole.UserId.Equals(user.Id))
                .Select(userRole => userRole.Role.Name).ToListAsync();

            // 一件も見つからない時は null ではなく中身が空(Count がゼロ)
            // の List<string> オブジェクトを返す
            return roleNames;
        }

        public async Task<bool> IsInRoleAsync(User user, string roleName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (user == null) throw new ArgumentNullException(nameof(user));
            if (string.IsNullOrEmpty(roleName)) 
                throw new ArgumentException("roleName");

            // UserRole テーブルの UserId フィールドが user.Id と一致する
            // UserRole オブジェクトを取得。Include を使ってナビゲーション
            // プロパティ経由 Role を紐づけているので Select メソッドで
            // ロール名のリストを取得できる
            var roleNames = await db.UserRoles
                .Include(userRole => userRole.Role)
                .Where(userRole => userRole.UserId.Equals(user.Id))
                .Select(userRole => userRole.Role.Name).ToListAsync();

            if (roleNames != null && roleNames.Count > 0)
            {
                // 引数 roleName は大文字に変換されて渡されるので ToUpper()
                // がないと true にならない。
                bool isInRole = roleNames.Any(
                                name => name.ToUpper() == roleName);
                return isInRole;
            }

            return false;
        }

        public async Task<IList<User>> GetUsersInRoleAsync(string roleName, 
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            if (string.IsNullOrEmpty(roleName)) 
                throw new ArgumentException("roleName");

            // roleName に該当する Role オブジェクトを取得
            var role = await db.Roles.SingleOrDefaultAsync(
                             role => role.Name.Equals(roleName),
                             cancellationToken);

            if (role != null)
            {
                var users = await db.UserRoles
                    .Include(userRole => userRole.User)
                    .Where(userRole => userRole.RoleId.Equals(role.Id))
                    .Select(userRole => userRole.User).ToListAsync();

                // 一件も見つからない時は null ではなく中身が空の
                // List<User> オブジェクトを返すはず
                return users;
            }

            return new List<User>();
        }
    }
}

(3) ロール管理画面の実装

先の記事「ASP.NET Identity のロール管理 (CORE)」と同等な方法で、ロールの表示・追加・変更・削除およびユーザーへのロールのアサイン・解除を行う機能を実装しました。

ただし、その記事に書いた「(1) ロールサービスの追加」は不要ですので注意してください。

実装した機能を使ってロールを作成し、ユーザーをロールにアサインした結果がこの記事の一番上にある画像です。

(4) /Account/AccessDenied の実装

コントローラーやアクションメソッドに [Authorize(Roles ="アドミ")] 属性を付与すると(ロール名は日本語 OK)、「アドミ」ロールを有してないユーザーがアクセスした場合は /Account/AccessDenied にリダイレクトされます。

/Account/AccessDenied を実装してないと、リダイレクトされた際 HTTP 404 Not Found エラーとなってしまいますので実装は必要です。

リダイレクトはフレームワークが自動的に行いますので、ユーザーにアクセス権限がないことを通知するだけの簡単なページを作れば OK です。


以上で完了です。

ロールによるアクセス制限は有効に働くようになります。ユーザーの持つロールに応じて /Account/Login および /Account/AccessDenied へのリダイレクト、許可されていればページの表示が期待通り動きます。

ユーザーがアサインされているロールの削除、ロールがアサインされているユーザーの削除も、内部で階層更新が行われているのか、問題なく実行でき、結果は期待通り SQL Server の当該テーブル反映されます。

その他、同じロール名の二重登録の防止機能が組み込まれており、例えば登録済みの Xxxxx というロール名を登録しようとすると Role name 'Xxxxx' is already taken. というエラーメッセージが出て登録に失敗します。

上記は上位レイヤーで処理されているようです。上記以外にもパスワードのハッシュや認証クッキーの発行など上位レイヤーの機能は多々あり、それを自力で実装するのは非常に難しいので、カスタム化する範囲は Identity Store レイヤー以下でないと手に負えないと思いました。

Tags: , , , ,

CORE

About this blog

2010年5月にこのブログを立ち上げました。その後 ブログ2 を追加し、ここは ASP.NET 関係のトピックス、ブログ2はそれ以外のトピックスに分けました。

Calendar

<<  October 2020  >>
MoTuWeThFrSaSu
2829301234
567891011
12131415161718
19202122232425
2627282930311
2345678

View posts in large calendar