WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

プロファイル情報の追加 (.NET 版)

by WebSurfer 26. May 2020 16:23

.NET Framework 版の ASP.NET MVC5 アプリに ASP.NET Identity を利用したユーザー認証を実装して、プロファイル情報としてハンドル名を追加し、ログイン時にページのヘッダ右上にハンドル名(デフォルトはメールアドレス)を表示する方法を書きます。

ハンドル名を表示

先の記事「プロファイル情報の追加 (CORE 版)」の .NET Framework 版です。CORE 版とは基本的なところでの大きな差はありませんが、細かいところでいろいろ違うので備忘録として残しておくことにしました。

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

以下の説明は、Visual Studio 2019 を使って Mvc5ProfileInfo という名前で作成した ASP.NET MVC5 アプリのプロジェクトをベースしています。

プロジェクトの作成

デフォルトでは認証は「認証なし」になっていますが、それを「個別のユーザーアカウント」に変更します。それによって ASP.NET Identity を利用したユーザー認証のためのソースコードが一式自動生成されます。

(2) HandleName プロパティの追加

Models/IdentityModels.cs ファイルに IdentityUser を継承した ApplicationUser クラスが定義されています。それに HandleName プロパティを追加します。以下の画像の赤枠部分がそれです。

HandleName プロパティの追加

そのあと Migration 操作を行うと Entity Framework Code First の機能によりデータベースには HandleName フィールドが作られます。HandleName フィールドはデータアノテーション属性の Required により NULL 不可に、StringLength の引数 256 により nvarchar(256) になります。

(3) Enable-Migrations / Add-Migration

Visual Studio のパッケージマネージャーコンソールで Enable-Migrations コマンドを実行します。成功するとプロジェクトのルート直下に Migrations フォルダが生成され、その中に Configuration.cs という名前のクラスファイルが生成されます。

次に、Add-Migration Initial コマンドを実行します(Initial という名前は任意です)。成功すると Migrations フォルダに xxxxx_Initial.cs という名前のクラスファイルが生成されます。(xxxxx は作成日時)

Initial.cs

その中に、dbo.AspNetUsers テーブルを SQL Server に生成するコードがあります。上の画像の赤枠のコードを見てください。ステップ (2) で HandleName プロパティに付与した属性に従って HandleName というフィールドを NULL 不可、nvarchar(256) で生成するようになっています。

(注: この記事ではプロジェクト作成直後のデータベースには何もない状態から Migration 関係の操作を行っています。データーベースがすでに生成済みで、それに HandleName を追加する場合とは手順が違うので注意してください)

(4) Update-Database

Visual Studio のパッケージマネージャーコンソールで Update-Database コマンドを実行します。成功すると ASP.NET Identity 用のデータベースが Entity Framework Code First の機能によって追加されます。

ASP.NET Identity 用のデータベース

この記事では Visual Studio でプロジェクトを作成したときに自動生成された web.config をそのまま使っています。その中に "DefaultConnection" という名前で LocalDB に接続する接続文字列が定義されており、aspnet-Mvc5ProfileInfo-20200526103410.mdf という名前(数字はプロジェクトの作成日時)の .mdf ファイルを動的に LocalDB にアタッチして使うように設定されています。

Models/IdentityModels.cs ファイルには IdentityDbContext<ApplicationUser> を継承したコンテキストクラス ApplicationDbContext が定義されており、web.config の接続文字列 "DefaultConnection" を使うように設定されています。

Update-Database コマンドによって、web.config の接続文字列で LocalDB に接続し、指定された名前のデータベースを LocalDB に作成し、ステップ (3) で生成されたクラスファイル xxxxx_Initial.cs に従って ASP.NET Identity 用のテーブルを生成します。

その結果が上の画像です。aspnet-Mvc5ProfileInfo-20200526103410 という名前のデータベースが作成され、その中の dbo.AspNetUsers テーブルにステップ (2) で HandleName プロパティを追加したとおり、HandleName フィールドが NULL 不可 nvarchar(256) で生成されています。

(5) Register アクションメソッドの修正

Controllers/AccountController.cs の Register アクションメソッドに以下の画像の赤枠のコードを追加します。登録時にハンドル名を自動的に Email と同じになるようにするものです。

Register アクションメソッドの修正

ステップ (4) の画像の通り dbo.AspNetUsers テーブルの HandleName フィールドは NULL 不可になっています。そのため、新規ユーザーの登録を行う際 HandleName を入力しないと CreateAsync でエラーとなります。なので、初回登録時はとりあえずハンドル名は Email と同じになるようにしました。

登録時にユーザーにハンドル名を決めて入力してもらうようにもできますが、そういう手間を増やすと嫌がられそうですし、ユーザーによってはハンドル名は Email と同じでよいという人もいるでしょうから。

ここまで実装できれば、アプリケーションを実行してユーザー登録が可能になり、登録操作を行うと dbo.AspNetUsers テーブルの HandleName フィールドにはメールアドレスと同じ文字列が設定されているはずです。

(6) ハンドル名の変更機能を実装

ログイン後、ページのヘッダの右上のユーザー名(デフォルトで Email)をクリックすると Manage/Index ページに遷移しますが、そこからさらにリンクをたどって別ページに飛んで、そこでユーザーがハンドル名を任意に変更できるようにします。

ハンドル名の変更は、Manage コントローラーに ChangeHandleName という名前のアクションメソッドを追加しそこで行うようにします。

まず、Manage/Index ページのビュー Views/Manage/Index.cshtml に ChangeHandleName アクションメソッドへのリンクを追加します。

ChangeHandleName へのリンク追加

変更が完了した際に Manage/Index ページに表示するためのメッセージを追加します。Controllers/ManageController.cs ファイルを開いて以下の画像の赤枠のコードを追加します(2 箇所)。

メッセージを追加

メッセージを追加

Models/ManageViewModels.cs に ChangeHandleName アクションメソッドとビューの間でデータをやり取りするための ChangeHandleNameViewModel クラスを追加します。入力できるハンドル名の長さはとりあえず 30 文字に制限してみました。

// HandleName 変更用に追加
public class ChangeHandleNameViewModel
{
    [Required]
    [Display(Name = "旧ハンドル名")]
    public string OldHandleName { get; set; }

    [Required(ErrorMessage = "{0}は必須")]
    [StringLength(30, ErrorMessage = "{0} は {1} 文字以内")]
    [Display(Name = "新ハンドル名")]
    public string NewHandleName { get; set; }
}

Views/Manage/Index.cshtml に ChangeHandleName アクションメソッドのコードを追加します。旧ハンドル名を入力できるようになっていたり、いきなり例外をスローするところがちょっと乱暴かもしれませんが、検証用ということで・・・

// GET: /Manage/ChangeHandleName
public async Task<ActionResult> ChangeHandleName()
{
    var user = await UserManager.FindByIdAsync(User.Identity.GetUserId());
    if (user == null)
    {
        throw new InvalidOperationException(
            "ログイン中のユーザー情報が削除されました。");
    }

    var model = new ChangeHandleNameViewModel();
    model.OldHandleName = user.HandleName;
    return View(model);
}

// POST: /Manage/ChangeHandleName
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> ChangeHandleName(ChangeHandleNameViewModel model)
{
    if (!ModelState.IsValid)
    {
        return View(model);
    }

    var user = await UserManager.FindByIdAsync(User.Identity.GetUserId());
    if (user == null)
    {
        throw new InvalidOperationException(
            "ログイン中のユーザー情報が削除されました。");
    }

    if (model.OldHandleName != user.HandleName)
    {
        throw new InvalidOperationException(
            "DB に既存のハンドル名と旧ハンドル名が一致しません。");
    }

    user.HandleName = model.NewHandleName;
    var result = await UserManager.UpdateAsync(user);
    if (result.Succeeded)
    {
        return RedirectToAction("Index", 
            new { Message = ManageMessageId.ChangeHandleNameSuccess });
    }
    AddErrors(result);
    return View(model);
}

アクションメソッドを追加したら、スキャフォールディングのデザイナで[テンプレート(T)]を Edit とし[モデルクラス(M)]を上に定義した ChangeHandleNameViewModel としてビューを追加します。コードは自動生成されたものがほぼそのまま使えますのでこの記事に書くのは割愛します。

(7) ハンドル名の変更操作

ユーザーがログインするとページのヘッダの右上に Hello xxxxx! と表示され、それが Manage/Index ページへのリンクになります。それをクリックして Manage/Index ページを表示し、その中のリンク[ハンドル名の変更]をクリックするとすると Manage/ChangeHandleName ページに遷移します。

ChangeHandleName ページ

上の[新ハンドル名]テキストボックスに新しいハンドル名を入力し[Save]ボタンをクリックするとデータベースの HandleName フィールドが更新され、その後 Manage/Index ページにリダイレクトされます。

Manage/Index ページにリダイレクト

赤枠で示した部分にステップ (6) で追加したメッセージが表示されているところに注目してください。

(8) ClaimsIdentity へ追加

ページのヘッダの右上に Hello xxxxx! と表示される xxxxx にはデフォルトではメールアドレスが表示されますが(ステップ (7) の画像参照)、それをハンドル名に変更します。結果、この記事の一番上の画像のようになります。

プロファイル情報を Claim として ClaimsIdentity オブジェクトに追加し、拡張メソッドを使って ClaimsIdentity オブジェクトからハンドル名を取得・表示するようにしています。

プロジェクト作成時に自動生成された Models/IdentityModels.cs ファイルの ApplicationUser クラスにそのコードを書く場所がコメントで「ここにカスタム ユーザー クレームを追加します」と示されているので、そこに追加します。以下の画像の上の赤枠部分の通りです。

カスタムユーザークレーム

ClaimsIdentity オブジェクトからハンドル名を取得する拡張メソッドもそこに書いておきます。画像の下の赤枠のコードがそれです。

画像には表示されていませんが、using 句で名前空間 System.Linq と System.Security.Principal を取り込むようにしてください。

拡張メソッドは名前空間をインポートすればスコープの中に取り込むことができます。この記事の一番上の画像のようにレイアウトページの右上に表示する場合は Views/Shared/_LoginPartial.schtml に名前空間 Mvc5ProfileInfo.Models を取り込んで User.Identity.GetHandleName() というコードで取得します。

ClaimsIdentity オブジェクトにハンドル名を追加すると、認証クッキーにハンドル名が含まれるようになります。認証クッキーからハンドル名を取得できれば、毎回データベースにクエリを投げて取得するより負荷は軽い(であろう)というのが ClaimsIdentity を使う理由です。

Tags: , , ,

MVC

ASP.NET Identity で MySQL 利用 (.NET 版)

by WebSurfer 6. May 2020 21:43

.NET Framework 版(Core 版ではありません)の ASP.NET Identity でユーザー情報のストアに MySQL を利用するにはどうするかということを書きます。(Core 3.1 版は別の記事「ASP.NET Identity で MySQL 利用 (CORE 版)」を見てください)

参考にさせていただいたのは「ASP.NET MVC + MySQL で開発環境構築」という記事です(以下、参考記事と書きます)

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

Visual Studio 2019 のテンプレートを利用して .NET Framework 版の ASP.NET MVC5 アプリのプロジェクトを自動生成します。

新しい ASP.NET Web アプルケーションを作成する

認証に「個別のユーザーアカウント」を選ぶと ASP.NET Identity を使ったクッキーベースの認証システムが実装され、Entity Framework Code First の機能を使って LocalDB (SQL Server) にユーザー情報を保持するためのデーターベースを作るコードが生成されます。

それを LocalDB (SQL Server) ではなくて MySQL にユーザー情報を保持するためのデーターベースを作り、それを利用するように変更します。

(2) 参照設定

ソリューションエクスプローラーで参照を右クリックして MySql.Data と MySql.Data.EntityFramework を参照に追加します。下の画像を見てください。

参照設定

先の記事「MySQL をインストールしました(その 3)」で書きましたように MySQL 8.0.19 をインストールしており、その時同時にインストールした Connector/NET 8.0.19 に含まれる MySql.Data, MySql.Data.EntityFramework を使うことにしました。

参考記事では NuGet で MySql.Data.Entity をインストールしたと書いてありましたが、NuGet にあるのは今でもバージョンが古いようです。

(3) 接続文字列の変更

テンプレートで自動生成された web.config の接続文字列は LocalDB を利用するように設定されていますので、それを MySQL に接続するように変更します。

以下のような感じです。例えば、database=identity というようにデータベース名を指定すると、Entity Framework Code First の機能を使って identity という名前のデータベースを新たに生成し、そこに必要なテーブルを生成してくれます。(データベース名は任意で、この記事で identity としたのは単なる例です)

<add name="DefaultConnection"
    connectionString="server=localhost;user id=root;password=*****;
    database=identity"
    providerName="MySql.Data.MySqlClient" />

(4) Enable-Migrations

上記 (3) の設定の後、Enable-Migrations を実行すると以下のエラーになると思います��

"ADO.NET プロバイダーに、不変名が 'MySql.Data.MySqlClient' の Entity Framework プロバイダーがありません。アプリケーションの構成ファイルの "entityFramework" セクションにプロバイダーが登録されていることを確認してください"

それを解決するためには、エラーメッセージに応じて、自動生成された web.config の entityFramework/providers セクションに以下のコードを追加します。

<provider 
  invariantName="MySql.Data.MySqlClient" 
  type="MySql.Data.MySqlClient.MySqlProviderServices, 
        MySql.Data.EntityFramework,Version=8.0.19.0, Culture=neutral, 
        PublicKeyToken=c5687fc88969c44d" />

NuGet で MySql.Data.Entity をインストールした場合は web.config への上記の provider の追加は NuGet が面倒を見てくれるようです。

上のコードを web.config に追加した後 Enable-Migrations を実行すると Migrations フォルダに Configuration.cs ファイルが生成されているはずです。

(5) Add-Migration

Add-Migration Initial を実行します(Initial という名前は任意です)。そうすると、参考記事と同様に以下のエラーになると思います。

"プロバイダー 'MySql.Data.MySqlClient' で MigrationSqlGenerator が見つかりませんでした。対象の移行構成クラスで SetSqlGenerator メソッドを使用して、追加の SQL ジェネレーターを登録してください"

エラーメッセージに従って Configuration.cs に手を加えます。以下のコードのコメントで「追加」としている部分がそれです。

namespace Mvc5AppIdentityMySql.Migrations
{
  using System;
  using System.Data.Entity;
  using System.Data.Entity.Migrations;
  using System.Linq;

  // 追加
  using MySql.Data.EntityFramework;

  internal sealed class Configuration : 
    DbMigrationsConfiguration<Mvc5AppIdentityMySql.Models.ApplicationDbContext>
  {
    public Configuration()
    {
      AutomaticMigrationsEnabled = false;

      // 追加
      SetSqlGenerator("MySql.Data.MySqlClient", 
                      new MySqlMigrationSqlGenerator());
    }

    protected override void Seed(
      Mvc5AppIdentityMySql.Models.ApplicationDbContext context)
    {
    }
  }
}

エラーメッセージの SetSqlGenerator メソッドというのは DbMigrationsConfiguration クラスの SetSqlGenerator(String, MigrationSqlGenerator) メソッド です。これをコンストラクタに追加します。

SetSqlGenerator メソッドの第 1 引数には MySQL のプロバイダ名 "MySql.Data.MySqlClient" を与え、第 2 引数には MySqlMigrationSqlGenerator クラスを初期化して与えます。

MySqlMigrationSqlGenerator クラスが属する名前空間は Connector/NET 8.0 になって変わったようで MySql.Data.EntityFramework となっていますので注意してください。(v6.x の時代は MySql.Data.Entity だったはずです)

参考記事に書いてあったコードは上記と違いますが、参考記事の通りとしても問題なく Add-Migration Initial, Update-Database できることは確認できました。しかし、参考記事のコードがエラーメッセージの「追加の SQL ジェネレーターを登録」をどう解決しているのか分からなかったので、エラーメッセージ通りにコーディングしました。

(6) Update-Database

Update-Database で MySQL に必要なデータベースとテーブルを生成します。成功すると、上のステップ「(5) Add-Migration」で生成された Configuration.cs ファイルの内容に従って ASP.NET Identity 用のテーブルが生成されます。下の画像を見てください。

生成されたデータベース

データーベース / テーブルが生成できましたので、Visual Studio から MVC アプリを動かしてユーザー登録できます。登録したユーザーはデーターベースに反映されています。もちろん登録した ID とパスワードでアプリにログインできるようになります。

参考記事では Update-Database で "Specified key was too long; max key length is 767 bytes" というエラーとなったそうです。なぜ参考記事でその問題が起こったのか、この記事では問題なかったかは以下のようなことだと思います。

テーブル AspNetUserRoles は UserId と RoleId がそれぞれ nvarchar(128) で連結主キーの設定になるのですが、charset が utf8 で一文字 3 バイトですと 128 x 2 x 3 = 768 となって制限の 767 バイトを超えます。それが参考記事の問題のようです。

一方、この記事では MySQL 8.x を使っているので上限が 3,072 バイトになっています。(MySQL 5.7 以降では _large_prefix が ON となっており、767 バイトの制限が 3,072 バイトに拡張されているとのこと)。文字コードは utf8mb4 なので一文字 4 バイトになり、128 x 2 x 4 = 1024 < 3072 なので問題ないということのようです。


旧来のフォーム認証の場合、ASP.NET の認証・承認システムとデ���タベースの間にプロバイダを配置して、プロバイダを入れ替えることによりいろいろなデータベースに対応していました。例えば SQL Server の場合は専用の SqlMembershipProvider が提供されています。

異なるデーターベースを使う場合はプロバイダを入れ替えて対応します。例えば MySQL の場合は Oracle が提供している MySQLMembershipProvider を利用できます。ただし、プロバイダが提供されていなければ自力でコードを書いてプロバイダを作成しなければなりません。

ASP.NET Identity では上に書いたように参照設定の追加、web.config の設定、追加の SQL ジェネレーター登録(これはすべてのケースで必要かは分かりませんが)で可能なようです。個人的には以前より分かりにくくなった感じがしますが・・・

Tags: ,

MVC

JSON シリアライズの際の循環参照エラー

by WebSurfer 8. March 2020 15:51

.NET Framework の ASP.NET MVC アプリや Web API アプリでオブジェクトを JSON 文字列にシリアライズするときの循環参照エラーの問題とその回避方法を書きます。

循環参照エラー

例えば、Entity Framework 6 の Code First 機能を利用して、以下のコード(Microsoft のチュートリアル「新しいデータベースへの Code First」のサンプルコードです)からデーターベースを生成し、Linq to Entities で取得したデータを JSON にシリアライズするケースを考えます。

public class Blog
{
    public int BlogId { get; set; }
    public string Name { get; set; }

    public virtual List<Post> Posts { get; set; }
}

public class Post
{
    public int PostId { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }

    public int BlogId { get; set; }
    public virtual Blog Blog { get; set; }
}

public class BloggingContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
    public DbSet<Post> Posts { get; set; }
}

以下のコードのように、Linq to Enitities を使ってデータベースからデータを取得し、ASP.NET MVC5 の Controller.Json メソッドを使って JSON 文字列にシリアライズしてみます。

using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using Mvc5App.DAL;
using System.Data.Entity;

namespace Mvc5App.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult Json()
        {
            var db = new BloggingContext();
            var list = db.Blogs.Include(b => b.Posts);
            return Json(list, JsonRequestBehavior.AllowGet);
        }
    }
}

上のアクションメソッド Json を呼び出すと、この記事の上の画像の通り、循環参照エラーになります。

(注: ちなみに、上のコードを var list = db.Blogs; に変えるとシリアライズの際に遅延ローディングが起こって "この Command に関連付けられている DataReader が既に開かれています。このコマンドを最初に閉じる必要があります" というエラーになります。var list = db.Blogs.ToList(); とすると、シリアライズの際に遅延ローディングが起こるのは同じですが、その時は DataReader が閉じているので循環参照のところまで進んで、上の画像と同じエラーになります)

ASP.NET Web API でも同様で、return db.Blogs.Include(b => b.Posts); とするとシリアライズする際に循環参照が生じて JSON へのシリアライズに失敗します。

原因は Post クラスにナビゲーションプロパティ Blog が定義されているためで、それをシリアライズしようとすると循環参照が発生し InvalidOperationException がスローされるからだそうです。ググってヒットした記事「ASP.NET Web API で循環参照なモデルの公開を解決する」を参考にさせていただきました。

その記事に書いてありますが、.NET Framework の ASP.NET Web API の JSON シリアライザは Newtonsoft の Json.NET のもので、JsonIgnoreAttribute Class という属性が利用できます。なので、上の Post クラスの Blog プロパティに [JsonIgnore] を付与すればシリアライズの際の循環参照エラーは回避できます。

しかしながら、MVC5 アプリで利用する Controller.Json メソッドは内部で JavaScriptSerializer クラスを使っており、JsonIgnore 属性は効果がありません。

なので、Microsoft のドキュメント「Create Data Transfer Objects (DTOs)」を参考にして以下のコードのようにしてみました。

public class BlogDto
{
    public int BlogId { get; set; }
    public string Name { get; set; }
    public List<PostDto> Posts { get; set; }
}

public class PostDto
{
    public int PostId { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public int BlogId { get; set; }
}

public ActionResult Json()
{
    var db = new BloggingContext();

    var list = db.Blogs.Include(b => b.Posts).
               Select(b => new BlogDto
               {
                   BlogId = b.BlogId,
                   Name = b.Name,
                   Posts = b.Posts.Select(p => new PostDto
                   {
                       PostId = p.PostId,
                       Title = p.Title,
                       Content = p.Content,
                       BlogId = p.BlogId
                   }).ToList()
               });

    return Json(list, JsonRequestBehavior.AllowGet);
}

ナビゲーションプロパティを除いた別の入れ物(上の BlogDto クラス、PostDto クラス)に入れてシリアライズしているわけですから、当然ながら循環参照の問題はなくなって JSON 文字列にシリアライズできます。

循環参照を避ける以外に、必要なデータだけを望む形で JSON 文字列にシリアライズするということも出来るわけですから、積極的に DTO を使うのが正解だと思います。

なお、.NET Core 3.0 以降には JsonIgnoreAttribute クラスが利用できるので、上の .NET Framework のケースとは話は違ってきて、MVC, Web API どちらの場合も循環参照になるプロパティに [JsonIgnore] を付与すればシリアライズできます。

(Microsoft のドキュメント Add Newtonsoft.Json-based JSON format support を見ると ASP.NET Core 2.x 以前は Newtonsoft.Json パッケージが、ASP.NET Core 3.x 以降は System.Text.Json が使われているそうです)

Tags: , , ,

MVC

About this blog

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

Calendar

<<  October 2021  >>
MoTuWeThFrSaSu
27282930123
45678910
11121314151617
18192021222324
25262728293031
1234567

View posts in large calendar