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 つのチュートリアルでは、関連データを操作しました。 このチュートリアルでは、コンカレンシーを処理する方法について説明します。 Department
エンティティを操作する Web ページを作成します。Department
エンティティを編集および削除するページはコンカレンシー エラーを処理します。 次の図は Index ページと Delete ページのものです。コンカレンシーで競合が発生すると、メッセージが表示されます。
コンカレンシーの競合
あるユーザーがあるエンティティのデータを編集目的で表示したとき、別のユーザーが同じエンティティのデータを最初のユーザーの変更がデータベースに書き込まれる前に更新すると、コンカレンシーの競合が発生します。 このような競合の検出を有効にしないと、最後にデータベースを更新したユーザーが他のユーザーの変更を上書きすることになります。 多くのアプリケーションでは、このリスクが許容されています。ユーザーや更新がわずかであれば、あるいは変更が一部上書きされても大きな問題なければ、コンカレンシーのプログラミングにかかるコストが利点よりも重視されることがあります。 その場合、コンカレンシーの競合を処理するようにアプリケーションを構成する必要はありません。
ペシミスティック コンカレンシー (ロック)
コンカレンシーで偶発的にデータが失われる事態をアプリケーションで回避する必要があれば、その方法としてデータベース ロックがあります。 これはペシミスティック コンカレンシーと呼ばれています。 たとえば、データベースから行を読む前に、読み取り専用か更新アクセスでロックを要求します。 更新アクセスで行をロックすると、他のユーザーはその行を読み取り専用または更新アクセスでロックできなくなります。変更中のデータのコピーが与えられるためです。 読み取り専用で行をロックすると、他のユーザーはその行を読み取り専用でロックできますが、更新アクセスではロックできません。
ロックの利用には短所があります。 プログラムが複雑になります。 データベース管理に大量のリソースが必要であり、アプリケーションのユーザー数が増えるにつれてパフォーマンスの問題が発生する可能性があります (つまり、スケーリングがうまくいきません)。 そのような理由から、一部のデータベース管理システムはペシミスティック コンカレンシーに対応していません。 Entity Framework にも組み込まれておらず、このチュートリアルでは実装方法を説明しません。
オプティミスティック コンカレンシー
ペシミスティック コンカレンシーの代わりとなるのがオプティミスティック コンカレンシーです。 オプティミスティック コンカレンシーでは、コンカレンシーの競合の発生を許し、発生したら適切に対処します。 たとえば、John が Department Edit ページを実行し、English 部署の予算を $350,000.00 から $0.00 に変更するとします。
John が [保存] をクリックする前に Jane が同じページにアクセスし、[開始日] フィールドを 9/1/2007 から 8/8/2013 に変更します。
John が [保存] を先にクリックし、ブラウザーが Index ページに戻ったときに変更を確認し、Jane が [保存] をクリックします。 この後の動作は、コンカレンシーの競合の処理方法によって決定します。 次のようなオプションがあります。
ユーザーが変更したプロパティを追跡記録し、それに該当する列だけをデータベースで更新できます。 例のシナリオでは、2 人のユーザーが異なるプロパティを更新したため、データは失われません。 今度誰かが English 部署を閲覧すると、John の変更と Jane の変更が両方とも表示されます。開始日が 8/8/2013 で予算が 0 ドルです。
この更新方法では、データの損失につながる可能性がある競合の数を減らすことができますが、あるエンティティの同じプロパティに対して行われた変更が競合する場合、データの損失は避けられません。 Entity Framework がこのように動作するかどうかは、更新コードの実装方法に依存します。 これは Web アプリケーションの場合、実用的ではない場合が多いです。あるエンティティの新しい値に加え、元のプロパティ値もすべて追跡記録するため、大量のステータスを更新することになるからです。 大量のステータスを更新するとなると、サーバー リソースが必要になるか、Web ページ自体 (非表示フィールドなど) に含める必要があるため、アプリケーションのパフォーマンスに影響が出ます。
Jane の変更で John の変更を上書きするように指定できます。 今度誰かが English 部署を閲覧すると、日付は 8/8/2013 ですが、金額が $350,000.00 に戻っています。 これは Client Wins (クライアント側に合わせる) シナリオまたは Last in Wins (最終書き込み者優先) シナリオと呼ばれています。 (クライアントからの値がすべて、データ ストアの値より優先されます)このセクションの冒頭でお伝えしたように、コンカレンシー処理について何のコーディングもしない場合、これが自動的に行われます。
データベースで Jane の変更が更新されないようにできます。 通常、エラー メッセージが表示され、Jane にデータの現在の状態が伝えられます。Jane は望むなら変更を再適用できます。 これは Store Wins (ストア側に合わせる) シナリオと呼ばれています。 (クライアントが送信した値よりデータストアの値が優先されます。)このチュートリアルでは、Store Wins シナリオを実装します。 この手法では、変更が上書きされるとき、それが必ずユーザーに警告されます。
コンカレンシーの競合の検出
Entity Framework がスローする OptimisticConcurrencyException 例外を処理することで競合を解決できます。 このような例外がスローされるタイミングを認識する目的で、Entity Framework は競合を検出できなければなりません。 そのため、データベースとデータ モデルを適宜構成する必要があります。 競合検出を有効にするためのオプションには次のようなものがあります。
行が変更されたタイミングを判断するトラッキング列をデータベース テーブルに追加します。 その後、Entity Framework を構成し、SQL の
Update
またはDelete
コマンドのWhere
句にその列を追加できます。トラッキング列のデータ型は通常、rowversion です。 rowversion 値は連続番号であり、行が更新されるたびに増えます。
Update
またはDelete
コマンドでは、Where
句にトラッキング列の元の値が含まれます (元の行バージョン)。 更新中の行が別のユーザーによって変更された場合、rowversion
列の値は元の値とは異なります。Where
句に起因し、Update
またはDelete
ステートメントは更新する行を見つけられません。Update
またはDelete
コマンドによって行が更新されていないことを Entity Framework が確認すると (影響を受けた行の数が 0 のとき)、それをコンカレンシーの競合として解釈します。Entity Framework を構成し、テーブルで
Update
コマンドとDelete
コマンドのWhere
句にすべての列の元の値を追加します。最初のオプションと同様に、行が最初に読み取られてから行に変更があった場合、
Where
句は更新する行を返さず、Entity Framework はそれをコンカレンシーの競合として解釈します。 データベース テーブルに列がたくさんある場合、この手法では結果的に大量のWhere
句が出現し、大量のステータスを保守管理しなければならなくなります。 前述のように、大量のステータスを更新するとなると、サーバー リソースが必要になるか、Web ページ自体に含める必要があるため、アプリケーションのパフォーマンスに影響が出ます。 そのため、この手法は一般的には推奨されません。このチュートリアルでも利用しません。コンカレンシーにこの手法を導入する場合、コンカレンシーを追跡記録するエンティティのすべての主キーではないプロパティに ConcurrencyCheck 属性を追加し、印を付ける必要があります。 これで、Entity Framework では、
UPDATE
ステートメントの SQLWHERE
句にすべての列を含めることができます。
このチュートリアルの残りの部分では、Department
エンティティに 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)]
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 属性によって、データベースに送信された Update
コマンドと Delete
コマンドの Where
句にこの列が追加されます。 前のバージョンの SQL Server では、SQL rowversion 型に取って代わられる前、SQL Timestamp というデータ型が使用されていたため、この属性は Timestamp と呼ばれています。 rowversion
の .NET 型はバイト配列です。 fluent API を使用する場合、次の例に示すように、IsConcurrencyToken メソッドを使用して、トラッキング プロパティを指定できます。
modelBuilder.Entity<Department>()
.Property(p => p.RowVersion).IsConcurrencyToken();
GitHub issue「IsConcurrencyToken を IsRowVersion に置き換える」をご覧ください。
プロパティを追加し、データベース モデルを変更したので、別の移行を行う必要があります。 パッケージ マネージャー コンソール (PMC) で、次のコマンドを入力します。
Add-Migration RowVersion
Update-Database
部門コントローラーを作成する
次の設定を使用して、他のコントローラーと同じ方法で Department
コントローラーを作成し、表示します。
Controllers\DepartmentController.cs で、using
ステートメントを追加します。
using System.Data.Entity.Infrastructure;
このファイル内のすべての場所で "LastName" を "FullName"に変更します (4 回登場します)。それにより、部署の管理者のドロップダウン リストに講師の姓だけでなく、姓名が表示されます。
ViewBag.InstructorID = new SelectList(db.Instructors, "InstructorID", "FullName");
HttpPost
Edit
メソッドの既存のコードを次のコードに置き換えます。
[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.");
最後になりますが、このコードで Department
オブジェクトの RowVersion
値がデータベースから取得された新しい値に設定されます。 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 で、既存のコードを次のコードに置き換えて行リンクを左に移動し、ページタイトルと列見出しを変更して、Administrator 列の 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>
オプティミスティック コンカレンシー処理のテスト
サイトを実行し、[部門] をクリックします。
Kim Abercrombie の [編集] ハイパーリンクを右クリックし、[新しいタブで開く] を選択し、Kim Abercrombie の [編集] ハイパーリンクをクリックします。 2 つのウィンドウに同じ情報が表示されます。
最初のブラウザー ウィンドウでフィールドを変更し、[保存] をクリックします。
値が変更された Index ページがブラウザーに表示されます。
2 番目のブラウザー ウィンドウで任意のフィールドを変更し、[保存] をクリックします。
2 番目のブラウザー ウィンドウで [保存] をクリックします。 エラー メッセージが表示されます。
[保存] をもう一度クリックします。 2 番目のブラウザーで入力した値は、最初のブラウザーで変更したデータの元の値と共に保存されます。 Index ページが表示されると、保存した値を確認できます。
Delete ページの更新
Delete ページの場合、Entity Framework は、同様の方法で部署を編集している他のユーザーが起こしたコンカレンシーの競合を検出します。 HttpGet
Delete
メソッドが確認ビューを表示すると、ビューには元のRowVersion
値が非表示フィールドに含まれます。 その後、その値は、ユーザーが削除を確認したときに呼び出される HttpPost
Delete
メソッドで使用できます。 Entity Framework で SQL DELETE
コマンドが作成されるとき、WHERE
句と元の RowVersion
値が含まれます。 コマンドの結果、影響を受けた行が 0 であれば (削除確認ページの表示後に行が変更された)、コンカレンシー例外がスローされます。確定ページをエラー メッセージと共に再表示するために、エラー フラグが true
に設定された状態で HttpGet Delete
メソッドが呼び出されます。 別のユーザーが行を削除したため、影響を受けた行が 0 になることもあります。その場合、別のエラー メッセージが表示されます。
DepartmentController.csで、HttpGet
Delete
メソッドを次のコードに置き換えます。
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
プロパティを使用してエラー メッセージがビューに送信されます。
HttpPost
Delete
メソッドのコード (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
に変更しています。 HttpPost
メソッドに一意のシグネチャを与えるためにDeleteConfirmed
HttpPost
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>
<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>
}
このコードは、見出しの h2
と h3
の間にエラー メッセージを追加します。
<p class="error">@ViewBag.ConcurrencyErrorMessage</p>
LastName
は、Administrator
フィールドの FullName
に置き換えられます。
<div class="display-label">
@Html.LabelFor(model => model.InstructorID)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.Administrator.FullName)
</div>
最後に、Html.BeginForm
ステートメントの後に、DepartmentID
プロパティと RowVersion
プロパティの非表示フィールドを追加します。
@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)
Departments Index ページを実行します。 English 部署の [削除] ハイパーリンクを右クリックし、[新しいウィンドウで開く] を選択します。次に最初のウィンドウで English 部署の [編集] ハイパーリンクをクリックします。
最初のウィンドウで、いずれかの値を変更し、 [保存] をクリックします。
[インデックス] ページで変更が確認されます。
2 番目のウィンドウで [削除] をクリックします。
コンカレンシー エラー メッセージが表示されます。Department 値がデータベースの現在の内容で更新されています。
[削除] をもう一度クリックすると、Index ページにリダイレクトされます。Index ページには、部署が削除されていることが表示されます。
まとめ
コンカレンシーの競合処理の入門編はこれで終わりです。 さまざまなコンカレンシー シナリオを処理するその他の方法については、Entity Framework チーム ブログの「オプティミスティック コンカレンシー パターン」と「プロパティ値の操作」をご覧ください。 次のチュートリアルでは、Instructor
エンティティと Student
エンティティの Table-Per-Hierarchy 継承の実装方法について表示します。
他の Entity Framework リソースへのリンクは、ASP.NET データ アクセス コンテンツ マップに関するページにあります。