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

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

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

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

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

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

前提条件

コンカレンシーの競合

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

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

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

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

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

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

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

John は最初に [保存 ] をクリックし、ブラウザーが [インデックス] ページに戻ると変更が表示され、[保存] をクリック します。 この後の動作は、コンカレンシーの競合の処理方法によって決定します。 次のようなオプションがあります。

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

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

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

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

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

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

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

    通常、追跡列のデータ型は rowversion ですrowversion 値は、行が更新されるたびにインクリメントされる連続した数値です。 Updateまたは Delete コマンドでは、 句にWhere追跡列の元の値 (元の行バージョン) が含まれます。 更新中の行が別のユーザーによって変更された場合、列の rowversion 値は元の値とは異なるため Update 、 句のために または Delete ステートメントで更新する行が Where 見つかりません。 Entity Framework は、 または Delete コマンドによってUpdate更新された行がないことを検出すると (つまり、影響を受ける行の数が 0 の場合)、コンカレンシーの競合として解釈されます。

  • および コマンドの 句UpdateDeleteにテーブル内のすべての列の元の値を含むように Entity Framework をWhere構成します。

    最初のオプションと同様に、行が最初に読み取られた後に行内の何かが変更された場合、 Where 句は更新する行を返しません。これは、Entity Framework がコンカレンシーの競合として解釈します。 列が多いデータベース テーブルの場合、この方法では非常に大きな Where 句が発生する可能性があり、大量の状態を維持する必要があります。 先に述べたように、大量のステータスを保守管理することになると、アプリケーションのパフォーマンスに影響が出ます。 そのため、この手法は一般的には推奨されません。このチュートリアルでも利用しません。

    この方法をコンカレンシーに実装する場合は、 ConcurrencyCheck 属性を追加して、コンカレンシーを追跡するエンティティ内のすべての非主キー プロパティをマークする必要があります。 この変更により、Entity Framework はステートメントの UPDATE SQL WHERE 句にすべての列を含めることができます。

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

オプティミスティック コンカレンシーを追加する

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 属性は、データベースに送信される コマンドと Delete コマンドの Update 句にWhereこの列が含まれることを指定します。 以前のバージョンのSQL Serverでは、SQL rowversion によって置き換えられる前に SQL タイムスタンプ データ型が使用されていたため、この属性は Timestamp と呼ばれます。 rowversion の .Net 型はバイト配列です。

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

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

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

Add-Migration RowVersion
Update-Database

部門コントローラーの変更

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

using System.Data.Entity.Infrastructure;

DepartmentController.cs ファイルで、"LastName" の 4 つの出現をすべて "FullName" に変更して、部門管理者のドロップダウン リストに姓だけでなく講師のフル ネームが含まれるようにします。

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

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

[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 実行後 FindAsync と実行前 SaveChanges に部門が削除された場合にのみ null になります)。

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.");

最後に、オブジェクトの値をRowVersionDepartmentデータベースから取得した新しい値に設定します。 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)

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

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

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

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

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

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

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

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

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

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

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

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 送信されます。

メソッドの HttpPostDelete コード (という名前 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 に変更しています。 メソッドに一意のシグネチャを HttpPostDelete 与える メソッド DeleteConfirmed という名前の HttpPost スキャフォールディング されたコード。 ( CLR では、異なるメソッド パラメーターを持つオーバーロードされたメソッドが必要です。シグネチャが一意になったので、MVC 規則に従い、 メソッドと HttpGet delete メソッドに同じ名前をHttpPost使用できます。

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

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>

このコードでは、 見出しとh3見出しの間にエラー メッセージがh2追加されます。

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

フィールドで を LastNameFullNameAdministrator 置き換えます。

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

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

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

[Departments Index]\(部門インデックス\) ページを実行します。 英語部門の [削除 ] ハイパーリンクを右クリックし、[ 新しいタブで開く ] を選択し、最初のタブで英語部門の [編集 ] ハイパーリンクをクリックします。

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

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

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

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

Department_Delete_confirmation_page_with_concurrency_error

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

コードを取得する

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

その他のリソース

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

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

次の手順

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

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

次の記事に進み、データ モデルに継承を実装する方法について説明します。