チュートリアル: 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 の場合)、コンカレンシーの競合として解釈されます。および コマンドの 句
Update
Delete
にテーブル内のすべての列の元の値を含むように Entity Framework をWhere
構成します。最初のオプションと同様に、行が最初に読み取られた後に行内の何かが変更された場合、
Where
句は更新する行を返しません。これは、Entity Framework がコンカレンシーの競合として解釈します。 列が多いデータベース テーブルの場合、この方法では非常に大きなWhere
句が発生する可能性があり、大量の状態を維持する必要があります。 先に述べたように、大量のステータスを保守管理することになると、アプリケーションのパフォーマンスに影響が出ます。 そのため、この手法は一般的には推奨されません。このチュートリアルでも利用しません。この方法をコンカレンシーに実装する場合は、 ConcurrencyCheck 属性を追加して、コンカレンシーを追跡するエンティティ内のすべての非主キー プロパティをマークする必要があります。 この変更により、Entity Framework はステートメントの
UPDATE
SQLWHERE
句にすべての列を含めることができます。
このチュートリアルの残りの部分では、 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");
メソッドの既存のコードを 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
実行後 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.");
最後に、オブジェクトの値をRowVersion
Department
データベースから取得した新しい値に設定します。 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 は、同様の方法で部署を編集している他のユーザーが起こしたコンカレンシーの競合を検出します。 メソッドが HttpGet
Delete
確認ビューを表示すると、ビューには非表示フィールドに元 RowVersion
の値が含まれます。 その後、その値は、ユーザーが削除を HttpPost
Delete
確認したときに呼び出される メソッドで使用できます。 Entity Framework によって SQL DELETE
コマンドが作成されると、元RowVersion
の値をWHERE
持つ 句が含まれます。 コマンドによって影響を受ける行が 0 行になった場合 (つまり、削除の確認ページが表示された後に行が変更されたことを意味します)、コンカレンシー例外がスローされ HttpGet Delete
、エラー メッセージを含む確認ページを再表示するために、エラー フラグを に true
設定して メソッドが呼び出されます。 また、行が別のユーザーによって削除されたため、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
Delete
与える メソッド 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>
フィールドで を LastName
FullName
に Administrator
置き換えます。
<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 値がデータベースの現在の内容で更新されています。
[削除] をもう一度クリックすると、Index ページにリダイレクトされます。Index ページには、部署が削除されていることが表示されます。
コードを取得する
その他のリソース
他の Entity Framework リソースへのリンクは、「 ASP.NET データ アクセス - 推奨リソース」にあります。
さまざまなコンカレンシー シナリオを処理するその他の方法については、MSDN の「 オプティミスティック コンカレンシー パターン 」および 「プロパティ値の操作 」を参照してください。 次のチュートリアルでは、 エンティティと Student
エンティティに対して階層ごとのテーブル継承をInstructor
実装する方法を示します。
次の手順
このチュートリアルでは、次の作業を行いました。
- コンカレンシーの競合について学習した
- オプティミスティック コンカレンシーを追加しました
- 変更された部門コントローラー
- テストされたコンカレンシー処理
- Delete ページを更新した
次の記事に進み、データ モデルに継承を実装する方法について説明します。