ASP.NET MVCアプリケーションの並行処理
これは、MVC 5シリーズを使用したMicrosoftの公式チュートリアルGetting Entity Framework 6 Code Firstを翻訳したものです。これが10番目の記事です:ASP.NET MVCアプリケーションの並行処理
原文:ASP.NET MVC 5アプリケーションでのEntity Framework 6による並行処理
前のチュートリアルでは、データを更新する方法を学びました。このチュートリアルでは、複数のユーザーが同じエンティティを同時に更新した場合の競合を処理する方法を示します。
部門エンティティを処理するようにWebページを変更して、同時エラーを処理できるようにします。以下のスクリーンショットは、インデックスページと削除ページ、およびいくつかの同時実行競合エラーメッセージを示しています。
同時実行の競合
あるユーザーがエンティティのデータを表示して編集し、最初のユーザーの変更がデータベースに書き込まれる前に別のユーザーが同じエンティティのデータを更新すると、同時競合が発生します。この競合検出を有効にしない場合、データベースを最後に更新したユーザーが、他のユーザーがデータベースに加えた変更を上書きします。ほとんどのアプリケーションでは、このリスクは許容できます。ユーザー数または更新数が少ない場合、またはデータ更新のカバレッジの問題がそれほど重要ではない場合、同時競合を実装するコストはそれよりも大きくなる可能性がありますメリット。この場合、同時実行の競合を処理するようにアプリケーションを構成する必要はありません。
ペシミスティック並行性(ロック)
アプリケーションが同時実行による偶発的なデータ損失を防ぐ必要がある場合、これを行う1つの方法は、データベースロックを使用することです。これは悲観的同時実行と呼ばれます。たとえば、データベースから行を読み取る前に、読み取り専用または更新されたアクセスロックを要求します。行への更新アクセスをロックすると、その行が読み取り専用であっても更新されていても、他のユーザーはその行をロックできません。彼らが取得するデータは変更プロセスの単なるコピーだからです。行への読み取り専用アクセスをロックすると、他のユーザーもその行を読み取り専用アクセス用にロックできますが、更新することはできません。
管理ロックにも欠点があります。それはプログラミングをより複雑にします。また、データベース管理リソース(多数)が必要であり、アプリケーションのユーザー数の増加などのパフォーマンスの問題を引き起こす可能性があります。これらの理由により、すべてのデータベース管理システムが悲観的な同時実行性をサポートしているわけではありません。Entity Frameworkにはペシミスティック並行性のサポートが組み込まれており、このチュートリアルではそれを実装する方法については説明しません。
楽観的同時実行性
悲観的同時実行の代替手段は、楽観的同時実行です。楽観的同時実行性とは、実行中の同時実行性の競合が発生し、発生した変更に適切に対応することを意味します。たとえば、通行人Aは、部門編集ページで英語の予算を350,000.00から0.00に変更しました。
通行人が変更を保存する前に、通行人Bもページを開き、開始日フィールドを2014年5月2日に変更しました。
通行人は最初に「保存」をクリックし、彼は自分が行った変更を索引ページで確認し、通行人Bも「保存」をクリックしました。次に何が起こるかは、同時実行の競合をどのように処理するかによって異なります。
ユーザーの変更された属性を追跡し、データベース内の対応する列のみを更新できます。この例では、2人の異なる属性が2人の異なるユーザーによって更新されるため、データは失われません。通行人Cは、ページを閲覧するときにAとBが行った変更を同時に表示します(開始日は2013年8月8日、予算は0元)。
この更新方法では競合を減らすことができますが、同じ属性に変更を加えた場合でもデータが失われる可能性があります。Entity Frameworkを機能させるためにこの方法を使用するかどうかは、更新コードの実装方法によって異なります。これは多くの場合、実際のWebアプリケーションではベストプラクティスではありません。エンティティのすべての元の属性と新しい値を追跡するには、大量の状態を維持する必要があるためです。大量の状態を維持すると、アプリケーションのパフォーマンスに影響を与える可能性があります。これには、より多くのサーバーリソースが必要になるためです。Bの変更をAの変更よりも優先させることができます。Cがページを閲覧すると、開始日が2013年8月8日で、予算が350,000.00元に戻されます。これはclient-all-takesまたはlast-take-allと呼ばれます。(クライアントからの値が最初に保存された値より優先され、すべてのデータが上書きされます)。
Bの変更がデータベースに保存されないようにすることもできます。通常、上書きされるデータの違いを示すエラーメッセージが表示され、ユーザーが必要に応じて変更を再送信できるようにします。これはstorage-take-allと呼ばれます。(保存された値はクライアントから送信された値よりも優先されます)このチュートリアルではこのソリューションを実装して、ユーザーにプロンプトを表示する前に他のユーザーの変更が上書きされないようにします。
同時競合を検出する
Entity Frameworkによって発生したOptimisticConcurrencyException例外を処理することで、競合を解決できます。これらの例外がいつどこで発生したかを知るために、Entity Frameworkは競合を検出できる必要があります。したがって、以下を含むデータベースとデータモデルを適切に構成する必要があります。
データテーブルには、変更を追跡するための列が含まれています。その後、検出のために更新または削除されたときにこの列を含めるようにEntity Frameworkを構成できます。
追跡列のデータ型は通常、行バージョンです。行バージョンの値は、更新されるたびに増分される連続番号です。更新または削除コマンドでは、Whereステートメントに追跡列の元の値が含まれます。別のユーザーが更新中の行を変更すると、行バージョンの値は元のバージョンと一致しなくなります。したがって、更新および削除ステートメントでは、更新する行を見つけることができません。更新または削除時に行が更新されない場合、Entity Frameworkはコマンドを並行競合として認識します。Entity Frameworkを構成して、データテーブルの各列の元の値をupdateコマンドとdeleteコマンドのWhere句に含めます。
最初の方法と同様に、初めて読み取られた後にデータ行が変更された場合、Where句は更新される行を見つけられず、エンティティボックスは同時競合として解釈されます。複数の列を持つデータベーステーブルの場合、この方法ではWhere文が非常に大きくなり、多数の状態を維持する必要があります。前述のように、多数の状態を維持すると、アプリケーションのパフォーマンスに影響を与える可能性があります。したがって、この方法は一般に推奨されておらず、このチュートリアルでは使用されません。
このメソッドを実装して同時実行性を実現する場合は、エンティティーのすべての非主キー属性にConcurrencyCheck機能を追加する必要があります。この変更により、Entity Frameworkはすべてのマークされた列をupdateステートメントのWhere句に含めることができます。
このチュートリアルの残りの部分では、行エンティティを追加して、Departmentエンティティの属性を追跡します。
楽観的同時実行に必要な属性を部門エンティティに追加します
Models \ Department.csで、RowCersionという名前の追跡属性を追加します。
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句に列が含まれることを指定します。以前のバージョンのSQL ServerがSQL Timestampデータ型を使用していたため、この属性はタイムスタンプと呼ばれます。.Netタイプの行バージョンはバイト配列です。
Fluent APIを使用する場合は、次の例のように、IsConcurrencyTokenメソッドを使用して追跡属性を指定できます。
modelBuilder.Entity<Department>()
.Property(p => p.RowVersion).IsConcurrencyToken();
データベースモデルを変更したので、別の移行を行う必要があります。パッケージマネージャコンソールで、次のコマンドを入力します。
Add-Migration RowVersion
Update-Database
部門コントローラーの変更
DepartmentController.csに、usingステートメントを追加します。
using System.Data.Entity.Infrastructure;
ファイル内のすべての「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);
}
ビューは、元のRowVersion値を非表示フィールドに格納します。モデルバインダーがシステムのインスタンスを作成すると、オブジェクトには元のRowVersion属性値と、編集ページで入力されたユーザーなどの他の属性の新しい値が含まれます。次に、Entity Frameworkは更新コマンドを作成します。これにより、クエリのWhere句にRowVersion値が含まれます。
行が更新されない場合(元のRowVersion値と一致する行が見つからない場合)、Entity FrameworkはDbUpdateConcurrencyException例外をスローし、影響を受ける部門エンティティをキャッチコードブロックの例外オブジェクトから取得します。
var entry = ex.Entries.Single();
オブジェクトのEntityプロパティには、ユーザーが入力した新しい値が含まれます。GetDatabaseValuesメソッドを呼び出して、データベースから元の値を読み取ることもできます。
var clientValues = (Department)entry.Entity;
var databaseEntry = entry.GetDatabaseValues();
誰かがデータベースから行を削除した場合、GetDataBaseValueメソッドはnullを返します。それ以外の場合は、返されたオブジェクトをDepartmentクラスにキャストして、Departmentのプロパティにアクセスする必要があります。
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値をデータベースから取得した新しい値に設定します。新しい値は、編集ページが再表示されるときに非表示フィールドに格納されます。次にユーザーが[保存]をクリックしたときに、再表示された編集ページで引き続き同時エラーがキャッチされます。
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つの部門ページを繰り返し開きます。
同じ部門の編集ページを2つのウィンドウで同時に開き、いずれかのページを編集して保存します。
値がデータベースに保存されていることがわかります。
2番目のウィンドウでフィールドを変更して保存します。
同時実行エラーメッセージが表示されます。
もう一度[保存]をクリックすると、2番目のブラウザのデータベース値によって、最初のウィンドウでの保存がデータベースに上書きされます。
削除ページを更新
Entity Frameworkは、ページを削除するために、同様の方法を使用して同時競合を検出します。HttpGetのDeleteメソッドが確認ビューを表示すると、元のRowVersion値がビューの非表示フィールドに含まれます。ユーザーが削除を確認すると、値は渡され、HttpPostのDeleteメソッドで呼び出されるのに十分です。Entity FrameworkがSQL Deleteコマンドを作成すると、元のRowVersion値がWhere句に含まれます。コマンドの実行後に影響を受ける行がない場合は、並行例外がスローされます。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を受け取ります
public async Task<ActionResult> DeleteConfirmed(int id)
このパラメーターを変更し、モデルバインダーを使用して、Departmentエンティティを作成しました。これにより、RowVersionプロパティ値にアクセスできます。
public async Task<ActionResult> Delete(Department department)
同時に、メソッド名をDeleteConfirmedからDeleteに変更しました。HttpPostメソッドに一意の署名を与えるため、HttpPost DeleteメソッドのスキャフォールディングコードはDelete名を使用します。(CLRでは、オーバーロードのために異なるパラメーターを持つメソッドが必要です。シグネチャは一意になりました。MVC規則を維持し、HttpPostメソッドとHttpGetメソッドで同じメソッド名を使用できます。)
同時エラーがキャッチされると、コードは削除確認ページを再表示し、同時エラーメッセージが表示されることを示すフラグを提供します。
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>
<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>
コードは、h2とh3の間にエラーメッセージを追加します。
<p class="error">@ViewBag.ConcurrencyErrorMessage</p>
LastNameをFullNameに置き換えます。
<dt>
Administrator
</dt>
<dd>
@Html.DisplayFor(model => model.Administrator.FullName)
</dd>
最後に、Html.BeginFormステートメントの後に非表示フィールドを追加します。
@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)
アプリケーションを実行し、システムインデックスページを開き、自然科学の削除ハイパーリンクを右クリックして、新しいウィンドウで開くことを選択します。次に、最初のウィンドウで[編集]をクリックして、予算を変更して保存します。
変更がデータベースに保存されました。
2番目のウィンドウで[削除]ボタンをクリックすると、同時エラーメッセージが表示されます。
この時点でもう一度[削除]をクリックすると、エンティティが削除され、インデックスページにリダイレクトされます。
まとめ
このセクションでは、同時競合に対処する方法を紹介しました。同時競合の処理の詳細については、MSDNを参照してください。次のセクションでは、インストラクターと生徒のエンティティテーブルの継承の各レベルを実装する方法を紹介します。
著者情報
Tom Dykstra -Tom Dykstraは、Microsoft Web Platform and Toolsチームのシニアプログラマー兼ライターです。