第 8 部分,ASP.NET Core 中的 Razor Pages 和 EF Core - 并发

Tom DykstraJon P Smith

Contoso University Web 应用演示了如何使用 EF Core 和 Visual Studio 创建 Razor Pages Web 应用。 若要了解系列教程,请参阅第一个教程

如果遇到无法解决的问题,请下载已完成的应用,然后对比该代码与按教程所创建的代码。

本教程介绍如何处理多个用户并发更新同一实体时出现的冲突。

并发冲突

在以下情况下,会发生并发冲突:

  • 用户导航到实体的编辑页面。
  • 第一个用户的更改还未写入数据库之前,另一用户更新同一实体。

如果未启用并发检测,则最后更新数据库的人员将覆盖其他用户的更改。 如果这种风险是可以接受的,则并发编程的成本可能会超过收益。

悲观并发

预防并发冲突的一种方法是使用数据库锁定。 这称为悲观并发。 应用在读取要更新的数据库行之前,将请求锁定。 锁定某一行的更新访问权限之后,其他用户在第一个锁定释放之前无法锁定该行。

管理锁定有缺点。 它的编程可能很复杂,并且随着用户增加可能会导致性能问题。 Entity Framework Core 不提供对悲观并发的内置支持。

开放式并发

乐观并发允许发生并发冲突,并在并发冲突发生时作出正确反应。 例如,Jane 访问院系编辑页面,将英语系的预算从 350,000.00 美元更改为 0.00 美元。

Changing budget to 0

在 Jane 单击“保存”之前,John 访问了相同页面,并将开始日期字段从 2007/1/9 更改为 2013/1/9。

Changing start date to 2013

Jane 单击“保存”后看到更改生效,因为浏览器会显示预算金额为零的“索引”页面。

John 单击“编辑”页面上的“保存”,但页面的预算仍显示为 350,000.00 美元。 接下来的情况取决于并发冲突的处理方式:

  • 跟踪用户已修改的属性,并仅更新数据库中相应的列。

    在这种情况下,数据不会丢失。 两个用户更新了不同的属性。 下次有人浏览英语系时,将看到 Jane 和 John 两个人的更改。 这种更新方法可以减少导致数据丢失的冲突数。 这种方法具有一些缺点:

    • 无法避免数据丢失,如果对同一属性进行竞争性更改的话。
    • 通常不适用于 Web 应用。 它需要维持重要状态,以便跟踪所有提取值和新值。 维持大量状态可能影响应用性能。
    • 可能会增加应用复杂性(与实体上的并发检测相比)。
  • 让 John 的更改覆盖 Jane 的更改。

    下次有人浏览英语系时,将看到 2013/9/1 和提取的值 350,000.00 美元。 这种方法称为“客户端优先”或“最后一个优先”方案 。 客户端的所有值优先于数据存储的值。 基架代码不处理并发,“客户端优先”方案会自动执行。

  • 阻止在数据库中更新 John 的更改。 应用通常会:

    • 显示错误消息。
    • 显示数据的当前状态。
    • 允许用户重新应用更改。

    这称为“存储优先”方案。 数据存储值优先于客户端提交的值。 本教程中使用了“存储优先”方案。 此方法可确保用户在未收到警报时不会覆盖任何更改。

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;

前面突出显示的代码显示包含 ConcurrencyTokenWHERE 子句。 如果数据库 ConcurrencyToken 不等于 ConcurrencyToken 参数 @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");

搭建“院系”页面的基架

遵循搭建“学生”页的基架中的说明,但以下情况除外:

  • 创建“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 版本中的差异,并帮助:

  • 创作者维护一个同时用于这两个版本的代码库。
  • SQLite 开发人员将应用部署到 Azure 并使用 SQL Azure。

生成项目。

更新“索引”页

基架工具为“索引”页创建了 ConcurrencyToken 列,但生产应用中不会显示该字段。 本教程中显示 ConcurrencyToken 的最后一个部分,以帮助展示并发处理的工作原理。 无法保证最后一个部分本身是唯一的。

更新 Pages\Departments\Index.cshtml 页:

  • 用院系替换索引。
  • 更改包含 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.");
        }
    }
}

并发更新

当在 OnGetAsync 方法中提取时,OriginalValue 是使用实体中的 ConcurrencyToken 值进行更新。 EF Core 使用包含原始 ConcurrencyToken 值的 WHERE 子句生成 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 使用原始 ConcurrencyToken 值,该值来自于 SQL UPDATE 语句的 WHERE 子句中所显示的 Department 实体。

下面的代码演示了 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 值设置为从数据库检索的新值。 用户下次单击“保存”时,将仅捕获最后一次显示编辑页后发生的并发错误。

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;

更新“编辑”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

使用编辑页测试并发冲突

在英语系打开编辑的两个浏览器实例:

  • 运行应用,然后选择“院系”。
  • 右键单击英语系的“编辑”超链接,然后选择“在新选项卡中打开” 。
  • 在第一个选项卡中,单击英语系的“编辑”超链接。

两个浏览器选项卡显示相同信息。

在第一个浏览器选项卡中更改名称,然后单击“保存”。

Department Edit page 1 after change

浏览器显示更改值并更新 ConcurrencyToken 标记后的索引页。 请注意更新后的 ConcurrencyToken 标记,它在其他选项卡的第二回发中显示。

在第二个浏览器选项卡中更改不同字段。

Department Edit page 2 after change

单击“ 保存”。 可看见所有不匹配数据库值的字段的错误消息:

Department Edit page error message

此浏览器窗口将不会更改名称字段。 将当前值(语言)复制并粘贴到名称字段。 退出选项卡。客户端验证将删除错误消息。

再次单击“保存”。 保存在第二个浏览器选项卡中输入的值。 在索引页中可以看到保存的值。

更新“删除”页面模型

使用以下代码更新 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,以便回发能够绑定值。

测试并发冲突

创建测试系。

在测试系打开删除的两个浏览器实例:

  • 运行应用,然后选择“院系”。
  • 右键单击测试系的“删除”超链接,然后选择“在新选项卡中打开” 。
  • 单击测试系的“编辑”超链接。

两个浏览器选项卡显示相同信息。

在第一个浏览器选项卡中更改预算,然后单击“保存”。

浏览器显示更改值并更新 ConcurrencyToken 标记后的索引页。 请注意更新后的 ConcurrencyToken 标记,它在其他选项卡的第二回发中显示。

从第二个选项卡中删除测试部门。并发错误显示来自数据库的当前值。 单击“删除”删除实体,除非 ConcurrencyToken 已更新。

其他资源

后续步骤

这是本系列的最后一个教程。 本系列教程的 MVC 版本中介绍了其他主题。

本教程介绍如何处理多个用户并发更新同一实体(同时)时出现的冲突。

并发冲突

在以下情况下,会发生并发冲突:

  • 用户导航到实体的编辑页面。
  • 第一个用户的更改还未写入数据库之前,另一用户更新同一实体。

如果未启用并发检测,则最后更新数据库的人员将覆盖其他用户的更改。 如果这种风险是可以接受的,则并发编程的成本可能会超过收益。

悲观并发(锁定)

预防并发冲突的一种方法是使用数据库锁定。 这称为悲观并发。 应用在读取要更新的数据库行之前,将请求锁定。 锁定某一行的更新访问权限之后,其他用户在第一个锁定释放之前无法锁定该行。

管理锁定有缺点。 它的编程可能很复杂,并且随着用户增加可能会导致性能问题。 Entity Framework Core 未提供对它的内置支持,并且本教程不展示其实现方式。

开放式并发

乐观并发允许发生并发冲突,并在并发冲突发生时作出正确反应。 例如,Jane 访问院系编辑页面,将英语系的预算从 350,000.00 美元更改为 0.00 美元。

Changing budget to 0

在 Jane 单击“保存”之前,John 访问了相同页面,并将开始日期字段从 2007/1/9 更改为 2013/1/9。

Changing start date to 2013

Jane 单击“保存”后看到更改生效,因为浏览器会显示预算金额为零的“索引”页面。

John 单击“编辑”页面上的“保存”,但页面的预算仍显示为 350,000.00 美元。 接下来的情况取决于并发冲突的处理方式:

  • 可以跟踪用户已修改的属性,并仅更新数据库中相应的列。

    在这种情况下,数据不会丢失。 两个用户更新了不同的属性。 下次有人浏览英语系时,将看到 Jane 和 John 两个人的更改。 这种更新方法可以减少导致数据丢失的冲突数。 这种方法具有一些缺点:

    • 无法避免数据丢失,如果对同一属性进行竞争性更改的话。
    • 通常不适用于 Web 应用。 它需要维持重要状态,以便跟踪所有提取值和新值。 维持大量状态可能影响应用性能。
    • 可能会增加应用复杂性(与实体上的并发检测相比)。
  • 可让 John 的更改覆盖 Jane 的更改。

    下次有人浏览英语系时,将看到 2013/9/1 和提取的值 350,000.00 美元。 这种方法称为“客户端优先”或“最后一个优先”方案 。 (客户端的所有值优先于数据存储的值。)如果不对并发处理进行任何编码,则自动执行“客户端优先”。

  • 可以阻止在数据库中更新 John 的更改。 应用通常会:

    • 显示错误消息。
    • 显示数据的当前状态。
    • 允许用户重新应用更改。

    这称为“存储优先”方案。 (数据存储值优先于客户端提交的值。)本教程实施“存储优先”方案。 此方法可确保用户在未收到警报时不会覆盖任何更改。

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 子句而无法找到要更新的行。 如果 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

数据库生成有序的行版本号,该版本号随着每次行的更新递增。 在 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;

前面突出显示的代码显示包含 RowVersionWHERE 子句。 如果数据库 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

更新数据库

添加 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
    

搭建“院系”页面的基架

  • 遵循搭建“学生”页的基架中的说明,但以下情况除外:

  • 创建“Pages/Departments”文件夹。

  • Department 用于模型类。

    • 使用现有的上下文类,而不是新建上下文类。

生成项目。

更新“索引”页

基架工具为“索引”页创建了 RowVersion 列,但生产应用中不会显示该字段。 本教程中显示 RowVersion 的最后一个字节,以帮助展示并发处理的工作原理。 无法保证最后一个字节本身是唯一的。

更新 Pages\Departments\Index.cshtml 页:

  • 用院系替换索引。
  • 更改包含 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>

更新编辑页模型

使用以下代码更新 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.");
        }
    }
}

当在 OnGetAsync 方法中提取时,OriginalValue 是使用实体中的 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 中的值是最初在“编辑”页的 Get 请求中所提取的实体中的值。 通过 Razor 页面中显示将要编辑的实体的隐藏字段将该值提供给 OnPost 方法。 模型绑定器将隐藏字段值复制到 Department.RowVersion
  • OriginalValue 是 EF Core 将用于 Where 子句的值。 在执行突出显示的代码行之前,OriginalValue 具有在此方法中调用 FirstOrDefaultAsync 时数据库中的值,该值可能与“编辑”页面上所显示的值不同。
  • 突出显示的代码可确保 EF Core 使用原始 RowVersion 值,该值来自于 SQL UPDATE 语句的 Where 子句中所显示的 Department 实体。

发生并发错误时,以下突出显示的代码会获取客户端值(发布到此方法的值)和数据库值。

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var exceptionEntry = ex.Entries.Single();
        var clientValues = (Department)exceptionEntry.Entity;
        var databaseEntry = exceptionEntry.GetDatabaseValues();
        if (databaseEntry == null)
        {
            ModelState.AddModelError(string.Empty, "Unable to save. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await setDbErrorMessage(dbValues, clientValues, _context);

        // Save the current RowVersion so next postback
        // matches unless an new concurrency issue happens.
        Department.RowVersion = (byte[])dbValues.RowVersion;
        // Clear the model error for the next postback.
        ModelState.Remove("Department.RowVersion");
    }

以下代码为每列添加自定义错误消息,这些列中的数据库值与发布到 OnPostAsync 的值不同:

private async Task setDbErrorMessage(Department dbValues,
        Department clientValues, SchoolContext context)
{

    if (dbValues.Name != clientValues.Name)
    {
        ModelState.AddModelError("Department.Name",
            $"Current value: {dbValues.Name}");
    }
    if (dbValues.Budget != clientValues.Budget)
    {
        ModelState.AddModelError("Department.Budget",
            $"Current value: {dbValues.Budget:c}");
    }
    if (dbValues.StartDate != clientValues.StartDate)
    {
        ModelState.AddModelError("Department.StartDate",
            $"Current value: {dbValues.StartDate:d}");
    }
    if (dbValues.InstructorID != clientValues.InstructorID)
    {
        Instructor dbInstructor = await _context.Instructors
           .FindAsync(dbValues.InstructorID);
        ModelState.AddModelError("Department.InstructorID",
            $"Current value: {dbInstructor?.FullName}");
    }

    ModelState.AddModelError(string.Empty,
        "The record you attempted to edit "
      + "was modified by another user after you. The "
      + "edit operation was canceled and the current values in the database "
      + "have been displayed. If you still want to edit this record, click "
      + "the Save button again.");
}

以下突出显示的代码将 RowVersion 值设置为从数据库检索的新值。 用户下次单击“保存”时,将仅捕获最后一次显示编辑页后发生的并发错误。

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var exceptionEntry = ex.Entries.Single();
        var clientValues = (Department)exceptionEntry.Entity;
        var databaseEntry = exceptionEntry.GetDatabaseValues();
        if (databaseEntry == null)
        {
            ModelState.AddModelError(string.Empty, "Unable to save. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await setDbErrorMessage(dbValues, clientValues, _context);

        // Save the current RowVersion so next postback
        // matches unless an new concurrency issue happens.
        Department.RowVersion = (byte[])dbValues.RowVersion;
        // Clear the model error for the next postback.
        ModelState.Remove("Department.RowVersion");
    }

ModelState 具有旧的 RowVersion 值,因此需使用 ModelState.Remove 语句。 在 Razor 页面中,当两者都存在时,字段的 ModelState 值优于模型属性值。

更新“编辑”页

使用以下代码更新 Pages/Departments/Edit.cshtml

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
    ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Department.DepartmentID" />
            <input type="hidden" asp-for="Department.RowVersion" />
            <div class="form-group">
                <label>RowVersion</label>
                @Model.Department.RowVersion[7]
            </div>
            <div class="form-group">
                <label asp-for="Department.Name" class="control-label"></label>
                <input asp-for="Department.Name" class="form-control" />
                <span asp-validation-for="Department.Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.Budget" class="control-label"></label>
                <input asp-for="Department.Budget" class="form-control" />
                <span asp-validation-for="Department.Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.StartDate" class="control-label"></label>
                <input asp-for="Department.StartDate" class="form-control" />
                <span asp-validation-for="Department.StartDate" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <label class="control-label">Instructor</label>
                <select asp-for="Department.InstructorID" class="form-control"
                        asp-items="@Model.InstructorNameSL"></select>
                <span asp-validation-for="Department.InstructorID" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>
<div>
    <a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

前面的代码:

  • page 指令从 @page 更新为 @page "{id:int}"
  • 添加隐藏的行版本。 必须添加 RowVersion,以便回发能够绑定值。
  • 显示 RowVersion 的最后一个字节以进行调试。
  • ViewData 替换为强类型 InstructorNameSL

使用编辑页测试并发冲突

在英语系打开编辑的两个浏览器实例:

  • 运行应用,然后选择“院系”。
  • 右键单击英语系的“编辑”超链接,然后选择“在新选项卡中打开” 。
  • 在第一个选项卡中,单击英语系的“编辑”超链接。

两个浏览器选项卡显示相同信息。

在第一个浏览器选项卡中更改名称,然后单击“保存”。

Department Edit page 1 after change

浏览器显示更改值并更新 rowVersion 标记后的索引页。 请注意更新后的 rowVersion 标记,它在其他选项卡的第二回发中显示。

在第二个浏览器选项卡中更改不同字段。

Department Edit page 2 after change

单击“ 保存”。 可看见所有不匹配数据库值的字段的错误消息:

Department Edit page error message

此浏览器窗口将不会更改名称字段。 将当前值(语言)复制并粘贴到名称字段。 退出选项卡。客户端验证将删除错误消息。

再次单击“保存”。 保存在第二个浏览器选项卡中输入的值。 在索引页中可以看到保存的值。

更新“删除”页面模型

使用以下代码更新 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,以便回发能够绑定值。

测试并发冲突

创建测试系。

在测试系打开删除的两个浏览器实例:

  • 运行应用,然后选择“院系”。
  • 右键单击测试系的“删除”超链接,然后选择“在新选项卡中打开” 。
  • 单击测试系的“编辑”超链接。

两个浏览器选项卡显示相同信息。

在第一个浏览器选项卡中更改预算,然后单击“保存”。

浏览器显示更改值并更新 rowVersion 标记后的索引页。 请注意更新后的 rowVersion 标记,它在其他选项卡的第二回发中显示。

从第二个选项卡中删除测试部门。并发错误显示来自数据库的当前值。 单击“删除”删除实体,除非 RowVersion 已更新。

其他资源

后续步骤

这是本系列的最后一个教程。 本系列教程的 MVC 版本中介绍了其他主题。

本教程介绍如何处理多个用户并发更新同一实体(同时)时出现的冲突。 如果遇到无法解决的问题,请下载或查看已完成的应用下载说明

并发冲突

在以下情况下,会发生并发冲突:

  • 用户导航到实体的编辑页面。
  • 第一个用户的更改还未写入数据库之前,另一用户更新同一实体。

如果未启用并发检测,当发生并发更新时:

  • 最后一个更新优先。 也就是最后一个更新的值保存至数据库。
  • 第一个并发更新将会丢失。

开放式并发

乐观并发允许发生并发冲突,并在并发冲突发生时作出正确反应。 例如,Jane 访问院系编辑页面,将英语系的预算从 350,000.00 美元更改为 0.00 美元。

Changing budget to 0

在 Jane 单击“保存”之前,John 访问了相同页面,并将开始日期字段从 2007/1/9 更改为 2013/1/9。

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 美元。 这种方法称为“客户端优先”或“最后一个优先”方案 。 (客户端的所有值优先于数据存储的值。)如果不对并发处理进行任何编码,则自动执行“客户端优先”。

  • 可以阻止在数据库中更新 John 的更改。 应用通常会:

    • 显示错误消息。
    • 显示数据的当前状态。
    • 允许用户重新应用更改。

    这称为“存储优先”方案。 (数据存储值优先于客户端提交的值。)本教程实施“存储优先”方案。 此方法可确保用户在未收到警报时不会覆盖任何更改。

处理并发

当属性配置为并发令牌时:

数据库和数据模型必须配置为支持引发 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;

前面突出显示的代码显示包含 RowVersionWHERE 子句。 如果数据库 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 方法:

  • 运行迁移以更新数据库。

构架院系模型

按照为“学生”模型搭建基架中的说明操作,并对模型类使用 Department

上述命令为 Department 模型创建基架。 在 Visual Studio 中打开项目。

生成项目。

更新院系索引页

基架引擎为索引页创建 RowVersion 列,但不应显示该字段。 本教程中显示 RowVersion 的最后一个字节,以帮助理解并发。 不能保证最后一个字节是唯一的。 实际应用不会显示 RowVersionRowVersion 的最后一个字节。

更新索引页:

  • 用院系替换索引。
  • 将包含 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>

更新编辑页模型

使用以下代码更新 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 为实体提取后的值。 使用此方法调用 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 值设置为从数据库检索的新值。 用户下次单击“保存”时,将仅捕获最后一次显示编辑页后发生的并发错误。

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 具有旧的 RowVersion 值,因此需使用 ModelState.Remove 语句。 在 Razor 页面中,当两者都存在时,字段的 ModelState 值优于模型属性值。

更新“编辑”页

使用以下标记更新 Pages/Departments/Edit.cshtml

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
    ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Department.DepartmentID" />
            <input type="hidden" asp-for="Department.RowVersion" />
            <div class="form-group">
                <label>RowVersion</label>
                @Model.Department.RowVersion[7]
            </div>
            <div class="form-group">
                <label asp-for="Department.Name" class="control-label"></label>
                <input asp-for="Department.Name" class="form-control" />
                <span asp-validation-for="Department.Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.Budget" class="control-label"></label>
                <input asp-for="Department.Budget" class="form-control" />
                <span asp-validation-for="Department.Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.StartDate" class="control-label"></label>
                <input asp-for="Department.StartDate" class="form-control" />
                <span asp-validation-for="Department.StartDate" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <label class="control-label">Instructor</label>
                <select asp-for="Department.InstructorID" class="form-control"
                        asp-items="@Model.InstructorNameSL"></select>
                <span asp-validation-for="Department.InstructorID" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-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

使用编辑页测试并发冲突

在英语系打开编辑的两个浏览器实例:

  • 运行应用,然后选择“院系”。
  • 右键单击英语系的“编辑”超链接,然后选择“在新选项卡中打开” 。
  • 在第一个选项卡中,单击英语系的“编辑”超链接。

两个浏览器选项卡显示相同信息。

在第一个浏览器选项卡中更改名称,然后单击“保存”。

Department Edit page 1 after change

浏览器显示更改值并更新 rowVersion 标记后的索引页。 请注意更新后的 rowVersion 标记,它在其他选项卡的第二回发中显示。

在第二个浏览器选项卡中更改不同字段。

Department Edit page 2 after change

单击“保存” 。 可看见所有不匹配数据库值的字段的错误消息:

Department Edit page error message 1

此浏览器窗口将不会更改名称字段。 将当前值(语言)复制并粘贴到名称字段。 退出选项卡。客户端验证将删除错误消息。

Department Edit page error message 2

再次单击“保存”。 保存在第二个浏览器选项卡中输入的值。 在索引页中可以看到保存的值。

更新“删除”页

使用以下代码更新“删除”页模型:

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,以便回发绑定值。

使用删除页测试并发冲突

创建测试系。

在测试系打开删除的两个浏览器实例:

  • 运行应用,然后选择“院系”。
  • 右键单击测试系的“删除”超链接,然后选择“在新选项卡中打开” 。
  • 单击测试系的“编辑”超链接。

两个浏览器选项卡显示相同信息。

在第一个浏览器选项卡中更改预算,然后单击“保存”。

浏览器显示更改值并更新 rowVersion 标记后的索引页。 请注意更新后的 rowVersion 标记,它在其他选项卡的第二回发中显示。

从第二个选项卡中删除测试部门。并发错误显示来自数据库的当前值。 单击“删除”删除实体,除非 RowVersion 已更新。

请参阅继承了解如何继承数据模型。

其他资源