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

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

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

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

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

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

前提条件

コンカレンシーの競合

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

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

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

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

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

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

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

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

  • ユーザーが変更したプロパティを追跡記録し、それに該当する列だけをデータベースで更新できます。 例のシナリオでは、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 がスローする オプティミスティックConcurrencyException 例外を処理します。 このような例外がスローされるタイミングを認識する目的で、Entity Framework は競合を検出できなければなりません。 そのため、データベースとデータ モデルを適宜構成する必要があります。 競合検出を有効にするためのオプションには次のようなものがあります。

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

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

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

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

    コンカレンシーにこのアプローチを実装する場合は、コンカレンシー チェック 属性を追加して、コンカレンシーを追跡するエンティティ内のすべての非主キー プロパティをマークする必要があります。 この変更により、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 属性は、この列がデータベースにWhere送信される句UpdateDeleteコマンドに含まれることを指定します。 以前のバージョンのSQL Serverでは、SQL 行バージョンが置き換えられる前に 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値を持つ行がない) 場合、Entity Framework は例外をDbUpdateConcurrencyExceptionスローし、ブロック内のcatchコードは例外オブジェクトから影響を受けるDepartmentエンティティを取得RowVersionします。

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>

フィールド内でAdministrator置きFullName換えられますLastName

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

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

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

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

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

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

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

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

Department_Delete_confirmation_page_with_concurrency_error

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

コードを入手する

完成したプロジェクトをダウンロードする

その他のリソース

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

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

次の手順

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

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

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