次の方法で共有


チュートリアル: ASP.NET MVC 5 アプリで EF を使用してコンカレンシーを処理する

先のチュートリアルでは、データを更新する方法について学習しました。 このチュートリアルでは、複数のユーザーが同じエンティティを同時に更新するときの競合を処理するためにオプティミスティック同時実行制御を使用する方法について説明します。 Department エンティティを操作する Web ページを変更して、コンカレンシー エラーを処理するようにします。 次の図は Edit ページと Delete ページのものです。コンカレンシーで競合が発生すると、メッセージが表示されます。

現在の値が強調表示されている [部署名]、[予算]、[開始日]、[管理者] の値が表示された [編集] ページを示すスクリーンショット。

削除操作に関するメッセージと [削除] ボタンを含むレコードの [削除] ページを示すスクリーンショット。

このチュートリアルでは、次の作業を行いました。

  • コンカレンシーの競合について学習する
  • オプティミスティック同時実行制御を追加する
  • Department コントローラーを変更する
  • コンカレンシー処理をテストする
  • [削除] ページを更新する

前提条件

コンカレンシーの競合

あるユーザーがあるエンティティのデータを編集目的で表示したとき、別のユーザーが同じエンティティのデータを最初のユーザーの変更がデータベースに書き込まれる前に更新すると、コンカレンシーの競合が発生します。 このような競合の検出を有効にしないと、最後にデータベースを更新したユーザーが他のユーザーの変更を上書きすることになります。 多くのアプリケーションでは、このリスクが許容されています。ユーザーや更新がわずかであれば、あるいは変更が一部上書きされても大きな問題なければ、コンカレンシーのプログラミングにかかるコストが利点よりも重視されることがあります。 その場合、コンカレンシーの競合を処理するようにアプリケーションを構成する必要はありません。

ペシミスティック コンカレンシー (ロック)

コンカレンシーで偶発的にデータが失われる事態をアプリケーションで回避する必要があれば、その方法としてデータベース ロックがあります。 これはペシミスティック コンカレンシーと呼ばれています。 たとえば、データベースから行を読む前に、読み取り専用か更新アクセスでロックを要求します。 更新アクセスで行をロックすると、他のユーザーはその行を読み取り専用または更新アクセスでロックできなくなります。変更中のデータのコピーが与えられるためです。 読み取り専用で行をロックすると、他のユーザーはその行を読み取り専用でロックできますが、更新アクセスではロックできません。

ロックの利用には短所があります。 プログラムが複雑になります。 相当なデータベース管理リソースが必要になります。アプリケーションの利用者数が増えると、パフォーマンス上の問題を引き起こすことがあります。 そのような理由から、一部のデータベース管理システムはペシミスティック コンカレンシーに対応していません。 Entity Framework にも組み込まれておらず、このチュートリアルでは実装方法を説明しません。

オプティミスティック コンカレンシー

ペシミスティック コンカレンシーの代わりとなるのがオプティミスティック コンカレンシーです。 オプティミスティック コンカレンシーでは、コンカレンシーの競合の発生を許し、発生したら適切に対処します。 たとえば、John が Department Edit ページを実行し、English 部署の予算を $350,000.00 から $0.00 に変更するとします。

John が [保存] をクリックする前に Jane が同じページにアクセスし、[開始日] フィールドを 9/1/2007 から 8/8/2013 に変更します。

John が [保存] を先にクリックし、ブラウザーが Index ページに戻ったときに変更を確認し、Jane が [保存] をクリックします。 この後の動作は、コンカレンシーの競合の処理方法によって決定します。 次のようなオプションがあります。

  • ユーザーが変更したプロパティを追跡記録し、それに該当する列だけをデータベースで更新できます。 例のシナリオでは、2 人のユーザーが異なるプロパティを更新したため、データは失われません。 今度誰かが English 部署を閲覧すると、John の変更と Jane の変更が両方とも表示されます。開始日が 8/8/2013 で予算が 0 ドルです。

    この更新方法では、データの損失につながる可能性がある競合の数を減らすことができますが、あるエンティティの同じプロパティに対して行われた変更が競合する場合、データの損失は避けられません。 Entity Framework がこのように動作するかどうかは、更新コードの実装方法に依存します。 これは Web アプリケーションの場合、実用的ではない場合が多いです。あるエンティティの新しい値に加え、元のプロパティ値もすべて追跡記録するため、大量のステータスを更新することになるからです。 大量のステータスを更新するとなると、サーバー リソースが必要になるか、Web ページ自体 (非表示フィールドなど) や Cookie に含める必要があるため、アプリケーションのパフォーマンスに影響が出ます。

  • Jane の変更で John の変更を上書きするように指定できます。 今度誰かが English 部署を閲覧すると、日付は 8/8/2013 ですが、金額が $350,000.00 に戻っています。 これは Client Wins (クライアント側に合わせる) シナリオまたは Last in Wins (最終書き込み者優先) シナリオと呼ばれています。 (クライアントからの値がすべて、データ ストアの値より優先されます。)このセクションの冒頭でお伝えしたように、コンカレンシー処理について何のコーディングもしない場合、これが自動的に行われます。

  • データベースで Jane の変更が更新されないようにできます。 通常、エラー メッセージが表示され、Jane にデータの現在の状態が伝えられます。Jane は望むなら変更を再適用できます。 これは Store Wins (ストア側に合わせる) シナリオと呼ばれています。 (クライアントが送信した値よりデータストアの値が優先されます。)このチュートリアルでは、Store Wins シナリオを実装します。 この手法では、変更が上書きされるとき、それが必ずユーザーに警告されます。

コンカレンシーの競合の検出

Entity Framework がスローする OptimisticConcurrencyException 例外を処理することで競合を解決できます。 このような例外がスローされるタイミングを認識する目的で、Entity Framework は競合を検出できなければなりません。 そのため、データベースとデータ モデルを適宜構成する必要があります。 競合検出を有効にするためのオプションには次のようなものがあります。

  • 行が変更されたタイミングを判断するトラッキング列をデータベース テーブルに追加します。 その後、Entity Framework を構成し、SQL の Update または Delete コマンドの Where 句にその列を追加できます。

    トラッキング列のデータ型は通常、rowversion です。 rowversion 値は連続番号であり、行が更新されるたびに増えます。 Update または Delete コマンドでは、Where 句にトラッキング列の元の値が含まれます (元の行バージョン)。 更新中の行が別のユーザーによって変更された場合、rowversion 列の値は元の値とは異なります。Where 句に起因し、Update または Delete ステートメントは更新する行を見つけられません。 Update または Delete コマンドによって行が更新されていないことを Entity Framework が確認すると (影響を受けた行の数が 0 のとき)、それをコンカレンシーの競合として解釈します。

  • Entity Framework を構成し、テーブルで Update コマンドと Delete コマンドの Where 句にすべての列の元の値を追加します。

    最初のオプションと同様に、行が最初に読み取られてから行に変更があった場合、Where 句は更新する行を返さず、Entity Framework はそれをコンカレンシーの競合として解釈します。 データベース テーブルに列がたくさんある場合、この手法では結果的に大量の Where 句が出現し、大量のステータスを保守管理しなければならなくなります。 先に述べたように、大量のステータスを保守管理することになると、アプリケーションのパフォーマンスに影響が出ます。 そのため、この手法は一般的には推奨されません。このチュートリアルでも利用しません。

    コンカレンシーにこの手法を導入する場合、コンカレンシーを追跡記録するエンティティのすべての主キーではないプロパティに ConcurrencyCheck 属性を追加し、印を付ける必要があります。 これで、Entity Framework では、UPDATE ステートメントの SQL WHERE 句にすべての列を含めることができます。

このチュートリアルの残りの部分では、Department エンティティに rowversion トラッキング プロパティを追加し、コントローラーとビューを作成し、すべてが適切に動作することをテストで確認します。

オプティミスティック同時実行制御を追加する

Models\Department.cs で、RowVersion という名前のトラッキング プロパティを追加します。

public class Department
{
    public int DepartmentID { get; set; }

    [StringLength(50, MinimumLength = 3)]
    public string Name { get; set; }

    [DataType(DataType.Currency)]
    [Column(TypeName = "money")]
    public decimal Budget { get; set; }

    [DataType(DataType.Date)]
    [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
    [Display(Name = "Start Date")]
    public DateTime StartDate { get; set; }

    [Display(Name = "Administrator")]
    public int? InstructorID { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; }

    public virtual Instructor Administrator { get; set; }
    public virtual ICollection<Course> Courses { get; set; }
}

Timestamp 属性によって、データベースに送信された Update コマンドと Delete コマンドの Where 句にこの列が追加されます。 前のバージョンの SQL Server では、SQL rowversion 型に取って代わられる前、SQL Timestamp というデータ型が使用されていたため、この属性は Timestamp と呼ばれています。 rowversion の .Net 型はバイト配列です。

fluent API を使用する場合、次の例に示すように、IsConcurrencyToken メソッドを使用して、トラッキング プロパティを指定できます。

modelBuilder.Entity<Department>()
    .Property(p => p.RowVersion).IsConcurrencyToken();

プロパティを追加し、データベース モデルを変更したので、別の移行を行う必要があります。 パッケージ マネージャー コンソール (PMC) で、次のコマンドを入力します。

Add-Migration RowVersion
Update-Database

Department コントローラーを変更する

Controllers\DepartmentController.cs で、using ステートメントを追加します。

using System.Data.Entity.Infrastructure;

DepartmentsController.cs ファイルに 4 回登場する "LastName" をすべて "FullName" に変更します。それにより、部署の管理者のドロップダウン リストに講師の姓だけでなく、姓名が表示されます。

ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName");

HttpPost Edit メソッドの既存のコードを次のコードに置き換えます。

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Edit(int? id, byte[] rowVersion)
{
    string[] fieldsToBind = new string[] { "Name", "Budget", "StartDate", "InstructorID", "RowVersion" };

    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }

    var departmentToUpdate = await db.Departments.FindAsync(id);
    if (departmentToUpdate == null)
    {
        Department deletedDepartment = new Department();
        TryUpdateModel(deletedDepartment, fieldsToBind);
        ModelState.AddModelError(string.Empty,
            "Unable to save changes. The department was deleted by another user.");
        ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", deletedDepartment.InstructorID);
        return View(deletedDepartment);
    }

    if (TryUpdateModel(departmentToUpdate, fieldsToBind))
    {
        try
        {
            db.Entry(departmentToUpdate).OriginalValues["RowVersion"] = rowVersion;
            await db.SaveChangesAsync();

            return RedirectToAction("Index");
        }
        catch (DbUpdateConcurrencyException ex)
        {
            var entry = ex.Entries.Single();
            var clientValues = (Department)entry.Entity;
            var databaseEntry = entry.GetDatabaseValues();
            if (databaseEntry == null)
            {
                ModelState.AddModelError(string.Empty,
                    "Unable to save changes. The department was deleted by another user.");
            }
            else
            {
                var databaseValues = (Department)databaseEntry.ToObject();

                if (databaseValues.Name != clientValues.Name)
                    ModelState.AddModelError("Name", "Current value: "
                        + databaseValues.Name);
                if (databaseValues.Budget != clientValues.Budget)
                    ModelState.AddModelError("Budget", "Current value: "
                        + String.Format("{0:c}", databaseValues.Budget));
                if (databaseValues.StartDate != clientValues.StartDate)
                    ModelState.AddModelError("StartDate", "Current value: "
                        + String.Format("{0:d}", databaseValues.StartDate));
                if (databaseValues.InstructorID != clientValues.InstructorID)
                    ModelState.AddModelError("InstructorID", "Current value: "
                        + db.Instructors.Find(databaseValues.InstructorID).FullName);
                ModelState.AddModelError(string.Empty, "The record you attempted to edit "
                    + "was modified by another user after you got the original value. The "
                    + "edit operation was canceled and the current values in the database "
                    + "have been displayed. If you still want to edit this record, click "
                    + "the Save button again. Otherwise click the Back to List hyperlink.");
                departmentToUpdate.RowVersion = databaseValues.RowVersion;
            }
        }
        catch (RetryLimitExceededException /* dex */)
        {
            //Log the error (uncomment dex variable name and add a line here to write a log.)
            ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator.");
        }
    }
    ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", departmentToUpdate.InstructorID);
    return View(departmentToUpdate);
}

FindAsync が null を返した場合、部署は別のユーザーが削除しています。 表示されているコードは、送信されたフォーム値を利用して部署エンティティを作成します。編集ページはエラー メッセージと共に再表示できます。 あるいは、部署フィールドを再表示せず、エラー メッセージのみを表示するのであれば、部署エンティティを再作成する必要はないでしょう。

ビューの非表示フィールドに元の RowVersion 値が保管されます。このメソッドは rowVersion パラメーターでその値を受け取ります。 SaveChanges を呼び出す前に、エンティティの OriginalValues コレクションにその元の RowVersion プロパティ値を置く必要があります。 次に、Entity Framework で SQL UPDATE コマンドが作成されるとき、元の RowVersion 値が含まれる行を探す WHERE 句がそのコマンドに含まれます。

UPDATE コマンドの影響を受ける行がない場合 (元 RowVersion の値を持つ行がない場合)、Entity Framework は DbUpdateConcurrencyException 例外をスローし、catch ブロック内のコードは例外オブジェクトから影響を受ける Department エンティティを取得します。

var entry = ex.Entries.Single();

このオブジェクトには、ユーザーによって Entity プロパティに入力された新しい値があり、GetDatabaseValues メソッドを呼び出すことで、データベースから読み取られた値を取得できます。

var clientValues = (Department)entry.Entity;
var databaseEntry = entry.GetDatabaseValues();

その他のユーザーによってデータベースから行が削除された場合、GetDatabaseValues メソッドは null を返します。それ以外の場合は、Department プロパティにアクセスするために、返されたオブジェクトを Department クラスにキャストする必要があります。 (既に削除について確認してあるため、databaseEntry が null になるのは、FindAsync の実行後および SaveChanges の実行前に部署が削除された場合のみです)。

if (databaseEntry == null)
{
    ModelState.AddModelError(string.Empty,
        "Unable to save changes. The department was deleted by another user.");
}
else
{
    var databaseValues = (Department)databaseEntry.ToObject();

次に、このコードにより、編集ページでユーザーが入力したものとデータベース値が異なる列ごとにカスタムのエラー メッセージが追加されます。

if (databaseValues.Name != currentValues.Name)
    ModelState.AddModelError("Name", "Current value: " + databaseValues.Name);
    // ...

より長いエラー メッセージは、何が起こったか、およびそれに対して何をすべきかを説明します。

ModelState.AddModelError(string.Empty, "The record you attempted to edit "
    + "was modified by another user after you got the original value. The"
    + "edit operation was canceled and the current values in the database "
    + "have been displayed. If you still want to edit this record, click "
    + "the Save button again. Otherwise click the Back to List hyperlink.");

最後になりますが、このコードで Department オブジェクトの RowVersion 値がデータベースから取得された新しい値に設定されます。 Edit ページが再表示されるとき、この新しい RowVersion 値が非表示フィールドに保存されます。今度ユーザーが [保存] をクリックすると、Edit ページの再表示後に発生したコンカレンシー エラーのみが取得されます。

Views\Department\Edit.cshtml で、DepartmentID プロパティの非表示フィールドのすぐ後ろに RowVersion プロパティ値を保存する非表示フィールドを追加します。

@model ContosoUniversity.Models.Department

@{
    ViewBag.Title = "Edit";
}

<h2>Edit</h2>

@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()
    
    <div class="form-horizontal">
        <h4>Department</h4>
        <hr />
        @Html.ValidationSummary(true)
        @Html.HiddenFor(model => model.DepartmentID)
        @Html.HiddenFor(model => model.RowVersion)

コンカレンシー処理をテストする

サイトを実行し、[部署] をクリックします。

English 部署の [編集] ハイパーリンクを右クリックし、[新しいタブで開く] を選択し、English 部署の [編集] ハイパーリンクをクリックします。 2 つのタブに同じ情報が表示されます。

最初のブラウザー タブでフィールドを変更し、 [保存] をクリックします。

値が変更された Index ページがブラウザーに表示されます。

2 番目のブラウザー タブでフィールドを変更し、[保存] をクリックします。 エラー メッセージが表示されます。

別のユーザーによって値が変更されたために操作が取り消されたことを説明するメッセージが表示された [編集] ページを示すスクリーンショット。

[保存] をもう一度クリックします。 2 番目のブラウザー タブで入力した値は、最初のブラウザーで変更したデータの元の値と共に保存されます。 Index ページが表示されると、保存した値を確認できます。

[削除] ページを更新する

Delete ページの場合、Entity Framework は、同様の方法で部署を編集している他のユーザーが起こしたコンカレンシーの競合を検出します。 HttpGet Deleteメソッドが確認ビューを表示すると、ビューには元のRowVersion値が非表示フィールドに含まれます。 その後、その値は、ユーザーが削除を確認したときに呼び出される HttpPost Delete メソッドで使用できます。 Entity Framework で SQL DELETE コマンドが作成されるとき、WHERE 句と元の RowVersion 値が含まれます。 コマンドの結果、影響を受けた行が 0 であれば (削除確認ページの表示後に行が変更された)、コンカレンシー例外がスローされます。確定ページをエラー メッセージと共に再表示するために、エラー フラグが true に設定された状態で HttpGet Delete メソッドが呼び出されます。 別のユーザーが行を削除したため、影響を受けた行が 0 になることもあります。その場合、別のエラー メッセージが表示されます。

DepartmentController.csで、HttpGet Delete メソッドを次のコードに置き換えます。

public async Task<ActionResult> Delete(int? id, bool? concurrencyError)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    Department department = await db.Departments.FindAsync(id);
    if (department == null)
    {
        if (concurrencyError.GetValueOrDefault())
        {
            return RedirectToAction("Index");
        }
        return HttpNotFound();
    }

    if (concurrencyError.GetValueOrDefault())
    {
        ViewBag.ConcurrencyErrorMessage = "The record you attempted to delete "
            + "was modified by another user after you got the original values. "
            + "The delete operation was canceled and the current values in the "
            + "database have been displayed. If you still want to delete this "
            + "record, click the Delete button again. Otherwise "
            + "click the Back to List hyperlink.";
    }

    return View(department);
}

このメソッドは、コンカレンシー エラー後にページが再表示されたかどうかを示すオプション パラメーターを受け取ります。 このフラグが true の場合、ViewBag プロパティを使用してエラー メッセージがビューに送信されます。

HttpPost Delete メソッドのコード (DeleteConfirmed という名前) を次のコードに置き換えます。

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Delete(Department department)
{
    try
    {
        db.Entry(department).State = EntityState.Deleted;
        await db.SaveChangesAsync();
        return RedirectToAction("Index");
    }
    catch (DbUpdateConcurrencyException)
    {
        return RedirectToAction("Delete", new { concurrencyError = true, id=department.DepartmentID });
    }
    catch (DataException /* dex */)
    {
        //Log the error (uncomment dex variable name after DataException and add a line here to write a log.
        ModelState.AddModelError(string.Empty, "Unable to delete. Try again, and if the problem persists contact your system administrator.");
        return View(department);
    }
}

置き換えたスキャフォールディングされたコードで、このメソッドがレコード ID を 1 つだけ受け取りました。

public async Task<ActionResult> DeleteConfirmed(int id)

このパラメーターをモデル バインダーによって作成された Department エンティティ インスタンスに変更しています。 それにより、レコード キーに加え、RowVersion プロパティ値にアクセスできます。

public async Task<ActionResult> Delete(Department department)

また、アクション メソッドの名前を DeleteConfirmed から Delete に変更しています。 HttpPost メソッドに一意のシグネチャを与えるためにDeleteConfirmedHttpPost Delete メソッドという名前のスキャフォールディングされたコード。 (CLR では、さまざまなメソッド パラメーターを持つために、オーバーロードされたメソッドを必要とします。)シグネチャが一意になっているので、MVC 規則に準拠し、HttpPostHttpGet 削除メソッドに同じ名前を利用できます。

コンカレンシー エラーがキャッチされた場合、このコードは削除確認ページを再表示し、コンカレンシー エラー メッセージを表示するかどうかを示すフラグを提供します。

Views\Department\Delete.cshtml で、スキャフォールディングされたコードを次のコードで置き換えます。このコードは、DepartmentID プロパティと RowVersion プロパティのエラー メッセージ フィールドと非表示フィールドを追加します。 変更が強調表示されます。

@model ContosoUniversity.Models.Department

@{
    ViewBag.Title = "Delete";
}

<h2>Delete</h2>

<p class="error">@ViewBag.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="dl-horizontal">
        <dt>
            Administrator
        </dt>

        <dd>
            @Html.DisplayFor(model => model.Administrator.FullName)
        </dd>

        <dt>
            @Html.DisplayNameFor(model => model.Name)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.Name)
        </dd>

        <dt>
            @Html.DisplayNameFor(model => model.Budget)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.Budget)
        </dd>

        <dt>
            @Html.DisplayNameFor(model => model.StartDate)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.StartDate)
        </dd>

    </dl>

    @using (Html.BeginForm()) {
        @Html.AntiForgeryToken()
        @Html.HiddenFor(model => model.DepartmentID)
        @Html.HiddenFor(model => model.RowVersion)

        <div class="form-actions no-color">
            <input type="submit" value="Delete" class="btn btn-default" /> |
            @Html.ActionLink("Back to List", "Index")
        </div>
    }
</div>

このコードは、見出しの h2h3 の間にエラー メッセージを追加します。

<p class="error">@ViewBag.ConcurrencyErrorMessage</p>

LastName は、Administrator フィールドの FullName に置き換えられます。

<dt>
  Administrator
</dt>
<dd>
  @Html.DisplayFor(model => model.Administrator.FullName)
</dd>

最後に、Html.BeginForm ステートメントの後に、DepartmentID プロパティと RowVersion プロパティの非表示フィールドを追加します。

@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)

Departments Index ページを実行します。 English 部署の [削除] ハイパーリンクを右クリックし、[新しいタブで開く] を選択します。次に最初のタブで English 部署の [編集] ハイパーリンクをクリックします。

最初のウィンドウで、いずれかの値を変更し、[保存] をクリックします。

[インデックス] ページで変更が確認されます。

2 番目のタブで [削除] をクリックします。

コンカレンシー エラー メッセージが表示されます。Department 値がデータベースの現在の内容で更新されています。

Department_Delete_confirmation_page_with_concurrency_error

[削除] をもう一度クリックすると、Index ページにリダイレクトされます。Index ページには、部署が削除されていることが表示されます。

コードを取得する

完成したプロジェクトのダウンロード

その他のリソース

他の Entity Framework リソースへのリンクは、「ASP.NET データ アクセス - 推奨リソース」にあります。

さまざまなコンカレンシー シナリオを処理するその他の方法については、MSDN の「オプティミスティック コンカレンシー パターン」と「プロパティ値の操作」をご覧ください。 次のチュートリアルでは、Instructor エンティティと Student エンティティの Table-Per-Hierarchy 継承の実装方法について表示します。

次のステップ

このチュートリアルでは、次の作業を行いました。

  • コンカレンシーの競合について学習した
  • オプティミスティック コンカレンシーを追加しました
  • Department コントローラーを変更しました
  • コンカレンシー処理をテストしました
  • Delete ページを更新した

次の記事に進み、データ モデルで継承を実装する方法についてご確認ください。