ASP.NET MVC アプリケーションでの Entity Framework でのコンカレンシーの処理 (7/10)

作成者: Tom Dykstra

Contoso University サンプル Web アプリケーションでは、Entity Framework 5 Code First と Visual Studio 2012 を使用して ASP.NET MVC 4 アプリケーションを作成する方法を示します。 チュートリアル シリーズについては、シリーズの最初のチュートリアルをご覧ください。

Note

解決できない問題が発生した場合は、 完了した章をダウンロード して、問題を再現してみてください。 通常、コードを完成したコードと比較することで、問題の解決策を見つけることができます。 一般的なエラーとその解決方法については、「エラーと回避策」を参照してください。

前の 2 つのチュートリアルでは、関連データを操作しました。 このチュートリアルでは、コンカレンシーを処理する方法について説明します。 エンティティを操作する Web ページを Department 作成します。エンティティを編集および削除 Department するページはコンカレンシー エラーを処理します。 次の図は、[インデックス] ページと [削除] ページを示しています。これには、コンカレンシーの競合が発生した場合に表示されるメッセージが含まれます。

スクリーンショットは、編集前の [Contoso University Departments]\(Contoso 大学の部門\) ページを示しています。

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

コンカレンシーの競合

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

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

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

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

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

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

Changing_English_dept_budget_to_100000

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

Changing_English_dept_start_date_to_1999

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

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

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

  • 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 句が発生する可能性があり、大量の状態を維持する必要があります。 前述のように、大量の状態を維持すると、サーバー リソースが必要であるか、Web ページ自体に含める必要があるため、アプリケーションのパフォーマンスに影響する可能性があります。 そのため、この方法は一般的には推奨されません。このチュートリアルで使用する方法ではありません。

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

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

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)]
    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();

GitHub の問題 「IsConcurrencyToken を IsRowVersion に置き換える」を参照してください。

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

Add-Migration RowVersion
Update-Database

部門コントローラーを作成する

次の設定を Department 使用して、他のコントローラーと同じ方法でコントローラーとビューを作成します。

Add_Controller_dialog_box_for_Department_controller

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

using System.Data.Entity.Infrastructure;

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

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

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

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Edit(
   [Bind(Include = "DepartmentID, Name, Budget, StartDate, RowVersion, InstructorID")]
    Department department)
{
   try
   {
      if (ModelState.IsValid)
      {
         db.Entry(department).State = EntityState.Modified;
         db.SaveChanges();
         return RedirectToAction("Index");
      }
   }
   catch (DbUpdateConcurrencyException ex)
   {
      var entry = ex.Entries.Single();
      var clientValues = (Department)entry.Entity;
      var databaseValues = (Department)entry.GetDatabaseValues().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.");
      department.RowVersion = databaseValues.RowVersion;
   }
   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 save changes. Try again, and if the problem persists contact your system administrator.");
   }

   ViewBag.InstructorID = new SelectList(db.Instructors, "InstructorID", "FullName", department.InstructorID);
   return View(department);
}

ビューには、元 RowVersion の値が非表示フィールドに格納されます。 モデル バインダーがインスタンスを department 作成すると、そのオブジェクトには、ユーザーが [編集] ページに入力した、元 RowVersion のプロパティ値と他のプロパティの新しい値が含まれます。 次に、Entity Framework で SQL UPDATE コマンドが作成されると、そのコマンドには、元RowVersionの値を持つ行を検索する句が含まれますWHERE

コマンドの影響を UPDATE 受ける行がない場合 (元 RowVersion の値を持つ行がない場合)、Entity Framework は例外を DbUpdateConcurrencyException スローし、ブロック内の catch コードは例外オブジェクトから影響を受ける Department エンティティを取得します。 このエンティティには、データベースから読み取られた値と、ユーザーが入力した新しい値の両方があります。

var entry = ex.Entries.Single();
var clientValues = (Department)entry.Entity;
var databaseValues = (Department)entry.GetDatabaseValues().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()
    @Html.ValidationSummary(true)

    <fieldset>
        <legend>Department</legend>

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

        <div class="editor-label">
            @Html.LabelFor(model => model.Name)
        </div>

Views\Department\Index.cshtml で、既存のコードを次のコードに置き換えて行リンクを左に移動し、管理者列ではなくLastNameページ タイトルと列見出しを表示FullNameするように変更します。

@model IEnumerable<ContosoUniversity.Models.Department>

@{
    ViewBag.Title = "Departments";
}

<h2>Departments</h2>

<p>
    @Html.ActionLink("Create New", "Create")
</p>
<table>
    <tr>
        <th></th>
        <th>Name</th>
        <th>Budget</th>
        <th>Start Date</th>
        <th>Administrator</th>
    </tr>

@foreach (var item in Model) {
    <tr>
        <td>
            @Html.ActionLink("Edit", "Edit", new { id=item.DepartmentID }) |
            @Html.ActionLink("Details", "Details", new { id=item.DepartmentID }) |
            @Html.ActionLink("Delete", "Delete", new { id=item.DepartmentID })
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.Name)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.Budget)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.StartDate)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.Administrator.FullName)
        </td>
    </tr>
}

</table>

オプティミスティック コンカレンシー処理のテスト

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

[Contoso University Departments]\(Contoso 大学の部門\) ページを示すスクリーンショット。

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

Department_Edit_page_before_changes

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

Department_Edit_page_1_after_change

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

Departments_Index_page_after_first_budget_edit

2 番目のブラウザー ウィンドウで任意のフィールドを変更し、[ 保存] をクリックします。

Department_Edit_page_2_after_change

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

エラー メッセージが表示された [大学] ページを示すスクリーンショット。ユーザーが [保存] をもう一度選択できる状態です。

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

Department_Index_page_with_change_from_second_browser

削除ページの更新

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

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

public ActionResult Delete(int id, bool? concurrencyError)
{
    Department department = db.Departments.Find(id);

    if (concurrencyError.GetValueOrDefault())
    {
        if (department == null)
        {
            ViewBag.ConcurrencyErrorMessage = "The record you attempted to delete "
                + "was deleted by another user after you got the original values. "
                + "Click the Back to List hyperlink.";
        }
        else
        {
            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 ActionResult Delete(Department department)
{
    try
    {
        db.Entry(department).State = EntityState.Deleted;
        db.SaveChanges();
        return RedirectToAction("Index");
    }
    catch (DbUpdateConcurrencyException)
    {
        return RedirectToAction("Delete", new { concurrencyError=true } );
    }
    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 ActionResult DeleteConfirmed(int id)

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

public ActionResult Delete(Department department)

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

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

Views\Department\Delete.cshtml で、スキャフォールディングされたコードを、書式設定を変更し、エラー メッセージ フィールドを追加する次のコードに置き換えます。 変更が強調表示されます。

@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>
<fieldset>
    <legend>Department</legend>

    <div class="display-label">
         @Html.DisplayNameFor(model => model.Name)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.Name)
    </div>

    <div class="display-label">
         @Html.DisplayNameFor(model => model.Budget)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.Budget)
    </div>

    <div class="display-label">
         @Html.DisplayNameFor(model => model.StartDate)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.StartDate)
    </div>

    <div class="display-label">
         @Html.DisplayNameFor(model => model.Administrator.FullName)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.Administrator.FullName)
    </div>
</fieldset>
@using (Html.BeginForm()) {
    @Html.AntiForgeryToken()
   @Html.HiddenFor(model => model.DepartmentID)
    @Html.HiddenFor(model => model.RowVersion)
    <p>
        <input type="submit" value="Delete" /> |
        @Html.ActionLink("Back to List", "Index")
    </p>
}

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

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

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

<div class="display-label">
    @Html.LabelFor(model => model.InstructorID)
</div>
<div class="display-field">
    @Html.DisplayFor(model => model.Administrator.FullName)
</div>

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

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

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

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

Department_Edit_page_after_change_before_delete

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

Departments_Index_page_after_budget_edit_before_delete

2 番目のウィンドウで、[ 削除] をクリックします。

Department_Delete_confirmation_page_before_concurrency_error

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

Department_Delete_confirmation_page_with_concurrency_error

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

まとめ

コンカレンシーの競合処理の入門編はこれで終わりです。 さまざまなコンカレンシー シナリオを処理するその他の方法については、Entity Framework チーム ブログの「 オプティミスティック コンカレンシー パターン 」と 「プロパティ値の操作 」を参照してください。 次のチュートリアルでは、 エンティティと Student エンティティに対して階層ごとのテーブル継承をInstructor実装する方法を示します。

他の Entity Framework リソースへのリンクは、 ASP.NET データ アクセス コンテンツ マップにあります。