Contoso 大学の Web アプリでは、EF Core と Visual Studio を使用した Razor Pages Web アプリの作成方法を示しています。 チュートリアルシリーズについては、最初のチュートリアルを参照してください。
自力で解決できない問題に直面した場合は、完成したアプリをダウンロードし、そのコードをチュートリアルに従って作成したコードと比較してください。
このチュートリアルでは、複数のユーザーがエンティティを同時に更新するときの競合の処理方法について説明します。
同時実行の競合
コンカレンシーの競合は、次のような場合に発生します。
- ユーザーがエンティティの Edit ページに移動する。
- 最初のユーザーの変更がデータベースに書き込まれる前に、別のユーザーが同じエンティティを更新した場合。
コンカレンシーの検出が有効になっていない場合は、最後にデータベースを更新したユーザーが、他のユーザーの変更を上書きします。 このリスクが許容可能な場合は、コンカレンシーのプログラミングのコストが利点を上回る可能性があります。
悲観的同時実行制御
コンカレンシーの競合を防ぐ方法の 1 つは、データベースのロックを使用することです。 これはペシミスティック コンカレンシーと呼ばれています。 アプリでは、更新予定のデータベース行を読み取る前に、ロックを要求します。 行が更新アクセス用にロックされた後は、最初のロックが解除されるまで、他のユーザーはその行をロックできなくなります。
ロックの管理には短所があります。 プログラミングが複雑になる可能性があり、ユーザー数が増えるにつれてパフォーマンスの問題が発生する可能性があります。 Entity Framework Core では、悲観的同時実行は組み込みではサポートされていません。
オプティミスティック コンカレンシー
楽観的同時実行制御では、同時実行の競合が発生することを許容し、実際に発生した場合に適切に対応します。 たとえば、Jane が部署編集ページを開き、英語学科の予算を $350,000.00 から $0.00 に変更したとします。
Jane が [保存] をクリックする前に John が同じページにアクセスし、[開始日] フィールドを 9/1/2007 から 9/1/2013 に変更します。
Jane が最初に [保存] をクリックすると変更が反映されます。これは、ブラウザーの Index ページに予算として 0 が表示されるためです。
John が Edit ページの [保存] をクリックします。このとき、予算は $350,000.00 と表示されています。 この後の動作は、コンカレンシーの競合の処理方法によって決定されます。
ユーザーがどのプロパティを変更したかを追跡し、データベース内の対応する列のみを更新します。
このシナリオでは、データは失われません。 2 人のユーザーが別のプロパティを更新しました。 今度誰かが英語部門を参照すると、Jane の変更と John の変更が両方とも表示されます。 この更新方法では、データが失われる原因となる競合の数を減らすことができます。 このアプローチにはいくつかの欠点があります。
- 競合する変更が同じプロパティに加えられた場合、データの損失を回避することはできません。
- Web アプリでは、一般的に実用的ではありません。 フェッチされたすべての値と新しい値を追跡するために、かなりの状態を維持することが必要になります。 大量の状態を保守管理することになると、アプリケーションのパフォーマンスに影響が出ます。
- エンティティでのコンカレンシーの検出と比較して、アプリの複雑さが増す可能性があります。
John の変更で Jane の変更を上書きします。
今度誰かが English 部署を閲覧すると、9/1/2013 の日付と、フェッチされた $350,000.00 の値が表示されます。 このアプローチは Client Wins (クライアント側に合わせる) シナリオまたは Last in Wins (最終書き込み者優先) シナリオと呼ばれています。 クライアントからの値がすべて、データ ストアの値より優先されます。 スキャフォールディング コードではコンカレンシー処理は行われず、'Client Wins' が自動的に適用されます。
John の変更がデータベースに更新されないようにします。 通常、アプリは次のように動作します:
- エラー メッセージを表示します。
- データの現在の状態を表示します。
- ユーザーが変更を再適用できるようになります。
これは Store Wins (ストア側に合わせる) シナリオと呼ばれています。 クライアントが送信した値よりデータストアの値が優先されます。 このチュートリアルでは、Store Wins シナリオが使用されます。 この手法では、変更が上書きされるとき、それが必ずユーザーに警告されます。
EF Core での競合の検出
コンカレンシー トークンとして構成されたプロパティは、オプティミスティック同時実行制御を実装するために使用されます。 SaveChanges または SaveChangesAsync によって更新または削除操作がトリガーされると、データベース内のコンカレンシー トークンの値が EF Core によって読み取られた元の値と比較されます。
- 値が一致した場合、操作は完了できます。
- 値が一致しない場合、EF Core では、別のユーザーが競合する処理を実行したと仮定して、現在のトランザクションを中断し、DbUpdateConcurrencyException をスローします。
現在の処理と競合する処理を別のユーザーまたはプロセスが実行することを、"コンカレンシーの競合" といいます。
リレーショナル データベースでは、EF Core は WHERE および UPDATE ステートメントの DELETE 句でコンカレンシー トークンの値を確認し、コンカレンシーの競合を検出します。
データ モデルは、いつ行が変更されたかを判断するために使用できる追跡列を含めることによって、競合検出を有効にするように構成する必要があります。 EF では、コンカレンシー トークンに対して 2 つのアプローチを提供します。
モデルのプロパティに
[ConcurrencyCheck]または IsConcurrencyToken を適用する。 この方法は推奨されません。 詳細については、EF Core のコンカレンシー トークンに関する記事を参照してください。モデル内のコンカレンシー トークンに TimestampAttribute または IsRowVersion を適用する。 これはこのチュートリアルで使用する方法です。
SQL Server の方法と SQLite 実装の詳細は少し異なります。 相違点を一覧で示した差分ファイルを、このチュートリアルで後ほど示します。 [Visual Studio] タブには、SQL Server のアプローチが表示されます。 [Visual Studio Code] タブには、SQLite などの SQL Server 以外のデータベースの方法が表示されます。
- 行が変更されたタイミングを判断するために使用するトラッキング列をモデルに含めます。
- TimestampAttribute を同時実行プロパティに適用します。
以下の強調表示されているコードを使用して、Models/Department.cs ファイルを更新します。
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ContosoUniversity.Models
{
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; }
public int? InstructorID { get; set; }
[Timestamp]
public byte[] ConcurrencyToken { get; set; }
public Instructor Administrator { get; set; }
public ICollection<Course> Courses { get; set; }
}
}
TimestampAttribute により、列がコンカレンシー トラッキング列として識別されます。 fluent API は、トラッキング プロパティを指定する別の方法です。
modelBuilder.Entity<Department>()
.Property<byte[]>("ConcurrencyToken")
.IsRowVersion();
エンティティ プロパティの [Timestamp] 属性により、ModelBuilder メソッドに次のコードが生成されます。
b.Property<byte[]>("ConcurrencyToken")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
上記のコードでは次の操作が行われます。
- プロパティの型
ConcurrencyTokenをバイト配列に設定します。byte[]は SQL Server に必要な型です。 -
IsConcurrencyToken を呼び出します。
IsConcurrencyTokenは、プロパティをコンカレンシー トークンとして構成します。 更新時に、データベース内のコンカレンシー トークンの値が元の値と比較され、データベースからインスタンスが取得されてから変更されていないことが確認されます。 変更されている場合、DbUpdateConcurrencyException がスローされ、変更は適用されません。 -
ValueGeneratedOnAddOrUpdate を呼び出します。これにより、エンティティの追加時または更新時に自動的に値が生成されるように
ConcurrencyTokenプロパティが構成されます。 -
HasColumnType("rowversion")によって、SQL Server データベースの列の型が rowversion に設定されます。
次のコードは、EF Core 名が更新されたときに、Department によって生成される T-SQL の一部です。
SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
上の強調表示されたコードには、ConcurrencyToken を含む WHERE 句があります。 データベースの ConcurrencyToken が ConcurrencyToken パラメーター @p2 と等しくない場合、どの行も更新されません。
次の強調表示されたコードは、1 つの行のみが更新されたことを検証する T-SQL です。
SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
@@ROWCOUNT では、最後のステートメントの影響を受けた行数を返します。 更新された行がない場合、EF Core では DbUpdateConcurrencyException がスローされます。
移行を追加する
ConcurrencyToken プロパティを追加すると、データ モデルが変更されるため、移行が必要です。
プロジェクトをビルドします。
PMC で次のコマンドを実行します。
Add-Migration RowVersion
Update-Database
上のコマンドでは以下の操作が行われます。
-
Migrations/{time stamp}_RowVersion.cs移行ファイルが作成されます。 -
Migrations/SchoolContextModelSnapshot.csファイルが更新されます。 更新により、BuildModelメソッドに次のコードを追加します。
b.Property<byte[]>("ConcurrencyToken")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
Department ページのスキャフォールド
次の例外を除き、「Scaffold Student pages」の指示に従います。
- Pages/Departments フォルダーを作成します。
-
Departmentをモデル クラスに使用します。 - 新しいコンテキスト クラスを作成するのではなく、既存のコンテキスト クラスを使用します。
ユーティリティ クラスの追加
プロジェクト フォルダーに、以下のコードで Utility クラスを作成してください。
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(byte[] token)
{
return token[7].ToString();
}
}
}
Utility クラスは、コンカレンシー トークンの最後のいくつかの文字を表示するために使用される GetLastChars メソッドを提供します。 次のコードは、SQLite と SQL Server の両方で動作するコードを示しています。
#if SQLiteVersion
using System;
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(Guid token)
{
return token.ToString().Substring(
token.ToString().Length - 3);
}
}
}
#else
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(byte[] token)
{
return token[7].ToString();
}
}
}
#endif
#if SQLiteVersion
プリプロセッサ ディレクティブを使用すると、SQLite と SQL Server のバージョンの違いが分離され、次のことができます。
- 作成者は、両方のバージョンに対して 1 つのコードベースを保持できます。
- SQLite 開発者は、アプリを Azure にデプロイし、SQL Azure を使用します。
プロジェクトをビルドします。
Index ページを更新する
スキャフォールディング ツールにより Index ページに ConcurrencyToken 列が作成されましたが、そのフィールドは運用アプリには表示されないでしょう。 このチュートリアルでは、同時実行の処理がどのように動作するかを示すため、ConcurrencyToken の末尾部分が表示されます。 最後の部分は、それ自体では一意であるとは限りません。
Pages\Departments\Index.cshtml ページを更新:
- Department で Index を置き換えます。
-
ConcurrencyTokenが含まれるコードを、最後の数文字だけを表示するように変更します。 -
FirstMidNameをFullNameに置き換えます。
次のコードは、更新されたページを示しています。
@page
@model ContosoUniversity.Pages.Departments.IndexModel
@{
ViewData["Title"] = "Departments";
}
<h2>Departments</h2>
<p>
<a asp-page="Create">Create New</a>
</p>
<table class="table">
<thead>
<tr>
<th>
@Html.DisplayNameFor(model => model.Department[0].Name)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].Budget)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].StartDate)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].Administrator)
</th>
<th>
Token
</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Department)
{
<tr>
<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>
<td>
@Utility.GetLastChars(item.ConcurrencyToken)
</td>
<td>
<a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
<a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
<a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
</td>
</tr>
}
</tbody>
</table>
編集ページ モデルを更新
次のコードを使用して Pages/Departments/Edit.cshtml.cs を更新します。
using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;
namespace ContosoUniversity.Pages.Departments
{
public class EditModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
public EditModel(ContosoUniversity.Data.SchoolContext context)
{
_context = context;
}
[BindProperty]
public Department Department { get; set; }
// Replace ViewData["InstructorID"]
public SelectList InstructorNameSL { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Department = await _context.Departments
.Include(d => d.Administrator) // eager loading
.AsNoTracking() // tracking not required
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
// Use strongly typed data rather than ViewData.
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FirstMidName");
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
// Fetch current department from DB.
// ConcurrencyToken may have changed.
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Set ConcurrencyToken to value read in OnGetAsync
_context.Entry(departmentToUpdate).Property(
d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;
if (await TryUpdateModelAsync<Department>(
departmentToUpdate,
"Department",
s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await SetDbErrorMessage(dbValues, clientValues, _context);
// Save the current ConcurrencyToken so next postback
// matches unless an new concurrency issue happens.
Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
// Clear the model error for the next postback.
ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
}
}
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FullName", departmentToUpdate.InstructorID);
return Page();
}
private IActionResult HandleDeletedDepartment()
{
// ModelState contains the posted data because of the deletion error
// and overides the Department instance values when displaying Page().
ModelState.AddModelError(string.Empty,
"Unable to save. The department was deleted by another user.");
InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
return Page();
}
private async Task SetDbErrorMessage(Department dbValues,
Department clientValues, SchoolContext context)
{
if (dbValues.Name != clientValues.Name)
{
ModelState.AddModelError("Department.Name",
$"Current value: {dbValues.Name}");
}
if (dbValues.Budget != clientValues.Budget)
{
ModelState.AddModelError("Department.Budget",
$"Current value: {dbValues.Budget:c}");
}
if (dbValues.StartDate != clientValues.StartDate)
{
ModelState.AddModelError("Department.StartDate",
$"Current value: {dbValues.StartDate:d}");
}
if (dbValues.InstructorID != clientValues.InstructorID)
{
Instructor dbInstructor = await _context.Instructors
.FindAsync(dbValues.InstructorID);
ModelState.AddModelError("Department.InstructorID",
$"Current value: {dbInstructor?.FullName}");
}
ModelState.AddModelError(string.Empty,
"The record you attempted to edit "
+ "was modified by another user after you. 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.");
}
}
}
並行性に関する更新
OriginalValue は、ConcurrencyToken メソッドでフェッチされたときのエンティティの OnGetAsync 値で更新されます。
EF Core では、SQL UPDATE 句に元の WHERE 値が含まれる ConcurrencyToken コマンドが生成されます。
UPDATE コマンドの影響を受ける行がない場合は、DbUpdateConcurrencyException 例外がスローされます。 元の ConcurrencyToken の値を持つ行がない場合、UPDATE コマンドによって影響を受ける行はありません。
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
// Fetch current department from DB.
// ConcurrencyToken may have changed.
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Set ConcurrencyToken to value read in OnGetAsync
_context.Entry(departmentToUpdate).Property(
d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;
前述の強調表示されたコードでは、以下のとおりです。
-
Department.ConcurrencyToken内の値は、GetページのEdit要求でエンティティがフェッチされたときの値です。 その値は、編集対象のエンティティを表示する Razor ページの非表示フィールドによって、OnPostメソッドに提供されます。 非表示フィールドの値は、モデル バインダーによってDepartment.ConcurrencyTokenにコピーされます。 -
OriginalValueは、EF Core がWHERE句で使用するものです。 強調表示されたコード行が実行される前は、-
OriginalValueの値は、このメソッドでFirstOrDefaultAsyncが呼び出されたときのデータベース内の値です。 - この値は、Edit ページに表示される値とは異なる場合があります。
-
- 強調表示されたコードにより、EF Core では、表示された
Departmentエンティティの元のConcurrencyToken値が、SQLUPDATEステートメントのWHERE句で使用されるようになります。
次のコードは Department モデルを示します。
Department は次で初期化されます:
- EF クエリによる
OnGetAsyncメソッド。 -
Razor ページで model binding を使用して非表示フィールドにより行う
OnPostAsyncメソッド:
public class EditModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
public EditModel(ContosoUniversity.Data.SchoolContext context)
{
_context = context;
}
[BindProperty]
public Department Department { get; set; }
// Replace ViewData["InstructorID"]
public SelectList InstructorNameSL { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Department = await _context.Departments
.Include(d => d.Administrator) // eager loading
.AsNoTracking() // tracking not required
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
// Use strongly typed data rather than ViewData.
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FirstMidName");
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
// Fetch current department from DB.
// ConcurrencyToken may have changed.
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Set ConcurrencyToken to value read in OnGetAsync
_context.Entry(departmentToUpdate).Property(
d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;
上のコードは、ConcurrencyToken 要求の Department エンティティの HTTP POST 値が、ConcurrencyToken 要求の HTTP GET 値に設定されていることを示しています。
コンカレンシー エラーが発生すると、次の強調表示されたコードにより、クライアントの値 (このメソッドにポストされた値) とデータベースの値が取得されます。
if (await TryUpdateModelAsync<Department>(
departmentToUpdate,
"Department",
s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await SetDbErrorMessage(dbValues, clientValues, _context);
// Save the current ConcurrencyToken so next postback
// matches unless an new concurrency issue happens.
Department.ConcurrencyToken = dbValues.ConcurrencyToken;
// Clear the model error for the next postback.
ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
}
次のコードによって、OnPostAsync にポストされたのとは異なるデータベースの値がある各列に、カスタム エラー メッセージが追加されます。
private async Task SetDbErrorMessage(Department dbValues,
Department clientValues, SchoolContext context)
{
if (dbValues.Name != clientValues.Name)
{
ModelState.AddModelError("Department.Name",
$"Current value: {dbValues.Name}");
}
if (dbValues.Budget != clientValues.Budget)
{
ModelState.AddModelError("Department.Budget",
$"Current value: {dbValues.Budget:c}");
}
if (dbValues.StartDate != clientValues.StartDate)
{
ModelState.AddModelError("Department.StartDate",
$"Current value: {dbValues.StartDate:d}");
}
if (dbValues.InstructorID != clientValues.InstructorID)
{
Instructor dbInstructor = await _context.Instructors
.FindAsync(dbValues.InstructorID);
ModelState.AddModelError("Department.InstructorID",
$"Current value: {dbInstructor?.FullName}");
}
ModelState.AddModelError(string.Empty,
"The record you attempted to edit "
+ "was modified by another user after you. 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.");
}
次の強調表示されたコードによって、データベースから取得された新しい値に ConcurrencyToken の値を設定します。 次にユーザーが [保存] をクリックしたとき、Edit ページが最後に表示されて以降に発生したコンカレンシー エラーのみが検出されます。
if (await TryUpdateModelAsync<Department>(
departmentToUpdate,
"Department",
s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await SetDbErrorMessage(dbValues, clientValues, _context);
// Save the current ConcurrencyToken so next postback
// matches unless an new concurrency issue happens.
Department.ConcurrencyToken = dbValues.ConcurrencyToken;
// Clear the model error for the next postback.
ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
}
ModelState.Remove が以前の ModelState 値を持つため、ConcurrencyToken ステートメントが必要になります。
Razor ページでは、どちらも存在する場合は、フィールドの ModelState 値がモデル プロパティ値より優先されます。
SQL Server と SQLite コードの相違点
SQL Server と SQLite のバージョンの違いを次に示します。
+ using System; // For GUID on SQLite
+ departmentToUpdate.ConcurrencyToken = Guid.NewGuid();
_context.Entry(departmentToUpdate)
.Property(d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;
- Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
+ Department.ConcurrencyToken = dbValues.ConcurrencyToken;
Edit Razor ページを更新する
次のコードを使用して Pages/Departments/Edit.cshtml を更新します。
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
<div class="col-md-4">
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="Department.DepartmentID" />
<input type="hidden" asp-for="Department.ConcurrencyToken" />
<div class="form-group">
<label>Version</label>
@Utility.GetLastChars(Model.Department.ConcurrencyToken)
</div>
<div class="form-group">
<label asp-for="Department.Name" class="control-label"></label>
<input asp-for="Department.Name" class="form-control" />
<span asp-validation-for="Department.Name" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Department.Budget" class="control-label"></label>
<input asp-for="Department.Budget" class="form-control" />
<span asp-validation-for="Department.Budget" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Department.StartDate" class="control-label"></label>
<input asp-for="Department.StartDate" class="form-control" />
<span asp-validation-for="Department.StartDate" class="text-danger">
</span>
</div>
<div class="form-group">
<label class="control-label">Instructor</label>
<select asp-for="Department.InstructorID" class="form-control"
asp-items="@Model.InstructorNameSL"></select>
<span asp-validation-for="Department.InstructorID" class="text-danger">
</span>
</div>
<div class="form-group">
<input type="submit" value="Save" class="btn btn-primary" />
</div>
</form>
</div>
</div>
<div>
<a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}
前述のコード:
-
pageディレクティブを@pageから@page "{id:int}"に更新します。 - 非表示の行バージョンが追加されます。 ポストバックによって値がバインドされるように、
ConcurrencyTokenを追加する必要があります。 - デバッグのために、
ConcurrencyTokenの最後のバイトが表示されます。 -
ViewDataを厳密に型指定されたInstructorNameSLと置換します。
Edit ページでの同時実行競合のテスト
English 部署の 2 つの Edit のブラウザー インスタンスを開きます。
- アプリを実行し、部署を選択します。
- English 部署の [編集] ハイパーリンクを右クリックし、 [新しいタブで開く] を選択します。
- 最初のタブの English 部署の Edit ハイパーリンクをクリックします。
2 つのブラウザー タブに同じ情報が表示されます。
最初のブラウザー タブの名前を変更し、 [保存] をクリックします。
値が変更され、ConcurrencyToken インジケーターが更新された Index ページがブラウザーに表示されます。 更新された ConcurrencyToken インジケーターに注意してください。これは、他方のタブの 2 番目のポストバックに表示されています。
2 番目のブラウザー タブで別のフィールドを変更します。
[保存] をクリックします。 データベースの値と一致しないすべてのフィールドに、エラー メッセージが表示されます。
このブラウザー ウィンドウでは、Name フィールドの変更は意図されていませんでした。 Name フィールドに、現在の値 (言語) をコピーして貼り付けてください。 タブを終了します。クライアント側の検証によって、エラー メッセージが削除されます。
[保存] をもう一度クリックします。 2 番目のブラウザー タブに入力した値が保存されます。 Index ページで、保存した値が表示されます。
Delete ページ モデルを更新する
次のコードを使用して Pages/Departments/Delete.cshtml.cs を更新してください。
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;
namespace ContosoUniversity.Pages.Departments
{
public class DeleteModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
public DeleteModel(ContosoUniversity.Data.SchoolContext context)
{
_context = context;
}
[BindProperty]
public Department Department { get; set; }
public string ConcurrencyErrorMessage { get; set; }
public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
{
Department = await _context.Departments
.Include(d => d.Administrator)
.AsNoTracking()
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
if (concurrencyError.GetValueOrDefault())
{
ConcurrencyErrorMessage = "The record you attempted to delete "
+ "was modified by another user after you selected delete. "
+ "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.";
}
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
try
{
if (await _context.Departments.AnyAsync(
m => m.DepartmentID == id))
{
// Department.ConcurrencyToken value is from when the entity
// was fetched. If it doesn't match the DB, a
// DbUpdateConcurrencyException exception is thrown.
_context.Departments.Remove(Department);
await _context.SaveChangesAsync();
}
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException)
{
return RedirectToPage("./Delete",
new { concurrencyError = true, id = id });
}
}
}
}
フェッチ後にエンティティに変更があった場合、Delete ページによってコンカレンシーの競合が検出されます。
Department.ConcurrencyToken は、エンティティがフェッチされたときの行バージョンです。
EF Core が SQL DELETE コマンドを作成するとき、それには ConcurrencyToken を含む WHERE 句が含まれます。
SQL DELETE コマンドで影響を受ける行がゼロの場合:
-
ConcurrencyTokenコマンドのSQL DELETEがデータベースのConcurrencyTokenと一致しません。 -
DbUpdateConcurrencyException例外がスローされます。 -
OnGetAsyncがconcurrencyErrorと共に呼び出されます。
[削除] Razor ページを更新する
次のコードを使用して Pages/Departments/Delete.cshtml を更新してください。
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel
@{
ViewData["Title"] = "Delete";
}
<h1>Delete</h1>
<p class="text-danger">@Model.ConcurrencyErrorMessage</p>
<h3>Are you sure you want to delete this?</h3>
<div>
<h4>Department</h4>
<hr />
<dl class="row">
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Department.Name)
</dt>
<dd class="col-sm-10">
@Html.DisplayFor(model => model.Department.Name)
</dd>
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Department.Budget)
</dt>
<dd class="col-sm-10">
@Html.DisplayFor(model => model.Department.Budget)
</dd>
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Department.StartDate)
</dt>
<dd class="col-sm-10">
@Html.DisplayFor(model => model.Department.StartDate)
</dd>
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Department.ConcurrencyToken)
</dt>
<dd class="col-sm-10">
@Utility.GetLastChars(Model.Department.ConcurrencyToken)
</dd>
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Department.Administrator)
</dt>
<dd class="col-sm-10">
@Html.DisplayFor(model => model.Department.Administrator.FullName)
</dd>
</dl>
<form method="post">
<input type="hidden" asp-for="Department.DepartmentID" />
<input type="hidden" asp-for="Department.ConcurrencyToken" />
<input type="submit" value="Delete" class="btn btn-danger" /> |
<a asp-page="./Index">Back to List</a>
</form>
</div>
上記のコードは、次の変更を加えます。
-
pageディレクティブを@pageから@page "{id:int}"に更新します。 - エラー メッセージを追加します。
- [管理者] フィールドで FirstMidName が FullName に変更されます。
- 最後のバイトを表示するよう、
ConcurrencyTokenを変更します。 - 非表示の行バージョンを追加します。 ポストバックによって値がバインドされるように、
ConcurrencyTokenを追加する必要があります。
同時実行の競合をテスト
テスト部署を作成します。
テスト部門で Delete のブラウザー インスタンスを 2 つ開きます。
- アプリを実行し、部署を選択します。
- テスト部署の [削除] ハイパーリンクを右クリックし、 [新しいタブで開く] を選択します。
- テスト部署の [編集] ハイパーリンクをクリックします。
2 つのブラウザー タブに同じ情報が表示されます。
最初のブラウザー タブで予算を変更し、 [保存] をクリックします。
値が変更され、ConcurrencyToken インジケーターが更新された Index ページがブラウザーに表示されます。 更新された ConcurrencyToken インジケーターに注意してください。これは、他方のタブの 2 番目のポストバックに表示されています。
2 番目のタブから、テスト部署を削除してください。データベースの現在の値を含むコンカレンシー エラーが表示されます。
[削除] をクリックすると、ConcurrencyToken が更新されていない限り、エンティティが削除されます。
エンタープライズ Web アプリのパターン
信頼性が高く、セキュリティで保護され、パフォーマンスが高く、テスト可能でスケーラブルな ASP.NET Core アプリの作成に関するガイダンスについては、Enterprise Web アプリ パターンを参照してください。 パターンを実装する完全な運用品質のサンプル Web アプリを使用できます。
このチュートリアルでは、複数のユーザーがエンティティをコンカレントに更新するときの競合の処理方法について説明します。
同時実行の競合
コンカレンシーの競合は、次のような場合に発生します。
- ユーザーがエンティティの Edit ページに移動する。
- 最初のユーザーの変更がデータベースに書き込まれる前に、別のユーザーが同じエンティティを更新した場合。
コンカレンシーの検出が有効になっていない場合は、最後にデータベースを更新したユーザーが、他のユーザーの変更を上書きします。 このリスクが許容可能な場合は、コンカレンシーのプログラミングのコストが利点を上回る可能性があります。
悲観的同時実行制御 (ロック)
コンカレンシーの競合を防ぐ方法の 1 つは、データベースのロックを使用することです。 これはペシミスティック コンカレンシーと呼ばれています。 アプリでは、更新予定のデータベース行を読み取る前に、ロックを要求します。 行が更新アクセス用にロックされた後は、最初のロックが解除されるまで、他のユーザーはその行をロックできなくなります。
ロックの管理には短所があります。 プログラミングが複雑になる可能性があり、ユーザー数が増えるにつれてパフォーマンスの問題が発生する可能性があります。 Entity Framework Core にはそのサポートは組み込まれておらず、このチュートリアルでは実装方法を説明しません。
オプティミスティック コンカレンシー
楽観的同時実行制御では、同時実行の競合が発生することを許容し、実際に発生した場合に適切に対応します。 たとえば、Jane が部署編集ページを開き、英語学科の予算を $350,000.00 から $0.00 に変更したとします。
Jane が [保存] をクリックする前に、John が同じページを開き、[開始日] フィールドの値を 9/1/2007 から 9/1/2013 に変更します。
Jane が最初に [保存] をクリックすると変更が反映されます。これは、ブラウザーの Index ページに予算として 0 が表示されるためです。
John が Edit ページの [保存] をクリックします。このとき、予算は $350,000.00 と表示されています。 この後の動作は、コンカレンシーの競合の処理方法によって決定されます。
ユーザーが変更したプロパティを追跡し、データベース内の対応する列だけを更新できます。
このシナリオでは、データは失われません。 2 人のユーザーが別のプロパティを更新しました。 今度誰かが英語部門を参照すると、Jane の変更と John の変更が両方とも表示されます。 この更新方法では、データが失われる原因となる競合の数を減らすことができます。 このアプローチにはいくつかの欠点があります。
- 競合する変更が同じプロパティに加えられた場合、データの損失を回避することはできません。
- Web アプリでは、一般的に実用的ではありません。 フェッチされたすべての値と新しい値を追跡するために、かなりのステータスを維持することが必要になります。 大量のステータスを保守管理することになると、アプリケーションのパフォーマンスに影響が出ます。
- エンティティでのコンカレンシーの検出と比較して、アプリの複雑さが増す可能性があります。
John の変更で Jane の変更を上書きするように指定できます。
今度誰かが English 部署を閲覧すると、9/1/2013 の日付と、フェッチされた $350,000.00 の値が表示されます。 このアプローチは Client Wins (クライアント側に合わせる) シナリオまたは Last in Wins (最終書き込み者優先) シナリオと呼ばれています。 (クライアントからの値がすべて、データ ストアの値より優先されます。)コンカレンシー処理に対してコーディングを行わない場合、Client Wins が自動的に適用されます。
John の変更によってデータベースが更新されないようにできます。 通常、アプリは次のように動作します:
- エラー メッセージを表示します。
- データの現在のステータスが表示されます。
- ユーザーが変更を再適用できるようになります。
これはStore Winsシナリオと呼ばれます。 (クライアントが送信した値よりデータストアの値が優先されます。)このチュートリアルでは、Store Wins シナリオを実装します。 この手法では、変更が上書きされるとき、それが必ずユーザーに警告されます。
EF Core での競合の検出
EF Core は、競合が検出されると DbConcurrencyException 例外をスローします。 競合検出を有効にするには、データ モデルが構成されている必要があります。 競合検出を有効にするためのオプションには次のようなものがあります。
EF Core を構成して、Update コマンドと Delete コマンドの Where 句に concurrency tokens として構成された列の元の値を含めます。
SaveChangesが呼び出されると、ConcurrencyCheckAttribute 属性で注釈付けされたプロパティの元の値が Where 句によって検索されます。 行が最初に読み取られてからいずれかのコンカレンシー トークン プロパティが変更されている場合、Update ステートメントでは更新する行が検索されません。 EF Core では、それがコンカレンシーの競合として解釈されます。 データベース テーブルに列がたくさんある場合、この手法では結果的に非常に大きな Where 句が出現し、大量の状態情報が必要になります。 そのため、この手法は一般的には推奨されません。このチュートリアルでも利用しません。行が変更されたタイミングを判断するトラッキング列をデータベース テーブルに追加します。
SQL Server データベースでは、トラッキング列のデータ型は
rowversionです。rowversion値は連続番号であり、行が更新されるたびに増えます。 Update または Delete コマンドでは、Where 句にトラッキング列の元の値が含まれます (元の行バージョン番号)。 更新対象の行が別のユーザーによって変更されている場合、rowversion列の値は元の値とは異なります。 その場合、Update ステートメントまたは Delete ステートメントでは、Where 句のために対象の行を見つけることができません。 Update または Delete コマンドによって影響を受ける行がない場合、EF Core ではコンカレンシー例外がスローされます。
トラッキング プロパティを追加する
Models/Department.cs で、RowVersion という名前のトラッキング プロパティを追加します。
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ContosoUniversity.Models
{
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; }
public int? InstructorID { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; }
public Instructor Administrator { get; set; }
public ICollection<Course> Courses { get; set; }
}
}
TimestampAttribute 属性により、列がコンカレンシー トラッキング列として識別されます。 fluent API は、トラッキング プロパティを指定する別の方法です。
modelBuilder.Entity<Department>()
.Property<byte[]>("RowVersion")
.IsRowVersion();
SQL Server データベースでは、バイト配列として定義されたエンティティ プロパティの [Timestamp] 属性:
- 列が DELETE および UPDATE の WHERE 句に含まれるようにします。
- データベースの列の型を、rowversion に設定します。
データベースによって、行が更新されるたびに増える、連番の行バージョン番号が生成されます。
Update または Delete コマンドの Where 句には、フェッチされた行バージョンの値が含まれます。 フェッチ後に更新対象の行が変更されていた場合、次のようになります。
- 現在の行バージョンの値は、フェッチされた値と一致しません。
-
UpdateまたはDeleteコマンドでは、Where句がフェッチされた行バージョンの値を検索するため、行が見つかりません。 -
DbUpdateConcurrencyExceptionがスローされます。
次のコードは、Department 名が更新されたときに、EF Core によって生成された T-SQL の一部を示しています。
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
上の強調表示されたコードは、RowVersion を含む WHERE 句を示しています。 データベースの RowVersion が RowVersion パラメーター (@p2) と一致しない場合、行は更新されません。
次の強調表示されたコードは、1 つの行のみが更新されたことを検証する T-SQL です。
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
@@ROWCOUNT では、最後のステートメントの影響を受けた行数を返します。 更新された行がない場合、EF Core は DbUpdateConcurrencyException をスローします。
データベースを更新する
RowVersion プロパティを追加すると、データ モデルが変更されるため、移行が必要です。
プロジェクトをビルドします。
PMC で次のコマンドを実行します。
Add-Migration RowVersion
次のコマンド:
Migrations/{time stamp}_RowVersion.cs移行ファイルが作成されます。Migrations/SchoolContextModelSnapshot.csファイルが更新されます。 更新により、BuildModelメソッドに次の強調表示されたコードが追加されます。modelBuilder.Entity("ContosoUniversity.Models.Department", b => { b.Property<int>("DepartmentID") .ValueGeneratedOnAdd() .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); b.Property<decimal>("Budget") .HasColumnType("money"); b.Property<int?>("InstructorID"); b.Property<string>("Name") .HasMaxLength(50); b.Property<byte[]>("RowVersion") .IsConcurrencyToken() .ValueGeneratedOnAddOrUpdate(); b.Property<DateTime>("StartDate"); b.HasKey("DepartmentID"); b.HasIndex("InstructorID"); b.ToTable("Department"); });
PMC で次のコマンドを実行します。
Update-Database
Department ページのスキャフォールド
次の例外を除き、「Scaffold Student pages」の指示に従います。
Pages/Departments フォルダーを作成します。
Departmentをモデル クラスに使用します。- 新しいコンテキスト クラスを作成するのではなく、既存のコンテキスト クラスを使用します。
プロジェクトをビルドします。
Index ページを更新する
スキャフォールディング ツールにより Index ページに RowVersion 列が作成されましたが、そのフィールドは運用アプリには表示されないでしょう。 このチュートリアルでは、コンカレンシーの処理がどのように動作するのかを示すため、RowVersion の最後のバイトが表示されます。 最後のバイトは、それ自体では一意であるとは限りません。
Pages\Departments\Index.cshtml ページを更新:
- Department で Index を置き換えます。
-
RowVersionが含まれるコードを、バイト配列の最後のバイトだけが表示されるように変更します。 - FirstMidName を FullName で置き換えます。
次のコードは、更新されたページを示しています。
@page
@model ContosoUniversity.Pages.Departments.IndexModel
@{
ViewData["Title"] = "Departments";
}
<h2>Departments</h2>
<p>
<a asp-page="Create">Create New</a>
</p>
<table class="table">
<thead>
<tr>
<th>
@Html.DisplayNameFor(model => model.Department[0].Name)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].Budget)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].StartDate)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].Administrator)
</th>
<th>
RowVersion
</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Department)
{
<tr>
<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>
<td>
@item.RowVersion[7]
</td>
<td>
<a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
<a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
<a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
</td>
</tr>
}
</tbody>
</table>
Edit ページ モデルの更新
次のコードを使用して Pages/Departments/Edit.cshtml.cs を更新します。
using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;
namespace ContosoUniversity.Pages.Departments
{
public class EditModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
public EditModel(ContosoUniversity.Data.SchoolContext context)
{
_context = context;
}
[BindProperty]
public Department Department { get; set; }
// Replace ViewData["InstructorID"]
public SelectList InstructorNameSL { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Department = await _context.Departments
.Include(d => d.Administrator) // eager loading
.AsNoTracking() // tracking not required
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
// Use strongly typed data rather than ViewData.
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FirstMidName");
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
_context.Entry(departmentToUpdate)
.Property("RowVersion").OriginalValue = Department.RowVersion;
if (await TryUpdateModelAsync<Department>(
departmentToUpdate,
"Department",
s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await setDbErrorMessage(dbValues, clientValues, _context);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
}
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FullName", departmentToUpdate.InstructorID);
return Page();
}
private IActionResult HandleDeletedDepartment()
{
var deletedDepartment = new Department();
// ModelState contains the posted data because of the deletion error
// and will overide the Department instance values when displaying Page().
ModelState.AddModelError(string.Empty,
"Unable to save. The department was deleted by another user.");
InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
return Page();
}
private async Task setDbErrorMessage(Department dbValues,
Department clientValues, SchoolContext context)
{
if (dbValues.Name != clientValues.Name)
{
ModelState.AddModelError("Department.Name",
$"Current value: {dbValues.Name}");
}
if (dbValues.Budget != clientValues.Budget)
{
ModelState.AddModelError("Department.Budget",
$"Current value: {dbValues.Budget:c}");
}
if (dbValues.StartDate != clientValues.StartDate)
{
ModelState.AddModelError("Department.StartDate",
$"Current value: {dbValues.StartDate:d}");
}
if (dbValues.InstructorID != clientValues.InstructorID)
{
Instructor dbInstructor = await _context.Instructors
.FindAsync(dbValues.InstructorID);
ModelState.AddModelError("Department.InstructorID",
$"Current value: {dbInstructor?.FullName}");
}
ModelState.AddModelError(string.Empty,
"The record you attempted to edit "
+ "was modified by another user after you. 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.");
}
}
}
OriginalValue は、rowVersion メソッドでフェッチされたときのエンティティの OnGetAsync 値で更新されます。
EF Core は、WHERE 句に元の RowVersion 値を含む SQL の UPDATE コマンドを生成します。 UPDATE コマンドの影響を受ける行がない場合 (元の RowVersion 値が含まれる行がない)、DbUpdateConcurrencyException 例外がスローされます。
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
_context.Entry(departmentToUpdate)
.Property("RowVersion").OriginalValue = Department.RowVersion;
前述の強調表示されたコードでは、以下のとおりです。
-
Department.RowVersionの値は、Edit ページの Get 要求で元々取得されたときにエンティティに含まれていた値です。 その値は、編集対象のエンティティを表示する Razor ページの非表示フィールドによって、OnPostメソッドに提供されます。 非表示フィールドの値は、モデル バインダーによってDepartment.RowVersionにコピーされます。 -
OriginalValueは、EF Core が WHERE 句で使用するものです。 強調表示されたコード行が実行される前のOriginalValueの値は、このメソッドでFirstOrDefaultAsyncが呼び出されたときのデータベース内の値であり、これは Edit ページに表示されたものと異なる場合があります。 - 強調表示されたコードにより、EF Core では SQL UPDATE ステートメントの Where 句で表示された
RowVersionエンティティの元のDepartmentの値が確実に使用されます。
コンカレンシー エラーが発生すると、次の強調表示されたコードにより、クライアントの値 (このメソッドにポストされた値) とデータベースの値が取得されます。
if (await TryUpdateModelAsync<Department>(
departmentToUpdate,
"Department",
s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await setDbErrorMessage(dbValues, clientValues, _context);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
次のコードによって、OnPostAsync にポストされたのとは異なるデータベースの値がある各列に、カスタム エラー メッセージが追加されます。
private async Task setDbErrorMessage(Department dbValues,
Department clientValues, SchoolContext context)
{
if (dbValues.Name != clientValues.Name)
{
ModelState.AddModelError("Department.Name",
$"Current value: {dbValues.Name}");
}
if (dbValues.Budget != clientValues.Budget)
{
ModelState.AddModelError("Department.Budget",
$"Current value: {dbValues.Budget:c}");
}
if (dbValues.StartDate != clientValues.StartDate)
{
ModelState.AddModelError("Department.StartDate",
$"Current value: {dbValues.StartDate:d}");
}
if (dbValues.InstructorID != clientValues.InstructorID)
{
Instructor dbInstructor = await _context.Instructors
.FindAsync(dbValues.InstructorID);
ModelState.AddModelError("Department.InstructorID",
$"Current value: {dbInstructor?.FullName}");
}
ModelState.AddModelError(string.Empty,
"The record you attempted to edit "
+ "was modified by another user after you. 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.");
}
次の強調表示されたコードによって、データベースから取得された新しい値に RowVersion の値を設定します。 次回ユーザーが [保存] をクリックしたときに捕捉されるのは、Edit ページが最後に表示されて以降に発生した同時実行エラーのみです。
if (await TryUpdateModelAsync<Department>(
departmentToUpdate,
"Department",
s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await setDbErrorMessage(dbValues, clientValues, _context);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
ModelState の値が古い RowVersion であるため、ModelState.Remove ステートメントが必要になります。
Razor ページでは、どちらも存在する場合は、フィールドの ModelState 値がモデル プロパティ値より優先されます。
編集ページを変更する
次のコードを使用して Pages/Departments/Edit.cshtml を更新します。
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
<div class="col-md-4">
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="Department.DepartmentID" />
<input type="hidden" asp-for="Department.RowVersion" />
<div class="form-group">
<label>RowVersion</label>
@Model.Department.RowVersion[7]
</div>
<div class="form-group">
<label asp-for="Department.Name" class="control-label"></label>
<input asp-for="Department.Name" class="form-control" />
<span asp-validation-for="Department.Name" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Department.Budget" class="control-label"></label>
<input asp-for="Department.Budget" class="form-control" />
<span asp-validation-for="Department.Budget" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Department.StartDate" class="control-label"></label>
<input asp-for="Department.StartDate" class="form-control" />
<span asp-validation-for="Department.StartDate" class="text-danger">
</span>
</div>
<div class="form-group">
<label class="control-label">Instructor</label>
<select asp-for="Department.InstructorID" class="form-control"
asp-items="@Model.InstructorNameSL"></select>
<span asp-validation-for="Department.InstructorID" class="text-danger">
</span>
</div>
<div class="form-group">
<input type="submit" value="Save" class="btn btn-primary" />
</div>
</form>
</div>
</div>
<div>
<a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}
上記のコードでは次の操作が行われます。
-
pageディレクティブを@pageから@page "{id:int}"に更新します。 - 非表示の行バージョンを追加します。 ポストバックによって値がバインドされるように、
RowVersionを追加する必要があります。 - デバッグのために、
RowVersionの最後のバイトが表示されます。 -
ViewDataを厳密に型指定されたInstructorNameSLと置換します。
Edit ページでの同時実行競合のテスト
English 部署の 2 つの Edit のブラウザー インスタンスを開きます。
- アプリを起動し、部署を選択します。
- English 部門の [編集] ハイパーリンクを右クリックし、 [新しいタブで開く] を選択します。
- 最初のタブの English 部署の Edit ハイパーリンクをクリックします。
2 つのブラウザー タブに同じ情報が表示されます。
最初のブラウザー タブの名前を変更し、 [保存] をクリックします。
Index ページが、値が変更され、rowVersion インジケーターが更新され、ブラウザーに表示されます。 更新された rowVersion インジケーターに注意してください。これは、別のタブの 2 番目のポストバックに表示されています。
2 番目のブラウザー タブで別のフィールドを変更します。
[保存] をクリックします。 データベースの値と一致しないすべてのフィールドに、エラー メッセージが表示されます。
このブラウザー ウィンドウでは、Name フィールドの変更は意図されていませんでした。 Name フィールドに、現在の値 (言語) をコピーして貼り付けてください。 タブを終了します。クライアント側の検証によって、エラー メッセージが削除されます。
[保存] をもう一度クリックします。 2 番目のブラウザー タブに入力した値が保存されます。 Index ページで、保存した値が表示されます。
Delete ページ モデルを更新する
次のコードで Pages/Departments/Delete.cshtml.cs を更新します。
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;
namespace ContosoUniversity.Pages.Departments
{
public class DeleteModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
public DeleteModel(ContosoUniversity.Data.SchoolContext context)
{
_context = context;
}
[BindProperty]
public Department Department { get; set; }
public string ConcurrencyErrorMessage { get; set; }
public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
{
Department = await _context.Departments
.Include(d => d.Administrator)
.AsNoTracking()
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
if (concurrencyError.GetValueOrDefault())
{
ConcurrencyErrorMessage = "The record you attempted to delete "
+ "was modified by another user after you selected delete. "
+ "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.";
}
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
try
{
if (await _context.Departments.AnyAsync(
m => m.DepartmentID == id))
{
// Department.rowVersion value is from when the entity
// was fetched. If it doesn't match the DB, a
// DbUpdateConcurrencyException exception is thrown.
_context.Departments.Remove(Department);
await _context.SaveChangesAsync();
}
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException)
{
return RedirectToPage("./Delete",
new { concurrencyError = true, id = id });
}
}
}
}
フェッチ後にエンティティに変更があった場合、Delete ページによってコンカレンシーの競合が検出されます。
Department.RowVersion は、エンティティがフェッチされたときの行バージョンです。
EF Core が SQL DELETE コマンドを作成するとき、WHERE 句に RowVersion が含まれます。 SQL DELETE コマンドで影響を受ける行がゼロの場合、次が発生します。
- SQL DELETE コマンドの
RowVersionがデータベースのRowVersionと一致しません。 - DbUpdateConcurrencyException 例外がスローされます。
-
OnGetAsyncがconcurrencyErrorと共に呼び出されます。
[削除] ページを更新する
次のコードを使用して Pages/Departments/Delete.cshtml を更新してください。
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel
@{
ViewData["Title"] = "Delete";
}
<h2>Delete</h2>
<p class="text-danger">@Model.ConcurrencyErrorMessage</p>
<h3>Are you sure you want to delete this?</h3>
<div>
<h4>Department</h4>
<hr />
<dl class="dl-horizontal">
<dt>
@Html.DisplayNameFor(model => model.Department.Name)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Name)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.Budget)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Budget)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.StartDate)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.StartDate)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.RowVersion)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.RowVersion[7])
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.Administrator)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Administrator.FullName)
</dd>
</dl>
<form method="post">
<input type="hidden" asp-for="Department.DepartmentID" />
<input type="hidden" asp-for="Department.RowVersion" />
<div class="form-actions no-color">
<input type="submit" value="Delete" class="btn btn-danger" /> |
<a asp-page="./Index">Back to List</a>
</div>
</form>
</div>
上記のコードは、次の変更を加えます。
-
pageディレクティブを@pageから@page "{id:int}"に更新します。 - エラー メッセージを追加します。
- [管理者] フィールドで FirstMidName が FullName に変更されます。
- 最後のバイトを表示するよう、
RowVersionを変更します。 - 非表示の行バージョンを追加します。 ポストバックによって値がバインドされるように、
RowVersionを追加する必要があります。
コンカレンシー競合のテスト
テスト部署を作成します。
テスト部門で Delete のブラウザー インスタンスを 2 つ開きます。
- アプリを実行し、部署を選択します。
- テスト部署の [削除] ハイパーリンクを右クリックし、 [新しいタブで開く] を選択します。
- テスト部署の [編集] ハイパーリンクをクリックします。
2 つのブラウザー タブに同じ情報が表示されます。
最初のブラウザー タブで予算を変更し、 [保存] をクリックします。
Index ページが、値が変更され、rowVersion インジケーターが更新され、ブラウザーに表示されます。 更新された rowVersion インジケーターに注意してください。これは、別のタブの 2 番目のポストバックに表示されています。
2 番目のタブから、テスト部署を削除してください。データベースの現在の値を含むコンカレンシー エラーが表示されます。
[削除] をクリックすると、RowVersion が更新されていない限り、エンティティが削除されます。
信頼性が高く、セキュリティで保護され、パフォーマンスが高く、テスト可能でスケーラブルな ASP.NET Core アプリの作成に関するガイダンスについては、Enterprise Web アプリ パターンを参照してください。 パターンを実装する完全な運用品質のサンプル Web アプリを使用できます。
このチュートリアルでは、複数のユーザーがエンティティをコンカレントに更新するときの競合の処理方法について説明します。 解決できない問題が発生した場合は、完成したアプリをダウンロードまたは表示してください。ダウンロードの方法はこちらをご覧ください。
同時実行の競合
コンカレンシーの競合は、次のような場合に発生します。
- ユーザーがエンティティの Edit ページに移動する。
- 最初のユーザーの変更がデータベースに書き込まれる前に、別のユーザーが同じエンティティを更新します。
コンカレンシーの検出が無効のとき、コンカレント更新が発生すると、次のようになります。
- 最後の更新が有効になります。 つまり、最後の更新時の値がデータベースに保存されます。
- 現在の更新のうち最初のものが失われます。
オプティミスティック コンカレンシー
楽観的同時実行制御では、同時実行の競合が発生することを許容し、実際に発生した場合に適切に対応します。 たとえば、Jane が部署編集ページを開き、英語学科の予算を $350,000.00 から $0.00 に変更したとします。
Jane が [保存] をクリックする前に、John が同じページを開き、[開始日] フィールドの値を 9/1/2007 から 9/1/2013 に変更します。
Janeはまず[保存]をクリックし、ブラウザーに Index ページが表示されると、自分の変更を確認できます。
John が Edit ページの [保存] をクリックします。このとき、予算は $350,000.00 と表示されています。 この後の動作は、コンカレンシーの競合の処理方法によって決定します。
オプティミスティック コンカレンシーには、次のオプションがあります。
ユーザーが変更したプロパティを追跡記録し、それに該当する列だけをデータベースで更新します。
このシナリオでは、データは失われません。 2 人のユーザーが別のプロパティを更新しました。 今度誰かが英語部門を参照すると、Jane の変更と John の変更が両方とも表示されます。 この更新方法では、データが失われる原因となる競合の数を減らすことができます。 この方法の特徴は次のとおりです。
- 競合する変更が同じプロパティに加えられた場合、データの損失を回避することはできません。
- Web アプリでは、一般的に実用的ではありません。 フェッチされたすべての値と新しい値を追跡するために、かなりのステータスを維持することが必要になります。 大量のステータスを保守管理することになると、アプリケーションのパフォーマンスに影響が出ます。
- エンティティでのコンカレンシーの検出と比較して、アプリは複雑になります。
John の変更で Jane の変更を上書きするように指定できます。
今度誰かが English 部署を閲覧すると、9/1/2013 の日付と、フェッチされた $350,000.00 の値が表示されます。 このアプローチは Client Wins (クライアント側に合わせる) シナリオまたは Last in Wins (最終書き込み者優先) シナリオと呼ばれています。 (クライアントからの値がすべて、データ ストアの値より優先されます。)コンカレンシー処理に対してコーディングを行わない場合、Client Wins が自動的に適用されます。
データベースで John の変更が更新されないようにできます。 通常、アプリの動作は次のようになります。
- エラー メッセージを表示します。
- データの現在のステータスが表示されます。
- ユーザーが変更を再適用できるようになります。
これはStore Winsシナリオと呼ばれます。 (クライアントが送信した値よりデータストアの値が優先されます。)このチュートリアルでは、Store Wins シナリオを実装します。 この手法では、変更が上書きされるとき、それが必ずユーザーに警告されます。
コンカレンシーの処理
プロパティがコンカレンシー トークンとして構成されている場合、次が実行されます。
- EF Core によって、フェッチ後にプロパティが変更されていないことが確認されます。 このチェックは、SaveChanges または SaveChangesAsync が呼び出されたときに発生します。
- フェッチ後にプロパティが変更されていると、DbUpdateConcurrencyException がスローされます。
データベースとデータ モデルは、DbUpdateConcurrencyException をスローできるように構成される必要があります。
プロパティでの同時実行競合の検出
コンカレンシーの競合は、ConcurrencyCheck 属性を使用し、プロパティ レベルで検出できます。 この属性は、モデルの複数のプロパティに適用できます。 詳細については、「Data Annotations-ConcurrencyCheck」を参照してください。
このチュートリアルでは、[ConcurrencyCheck] 属性は使用しません。
行のコンカレンシーの競合の検出
コンカレンシーの競合を検出するために、モデルに rowversion 追跡列が追加されました。
rowversion $
- SQL Server 専用です。 他のデータベースには、似たような機能がない場合があります。
- データベースからフェッチされた以降、エンティティに変更がないことを決定するために使用されます。
データベースによって、行が更新されるたびに増える、連続する rowversion 番号値が生成されます。
Update または Delete コマンドの Where 句には、フェッチされた rowversion の値が含まれます。 更新された行が変更された場合、次のようになります。
-
rowversionがフェッチされた値と一致しなくなります。 -
Update句にフェッチされたDeleteが含まれるので、Whereまたはrowversionコマンドでは行が検索されません。 -
DbUpdateConcurrencyExceptionがスローされます。
EF Core では、Update または Delete コマンドで行が更新されない場合、コンカレンシーの例外がスローされます。
Department エンティティにトラッキング プロパティを追加する
Models/Department.cs で、RowVersion という名前のトラッキング プロパティを追加します。
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ContosoUniversity.Models
{
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; }
public int? InstructorID { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; }
public Instructor Administrator { get; set; }
public ICollection<Course> Courses { get; set; }
}
}
Timestamp 属性では、この列は、Where および Update コマンドの Delete 句に含まれることを指定します。 前のバージョンの SQL Server では、SQL Timestamp 型に取って代わられる前、SQL timestamp というデータ型が使用されていたため、この属性は rowversion と呼ばれています。
Fluent API でも、トラッキング プロパティを指定できます。
modelBuilder.Entity<Department>()
.Property<byte[]>("RowVersion")
.IsRowVersion();
次のコードは、Department 名が更新されたときに、EF Core によって生成される T-SQL の一部です。
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
上の強調表示されたコードには、WHERE を含む RowVersion 句があります。 データベースの RowVersion が RowVersion パラメーター (@p2) と一致しない場合、行は更新されません。
次の強調表示されたコードは、1 つの行のみが更新されたことを検証する T-SQL です。
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
@@ROWCOUNT では、最後のステートメントの影響を受けた行数を返します。 更新された行がない場合、EF Core では DbUpdateConcurrencyException がスローされます。
Visual Studio の出力ウィンドウでは、生成された T-SQL EF Core を確認できます。
データベースを更新
RowVersion プロパティを追加すると、移行を必要とするデータベース モデルが変更されます。
プロジェクトをビルドします。 コマンド ウィンドウに次を入力します。
dotnet ef migrations add RowVersion
dotnet ef database update
上のコマンドでは以下の操作が行われます。
Migrations/{time stamp}_RowVersion.cs移行ファイルが追加されます。Migrations/SchoolContextModelSnapshot.csファイルが更新されます。 更新により、次の強調表示されたコードがBuildModelメソッドに追加されます。DBを更新するためにマイグレーションが実行されます。
部署モデルのスキャフォールディング
「Student モデルをスキャホールディングする」の手順に従い、モデル クラスの Department を使用します。
上記のコマンドは、Department モデルをスキャフォールディングします。 Visual Studio でプロジェクトを開きます。
プロジェクトをビルドします。
Departments Index ページを更新する
スキャフォールディング エンジンにより Index ページに RowVersion 列が作成されましたが、このフィールドは表示すべきではありません。 このチュートリアルでは、コンカレンシーを理解するために、RowVersion の最後のバイトが表示されています。 最後のバイトは、一意であるとは限りません。 実際のアプリでは、RowVersion や RowVersion の最後のバイトは表示されません。
Index ページを更新するために、次を実行します。
- Department で Index を置き換えます。
-
RowVersionを含むマークアップをRowVersionの最後のバイトで置き換えます。 - FirstMidName を FullName で置き換えます。
次のマークアップは、更新されたページを示しています。
@page
@model ContosoUniversity.Pages.Departments.IndexModel
@{
ViewData["Title"] = "Departments";
}
<h2>Departments</h2>
<p>
<a asp-page="Create">Create New</a>
</p>
<table class="table">
<thead>
<tr>
<th>
@Html.DisplayNameFor(model => model.Department[0].Name)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].Budget)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].StartDate)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].Administrator)
</th>
<th>
RowVersion
</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Department) {
<tr>
<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>
<td>
@item.RowVersion[7]
</td>
<td>
<a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
<a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
<a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
</td>
</tr>
}
</tbody>
</table>
Edit ページ モデルの更新
次のコードを使用して Pages/Departments/Edit.cshtml.cs を更新します。
using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;
namespace ContosoUniversity.Pages.Departments
{
public class EditModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
public EditModel(ContosoUniversity.Data.SchoolContext context)
{
_context = context;
}
[BindProperty]
public Department Department { get; set; }
// Replace ViewData["InstructorID"]
public SelectList InstructorNameSL { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Department = await _context.Departments
.Include(d => d.Administrator) // eager loading
.AsNoTracking() // tracking not required
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
// Use strongly typed data rather than ViewData.
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FirstMidName");
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
// null means Department was deleted by another user.
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Update the RowVersion to the value when this entity was
// fetched. If the entity has been updated after it was
// fetched, RowVersion won't match the DB RowVersion and
// a DbUpdateConcurrencyException is thrown.
// A second postback will make them match, unless a new
// concurrency issue happens.
_context.Entry(departmentToUpdate)
.Property("RowVersion").OriginalValue = Department.RowVersion;
if (await TryUpdateModelAsync<Department>(
departmentToUpdate,
"Department",
s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await SetDbErrorMessage(dbValues, clientValues, _context);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Must clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
}
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FullName", departmentToUpdate.InstructorID);
return Page();
}
private IActionResult HandleDeletedDepartment()
{
// ModelState contains the posted data because of the deletion error and will overide the Department instance values when displaying Page().
ModelState.AddModelError(string.Empty,
"Unable to save. The department was deleted by another user.");
InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
return Page();
}
private async Task SetDbErrorMessage(Department dbValues,
Department clientValues, SchoolContext context)
{
if (dbValues.Name != clientValues.Name)
{
ModelState.AddModelError("Department.Name",
$"Current value: {dbValues.Name}");
}
if (dbValues.Budget != clientValues.Budget)
{
ModelState.AddModelError("Department.Budget",
$"Current value: {dbValues.Budget:c}");
}
if (dbValues.StartDate != clientValues.StartDate)
{
ModelState.AddModelError("Department.StartDate",
$"Current value: {dbValues.StartDate:d}");
}
if (dbValues.InstructorID != clientValues.InstructorID)
{
Instructor dbInstructor = await _context.Instructors
.FindAsync(dbValues.InstructorID);
ModelState.AddModelError("Department.InstructorID",
$"Current value: {dbInstructor?.FullName}");
}
ModelState.AddModelError(string.Empty,
"The record you attempted to edit "
+ "was modified by another user after you. 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.");
}
}
}
コンカレンシーの問題を検出するため、OriginalValue は、取得元のエンティティの rowVersion 値で更新されます。
EF Core は、WHERE 句に元の RowVersion 値を含む SQL の UPDATE コマンドを生成します。 UPDATE コマンドの影響を受ける行がない場合 (元の RowVersion 値が含まれる行がない)、DbUpdateConcurrencyException 例外がスローされます。
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
// null means Department was deleted by another user.
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Update the RowVersion to the value when this entity was
// fetched. If the entity has been updated after it was
// fetched, RowVersion won't match the DB RowVersion and
// a DbUpdateConcurrencyException is thrown.
// A second postback will make them match, unless a new
// concurrency issue happens.
_context.Entry(departmentToUpdate)
.Property("RowVersion").OriginalValue = Department.RowVersion;
上のコードでは、エンティティがフェッチされたときの値は、Department.RowVersion です。 このメソッドで OriginalValue が呼び出されましたときのデータベース内の値は、FirstOrDefaultAsync です。
次のコードによって、クライアントの値 (このメソッドにポストされた値) とデータベースの値が取得されます。
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await SetDbErrorMessage(dbValues, clientValues, _context);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Must clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
次のコードによって、OnPostAsync にポストされたのとは異なるデータベースの値がある各列に、カスタム エラー メッセージが追加されます。
private async Task SetDbErrorMessage(Department dbValues,
Department clientValues, SchoolContext context)
{
if (dbValues.Name != clientValues.Name)
{
ModelState.AddModelError("Department.Name",
$"Current value: {dbValues.Name}");
}
if (dbValues.Budget != clientValues.Budget)
{
ModelState.AddModelError("Department.Budget",
$"Current value: {dbValues.Budget:c}");
}
if (dbValues.StartDate != clientValues.StartDate)
{
ModelState.AddModelError("Department.StartDate",
$"Current value: {dbValues.StartDate:d}");
}
if (dbValues.InstructorID != clientValues.InstructorID)
{
Instructor dbInstructor = await _context.Instructors
.FindAsync(dbValues.InstructorID);
ModelState.AddModelError("Department.InstructorID",
$"Current value: {dbInstructor?.FullName}");
}
ModelState.AddModelError(string.Empty,
"The record you attempted to edit "
+ "was modified by another user after you. 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.");
}
次のハイライトされたコードは、DB から取得した新しい値に RowVersion の値を設定します。 次回ユーザーが [保存] をクリックしたときに捕捉されるのは、Edit ページが最後に表示されて以降に発生した同時実行エラーのみです。
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await SetDbErrorMessage(dbValues, clientValues, _context);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Must clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
ModelState.Remove ステートメントは、ModelState が古い RowVersion 値を持っているため、必要です。
Razor ページでは、どちらも存在する場合は、フィールドの ModelState 値がモデル プロパティ値より優先されます。
[編集] ページを更新する
Pages/Departments/Edit.cshtml を次のマークアップで更新します。
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
<div class="col-md-4">
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="Department.DepartmentID" />
<input type="hidden" asp-for="Department.RowVersion" />
<div class="form-group">
<label>RowVersion</label>
@Model.Department.RowVersion[7]
</div>
<div class="form-group">
<label asp-for="Department.Name" class="control-label"></label>
<input asp-for="Department.Name" class="form-control" />
<span asp-validation-for="Department.Name" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Department.Budget" class="control-label"></label>
<input asp-for="Department.Budget" class="form-control" />
<span asp-validation-for="Department.Budget" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Department.StartDate" class="control-label"></label>
<input asp-for="Department.StartDate" class="form-control" />
<span asp-validation-for="Department.StartDate" class="text-danger">
</span>
</div>
<div class="form-group">
<label class="control-label">Instructor</label>
<select asp-for="Department.InstructorID" class="form-control"
asp-items="@Model.InstructorNameSL"></select>
<span asp-validation-for="Department.InstructorID" class="text-danger">
</span>
</div>
<div class="form-group">
<input type="submit" value="Save" class="btn btn-default" />
</div>
</form>
</div>
</div>
<div>
<a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}
上のマークアップでは以下の操作が行われます。
-
pageディレクティブを@pageから@page "{id:int}"に更新します。 - 非表示の行バージョンを追加します。 ポストバックで値をバインドできるように、
RowVersionを追加する必要があります。 - デバッグのために、
RowVersionの最後のバイトが表示されます。 -
ViewDataを厳密に型指定されたInstructorNameSLと置換します。
Edit ページでの同時実行競合のテスト
English 部署の 2 つの Edit のブラウザー インスタンスを開きます。
- アプリを実行し、部署を選択します。
- English 部署の [編集] ハイパーリンクを右クリックし、 [新しいタブで開く] を選択します。
- 最初のタブの English 部署の Edit ハイパーリンクをクリックします。
2 つのブラウザー タブに同じ情報が表示されます。
最初のブラウザー タブの名前を変更し、 [保存] をクリックします。
Index ページが、値が変更され、rowVersion インジケーターが更新され、ブラウザーに表示されます。 更新された rowVersion インジケーターに注意してください。これは、別のタブの 2 番目のポストバックに表示されています。
2 番目のブラウザー タブで別のフィールドを変更します。
[保存] をクリックします。 データベースの値と一致しないすべてのフィールドに、エラー メッセージが表示されます。
このブラウザー ウィンドウでは、Name フィールドの変更は意図されていませんでした。 Name フィールドに、現在の値 (言語) をコピーして貼り付けてください。 タブを終了します。クライアント側の検証によって、エラー メッセージが削除されます。
[保存] をもう一度クリックします。 2 番目のブラウザー タブに入力した値が保存されます。 Index ページで、保存した値が表示されます。
削除ページを更新する
次のコードを使用して、[削除] ページ モデルを更新します。
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;
namespace ContosoUniversity.Pages.Departments
{
public class DeleteModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
public DeleteModel(ContosoUniversity.Data.SchoolContext context)
{
_context = context;
}
[BindProperty]
public Department Department { get; set; }
public string ConcurrencyErrorMessage { get; set; }
public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
{
Department = await _context.Departments
.Include(d => d.Administrator)
.AsNoTracking()
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
if (concurrencyError.GetValueOrDefault())
{
ConcurrencyErrorMessage = "The record you attempted to delete "
+ "was modified by another user after you selected delete. "
+ "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.";
}
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
try
{
if (await _context.Departments.AnyAsync(
m => m.DepartmentID == id))
{
// Department.rowVersion value is from when the entity
// was fetched. If it doesn't match the DB, a
// DbUpdateConcurrencyException exception is thrown.
_context.Departments.Remove(Department);
await _context.SaveChangesAsync();
}
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException)
{
return RedirectToPage("./Delete",
new { concurrencyError = true, id = id });
}
}
}
}
フェッチ後にエンティティに変更があった場合、Delete ページによってコンカレンシーの競合が検出されます。
Department.RowVersion は、エンティティがフェッチされたときの行バージョンです。
EF Core が SQL DELETE コマンドを作成するとき、WHERE 句に RowVersion が含まれます。 SQL DELETE コマンドで影響を受けた行数が 0 の場合:
- SQL の DELETE コマンドの
RowVersionがデータベース内のRowVersionと一致しません。 - DbUpdateConcurrencyException 例外がスローされます。
-
OnGetAsyncがconcurrencyErrorと共に呼び出されます。
削除ページを更新する
次のコードを使用して Pages/Departments/Delete.cshtml を更新してください。
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel
@{
ViewData["Title"] = "Delete";
}
<h2>Delete</h2>
<p class="text-danger">@Model.ConcurrencyErrorMessage</p>
<h3>Are you sure you want to delete this?</h3>
<div>
<h4>Department</h4>
<hr />
<dl class="dl-horizontal">
<dt>
@Html.DisplayNameFor(model => model.Department.Name)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Name)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.Budget)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Budget)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.StartDate)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.StartDate)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.RowVersion)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.RowVersion[7])
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.Administrator)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Administrator.FullName)
</dd>
</dl>
<form method="post">
<input type="hidden" asp-for="Department.DepartmentID" />
<input type="hidden" asp-for="Department.RowVersion" />
<div class="form-actions no-color">
<input type="submit" value="Delete" class="btn btn-default" /> |
<a asp-page="./Index">Back to List</a>
</div>
</form>
</div>
上記のコードは、次の変更を加えます。
-
pageディレクティブを@pageから@page "{id:int}"に更新します。 - エラー メッセージを追加します。
- 「管理者」フィールドで FirstMidName を FullName に置き換えます。
- 最後のバイトを表示するよう、
RowVersionを変更します。 - 非表示の行バージョンを追加します。 ポストバックが値をバインドするように、
RowVersionを追加する必要があります。
Delete ページでのコンカレンシーの競合のテスト
テスト部署を作成します。
テスト部門で Delete のブラウザー インスタンスを 2 つ開きます。
- アプリを実行し、部署を選択します。
- テスト部署の [削除] ハイパーリンクを右クリックし、 [新しいタブで開く] を選択します。
- テスト部署の Edit ハイパーリンクをクリックします。
2 つのブラウザー タブに同じ情報が表示されます。
最初のブラウザー タブで予算を変更し、 [保存] をクリックします。
Index ページが、値が変更され、rowVersion インジケーターが更新され、ブラウザーに表示されます。 更新された rowVersion インジケーターに注意してください。これは、他方のタブの 2 番目のポストバックに表示されています。
第2タブでテスト部門を削除します。データベースの現在の値を含むコンカレンシー エラーが表示されます。
[削除] をクリックすると、RowVersion が更新されていない限り、エンティティが削除されます。
データモデルを継承する方法は、「継承」を参照してください。
エンタープライズ Web アプリのパターン
信頼性が高く、セキュリティで保護され、パフォーマンスが高く、テスト可能でスケーラブルな ASP.NET Core アプリの作成に関するガイダンスについては、Enterprise Web アプリ パターンを参照してください。 パターンを実装する完全な運用品質のサンプル Web アプリを使用できます。
関連するコンテンツ
次のステップ
これは、シリーズの最後のチュートリアルです。 このチュートリアル シリーズの MVC バージョンでは、その他のトピックについて説明されています。
ASP.NET Core