第 8 部分,在 ASP.NET Core 中使用 EF Core 的 Razor Pages - Concurrency

Tom DykstraJon P Smith

Contoso 大學 Web 應用程式將示範如何使用 EF Core 和 Visual Studio 來建立 Razor Pages Web 應用程式。 如需教學課程系列的資訊,請參閱第一個教學課程

如果您遇到無法解決的問題,請下載已完成的應用程式,並遵循本教學課程以將程式碼與您所建立的內容進行比較。

本教學課程會顯示如何在多位使用者並行更新實體時處理衝突。

並行衝突

並行衝突發生的時機:

  • 使用者巡覽至實體的編輯頁面。
  • 另一位使用者在第一位使用者的變更寫入到資料庫前更新相同實體。

若未啟用並行偵測,則最後更新資料庫的任何使用者所作出變更會覆寫前一位使用者所作出變更。 若您可以接受這種風險,則並行程式設計所帶來的成本便可能會超過其效益。

封閉式並行

其中一種避免並行衝突的方式是使用資料庫鎖定。 這稱之為封閉式並行存取。 在應用程式讀取要更新的資料庫資料列前,它會先要求鎖定。 針對更新存取鎖定資料列後,在第一個鎖定解除前,其他使用者都無法鎖定該資料列。

管理鎖定有幾個缺點。 這種程式可能會相當複雜,且可能會隨著使用者數量的增加而造成效能問題。 Entity Framework Core 沒有針對封閉式並行存取提供內建支援。

開放式並行存取

開放式並行存取允許並行衝突發生,並會在衝突發生時適當的作出反應。 例如,Jane 造訪了 Department 編輯頁面,然後將英文部門的預算從美金 $350,000.00 元調整到美金 $0.00 元。

Changing budget to 0

在 Jane 按一下 [儲存] 前,John 造訪了相同的頁面並將 [開始日期] 欄位從 2007/9/1 變更為 2013/9/1。

Changing start date to 2013

Jane 先按了一下 [儲存] 並看到她所做的變更生效,因為瀏覽器顯示 Budget 金額為零的 Index 頁。

John 在仍然顯示預算為美金 $350,000.00 的 [編輯] 頁面上按一下 [儲存]。 接下來將發生情況是由您處理並行衝突的方式決定:

  • 請追蹤使用者已修改的屬性,然後僅在資料庫中更新相對應的資料行。

    在此案例中,您將不會遺失任何資料。 兩位使用者更新了不同的屬性。 下一次當有人瀏覽英文部門時,他們便會同時看到 Jane 和 John 所作出的變更。 這種更新方法可以減少可能會導致資料遺失的衝突數目。 此方法有一些缺點:

    • 無法在使用者更新相同屬性時避免資料遺失。
    • 表示通常在 Web 應用程式中不實用。 它必須維持大量的狀態來追蹤所有擷取的值和新的值。 維持大量的狀態可能會影響應用程式的效能。
    • 與實體上的並行偵測相較之下,可能會增加應用程式的複雜度。
  • 讓 John 的變更覆寫 Jane 的變更。

    下一次當有人瀏覽英文部門時,他們便會看到開始日期為 2013/9/1,以及擷取的美金 $350,000.00 元預算金額。 這稱之為「用戶端獲勝 (Client Wins)」或「最後寫入為準 (Last in Wins)」案例。 所有來自用戶端的值都會優先於資料存放區中的資料。 scaffolded 程式碼不會進行並行處理,「用戶端獲勝」會自動發生。

  • 防止 John 的變更更新到資料庫中。 一般而言,應用程式會:

    • 顯示錯誤訊息。
    • 顯示資料的目前狀態。
    • 允許使用者重新套用變更。

    這稱之為「存放區獲勝 (Store Wins)」案例。 資料存放區的值會優先於用戶端所提交的值。 本教學課程中會使用「市集獲勝」案例。 這個方法可確保沒有任何變更會在使用者收到警示前遭到覆寫。

EF Core 中的衝突偵測

設定為並行權杖的屬性可用來實作開放式同步存取控制。 當更新或刪除作業是由 SaveChangesSaveChangesAsync 觸發時,資料庫中並行權杖的值會與 EF Core 所讀取的原始值比較:

  • 如果值相符,作業便能完成。
  • 如果值不相符,EF Core 就會假設另一位使用者已執行衝突作業,而將目前的交易中止,並擲回 DbUpdateConcurrencyException

當另一位使用者或流程正在執行與目前作業衝突的作業時,稱為並行衝突

在關聯式資料庫上,EF Core 會檢查 UPDATEDELETE 陳述式 WHERE 子句中並行權杖的值,以偵測並行衝突。

必須設定資料模型以啟用衝突偵測,方法是加入追蹤資料行,用以判斷資料列變更的時間。 EF 會提供並行權杖的兩個方法:

SQL Server 方法和 SQLite 實作詳細資料稍有不同。 稍後會在列出差異的教學課程中顯示差異檔案。 Visual Studio 索引標籤會顯示 SQL Server 方法。 Visual Studio Code 索引標籤會顯示非 SQL Server 資料庫的方法,例如 SQLite。

  • 在模型中,包含追蹤資料行,用於判斷資料列發生變更的時機。
  • 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 的必要類型。
  • 呼叫 IsConcurrencyTokenIsConcurrencyToken 會將屬性設定為並行權杖。 在更新時,資料庫中的並行權杖值會與原始值進行比較,以確保從資料庫擷取實例之後,值尚未變更。 如果已變更,會擲回 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;

上述醒目提示程式碼顯示 WHERE 子句中包含 ConcurrencyToken。 若資料庫 ConcurrencyTokenConcurrencyToken 參數 (@p2) 不相同,便不會更新任何資料列。

下列醒目提示程式碼顯示驗證確實有一個資料列獲得更新的 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");

Scaffold Department 頁面

遵循 Scaffold Student 頁面中的指示,下列部分除外:

  • 建立 Pages/Departments 資料夾。
  • 使用 Department 作為模型類別。
  • 使用現有內容類別,而非建立新的類別。

加入公用程式類別

在專案資料夾中,使用下列程式碼建立 Utility 類別:

namespace ContosoUniversity
{
    public static class Utility
    {
        public static string GetLastChars(byte[] token)
        {
            return token[7].ToString();
        }
    }
}

Utility 類別會提供 GetLastChars 用來顯示並行權杖最後幾個字元的方法。 下列程式碼會顯示與 SQLite ad 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 版本的差異,並協助:

  • 作者為這兩個版本維護一個程式碼基底。
  • SQLite 開發人員將應用程式部署至 Azure,並使用 SQL Azure。

組建專案。

更新 Index 頁面

Scaffolding 工具會為 Index 頁面建立 ConcurrencyToken 資料行,但該欄位不會在生產應用程式中顯示。 在本教學課程中,會顯示 ConcurrencyToken 的最後一個部分,來協助示範並行處理的運作方式。 最後一個部分本身不一定是唯一的。

更新 Pages\Departments\Index.cshtml 頁面:

  • 使用 Departments 取代 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 會在於 OnGetAsync 方法中進行擷取時,使用實體的 ConcurrencyToken 值來進行更新。 EF Core 會產生一個帶有包含了原始 ConcurrencyTokenWHERE 子句的 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 時資料庫中的值。
    • 此值可能與 [編輯] 頁面上所顯示的內容不同。
  • 醒目提示的程式碼會確保 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 值。

發生並行錯誤時,下列醒目提示的程式碼會取得用戶端值 (POST 到此方法的值) 及資料庫值。

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

下列程式碼會為每個其資料庫中值與 POST 到 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 值設為從資料庫所擷取新值。 下一次當使用者按一下 [儲存] 時,只有在上一次顯示 [編輯] 頁面之後發生的並行錯誤會被捕捉到。

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;

更新 [編輯 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 的最後一個位元組,作為偵錯用途。
  • 使用強型別的 InstructorNameSL 取代 ViewData

使用 [編輯] 頁面測試並行衝突

開啟兩個英文部門上 [編輯] 頁面的瀏覽器執行個體:

  • 執行應用程式並選取 Departments。
  • 以滑鼠右鍵按一下英文部門的編輯超連結,然後選取 [Open in new tab] (在新索引標籤中開啟)
  • 在第一個索引標籤中,按一下英文部門的編輯超連結。

兩個瀏覽器索引標籤會顯示相同的資訊。

在第一個瀏覽器索引標籤中變更名稱,然後按一下 [儲存]

Department Edit page 1 after change

瀏覽器會顯示 [索引] 頁面,當中包含了變更之後的值和更新後的 ConcurrencyToken 指標。 請注意更新後的 ConcurrencyToken 指標。它會顯示在另一個索引標籤中的第二個回傳上。

在第二個瀏覽器索引標籤中變更不同欄位。

Department Edit page 2 after change

按一下 [檔案] 。 您會看到所有不符合資料庫值欄位的錯誤訊息:

Department Edit page error message

此瀏覽器視窗並未嘗試變更 [名稱] 欄位。 複製並將目前的值 (語言 (Language)) 貼上 [名稱] 欄位。 按下 Tab 鍵切換至下一個欄位。用戶端驗證會移除錯誤訊息。

再次按一下 [儲存]。 您在第二個瀏覽器索引標籤中輸入的值已儲存。 您會在 [索引] 頁面中看到儲存的值。

更新 [刪除] 頁面模型

以下列程式碼來更新 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 });
            }
        }
    }
}

[刪除] 頁面會在實體擷取之後發生變更時偵測並行衝突。 Department.ConcurrencyToken 是擷取實體時的資料列版本。 當 EF Core 建立 SQL DELETE 命令時,它會包含一個帶有 ConcurrencyToken 的 WHERE 子句。 若 SQL DELETE 命令影響的資料列數目為零:

  • SQL DELETE 命令中 ConcurrencyToken 與資料庫中 ConcurrencyToken 不相符。
  • 會擲回 DbUpdateConcurrencyException 例外狀況。
  • 使用 concurrencyError 呼叫 OnGetAsync

更新 [刪除 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,以讓回傳繫結值。

測試並行衝突

建立一個測試部門。

開啟兩個測試部門上 [刪除] 頁面的瀏覽器執行個體:

  • 執行應用程式並選取 Departments。
  • 以滑鼠右鍵按一下測試部門的刪除超連結,然後選取 [Open in new tab] (在新索引標籤中開啟)
  • 按一下測試部門的編輯超連結。

兩個瀏覽器索引標籤會顯示相同的資訊。

在第一個瀏覽器索引標籤中變更預算,然後按一下 [儲存]

瀏覽器會顯示 [索引] 頁面,當中包含了變更之後的值和更新後的 ConcurrencyToken 指標。 請注意更新後的 ConcurrencyToken 指標。它會顯示在另一個索引標籤中的第二個回傳上。

從第二個索引標籤刪除測試部門。系統會顯示並行錯誤,且從資料庫取得的目前值。 按一下 [刪除] 會刪除實體,除非 ConcurrencyToken 已更新。

其他資源

下一步

這是一系列教學課程的最後一個教學課程。 本教學課程系列的 MVC 版本涵蓋了其他主題。

本教學課程會顯示如何在多位使用者同時並行更新實體時處理衝突。

並行衝突

並行衝突發生的時機:

  • 使用者巡覽至實體的編輯頁面。
  • 另一位使用者在第一位使用者的變更寫入到資料庫前更新相同實體。

若未啟用並行偵測,則最後更新資料庫的任何使用者所作出變更會覆寫前一位使用者所作出變更。 若您可以接受這種風險,則並行程式設計所帶來的成本便可能會超過其效益。

封閉式並行存取 (鎖定)

其中一種避免並行衝突的方式是使用資料庫鎖定。 這稱之為封閉式並行存取。 在應用程式讀取要更新的資料庫資料列前,它會先要求鎖定。 針對更新存取鎖定資料列後,在第一個鎖定解除前,其他使用者都無法鎖定該資料列。

管理鎖定有幾個缺點。 這種程式可能會相當複雜,且可能會隨著使用者數量的增加而造成效能問題。 Entity Framework Core 沒有提供這種功能的內建支援,本教學課程也不會示範如何進行實作。

開放式並行存取

開放式並行存取允許並行衝突發生,並會在衝突發生時適當的作出反應。 例如,Jane 造訪了 Department 編輯頁面,然後將英文部門的預算從美金 $350,000.00 元調整到美金 $0.00 元。

Changing budget to 0

在 Jane 按一下 [儲存] 前,John 造訪了相同的頁面並將 [開始日期] 欄位從 2007/9/1 變更為 2013/9/1。

Changing start date to 2013

Jane 先按了一下 [儲存] 並看到她所做的變更生效,因為瀏覽器顯示 Budget 金額為零的 Index 頁。

John 在仍然顯示預算為美金 $350,000.00 的 [編輯] 頁面上按一下 [儲存]。 接下來將發生情況是由您處理並行衝突的方式決定:

  • 您可以追蹤使用者修改的屬性,然後僅在資料庫中更新相對應的資料行。

    在此案例中,您將不會遺失任何資料。 兩位使用者更新了不同的屬性。 下一次當有人瀏覽英文部門時,他們便會同時看到 Jane 和 John 所作出的變更。 這種更新方法可以減少可能會導致資料遺失的衝突數目。 此方法有一些缺點:

    • 無法在使用者更新相同屬性時避免資料遺失。
    • 表示通常在 Web 應用程式中不實用。 它必須維持大量的狀態來追蹤所有擷取的值和新的值。 維持大量的狀態可能會影響應用程式的效能。
    • 與實體上的並行偵測相較之下,可能會增加應用程式的複雜度。
  • 您可以讓 John 的變更覆寫 Jane 的變更。

    下一次當有人瀏覽英文部門時,他們便會看到開始日期為 2013/9/1,以及擷取的美金 $350,000.00 元預算金額。 這稱之為「用戶端獲勝 (Client Wins)」或「最後寫入為準 (Last in Wins)」案例。 (所有來自用戶端的值都會優先於資料存放區中的資料。)若您沒有針對並行處理撰寫任何程式碼,便會自動發生「用戶端獲勝 (Client Wins)」的情況。

  • 您可以防止 John 的變更更新到資料庫中。 一般而言,應用程式會:

    • 顯示錯誤訊息。
    • 顯示資料的目前狀態。
    • 允許使用者重新套用變更。

    這稱之為「存放區獲勝 (Store Wins)」案例。 (資料存放區的值會優先於用戶端所提交的值。)您會在此教學課程中實作存放區獲勝案例。 這個方法可確保沒有任何變更會在使用者收到警示前遭到覆寫。

EF Core 中的衝突偵測

EF Core 會在偵測到衝突時擲回 DbConcurrencyException 例外狀況。 資料模型必須進行設定才能啟用衝突偵測。 啟用衝突偵測的選項包括下列項目:

  • 設定 EF Core,在 Update 和 Delete 命令的 Where 子句中將資料行的原始值設為並行權杖並包含在其中。

    呼叫 SaveChanges 時,Where 子句會尋找任何標註 ConcurrencyCheckAttribute 屬性的屬性原始值。 若從第一次讀取資料列後有任何的並行權杖屬性產生變更,更新陳述式便不會尋找要更新的資料列。 EF Core 會將其解譯為並行衝突。 針對擁有許多資料行的資料庫資料表,這種方法可能導致非常龐大的 Where 子句,且可能會需要維持龐大數量的狀態。 因此通常不建議使用這種方法,並且這種方法也不是此教學課程中所使用的方法。

  • 在資料庫資料表中,包含一個追蹤資料行,該資料行可用於決定資料列發生變更的時機。

    在 SQL Server 資料庫中,追蹤資料行的資料型別是 rowversionrowversion 的值是一個循序號碼,每一次資料列更新時都會遞增。 在 Update 或 Delete 命令中,Where 子句會包含追蹤資料行的原始值 (原始資料列版本號碼)。 若正在更新的資料列已由另一位使用者進行變更,則 rowversion 資料行中的值便會和原始值不同。 在這種情況下,Update 或 Delete 陳述式便會因為 Where 子句而無法找到要更新的資料列。 EF Core 會在 Update 或 Delete 命令沒有影響任何資料列時擲回並行例外狀況。

新增追蹤屬性

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

資料庫會產生循序的資料列版本號碼,會在每一次更新資料列時遞增。 在 UpdateDelete 命令中,Where 子句會包含擷取到的資料列版本值。 若正在更新的資料列從擷取之後已產生變更:

  • 目前的資料列版本值會與所擷取值不相符。
  • UpdateDelete 命令會因為 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;

上述醒目提示程式碼顯示 WHERE 子句中包含 RowVersion。 若資料庫 RowVersionRowVersion 參數 (@p2) 不相同,便不會更新任何資料列。

下列醒目提示程式碼顯示驗證確實有一個資料列獲得更新的 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
    

Scaffold Department 頁面

  • 遵循 Scaffold Student 頁面中的指示,下列部分除外:

  • 建立 Pages/Departments 資料夾。

  • 使用 Department 作為模型類別。

    • 使用現有內容類別,而非建立新的類別。

組建專案。

更新 Index 頁面

Scaffolding 工具會為 Index 頁面建立 RowVersion 資料行,但該欄位不會在生產應用程式中顯示。 在本教學課程中,會顯示 RowVersion 的最後一個位元組,來協助示範並行處理的運作方式。 最後一個位元組本身不一定是唯一的。

更新 Pages\Departments\Index.cshtml 頁面:

  • 使用 Departments 取代 Index。
  • 變更包含 RowVersion 的程式碼,僅顯示位元組陣列的最後一個位元組。
  • 使用 FullName 取代 FirstMidName。

下列程式碼會顯示更新後的頁面:

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

更新 [編輯] 頁面模型

以下列程式碼來更新 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 會產生一個帶有包含了原始 RowVersion 值 WHERE 子句的 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 值。

發生並行錯誤時,下列醒目提示的程式碼會取得用戶端值 (POST 到此方法的值) 及資料庫值。

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

下列程式碼會為每個其資料庫中值與 POST 到 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 值設為從資料庫所擷取新值。 下一次當使用者按一下 [儲存] 時,只有在上一次顯示 [編輯] 頁面之後發生的並行錯誤會被捕捉到。

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.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-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 的最後一個位元組,作為偵錯用途。
  • 使用強型別的 InstructorNameSL 取代 ViewData

使用 [編輯] 頁面測試並行衝突

開啟兩個英文部門上 [編輯] 頁面的瀏覽器執行個體:

  • 執行應用程式並選取 Departments。
  • 以滑鼠右鍵按一下英文部門的編輯超連結,然後選取 [Open in new tab] (在新索引標籤中開啟)
  • 在第一個索引標籤中,按一下英文部門的編輯超連結。

兩個瀏覽器索引標籤會顯示相同的資訊。

在第一個瀏覽器索引標籤中變更名稱,然後按一下 [儲存]

Department Edit page 1 after change

瀏覽器會顯示 [索引] 頁面,當中包含了變更之後的值和更新後的 rowVersion 指標。 請注意更新後的 rowVersion 指標。它會顯示在另一個索引標籤中的第二個回傳上。

在第二個瀏覽器索引標籤中變更不同欄位。

Department Edit page 2 after change

按一下 [檔案] 。 您會看到所有不符合資料庫值欄位的錯誤訊息:

Department Edit page error message

此瀏覽器視窗並未嘗試變更 [名稱] 欄位。 複製並將目前的值 (語言 (Language)) 貼上 [名稱] 欄位。 按下 Tab 鍵切換至下一個欄位。用戶端驗證會移除錯誤訊息。

再次按一下 [儲存]。 您在第二個瀏覽器索引標籤中輸入的值已儲存。 您會在 [索引] 頁面中看到儲存的值。

更新 [刪除] 頁面模型

以下列程式碼來更新 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 });
            }
        }
    }
}

[刪除] 頁面會在實體擷取之後發生變更時偵測並行衝突。 Department.RowVersion 是擷取實體時的資料列版本。 當 EF Core 建立 SQL DELETE 命令時,它會包含一個帶有 RowVersion 的 WHERE 子句。 若 SQL DELETE 命令影響的資料列數目為零:

  • SQL DELETE 命令中 RowVersion 與資料庫中 RowVersion 不相符。
  • 擲回 DbUpdateConcurrencyException。
  • 使用 concurrencyError 呼叫 OnGetAsync

更新 [刪除] 頁面

以下列程式碼來更新 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,以讓回傳繫結值。

測試並行衝突

建立一個測試部門。

開啟兩個測試部門上 [刪除] 頁面的瀏覽器執行個體:

  • 執行應用程式並選取 Departments。
  • 以滑鼠右鍵按一下測試部門的刪除超連結,然後選取 [Open in new tab] (在新索引標籤中開啟)
  • 按一下測試部門的編輯超連結。

兩個瀏覽器索引標籤會顯示相同的資訊。

在第一個瀏覽器索引標籤中變更預算,然後按一下 [儲存]

瀏覽器會顯示 [索引] 頁面,當中包含了變更之後的值和更新後的 rowVersion 指標。 請注意更新後的 rowVersion 指標。它會顯示在另一個索引標籤中的第二個回傳上。

從第二個索引標籤刪除測試部門。系統會顯示並行錯誤,且從資料庫取得的目前值。 按一下 [刪除] 會刪除實體,除非 RowVersion 已更新。

其他資源

下一步

這是一系列教學課程的最後一個教學課程。 本教學課程系列的 MVC 版本涵蓋了其他主題。

本教學課程會顯示如何在多位使用者同時並行更新實體時處理衝突。 若您遇到無法解決的問題,請下載或檢視完整應用程式。下載指示

並行衝突

並行衝突發生的時機:

  • 使用者巡覽至實體的編輯頁面。
  • 另一個使用者在第一個使用者的變更寫入到資料庫前更新了相同的實體。

當未啟用並行偵測時卻發生並行存取時:

  • 最後作出的更新會成功。 亦即,最後更新的值會儲存到資料庫。
  • 第一個作出的更新會遺失。

開放式並行存取

開放式並行存取允許並行衝突發生,並會在衝突發生時適當的作出反應。 例如,Jane 造訪了 Department 編輯頁面,然後將英文部門的預算從美金 $350,000.00 元調整到美金 $0.00 元。

Changing budget to 0

在 Jane 按一下 [儲存] 前,John 造訪了相同的頁面並將 [開始日期] 欄位從 2007/9/1 變更為 2013/9/1。

Changing start date to 2013

Jane 先按了一下 [儲存],並且在瀏覽器顯示 [索引] 頁面時看到她作出的變更。

Budget changed to zero

John 在仍然顯示預算為美金 $350,000.00 的 [編輯] 頁面上按一下 [儲存]。 接下來發生的情況便是由您處理並行衝突的方式決定。

開放式並行存取包含下列選項:

  • 您可以追蹤使用者修改的屬性,然後僅在資料庫中更新相對應的資料行。

    在此案例中,您將不會遺失任何資料。 兩位使用者更新了不同的屬性。 下一次當有人瀏覽英文部門時,他們便會同時看到 Jane 和 John 所作出的變更。 這種更新方法可以減少可能會導致資料遺失的衝突數目。 此方法:

    • 無法在使用者更新相同屬性時避免資料遺失。
    • 表示通常在 Web 應用程式中不實用。 它必須維持大量的狀態來追蹤所有擷取的值和新的值。 維持大量的狀態可能會影響應用程式的效能。
    • 與實體上的並行偵測相較之下,可能會增加應用程式的複雜度。
  • 您可以讓 John 的變更覆寫 Jane 的變更。

    下一次當有人瀏覽英文部門時,他們便會看到開始日期為 2013/9/1,以及擷取的美金 $350,000.00 元預算金額。 這稱之為「用戶端獲勝 (Client Wins)」或「最後寫入為準 (Last in Wins)」案例。 (所有來自用戶端的值都會優先於資料存放區中的資料。)若您沒有針對並行處理撰寫任何程式碼,便會自動發生「用戶端獲勝 (Client Wins)」的情況。

  • 您可以防止 John 的變更更新到資料庫中。 一般而言,應用程式會:

    • 顯示錯誤訊息。
    • 顯示資料的目前狀態。
    • 允許使用者重新套用變更。

    這稱之為「存放區獲勝 (Store Wins)」案例。 (資料存放區的值會優先於用戶端所提交的值。)您會在此教學課程中實作存放區獲勝案例。 這個方法可確保沒有任何變更會在使用者收到警示前遭到覆寫。

處理並行

當屬性已設定為並行權杖時:

資料庫和資料模型必須設定為支援擲回 DbUpdateConcurrencyException

在屬性上偵測並行衝突

您可以使用 ConcurrencyCheck 屬性來在屬性層級上偵測並行衝突。 屬性可套用至模型上的多個屬性。 如需詳細資訊,請參閱資料註解 - ConcurrencyCheck

此教學課程中不會使用 [ConcurrencyCheck] 屬性。

在資料列上偵測並行衝突

為了偵測並行衝突,一個 rowversion 追蹤資料行會新增到模型中。 rowversion

  • 是 SQL Server 的特定功能。 其他資料庫可能不會提供類似的功能。
  • 是用來判斷實體在從資料庫擷取之後是否有變更。

資料庫會產生一個循序 rowversion 數字,每一次資料列更新時該數字都會遞增。 在 UpdateDelete 命令中,Where 子句會包含 rowversion 的擷取值。 如果更新的資料列已變更:

  • rowversion 便不會符合擷取的值。
  • UpdateDelete 命令便會找不到資料列,因為 Where 子句包含了擷取的 rowversion
  • 於是便會擲回 DbUpdateConcurrencyException

在 EF Core 中,當 UpdateDelete 命令沒有更新任何資料列時,系統便會擲回並行例外狀況。

將追蹤屬性新增到 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 屬性表示此資料行會包含在 UpdateDelete 命令的 Where 子句中。 該屬性稱為 Timestamp,因為先前版本的 SQL Server 在以 SQL rowversion 類型取代之前使用了 SQL 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;

上述醒目提示程式碼顯示 WHERE 子句中包含 RowVersion。 若資料庫 RowVersion 不等於 RowVersion 參數 (@p2),則沒有任何資料列會獲得更新。

下列醒目提示程式碼顯示驗證確實有一個資料列獲得更新的 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 方法:

  • 執行移轉,以更新資料庫。

Scaffold Departments 模型

請遵循建立學生結構模型中的指示,並為模型類別使用 Department

上述命令會 Scaffold Department 模型。 在 Visual Studio 中開啟專案。

組建專案。

更新 Departments [索引] 頁面

Scaffolding 引擎會在 [索引] 頁面中建立 RowVersion 資料行,但該欄位不應該顯示出來。 在本教學課程中,RowVersion 的最後一個位元組會顯示出來,以協助您了解並行。 最後一個位元組不一定是唯一的。 實際的應用程式不會顯示 RowVersionRowVersion 的最後一個位元組。

更新 [索引] 頁面:

  • 使用 Departments 取代 Index。
  • 使用 RowVersion 的最後一個位元組取代包含 RowVersion 的標記。
  • 使用 FullName 取代 FirstMidName。

下列標記顯示了更新後的頁面:

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

更新 [編輯] 頁面模型

以下列程式碼來更新 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.");
        }
    }
}

為了偵測並行問題,系統會使用擷取實體時的 rowVersion 值更新 OriginalValue。 EF Core 會產生一個帶有包含了原始 RowVersion 值 WHERE 子句的 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 時在資料庫中的值。

下列程式碼會取得用戶端的值 (POST 到此方法的值) 以及資料庫的值:

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 的值設為從資料庫取得的新值。 下一次當使用者按一下 [儲存] 時,只有在上一次顯示 [編輯] 頁面之後發生的並行錯誤會被捕捉到。

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 的最後一個位元組,作為偵錯用途。
  • 使用強型別的 InstructorNameSL 取代 ViewData

使用 [編輯] 頁面測試並行衝突

開啟兩個英文部門上 [編輯] 頁面的瀏覽器執行個體:

  • 執行應用程式並選取 Departments。
  • 以滑鼠右鍵按一下英文部門的編輯超連結,然後選取 [Open in new tab] (在新索引標籤中開啟)
  • 在第一個索引標籤中,按一下英文部門的編輯超連結。

兩個瀏覽器索引標籤會顯示相同的資訊。

在第一個瀏覽器索引標籤中變更名稱,然後按一下 [儲存]

Department Edit page 1 after change

瀏覽器會顯示 [索引] 頁面,當中包含了變更之後的值和更新後的 rowVersion 指標。 請注意更新後的 rowVersion 指標。它會顯示在另一個索引標籤中的第二個回傳上。

在第二個瀏覽器索引標籤中變更不同欄位。

Department Edit page 2 after change

按一下 [檔案] 。 您會看到所有不符合資料庫值之欄位的錯誤訊息:

Department Edit page error message 1

此瀏覽器視窗並未嘗試變更 [名稱] 欄位。 複製並將目前的值 (語言 (Language)) 貼上 [名稱] 欄位。 按下 Tab 鍵切換至下一個欄位。用戶端驗證會移除錯誤訊息。

Department Edit page error message 2

再次按一下 [儲存]。 您在第二個瀏覽器索引標籤中輸入的值已儲存。 您會在 [索引] 頁面中看到儲存的值。

更新 [刪除] 頁面

以下列程式碼更新 *Delete* 頁面模型:

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

[刪除] 頁面會在實體擷取之後發生變更時偵測並行衝突。 Department.RowVersion 是擷取實體時的資料列版本。 當 EF Core 建立 SQL DELETE 命令時,它會包含一個帶有 RowVersion 的 WHERE 子句。 若 SQL DELETE 命令影響的資料列數目為零:

  • 表示 SQL DELETE 命令中的 RowVersion 不符合資料庫中的 RowVersion
  • 擲回 DbUpdateConcurrencyException。
  • 使用 concurrencyError 呼叫 OnGetAsync

更新 [刪除] 頁面

以下列程式碼來更新 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,以讓回傳繫結值。

使用 [刪除] 頁面測試並行衝突

建立一個測試部門。

開啟兩個測試部門上 [刪除] 頁面的瀏覽器執行個體:

  • 執行應用程式並選取 Departments。
  • 以滑鼠右鍵按一下測試部門的刪除超連結,然後選取 [Open in new tab] (在新索引標籤中開啟)
  • 按一下測試部門的編輯超連結。

兩個瀏覽器索引標籤會顯示相同的資訊。

在第一個瀏覽器索引標籤中變更預算,然後按一下 [儲存]

瀏覽器會顯示 [索引] 頁面,當中包含了變更之後的值和更新後的 rowVersion 指標。 請注意更新後的 rowVersion 指標。它會顯示在另一個索引標籤中的第二個回傳上。

從第二個索引標籤刪除測試部門。系統會使用從資料庫取得之目前的值顯示並行錯誤。 按一下 [刪除] 會刪除實體,除非 RowVersion 已更新。

請參閱繼承以了解如何繼承資料模型。

其他資源