パート 8、ASP.NET Core の Razor Pages と EF Core - コンカレンシー

Tom DykstraJon P Smith

Contoso 大学 Web アプリでは、EF Core と Visual Studio を使用して Razor Pages Web アプリを作成する方法を示します。 チュートリアル シリーズについては、最初のチュートリアルを参照してください。

解決できない問題が発生した場合は、完成したアプリをダウンロードし、チュートリアルに従って作成した内容とコードを比較します。

このチュートリアルでは、複数のユーザーがエンティティを同時に更新するときの競合の処理方法について説明します。

コンカレンシーの競合

コンカレンシーの競合は、次のような場合に発生します。

  • ユーザーがエンティティの Edit ページに移動した場合。
  • 最初のユーザーの変更がデータベースに書き込まれる前に、別のユーザーが同じエンティティを更新した場合。

コンカレンシーの検出が有効になっていない場合は、最後に行われたデータベースの更新により、他のユーザーの変更が上書きされます。 このリスクが許容可能な場合は、コンカレンシーのプログラミングのコストが利点を上回る可能性があります。

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

コンカレンシーの競合を防ぐ方法の 1 つは、データベースのロックを使用することです。 これはペシミスティック コンカレンシーと呼ばれています。 アプリでは、更新予定のデータベース行を読み取る前に、ロックを要求します。 行が更新アクセス用にロックされた後は、最初のロックが解除されるまで、他のユーザーはその行をロックできなくなります。

ロックの利用には短所があります。 プログラムが複雑になり、ユーザー数が増えるにつれてパフォーマンスの問題が発生する可能性があります。 Entity Framework Core にはペシミスティック コンカレンシーの組み込みサポートはありません。

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

オプティミスティック コンカレンシーでは、コンカレンシーの競合の発生が許可され、それが発生した場合に適切に対処されます。 たとえば、Jane が Department Edit ページにアクセスし、English 部署の予算を $350,000.00 から $0.00 に変更するとします。

Changing budget to 0

Jane が [保存] をクリックする前に John が同じページにアクセスし、[開始日] フィールドを 9/1/2007 から 9/1/2013 に変更します。

Changing start date to 2013

Jane が最初に [保存] をクリックすると変更が反映されます。これは、ブラウザーの Index ページに予算として 0 が表示されるためです。

John が Edit ページの [保存] をクリックします。このとき、予算は $350,000.00 と表示されています。 この後の動作は、コンカレンシーの競合の処理方法によって決定されます。

  • ユーザーが変更したプロパティを追跡記録し、それに該当する列だけをデータベースで更新します。

    このシナリオでは、データは失われません。 2 人のユーザーが別のプロパティを更新しました。 今度誰かが English 部署を閲覧すると、Jane の変更と John の変更が両方とも表示されます。 この更新方法では、データが失われる原因となる競合の数を減らすことができます。 このアプローチにはいくつかの利点があります。

    • 競合する変更が同じプロパティに加えられた場合、データの損失を回避することはできません。
    • Web アプリでは、一般的に実用的ではありません。 フェッチされたすべての値と新しい値を追跡するために、かなりのステータスを維持することが必要になります。 大量のステータスを保守管理することになると、アプリケーションのパフォーマンスに影響が出ます。
    • エンティティでのコンカレンシーの検出と比較して、アプリは複雑になります。
  • John の変更で Jane の変更を上書きするようにします。

    今度誰かが English 部署を閲覧すると、9/1/2013 の日付と、フェッチされた $350,000.00 の値が表示されます。 このアプローチは Client Wins (クライアント側に合わせる) シナリオまたは Last in Wins (最終書き込み者優先) シナリオと呼ばれています。 クライアントからの値がすべて、データ ストアの値より優先されます。 スキャフォールディング コードによってコンカレンシー処理は行われず、自動的にクライアント側に合わせられます。

  • データベースで John の変更が更新されないようにします。 通常、アプリの動作は次のようになります。

    • エラー メッセージが表示されます。
    • データの現在のステータスが表示されます。
    • ユーザーが変更を再適用できるようになります。

    これは Store Wins (ストア側に合わせる) シナリオと呼ばれています。 クライアントが送信した値よりデータストアの値が優先されます。 このチュートリアルでは、Store Wins シナリオが使用されます。 この手法では、変更が上書きされるとき、それが必ずユーザーに警告されます。

EF Core での競合の検出

コンカレンシー トークンとして構成されたプロパティは、オプティミスティック同時実行制御を実装するために使用されます。 SaveChanges または SaveChangesAsync によって更新または削除操作がトリガーされると、データベース内のコンカレンシー トークンの値が EF Core によって読み取られた元の値と比較されます。

  • 値が一致した場合、操作は完了できます。
  • 値が一致しない場合、EF Core では、別のユーザーが競合する処理を実行したと仮定して、現在のトランザクションを中断し、DbUpdateConcurrencyException をスローします。

現在の処理と競合する処理を別のユーザーまたはプロセスが実行することを、"コンカレンシーの競合" といいます。

リレーショナル データベースでは、EF Core は UPDATE および DELETE ステートメントの WHERE 句でコンカレンシー トークンの値を確認し、コンカレンシーの競合を検出します。

データ モデルは、いつ行が変更されたかを判断するために使用できる追跡列を含めることによって、競合検出を有効にするように構成する必要があります。 EF では、コンカレンシー トークンのための 2 つの方法があります。

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 に設定されます。

次のコードは、Department 名が更新されたときに、EF Core によって生成される 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 句があります。 データベースの ConcurrencyTokenConcurrencyToken パラメーター @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 ページをスキャフォールディングする

次の例外を除き、「Student ページをスキャフォールディングする」の指示に従います。

  • 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 が含まれるコードを、最後のいくつかの文字が表示されるように変更します。
  • FirstMidNameFullName で置き換え

次のコードでは、更新されたページが表示されます。

@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>

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

            // 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 は、OnGetAsync メソッドでフェッチされたときのエンティティの ConcurrencyToken 値で更新されます。 EF Core では、WHERE 句に元の ConcurrencyToken 値が含まれる SQL UPDATE コマンドが生成されます。 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 内の値は、Edit ページの Get 要求でエンティティがフェッチされたときの値です。 その値は、編集対象のエンティティが表示される Razor ページの非表示フィールドによって、OnPost メソッドに提供されます。 非表示フィールドの値は、モデル バインダーによって Department.ConcurrencyToken にコピーされます。
  • OriginalValue は、EF Core が WHERE 句で使用する内容を示します。 強調表示されたコード行が実行される前は、
    • OriginalValue の値は、このメソッドで FirstOrDefaultAsync が呼び出されたときのデータベース内の値です。
    • この値は、Edit ページに表示される値とは異なる場合があります。
  • 強調表示されたコードにより、EF Core では SQL UPDATE ステートメントの WHERE 句で表示された Department エンティティの元の ConcurrencyToken の値が使用されます。

次のコードは Department モデルを示します。 Department は次によって初期化されます。

  • EF クエリによる OnGetAsync メソッド。
  • モデル バインドを使用する、Razor ページの非表示フィールドによる 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;

上のコードは、HTTP POST 要求の Department エンティティの ConcurrencyToken 値が、HTTP GET 要求の 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 = 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 が以前の ConcurrencyToken 値を持つため、ModelState.Remove ステートメントが必要になります。 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 部署の [編集] ハイパーリンクをクリックします。

2 つのブラウザー タブに同じ情報が表示されます。

最初のブラウザー タブの名前を変更し、 [保存] をクリックします。

Department Edit page 1 after change

値が変更され、ConcurrencyToken インジケーターが更新された Index ページがブラウザーに表示されます。 更新された ConcurrencyToken インジケーターに注意してください。これは、他方のタブの 2 番目のポストバックに表示されています。

2 番目のブラウザー タブで別のフィールドを変更します。

Department Edit page 2 after change

[保存] をクリックします。 データベースの値と一致しないすべてのフィールドに、エラー メッセージが表示されます。

Department Edit page error message

このブラウザー ウィンドウでは、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 コマンドで影響を受ける行がゼロの場合、次が発生します。

  • SQL DELETE コマンドの ConcurrencyToken がデータベースの ConcurrencyToken と一致しません。
  • DbUpdateConcurrencyException 例外がスローされます。
  • OnGetAsyncconcurrencyError と共に呼び出されます。

[削除] 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 を追加する必要があります。

コンカレンシーの競合をテストする

テスト部署を作成します。

テスト部署の 2 つの Delete のブラウザー インスタンスを開きます。

  • アプリを実行し、部署を選択します。
  • テスト部署の [削除] ハイパーリンクを右クリックし、 [新しいタブで開く] を選択します。
  • テスト部署の [編集] ハイパーリンクをクリックします。

2 つのブラウザー タブに同じ情報が表示されます。

最初のブラウザー タブで予算を変更し、 [保存] をクリックします。

値が変更され、ConcurrencyToken インジケーターが更新された Index ページがブラウザーに表示されます。 更新された ConcurrencyToken インジケーターに注意してください。これは、他方のタブの 2 番目のポストバックに表示されています。

2 番目のタブから、テスト部署を削除します。データベースからの現在の値で、コンカレンシー エラーが表示されます。 [削除] をクリックすると、ConcurrencyToken が更新されていない限り、エンティティが削除されます。

その他の技術情報

次の手順

これは、シリーズの最後のチュートリアルです。 このチュートリアル シリーズの MVC バージョンでは、その他のトピックについて説明されています。

このチュートリアルでは、複数のユーザーがエンティティをコンカレントに更新するときの競合の処理方法について説明します。

コンカレンシーの競合

コンカレンシーの競合は、次のような場合に発生します。

  • ユーザーがエンティティの Edit ページに移動した場合。
  • 最初のユーザーの変更がデータベースに書き込まれる前に、別のユーザーが同じエンティティを更新した場合。

コンカレンシーの検出が有効になっていない場合は、最後に行われたデータベースの更新により、他のユーザーの変更が上書きされます。 このリスクが許容可能な場合は、コンカレンシーのプログラミングのコストが利点を上回る可能性があります。

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

コンカレンシーの競合を防ぐ方法の 1 つは、データベースのロックを使用することです。 これはペシミスティック コンカレンシーと呼ばれています。 アプリでは、更新予定のデータベース行を読み取る前に、ロックを要求します。 行が更新アクセス用にロックされた後は、最初のロックが解除されるまで、他のユーザーはその行をロックできなくなります。

ロックの利用には短所があります。 プログラムが複雑になり、ユーザー数が増えるにつれてパフォーマンスの問題が発生する可能性があります。 Entity Framework Core にはそのサポートは組み込まれておらず、このチュートリアルでは実装方法を説明しません。

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

オプティミスティック コンカレンシーでは、コンカレンシーの競合の発生が許可され、それが発生した場合に適切に対処されます。 たとえば、Jane が Department Edit ページにアクセスし、English 部署の予算を $350,000.00 から $0.00 に変更するとします。

Changing budget to 0

Jane が [保存] をクリックする前に John が同じページにアクセスし、[開始日] フィールドを 9/1/2007 から 9/1/2013 に変更します。

Changing start date to 2013

Jane が最初に [保存] をクリックすると変更が反映されます。これは、ブラウザーの Index ページに予算として 0 が表示されるためです。

John が Edit ページの [保存] をクリックします。このとき、予算は $350,000.00 と表示されています。 この後の動作は、コンカレンシーの競合の処理方法によって決定されます。

  • ユーザーが変更したプロパティを追跡記録し、それに該当する列だけをデータベースで更新できます。

    このシナリオでは、データは失われません。 2 人のユーザーが別のプロパティを更新しました。 今度誰かが English 部署を閲覧すると、Jane の変更と John の変更が両方とも表示されます。 この更新方法では、データが失われる原因となる競合の数を減らすことができます。 このアプローチにはいくつかの利点があります。

    • 競合する変更が同じプロパティに加えられた場合、データの損失を回避することはできません。
    • Web アプリでは、一般的に実用的ではありません。 フェッチされたすべての値と新しい値を追跡するために、かなりのステータスを維持することが必要になります。 大量のステータスを保守管理することになると、アプリケーションのパフォーマンスに影響が出ます。
    • エンティティでのコンカレンシーの検出と比較して、アプリは複雑になります。
  • John の変更で Jane の変更を上書きするように指定できます。

    今度誰かが English 部署を閲覧すると、9/1/2013 の日付と、フェッチされた $350,000.00 の値が表示されます。 このアプローチは Client Wins (クライアント側に合わせる) シナリオまたは Last in Wins (最終書き込み者優先) シナリオと呼ばれています。 (クライアントからの値がすべて、データ ストアの値より優先されます。)コンカレンシー処理に対してコーディングを行わない場合、自動的にクライアント側に合わせられます。

  • データベースで John の変更が更新されないようにできます。 通常、アプリの動作は次のようになります。

    • エラー メッセージが表示されます。
    • データの現在のステータスが表示されます。
    • ユーザーが変更を再適用できるようになります。

    これは Store Wins (ストア側に合わせる) シナリオと呼ばれています。 (クライアントが送信した値よりデータストアの値が優先されます。)このチュートリアルでは、Store Wins シナリオを実装します。 この手法では、変更が上書きされるとき、それが必ずユーザーに警告されます。

EF Core での競合の検出

EF Core では、競合が検出されると DbConcurrencyException 例外がスローされます。 競合検出が有効になるように、データ モデルを構成する必要があります。 競合検出を有効にするためのオプションには次のようなものがあります。

  • Update コマンドと Delete コマンドの Where 句でコンカレンシー トークンとして構成されている列の元の値を含むように、EF Core を構成します。

    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 句には、フェッチされた行バージョンの値が含まれます。 フェッチされた後で更新された行が変更された場合、次のようになります。

  • 現在の行バージョンの値は、フェッチされた値と一致しません。
  • Where 句ではフェッチされた行バージョンの値が検索されるので、Update または Delete コマンドでは行は見つかりません。
  • 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 句があります。 データベースの RowVersionRowVersion パラメーター (@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 ページをスキャフォールディングする

  • 次の例外を除き、「Student ページをスキャフォールディングする」の指示に従います。

  • 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 は、OnGetAsync メソッドでフェッチされたときのエンティティの 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);

    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 句で表示された 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");
    }

次のコードによって、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");
    }

ModelStateRowVersion 値が古いため、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 部署の [編集] ハイパーリンクをクリックします。

2 つのブラウザー タブに同じ情報が表示されます。

最初のブラウザー タブの名前を変更し、 [保存] をクリックします。

Department Edit page 1 after change

Index ページが、値が変更され、rowVersion インジケーターが更新され、ブラウザーに表示されます。 更新された rowVersion インジケーターに注意してください。これは、他方のタブの 2 番目のポストバックに表示されています。

2 番目のブラウザー タブで別のフィールドを変更します。

Department Edit page 2 after change

[保存] をクリックします。 データベースの値と一致しないすべてのフィールドに、エラー メッセージが表示されます。

Department Edit page error message

このブラウザー ウィンドウでは、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 コマンドを作成するとき、それには RowVersion 句が含まれる WHERE 句が含まれます。 SQL DELETE コマンドで影響を受ける行がゼロの場合、次が発生します。

  • SQL DELETE コマンドの RowVersion がデータベースの RowVersion と一致しません。
  • DbUpdateConcurrencyException 例外がスローされます。
  • OnGetAsyncconcurrencyError と共に呼び出されます。

[削除] ページを更新する

次のコードを使用して 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 を追加する必要があります。

コンカレンシーの競合をテストする

テスト部署を作成します。

テスト部署の 2 つの Delete のブラウザー インスタンスを開きます。

  • アプリを実行し、部署を選択します。
  • テスト部署の [削除] ハイパーリンクを右クリックし、 [新しいタブで開く] を選択します。
  • テスト部署の [編集] ハイパーリンクをクリックします。

2 つのブラウザー タブに同じ情報が表示されます。

最初のブラウザー タブで予算を変更し、 [保存] をクリックします。

Index ページが、値が変更され、rowVersion インジケーターが更新され、ブラウザーに表示されます。 更新された rowVersion インジケーターに注意してください。これは、他方のタブの 2 番目のポストバックに表示されています。

2 番目のタブから、テスト部署を削除します。データベースからの現在の値で、コンカレンシー エラーが表示されます。 [削除] をクリックすると、RowVersion が更新されていない限り、エンティティが削除されます。

その他の技術情報

次の手順

これは、シリーズの最後のチュートリアルです。 このチュートリアル シリーズの MVC バージョンでは、その他のトピックについて説明されています。

このチュートリアルでは、複数のユーザーがエンティティをコンカレントに更新するときの競合の処理方法について説明します。 解決できない問題が発生した場合は、完成したアプリをダウンロードまたは表示してください手順をダウンロードする

コンカレンシーの競合

コンカレンシーの競合は、次のような場合に発生します。

  • ユーザーがエンティティの Edit ページに移動した場合。
  • 最初のユーザーの変更がデータベースに書き込まれる前に、別のユーザーが同じエンティティを更新した場合。

コンカレンシーの検出が無効のとき、コンカレント更新が発生すると、次のようになります。

  • 最後の更新が有効になります。 つまり、最後に更新された値がデータベースに保存されます。
  • 現在の更新の最初のものは失われます。

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

オプティミスティック コンカレンシーでは、コンカレンシーの競合の発生が許可され、それが発生した場合に適切に対処されます。 たとえば、Jane が Department Edit ページにアクセスし、English 部署の予算を $350,000.00 から $0.00 に変更するとします。

Changing budget to 0

Jane が [保存] をクリックする前に John が同じページにアクセスし、[開始日] フィールドを 9/1/2007 から 9/1/2013 に変更します。

Changing start date to 2013

Jane が [保存] を先にクリックすると、ブラウザーの Index ページには、Jane の変更が反映されています。

Budget changed to zero

John が Edit ページの [保存] をクリックします。このとき、予算は $350,000.00 と表示されています。 この後の動作は、コンカレンシーの競合の処理方法によって決定します。

オプティミスティック コンカレンシーには、次のオプションがあります。

  • ユーザーが変更したプロパティを追跡記録し、それに該当する列だけをデータベースで更新します。

    このシナリオでは、データは失われません。 2 人のユーザーが別のプロパティを更新しました。 今度誰かが English 部署を閲覧すると、Jane の変更と John の変更が両方とも表示されます。 この更新方法では、データが失われる原因となる競合の数を減らすことができます。 この方法の特徴は次のとおりです。

    • 競合する変更が同じプロパティに加えられた場合、データの損失を回避することはできません。
    • Web アプリでは、一般的に実用的ではありません。 フェッチされたすべての値と新しい値を追跡するために、かなりのステータスを維持することが必要になります。 大量のステータスを保守管理することになると、アプリケーションのパフォーマンスに影響が出ます。
    • エンティティでのコンカレンシーの検出と比較して、アプリは複雑になります。
  • John の変更で Jane の変更を上書きするように指定できます。

    今度誰かが English 部署を閲覧すると、9/1/2013 の日付と、フェッチされた $350,000.00 の値が表示されます。 このアプローチは Client Wins (クライアント側に合わせる) シナリオまたは Last in Wins (最終書き込み者優先) シナリオと呼ばれています。 (クライアントからの値がすべて、データ ストアの値より優先されます。)コンカレンシー処理に対してコーディングを行わない場合、自動的にクライアント側に合わせられます。

  • データベースで John の変更が更新されないようにできます。 通常、アプリの動作は次のようになります。

    • エラー メッセージが表示されます。
    • データの現在のステータスが表示されます。
    • ユーザーが変更を再適用できるようになります。

    これは Store Wins (ストア側に合わせる) シナリオと呼ばれています。 (クライアントが送信した値よりデータストアの値が優先されます。)このチュートリアルでは、Store Wins シナリオを実装します。 この手法では、変更が上書きされるとき、それが必ずユーザーに警告されます。

コンカレンシーの処理

プロパティがコンカレンシー トークンとして構成されている場合、次が実行されます。

  • EF Core によって、フェッチ後にプロパティが変更されていないことが確認されます。 このチェックは、SaveChanges または SaveChangesAsync が呼び出されたときに発生します。
  • フェッチ後にプロパティが変更されていると、DbUpdateConcurrencyException がスローされます。

データベースとデータ モデルは、DbUpdateConcurrencyException のスローをサポートするように構成される必要があります。

プロパティのコンカレンシーの競合の検出

コンカレンシーの競合は、ConcurrencyCheck 属性を使用し、プロパティ レベルで検出できます。 この属性は、モデルの複数のプロパティに適用できます。 詳細については、「データの注釈 - ConcurrencyCheck」を参照してください。

このチュートリアルでは、[ConcurrencyCheck] 属性は使用しません。

行のコンカレンシーの競合の検出

コンカレンシーの競合を検出するために、モデルに rowversion 追跡列が追加されました。 rowversion は、次のとおりです。

  • SQL Server 専用です。 他のデータベースには、似たような機能がない場合があります。
  • データベースからフェッチされた以降、エンティティに変更がないことを決定するために使用されます。

データベースによって、行が更新されるたびに増える、連続する rowversion 番号値が生成されます。 Update または Delete コマンドの Where 句には、フェッチされた rowversion の値が含まれます。 更新された行が変更された場合、次のようになります。

  • rowversion がフェッチされた値と一致しなくなります。
  • Where 句にフェッチされた rowversion が含まれるので、Update または Delete コマンドでは行が検索されません。
  • 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 属性では、この列は、Update および Delete コマンドの Where 句に含まれることを指定します。 前のバージョンの SQL Server では、SQL rowversion 型に取って代わられる前、SQL timestamp というデータ型が使用されていたため、この属性は Timestamp と呼ばれています。

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;

上の強調表示されたコードには、RowVersion を含む WHERE 句があります。 データベースの RowVersionRowVersion パラメーター (@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 の出力ウィンドウでは、EF Core が生成する T-SQL を確認できます。

データベースの更新

RowVersion プロパティを追加すると、移行を必要とするデータベース モデルが変更されます。

プロジェクトをビルドします。 コマンド ウィンドウに次を入力します。

dotnet ef migrations add RowVersion
dotnet ef database update

上のコマンドでは以下の操作が行われます。

  • Migrations/{time stamp}_RowVersion.cs 移行ファイルが追加されます。

  • Migrations/SchoolContextModelSnapshot.cs ファイルが更新されます。 更新により、次の強調表示されたコードが BuildModel メソッドに追加されます。

  • データベースを更新するために移行が実行されます。

部署モデルのスキャフォールディング

Student モデルをスキャホールディングする」の手順に従い、モデル クラスの Department を使用します。

上記のコマンドは、Department モデルをスキャフォールディングします。 Visual Studio でプロジェクトを開きます。

プロジェクトをビルドします。

Departments Index ページを更新する

スキャフォールディング エンジンにより Index ページに RowVersion 列が作成されましたが、このフィールドは表示すべきではありません。 このチュートリアルでは、コンカレンシーを理解するために、RowVersion の最後のバイトが表示されています。 最後のバイトは、一意であるとは限りません。 実際のアプリでは、RowVersionRowVersion の最後のバイトは表示されません。

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 です。 このメソッドで FirstOrDefaultAsync が呼び出されましたときのデータベース内の値は、OriginalValue です。

次のコードによって、クライアントの値 (このメソッドにポストされた値) とデータベースの値が取得されます。

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.");
}

次の強調表示されたコードによって、データベースから取得された新しい値に 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");
}

ModelStateRowVersion 値が古いため、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-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 部署の [編集] ハイパーリンクをクリックします。

2 つのブラウザー タブに同じ情報が表示されます。

最初のブラウザー タブの名前を変更し、 [保存] をクリックします。

Department Edit page 1 after change

Index ページが、値が変更され、rowVersion インジケーターが更新され、ブラウザーに表示されます。 更新された rowVersion インジケーターに注意してください。これは、他方のタブの 2 番目のポストバックに表示されています。

2 番目のブラウザー タブで別のフィールドを変更します。

Department Edit page 2 after change

[保存] をクリックします。 データベースの値と一致しないすべてのフィールドに、エラー メッセージが表示されます。

Department Edit page error message 1

このブラウザー ウィンドウでは、Name フィールドの変更は意図されていませんでした。 Name フィールドに、現在の値 (言語) をコピーして貼り付けます。 タブを終了します。クライアント側の検証によって、エラー メッセージが削除されます。

Department Edit page error message 2

[保存] をもう一度クリックします。 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 コマンドを作成するとき、それには RowVersion 句が含まれる WHERE 句が含まれます。 SQL DELETE コマンドで影響を受ける行がゼロの場合、次が発生します。

  • SQL DELETE コマンドの RowVersion がデータベースの RowVersion と一致しません。
  • DbUpdateConcurrencyException 例外がスローされます。
  • OnGetAsyncconcurrencyError と共に呼び出されます。

[削除] ページを更新する

次のコードを使用して 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 ページでのコンカレンシーの競合のテスト

テスト部署を作成します。

テスト部署の 2 つの Delete のブラウザー インスタンスを開きます。

  • アプリを実行し、部署を選択します。
  • テスト部署の [削除] ハイパーリンクを右クリックし、 [新しいタブで開く] を選択します。
  • テスト部署の [編集] ハイパーリンクをクリックします。

2 つのブラウザー タブに同じ情報が表示されます。

最初のブラウザー タブで予算を変更し、 [保存] をクリックします。

Index ページが、値が変更され、rowVersion インジケーターが更新され、ブラウザーに表示されます。 更新された rowVersion インジケーターに注意してください。これは、他方のタブの 2 番目のポストバックに表示されています。

2 番目のタブから、テスト部署を削除します。データベースからの現在の値で、コンカレンシー エラーが表示されます。 [削除] をクリックすると、RowVersion が更新されていない限り、エンティティが削除されます。

データ モデルを継承する方法については、「継承」を参照してください。

その他の技術情報