ASP.NET Core 中的 Razor 页面和 EF Core - 并发 - 第 8 个教程(共 8 个)Razor Pages with EF Core in ASP.NET Core - Concurrency - 8 of 8

本文内容

作者:Rick AndersonTom DykstraJon P Smith

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

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

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

并发冲突Concurrency conflicts

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

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

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

悲观并发(锁定)Pessimistic concurrency (locking)

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

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

开放式并发Optimistic concurrency

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

将预算更改为零

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

将开始日期更改为 2013

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

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

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

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

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

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

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

    • 显示错误消息。
    • 显示数据的当前状态。
    • 允许用户重新应用更改。
      这称为“存储优先”方案 。(数据存储值优先于客户端提交的值。)本教程实施“存储优先”方案。此方法可确保用户在未收到警报时不会覆盖任何更改。

EF Core 中的冲突检测Conflict detection in EF Core

EF Core 在检测到冲突时会引发 DbConcurrencyException 异常。数据模型必须配置为启用冲突检测。启用冲突检测的选项包括以下项:

  • 配置 EF Core,在 Update 或 Delete 命令的 Where 子句中包含配置为并发令牌的列的原始值。

调用 SaveChanges 时,Where 子句查找使用 ConcurrencyCheck 特性注释的所有属性的原始值。如果在第一次读取行之后有任意并发令牌属性发生了更改,更新语句将无法查找到要更新的行。EF Core 将其解释为并发冲突。对于包含许多列的数据库表,此方法可能导致非常多的 Where 子句,并且可能需要大量的状态。因此通常不建议使用此方法,并且它也不是本教程中使用的方法。

  • 数据库表中包含一个可用于确定某行更改时间的跟踪列。

在 SQL Server 数据库中,跟踪列的数据类型是 rowversionrowversion 值是一个序列号,该编号随着每次行的更新递增。在 Update 或 Delete 命令中,Where 子句包含跟踪列的原始值(原始行版本号)。如果其他用户已更改要更新的行,则 rowversion 列中的值与原始值不同。在这种情况下,Update 或 Delete 语句会由于 Where 子句而无法找到要更新的行。如果 Update 或 Delete 命令未影响任何行,EF Core 会引发并发异常。

添加跟踪属性Add a tracking property

在 Models/Department.cs 中,添加名为 RowVersion 的跟踪属性 :

  1. using System;
  2. using System.Collections.Generic;
  3. using System.ComponentModel.DataAnnotations;
  4. using System.ComponentModel.DataAnnotations.Schema;
  5. namespace ContosoUniversity.Models
  6. {
  7. public class Department
  8. {
  9. public int DepartmentID { get; set; }
  10. [StringLength(50, MinimumLength = 3)]
  11. public string Name { get; set; }
  12. [DataType(DataType.Currency)]
  13. [Column(TypeName = "money")]
  14. public decimal Budget { get; set; }
  15. [DataType(DataType.Date)]
  16. [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
  17. [Display(Name = "Start Date")]
  18. public DateTime StartDate { get; set; }
  19. public int? InstructorID { get; set; }
  20. [Timestamp]
  21. public byte[] RowVersion { get; set; }
  22. public Instructor Administrator { get; set; }
  23. public ICollection<Course> Courses { get; set; }
  24. }
  25. }

Timestamp 特性用于将列标识为并发跟踪列。Fluent API 是指定跟踪属性的另一种方法:

  1. modelBuilder.Entity<Department>()
  2. .Property<byte[]>("RowVersion")
  3. .IsRowVersion();

对于 SQL Server 数据库,定义为字节数组的实体属性上的 [Timestamp] 特性:

  • 使列包含在 DELETE 和 UPDATE WHERE 子句中。
  • 将数据库中的列类型设置为 rowversion

数据库生成有序的行版本号,该版本号随着每次行的更新递增。UpdateDelete 命令中,Where 子句包括提取的行版本值。如果要更新的行在提取之后已更改:

  • 当前的行版本值与提取值不相匹配。
  • UpdateDelete 命令不查找行,因为 Where 子句会查找提取行的版本值。
  • 引发一个 DbUpdateConcurrencyException

以下代码显示更新 Department 名称时由 EF Core 生成的部分 T-SQL:

  1. SET NOCOUNT ON;
  2. UPDATE [Department] SET [Name] = @p0
  3. WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
  4. SELECT [RowVersion]
  5. FROM [Department]
  6. WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

前面突出显示的代码显示包含 RowVersionWHERE 子句。如果数据库 RowVersion 不等于 RowVersion 参数 (@p2),则不更新行。

以下突出显示的代码显示验证更新哪一行的 T-SQL:

  1. SET NOCOUNT ON;
  2. UPDATE [Department] SET [Name] = @p0
  3. WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
  4. SELECT [RowVersion]
  5. FROM [Department]
  6. WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

@@ROWCOUNT 返回受上一语句影响的行数。如果没有更新行,EF Core 会引发 DbUpdateConcurrencyException

对于 SQLite 数据库,定义为字节数组的实体属性上的 [Timestamp] 特性:

  • 使列包含在 DELETE 和 UPDATE WHERE 子句中。
  • 映射到 BLOB 列类型。

每当行更新时,数据库触发器都会将 RowVersion 列更新为新的随机字节数组。UpdateDelete 命令中,Where 子句包括 RowVersion 列的提取值。如果要更新的行在提取之后已更改:

  • 当前的行版本值与提取值不相匹配。
  • UpdateDelete 命令不查找行,因为 Where 子句会查找原始行版本值。
  • 引发一个 DbUpdateConcurrencyException

更新数据库Update the database

添加 RowVersion 属性可更改需要迁移的数据库模型。

生成项目。

  • 在 PMC 中运行以下命令:
  1. Add-Migration RowVersion
  • 在终端中运行以下命令:
  1. dotnet ef migrations add RowVersion

此命令:

  • 创建 Migrations/{time stamp}_RowVersion.cs 迁移文件 。

  • 更新 Migrations/SchoolContextModelSnapshot.cs 文件 。此次更新将以下突出显示的代码添加到 BuildModel 方法:

  1. modelBuilder.Entity("ContosoUniversity.Models.Department", b =>
  2. {
  3. b.Property<int>("DepartmentID")
  4. .ValueGeneratedOnAdd()
  5. .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
  6. b.Property<decimal>("Budget")
  7. .HasColumnType("money");
  8. b.Property<int?>("InstructorID");
  9. b.Property<string>("Name")
  10. .HasMaxLength(50);
  11. b.Property<byte[]>("RowVersion")
  12. .IsConcurrencyToken()
  13. .ValueGeneratedOnAddOrUpdate();
  14. b.Property<DateTime>("StartDate");
  15. b.HasKey("DepartmentID");
  16. b.HasIndex("InstructorID");
  17. b.ToTable("Department");
  18. });
  • 在 PMC 中运行以下命令:
  1. Update-Database
  • 打开 Migrations/<timestamp>_RowVersion.cs 文件,并添加以下突出显示的代码:
  1. using System;
  2. using Microsoft.EntityFrameworkCore.Migrations;
  3. namespace ContosoUniversity.Migrations
  4. {
  5. public partial class RowVersion : Migration
  6. {
  7. protected override void Up(MigrationBuilder migrationBuilder)
  8. {
  9. migrationBuilder.AddColumn<byte[]>(
  10. name: "RowVersion",
  11. table: "Department",
  12. rowVersion: true,
  13. nullable: true);
  14. migrationBuilder.Sql(
  15. @"
  16. UPDATE Department
  17. SET RowVersion = randomblob(8)
  18. ");
  19. migrationBuilder.Sql(
  20. @"
  21. CREATE TRIGGER SetRowVersionOnUpdate
  22. AFTER UPDATE ON Department
  23. BEGIN
  24. UPDATE Department
  25. SET RowVersion = randomblob(8)
  26. WHERE rowid = NEW.rowid;
  27. END
  28. ");
  29. migrationBuilder.Sql(
  30. @"
  31. CREATE TRIGGER SetRowVersionOnInsert
  32. AFTER INSERT ON Department
  33. BEGIN
  34. UPDATE Department
  35. SET RowVersion = randomblob(8)
  36. WHERE rowid = NEW.rowid;
  37. END
  38. ");
  39. }
  40. protected override void Down(MigrationBuilder migrationBuilder)
  41. {
  42. migrationBuilder.DropColumn(
  43. name: "RowVersion",
  44. table: "Department");
  45. }
  46. }
  47. }

前面的代码:

  • 将现有行更新为随机 blob 值。
  • 添加数据库触发器,该触发器在行更新时将 RowVersion 列设置为随机 blob 值。
    • 在终端中运行以下命令:
  1. dotnet ef database update

搭建“院系”页面的基架Scaffold Department pages

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

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

  • Department 用于模型类。

    • 使用现有的上下文类,而不是新建上下文类。
  • 创建“Pages/Departments”文件夹 。

  • 运行以下命令,搭建“院系”页的基架。

在 Windows 上:

  1. dotnet aspnet-codegenerator razorpage -m Department -dc SchoolContext -udl -outDir Pages\Departments --referenceScriptLibraries

在 Linux 或 macOS 上:

  1. dotnet aspnet-codegenerator razorpage -m Department -dc SchoolContext -udl -outDir Pages/Departments --referenceScriptLibraries

生成项目。

更新“索引”页Update the Index page

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

更新 Pages\Departments\Index.cshtml 页:

  • 用院系替换索引。
  • 更改包含 RowVersion 的代码,以便只显示字节数组的最后一个字节。
  • 将 FirstMidName 替换为 FullName。

以下代码显示更新后的页面:

  1. @page
  2. @model ContosoUniversity.Pages.Departments.IndexModel
  3. @{
  4. ViewData["Title"] = "Departments";
  5. }
  6. <h2>Departments</h2>
  7. <p>
  8. <a asp-page="Create">Create New</a>
  9. </p>
  10. <table class="table">
  11. <thead>
  12. <tr>
  13. <th>
  14. @Html.DisplayNameFor(model => model.Department[0].Name)
  15. </th>
  16. <th>
  17. @Html.DisplayNameFor(model => model.Department[0].Budget)
  18. </th>
  19. <th>
  20. @Html.DisplayNameFor(model => model.Department[0].StartDate)
  21. </th>
  22. <th>
  23. @Html.DisplayNameFor(model => model.Department[0].Administrator)
  24. </th>
  25. <th>
  26. RowVersion
  27. </th>
  28. <th></th>
  29. </tr>
  30. </thead>
  31. <tbody>
  32. @foreach (var item in Model.Department)
  33. {
  34. <tr>
  35. <td>
  36. @Html.DisplayFor(modelItem => item.Name)
  37. </td>
  38. <td>
  39. @Html.DisplayFor(modelItem => item.Budget)
  40. </td>
  41. <td>
  42. @Html.DisplayFor(modelItem => item.StartDate)
  43. </td>
  44. <td>
  45. @Html.DisplayFor(modelItem => item.Administrator.FullName)
  46. </td>
  47. <td>
  48. @item.RowVersion[7]
  49. </td>
  50. <td>
  51. <a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
  52. <a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
  53. <a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
  54. </td>
  55. </tr>
  56. }
  57. </tbody>
  58. </table>

更新编辑页模型Update the Edit page model

使用以下代码更新 Pages\Departments\Edit.cshtml.cs :

  1. using ContosoUniversity.Data;
  2. using ContosoUniversity.Models;
  3. using Microsoft.AspNetCore.Mvc;
  4. using Microsoft.AspNetCore.Mvc.RazorPages;
  5. using Microsoft.AspNetCore.Mvc.Rendering;
  6. using Microsoft.EntityFrameworkCore;
  7. using System.Linq;
  8. using System.Threading.Tasks;
  9. namespace ContosoUniversity.Pages.Departments
  10. {
  11. public class EditModel : PageModel
  12. {
  13. private readonly ContosoUniversity.Data.SchoolContext _context;
  14. public EditModel(ContosoUniversity.Data.SchoolContext context)
  15. {
  16. _context = context;
  17. }
  18. [BindProperty]
  19. public Department Department { get; set; }
  20. // Replace ViewData["InstructorID"]
  21. public SelectList InstructorNameSL { get; set; }
  22. public async Task<IActionResult> OnGetAsync(int id)
  23. {
  24. Department = await _context.Departments
  25. .Include(d => d.Administrator) // eager loading
  26. .AsNoTracking() // tracking not required
  27. .FirstOrDefaultAsync(m => m.DepartmentID == id);
  28. if (Department == null)
  29. {
  30. return NotFound();
  31. }
  32. // Use strongly typed data rather than ViewData.
  33. InstructorNameSL = new SelectList(_context.Instructors,
  34. "ID", "FirstMidName");
  35. return Page();
  36. }
  37. public async Task<IActionResult> OnPostAsync(int id)
  38. {
  39. if (!ModelState.IsValid)
  40. {
  41. return Page();
  42. }
  43. var departmentToUpdate = await _context.Departments
  44. .Include(i => i.Administrator)
  45. .FirstOrDefaultAsync(m => m.DepartmentID == id);
  46. if (departmentToUpdate == null)
  47. {
  48. return HandleDeletedDepartment();
  49. }
  50. _context.Entry(departmentToUpdate)
  51. .Property("RowVersion").OriginalValue = Department.RowVersion;
  52. if (await TryUpdateModelAsync<Department>(
  53. departmentToUpdate,
  54. "Department",
  55. s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
  56. {
  57. try
  58. {
  59. await _context.SaveChangesAsync();
  60. return RedirectToPage("./Index");
  61. }
  62. catch (DbUpdateConcurrencyException ex)
  63. {
  64. var exceptionEntry = ex.Entries.Single();
  65. var clientValues = (Department)exceptionEntry.Entity;
  66. var databaseEntry = exceptionEntry.GetDatabaseValues();
  67. if (databaseEntry == null)
  68. {
  69. ModelState.AddModelError(string.Empty, "Unable to save. " +
  70. "The department was deleted by another user.");
  71. return Page();
  72. }
  73. var dbValues = (Department)databaseEntry.ToObject();
  74. await setDbErrorMessage(dbValues, clientValues, _context);
  75. // Save the current RowVersion so next postback
  76. // matches unless an new concurrency issue happens.
  77. Department.RowVersion = (byte[])dbValues.RowVersion;
  78. // Clear the model error for the next postback.
  79. ModelState.Remove("Department.RowVersion");
  80. }
  81. }
  82. InstructorNameSL = new SelectList(_context.Instructors,
  83. "ID", "FullName", departmentToUpdate.InstructorID);
  84. return Page();
  85. }
  86. private IActionResult HandleDeletedDepartment()
  87. {
  88. var deletedDepartment = new Department();
  89. // ModelState contains the posted data because of the deletion error
  90. // and will overide the Department instance values when displaying Page().
  91. ModelState.AddModelError(string.Empty,
  92. "Unable to save. The department was deleted by another user.");
  93. InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
  94. return Page();
  95. }
  96. private async Task setDbErrorMessage(Department dbValues,
  97. Department clientValues, SchoolContext context)
  98. {
  99. if (dbValues.Name != clientValues.Name)
  100. {
  101. ModelState.AddModelError("Department.Name",
  102. $"Current value: {dbValues.Name}");
  103. }
  104. if (dbValues.Budget != clientValues.Budget)
  105. {
  106. ModelState.AddModelError("Department.Budget",
  107. $"Current value: {dbValues.Budget:c}");
  108. }
  109. if (dbValues.StartDate != clientValues.StartDate)
  110. {
  111. ModelState.AddModelError("Department.StartDate",
  112. $"Current value: {dbValues.StartDate:d}");
  113. }
  114. if (dbValues.InstructorID != clientValues.InstructorID)
  115. {
  116. Instructor dbInstructor = await _context.Instructors
  117. .FindAsync(dbValues.InstructorID);
  118. ModelState.AddModelError("Department.InstructorID",
  119. $"Current value: {dbInstructor?.FullName}");
  120. }
  121. ModelState.AddModelError(string.Empty,
  122. "The record you attempted to edit "
  123. + "was modified by another user after you. The "
  124. + "edit operation was canceled and the current values in the database "
  125. + "have been displayed. If you still want to edit this record, click "
  126. + "the Save button again.");
  127. }
  128. }
  129. }

OnGet 方法中提取 OriginalValue 时,该值使用实体中的 rowVersion 值更新。EF Core 使用包含原始 RowVersion 值的 WHERE 子句生成 SQL UPDATE 命令。如果没有行受到 UPDATE 命令影响(没有行具有原始 RowVersion 值),将引发 DbUpdateConcurrencyException 异常。

  1. public async Task<IActionResult> OnPostAsync(int id)
  2. {
  3. if (!ModelState.IsValid)
  4. {
  5. return Page();
  6. }
  7. var departmentToUpdate = await _context.Departments
  8. .Include(i => i.Administrator)
  9. .FirstOrDefaultAsync(m => m.DepartmentID == id);
  10. if (departmentToUpdate == null)
  11. {
  12. return HandleDeletedDepartment();
  13. }
  14. _context.Entry(departmentToUpdate)
  15. .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 实体。

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

  1. if (await TryUpdateModelAsync<Department>(
  2. departmentToUpdate,
  3. "Department",
  4. s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
  5. {
  6. try
  7. {
  8. await _context.SaveChangesAsync();
  9. return RedirectToPage("./Index");
  10. }
  11. catch (DbUpdateConcurrencyException ex)
  12. {
  13. var exceptionEntry = ex.Entries.Single();
  14. var clientValues = (Department)exceptionEntry.Entity;
  15. var databaseEntry = exceptionEntry.GetDatabaseValues();
  16. if (databaseEntry == null)
  17. {
  18. ModelState.AddModelError(string.Empty, "Unable to save. " +
  19. "The department was deleted by another user.");
  20. return Page();
  21. }
  22. var dbValues = (Department)databaseEntry.ToObject();
  23. await setDbErrorMessage(dbValues, clientValues, _context);
  24. // Save the current RowVersion so next postback
  25. // matches unless an new concurrency issue happens.
  26. Department.RowVersion = (byte[])dbValues.RowVersion;
  27. // Clear the model error for the next postback.
  28. ModelState.Remove("Department.RowVersion");
  29. }

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

  1. private async Task setDbErrorMessage(Department dbValues,
  2. Department clientValues, SchoolContext context)
  3. {
  4. if (dbValues.Name != clientValues.Name)
  5. {
  6. ModelState.AddModelError("Department.Name",
  7. $"Current value: {dbValues.Name}");
  8. }
  9. if (dbValues.Budget != clientValues.Budget)
  10. {
  11. ModelState.AddModelError("Department.Budget",
  12. $"Current value: {dbValues.Budget:c}");
  13. }
  14. if (dbValues.StartDate != clientValues.StartDate)
  15. {
  16. ModelState.AddModelError("Department.StartDate",
  17. $"Current value: {dbValues.StartDate:d}");
  18. }
  19. if (dbValues.InstructorID != clientValues.InstructorID)
  20. {
  21. Instructor dbInstructor = await _context.Instructors
  22. .FindAsync(dbValues.InstructorID);
  23. ModelState.AddModelError("Department.InstructorID",
  24. $"Current value: {dbInstructor?.FullName}");
  25. }
  26. ModelState.AddModelError(string.Empty,
  27. "The record you attempted to edit "
  28. + "was modified by another user after you. The "
  29. + "edit operation was canceled and the current values in the database "
  30. + "have been displayed. If you still want to edit this record, click "
  31. + "the Save button again.");
  32. }

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

  1. if (await TryUpdateModelAsync<Department>(
  2. departmentToUpdate,
  3. "Department",
  4. s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
  5. {
  6. try
  7. {
  8. await _context.SaveChangesAsync();
  9. return RedirectToPage("./Index");
  10. }
  11. catch (DbUpdateConcurrencyException ex)
  12. {
  13. var exceptionEntry = ex.Entries.Single();
  14. var clientValues = (Department)exceptionEntry.Entity;
  15. var databaseEntry = exceptionEntry.GetDatabaseValues();
  16. if (databaseEntry == null)
  17. {
  18. ModelState.AddModelError(string.Empty, "Unable to save. " +
  19. "The department was deleted by another user.");
  20. return Page();
  21. }
  22. var dbValues = (Department)databaseEntry.ToObject();
  23. await setDbErrorMessage(dbValues, clientValues, _context);
  24. // Save the current RowVersion so next postback
  25. // matches unless an new concurrency issue happens.
  26. Department.RowVersion = (byte[])dbValues.RowVersion;
  27. // Clear the model error for the next postback.
  28. ModelState.Remove("Department.RowVersion");
  29. }

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

更新 Razor 页面Update the Razor page

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

  1. @page "{id:int}"
  2. @model ContosoUniversity.Pages.Departments.EditModel
  3. @{
  4. ViewData["Title"] = "Edit";
  5. }
  6. <h2>Edit</h2>
  7. <h4>Department</h4>
  8. <hr />
  9. <div class="row">
  10. <div class="col-md-4">
  11. <form method="post">
  12. <div asp-validation-summary="ModelOnly" class="text-danger"></div>
  13. <input type="hidden" asp-for="Department.DepartmentID" />
  14. <input type="hidden" asp-for="Department.RowVersion" />
  15. <div class="form-group">
  16. <label>RowVersion</label>
  17. @Model.Department.RowVersion[7]
  18. </div>
  19. <div class="form-group">
  20. <label asp-for="Department.Name" class="control-label"></label>
  21. <input asp-for="Department.Name" class="form-control" />
  22. <span asp-validation-for="Department.Name" class="text-danger"></span>
  23. </div>
  24. <div class="form-group">
  25. <label asp-for="Department.Budget" class="control-label"></label>
  26. <input asp-for="Department.Budget" class="form-control" />
  27. <span asp-validation-for="Department.Budget" class="text-danger"></span>
  28. </div>
  29. <div class="form-group">
  30. <label asp-for="Department.StartDate" class="control-label"></label>
  31. <input asp-for="Department.StartDate" class="form-control" />
  32. <span asp-validation-for="Department.StartDate" class="text-danger">
  33. </span>
  34. </div>
  35. <div class="form-group">
  36. <label class="control-label">Instructor</label>
  37. <select asp-for="Department.InstructorID" class="form-control"
  38. asp-items="@Model.InstructorNameSL"></select>
  39. <span asp-validation-for="Department.InstructorID" class="text-danger">
  40. </span>
  41. </div>
  42. <div class="form-group">
  43. <input type="submit" value="Save" class="btn btn-primary" />
  44. </div>
  45. </form>
  46. </div>
  47. </div>
  48. <div>
  49. <a asp-page="./Index">Back to List</a>
  50. </div>
  51. @section Scripts {
  52. @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
  53. }

前面的代码:

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

使用编辑页测试并发冲突Test concurrency conflicts with the Edit page

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

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

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

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

更改后的“院系编辑”页 1

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

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

更改后的“院系编辑”页 2

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

“院系编辑”页错误消息

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

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

更新“删除”页Update the Delete page

使用以下代码更新 Pages/Departments/Delete.cshtml.cs :

  1. using ContosoUniversity.Models;
  2. using Microsoft.AspNetCore.Mvc;
  3. using Microsoft.AspNetCore.Mvc.RazorPages;
  4. using Microsoft.EntityFrameworkCore;
  5. using System.Threading.Tasks;
  6. namespace ContosoUniversity.Pages.Departments
  7. {
  8. public class DeleteModel : PageModel
  9. {
  10. private readonly ContosoUniversity.Data.SchoolContext _context;
  11. public DeleteModel(ContosoUniversity.Data.SchoolContext context)
  12. {
  13. _context = context;
  14. }
  15. [BindProperty]
  16. public Department Department { get; set; }
  17. public string ConcurrencyErrorMessage { get; set; }
  18. public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
  19. {
  20. Department = await _context.Departments
  21. .Include(d => d.Administrator)
  22. .AsNoTracking()
  23. .FirstOrDefaultAsync(m => m.DepartmentID == id);
  24. if (Department == null)
  25. {
  26. return NotFound();
  27. }
  28. if (concurrencyError.GetValueOrDefault())
  29. {
  30. ConcurrencyErrorMessage = "The record you attempted to delete "
  31. + "was modified by another user after you selected delete. "
  32. + "The delete operation was canceled and the current values in the "
  33. + "database have been displayed. If you still want to delete this "
  34. + "record, click the Delete button again.";
  35. }
  36. return Page();
  37. }
  38. public async Task<IActionResult> OnPostAsync(int id)
  39. {
  40. try
  41. {
  42. if (await _context.Departments.AnyAsync(
  43. m => m.DepartmentID == id))
  44. {
  45. // Department.rowVersion value is from when the entity
  46. // was fetched. If it doesn't match the DB, a
  47. // DbUpdateConcurrencyException exception is thrown.
  48. _context.Departments.Remove(Department);
  49. await _context.SaveChangesAsync();
  50. }
  51. return RedirectToPage("./Index");
  52. }
  53. catch (DbUpdateConcurrencyException)
  54. {
  55. return RedirectToPage("./Delete",
  56. new { concurrencyError = true, id = id });
  57. }
  58. }
  59. }
  60. }

删除页检测提取实体并更改时的并发冲突。提取实体后,Department.RowVersion 为行版本。EF Core 创建 SQL DELETE 命令时,它包括具有 RowVersion 的 WHERE 子句。如果 SQL DELETE 命令导致零行受影响:

  • SQL DELETE 命令中的 RowVersion 与数据库中的 RowVersion 不匹配。
  • 引发 DbUpdateConcurrencyException 异常。
  • 使用 concurrencyError 调用 OnGetAsync

更新“删除”Razor 页面Update the Delete Razor page

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

  1. @page "{id:int}"
  2. @model ContosoUniversity.Pages.Departments.DeleteModel
  3. @{
  4. ViewData["Title"] = "Delete";
  5. }
  6. <h2>Delete</h2>
  7. <p class="text-danger">@Model.ConcurrencyErrorMessage</p>
  8. <h3>Are you sure you want to delete this?</h3>
  9. <div>
  10. <h4>Department</h4>
  11. <hr />
  12. <dl class="dl-horizontal">
  13. <dt>
  14. @Html.DisplayNameFor(model => model.Department.Name)
  15. </dt>
  16. <dd>
  17. @Html.DisplayFor(model => model.Department.Name)
  18. </dd>
  19. <dt>
  20. @Html.DisplayNameFor(model => model.Department.Budget)
  21. </dt>
  22. <dd>
  23. @Html.DisplayFor(model => model.Department.Budget)
  24. </dd>
  25. <dt>
  26. @Html.DisplayNameFor(model => model.Department.StartDate)
  27. </dt>
  28. <dd>
  29. @Html.DisplayFor(model => model.Department.StartDate)
  30. </dd>
  31. <dt>
  32. @Html.DisplayNameFor(model => model.Department.RowVersion)
  33. </dt>
  34. <dd>
  35. @Html.DisplayFor(model => model.Department.RowVersion[7])
  36. </dd>
  37. <dt>
  38. @Html.DisplayNameFor(model => model.Department.Administrator)
  39. </dt>
  40. <dd>
  41. @Html.DisplayFor(model => model.Department.Administrator.FullName)
  42. </dd>
  43. </dl>
  44. <form method="post">
  45. <input type="hidden" asp-for="Department.DepartmentID" />
  46. <input type="hidden" asp-for="Department.RowVersion" />
  47. <div class="form-actions no-color">
  48. <input type="submit" value="Delete" class="btn btn-danger" /> |
  49. <a asp-page="./Index">Back to List</a>
  50. </div>
  51. </form>
  52. </div>

上面的代码执行以下更改:

  • page 指令从 @page 更新为 @page "{id:int}"
  • 添加错误消息。
  • 将“管理员”字段中的 FirstMidName 替换为 FullName 。
  • 更改 RowVersion 以显示最后一个字节。
  • 添加隐藏的行版本。必须添加 RowVersion,以便回发绑定值。

测试并发冲突Test concurrency conflicts

创建测试系。

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

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

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

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

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

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

其他资源Additional resources

后续步骤Next steps

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

上一个教程

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

并发冲突Concurrency conflicts

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

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

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

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

开放式并发Optimistic concurrency

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

将预算更改为零

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

将开始日期更改为 2013

Jane 先单击“保存”,并在浏览器显示索引页时看到她的更改 。

预算已更改为零

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

乐观并发包括以下选项:

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

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

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

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

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

    • 显示错误消息。
    • 显示数据的当前状态。
    • 允许用户重新应用更改。
      这称为“存储优先”方案 。(数据存储值优先于客户端提交的值。)本教程实施“存储优先”方案。此方法可确保用户在未收到警报时不会覆盖任何更改。

处理并发Handling concurrency

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

数据库和数据模型必须配置为支持引发 DbUpdateConcurrencyException

检测属性的并发冲突Detecting concurrency conflicts on a property

可使用 ConcurrencyCheck 特性在属性级别检测并发冲突。该特性可应用于模型上的多个属性。有关详细信息,请参阅数据注释 - ConcurrencyCheck

本教程中不使用 [ConcurrencyCheck] 特性。

检测行的并发冲突Detecting concurrency conflicts on a row

要检测并发冲突,请将 rowversion 跟踪列添加到模型。rowversion

  • 是 SQL Server 特定的。其他数据库可能无法提供类似功能。
  • 用于确定从数据库提取实体后未更改实体。

数据库生成 rowversion 序号,该数字随着每次行的更新递增。UpdateDelete 命令中,Where 子句包括 rowversion 的提取值。如果要更新的行已更改:

  • rowversion 不匹配提取值。
  • UpdateDelete 命令不能找到行,因为 Where 子句包含提取的 rowversion
  • 引发一个 DbUpdateConcurrencyException

在 EF Core 中,如果未通过 UpdateDelete 命令更新行,则引发并发异常。

向 Department 实体添加跟踪属性Add a tracking property to the Department entity

在 Models/Department.cs 中,添加名为 RowVersion 的跟踪属性 :

  1. using System;
  2. using System.Collections.Generic;
  3. using System.ComponentModel.DataAnnotations;
  4. using System.ComponentModel.DataAnnotations.Schema;
  5. namespace ContosoUniversity.Models
  6. {
  7. public class Department
  8. {
  9. public int DepartmentID { get; set; }
  10. [StringLength(50, MinimumLength = 3)]
  11. public string Name { get; set; }
  12. [DataType(DataType.Currency)]
  13. [Column(TypeName = "money")]
  14. public decimal Budget { get; set; }
  15. [DataType(DataType.Date)]
  16. [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
  17. [Display(Name = "Start Date")]
  18. public DateTime StartDate { get; set; }
  19. public int? InstructorID { get; set; }
  20. [Timestamp]
  21. public byte[] RowVersion { get; set; }
  22. public Instructor Administrator { get; set; }
  23. public ICollection<Course> Courses { get; set; }
  24. }
  25. }

Timestamp 特性指定此列包含在 UpdateDelete 命令的 Where 子句中。该特性称为 Timestamp,因为之前版本的 SQL Server 在 SQL rowversion 类型将其替换之前使用 SQL timestamp 数据类型。

Fluent API 还可指定跟踪属性:

  1. modelBuilder.Entity<Department>()
  2. .Property<byte[]>("RowVersion")
  3. .IsRowVersion();

以下代码显示更新 Department 名称时由 EF Core 生成的部分 T-SQL:

  1. SET NOCOUNT ON;
  2. UPDATE [Department] SET [Name] = @p0
  3. WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
  4. SELECT [RowVersion]
  5. FROM [Department]
  6. WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

前面突出显示的代码显示包含 RowVersionWHERE 子句。如果数据库 RowVersion 不等于 RowVersion 参数(@p2),则不更新行。

以下突出显示的代码显示验证更新哪一行的 T-SQL:

  1. SET NOCOUNT ON;
  2. UPDATE [Department] SET [Name] = @p0
  3. WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
  4. SELECT [RowVersion]
  5. FROM [Department]
  6. WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

@@ROWCOUNT 返回受上一语句影响的行数。在没有行更新的情况下,EF Core 引发 DbUpdateConcurrencyException

在 Visual Studio 的输出窗口中可看见 EF Core 生成的 T-SQL。

更新数据库Update the DB

添加 RowVersion 属性可更改数据库模型,这需要迁移。

生成项目。在命令窗口中输入以下命令:

  1. dotnet ef migrations add RowVersion
  2. dotnet ef database update

前面的命令:

  • 添加 Migrations/{time stamp}_RowVersion.cs 迁移文件 。

  • 更新 Migrations/SchoolContextModelSnapshot.cs 文件 。此次更新将以下突出显示的代码添加到 BuildModel 方法:

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

构架院系模型Scaffold the Departments model

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

运行下面的命令:

  1. dotnet aspnet-codegenerator razorpage -m Department -dc SchoolContext -udl -outDir Pages\Departments --referenceScriptLibraries

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

生成项目。

更新院系索引页Update the Departments Index page

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

更新索引页:

  • 用院系替换索引。
  • 将包含 RowVersion 的标记替换为 RowVersion 的最后一个字节。
  • 将 FirstMidName 替换为 FullName。

以下标记显示更新后的页面:

  1. @page
  2. @model ContosoUniversity.Pages.Departments.IndexModel
  3. @{
  4. ViewData["Title"] = "Departments";
  5. }
  6. <h2>Departments</h2>
  7. <p>
  8. <a asp-page="Create">Create New</a>
  9. </p>
  10. <table class="table">
  11. <thead>
  12. <tr>
  13. <th>
  14. @Html.DisplayNameFor(model => model.Department[0].Name)
  15. </th>
  16. <th>
  17. @Html.DisplayNameFor(model => model.Department[0].Budget)
  18. </th>
  19. <th>
  20. @Html.DisplayNameFor(model => model.Department[0].StartDate)
  21. </th>
  22. <th>
  23. @Html.DisplayNameFor(model => model.Department[0].Administrator)
  24. </th>
  25. <th>
  26. RowVersion
  27. </th>
  28. <th></th>
  29. </tr>
  30. </thead>
  31. <tbody>
  32. @foreach (var item in Model.Department) {
  33. <tr>
  34. <td>
  35. @Html.DisplayFor(modelItem => item.Name)
  36. </td>
  37. <td>
  38. @Html.DisplayFor(modelItem => item.Budget)
  39. </td>
  40. <td>
  41. @Html.DisplayFor(modelItem => item.StartDate)
  42. </td>
  43. <td>
  44. @Html.DisplayFor(modelItem => item.Administrator.FullName)
  45. </td>
  46. <td>
  47. @item.RowVersion[7]
  48. </td>
  49. <td>
  50. <a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
  51. <a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
  52. <a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
  53. </td>
  54. </tr>
  55. }
  56. </tbody>
  57. </table>

更新编辑页模型Update the Edit page model

使用以下代码更新 Pages\Departments\Edit.cshtml.cs :

  1. using ContosoUniversity.Data;
  2. using ContosoUniversity.Models;
  3. using Microsoft.AspNetCore.Mvc;
  4. using Microsoft.AspNetCore.Mvc.RazorPages;
  5. using Microsoft.AspNetCore.Mvc.Rendering;
  6. using Microsoft.EntityFrameworkCore;
  7. using System.Linq;
  8. using System.Threading.Tasks;
  9. namespace ContosoUniversity.Pages.Departments
  10. {
  11. public class EditModel : PageModel
  12. {
  13. private readonly ContosoUniversity.Data.SchoolContext _context;
  14. public EditModel(ContosoUniversity.Data.SchoolContext context)
  15. {
  16. _context = context;
  17. }
  18. [BindProperty]
  19. public Department Department { get; set; }
  20. // Replace ViewData["InstructorID"]
  21. public SelectList InstructorNameSL { get; set; }
  22. public async Task<IActionResult> OnGetAsync(int id)
  23. {
  24. Department = await _context.Departments
  25. .Include(d => d.Administrator) // eager loading
  26. .AsNoTracking() // tracking not required
  27. .FirstOrDefaultAsync(m => m.DepartmentID == id);
  28. if (Department == null)
  29. {
  30. return NotFound();
  31. }
  32. // Use strongly typed data rather than ViewData.
  33. InstructorNameSL = new SelectList(_context.Instructors,
  34. "ID", "FirstMidName");
  35. return Page();
  36. }
  37. public async Task<IActionResult> OnPostAsync(int id)
  38. {
  39. if (!ModelState.IsValid)
  40. {
  41. return Page();
  42. }
  43. var departmentToUpdate = await _context.Departments
  44. .Include(i => i.Administrator)
  45. .FirstOrDefaultAsync(m => m.DepartmentID == id);
  46. // null means Department was deleted by another user.
  47. if (departmentToUpdate == null)
  48. {
  49. return HandleDeletedDepartment();
  50. }
  51. // Update the RowVersion to the value when this entity was
  52. // fetched. If the entity has been updated after it was
  53. // fetched, RowVersion won't match the DB RowVersion and
  54. // a DbUpdateConcurrencyException is thrown.
  55. // A second postback will make them match, unless a new
  56. // concurrency issue happens.
  57. _context.Entry(departmentToUpdate)
  58. .Property("RowVersion").OriginalValue = Department.RowVersion;
  59. if (await TryUpdateModelAsync<Department>(
  60. departmentToUpdate,
  61. "Department",
  62. s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
  63. {
  64. try
  65. {
  66. await _context.SaveChangesAsync();
  67. return RedirectToPage("./Index");
  68. }
  69. catch (DbUpdateConcurrencyException ex)
  70. {
  71. var exceptionEntry = ex.Entries.Single();
  72. var clientValues = (Department)exceptionEntry.Entity;
  73. var databaseEntry = exceptionEntry.GetDatabaseValues();
  74. if (databaseEntry == null)
  75. {
  76. ModelState.AddModelError(string.Empty, "Unable to save. " +
  77. "The department was deleted by another user.");
  78. return Page();
  79. }
  80. var dbValues = (Department)databaseEntry.ToObject();
  81. await setDbErrorMessage(dbValues, clientValues, _context);
  82. // Save the current RowVersion so next postback
  83. // matches unless an new concurrency issue happens.
  84. Department.RowVersion = (byte[])dbValues.RowVersion;
  85. // Must clear the model error for the next postback.
  86. ModelState.Remove("Department.RowVersion");
  87. }
  88. }
  89. InstructorNameSL = new SelectList(_context.Instructors,
  90. "ID", "FullName", departmentToUpdate.InstructorID);
  91. return Page();
  92. }
  93. private IActionResult HandleDeletedDepartment()
  94. {
  95. var deletedDepartment = new Department();
  96. // ModelState contains the posted data because of the deletion error and will overide the Department instance values when displaying Page().
  97. ModelState.AddModelError(string.Empty,
  98. "Unable to save. The department was deleted by another user.");
  99. InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
  100. return Page();
  101. }
  102. private async Task setDbErrorMessage(Department dbValues,
  103. Department clientValues, SchoolContext context)
  104. {
  105. if (dbValues.Name != clientValues.Name)
  106. {
  107. ModelState.AddModelError("Department.Name",
  108. $"Current value: {dbValues.Name}");
  109. }
  110. if (dbValues.Budget != clientValues.Budget)
  111. {
  112. ModelState.AddModelError("Department.Budget",
  113. $"Current value: {dbValues.Budget:c}");
  114. }
  115. if (dbValues.StartDate != clientValues.StartDate)
  116. {
  117. ModelState.AddModelError("Department.StartDate",
  118. $"Current value: {dbValues.StartDate:d}");
  119. }
  120. if (dbValues.InstructorID != clientValues.InstructorID)
  121. {
  122. Instructor dbInstructor = await _context.Instructors
  123. .FindAsync(dbValues.InstructorID);
  124. ModelState.AddModelError("Department.InstructorID",
  125. $"Current value: {dbInstructor?.FullName}");
  126. }
  127. ModelState.AddModelError(string.Empty,
  128. "The record you attempted to edit "
  129. + "was modified by another user after you. The "
  130. + "edit operation was canceled and the current values in the database "
  131. + "have been displayed. If you still want to edit this record, click "
  132. + "the Save button again.");
  133. }
  134. }
  135. }

要检测并发问题,请使用来自所提取实体的 rowVersion 值更新 OriginalValueEF Core 使用包含原始 RowVersion 值的 WHERE 子句生成 SQL UPDATE 命令。如果没有行受到 UPDATE 命令影响(没有行具有原始 RowVersion 值),将引发 DbUpdateConcurrencyException 异常。

  1. public async Task<IActionResult> OnPostAsync(int id)
  2. {
  3. if (!ModelState.IsValid)
  4. {
  5. return Page();
  6. }
  7. var departmentToUpdate = await _context.Departments
  8. .Include(i => i.Administrator)
  9. .FirstOrDefaultAsync(m => m.DepartmentID == id);
  10. // null means Department was deleted by another user.
  11. if (departmentToUpdate == null)
  12. {
  13. return HandleDeletedDepartment();
  14. }
  15. // Update the RowVersion to the value when this entity was
  16. // fetched. If the entity has been updated after it was
  17. // fetched, RowVersion won't match the DB RowVersion and
  18. // a DbUpdateConcurrencyException is thrown.
  19. // A second postback will make them match, unless a new
  20. // concurrency issue happens.
  21. _context.Entry(departmentToUpdate)
  22. .Property("RowVersion").OriginalValue = Department.RowVersion;

在前面的代码中,Department.RowVersion 为实体提取后的值。使用此方法调用 FirstOrDefaultAsync 时,OriginalValue 为数据库中的值。

以下代码获取客户端值(向此方法发布的值)和数据库值:

  1. try
  2. {
  3. await _context.SaveChangesAsync();
  4. return RedirectToPage("./Index");
  5. }
  6. catch (DbUpdateConcurrencyException ex)
  7. {
  8. var exceptionEntry = ex.Entries.Single();
  9. var clientValues = (Department)exceptionEntry.Entity;
  10. var databaseEntry = exceptionEntry.GetDatabaseValues();
  11. if (databaseEntry == null)
  12. {
  13. ModelState.AddModelError(string.Empty, "Unable to save. " +
  14. "The department was deleted by another user.");
  15. return Page();
  16. }
  17. var dbValues = (Department)databaseEntry.ToObject();
  18. await setDbErrorMessage(dbValues, clientValues, _context);
  19. // Save the current RowVersion so next postback
  20. // matches unless an new concurrency issue happens.
  21. Department.RowVersion = (byte[])dbValues.RowVersion;
  22. // Must clear the model error for the next postback.
  23. ModelState.Remove("Department.RowVersion");
  24. }

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

  1. private async Task setDbErrorMessage(Department dbValues,
  2. Department clientValues, SchoolContext context)
  3. {
  4. if (dbValues.Name != clientValues.Name)
  5. {
  6. ModelState.AddModelError("Department.Name",
  7. $"Current value: {dbValues.Name}");
  8. }
  9. if (dbValues.Budget != clientValues.Budget)
  10. {
  11. ModelState.AddModelError("Department.Budget",
  12. $"Current value: {dbValues.Budget:c}");
  13. }
  14. if (dbValues.StartDate != clientValues.StartDate)
  15. {
  16. ModelState.AddModelError("Department.StartDate",
  17. $"Current value: {dbValues.StartDate:d}");
  18. }
  19. if (dbValues.InstructorID != clientValues.InstructorID)
  20. {
  21. Instructor dbInstructor = await _context.Instructors
  22. .FindAsync(dbValues.InstructorID);
  23. ModelState.AddModelError("Department.InstructorID",
  24. $"Current value: {dbInstructor?.FullName}");
  25. }
  26. ModelState.AddModelError(string.Empty,
  27. "The record you attempted to edit "
  28. + "was modified by another user after you. The "
  29. + "edit operation was canceled and the current values in the database "
  30. + "have been displayed. If you still want to edit this record, click "
  31. + "the Save button again.");
  32. }

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

  1. try
  2. {
  3. await _context.SaveChangesAsync();
  4. return RedirectToPage("./Index");
  5. }
  6. catch (DbUpdateConcurrencyException ex)
  7. {
  8. var exceptionEntry = ex.Entries.Single();
  9. var clientValues = (Department)exceptionEntry.Entity;
  10. var databaseEntry = exceptionEntry.GetDatabaseValues();
  11. if (databaseEntry == null)
  12. {
  13. ModelState.AddModelError(string.Empty, "Unable to save. " +
  14. "The department was deleted by another user.");
  15. return Page();
  16. }
  17. var dbValues = (Department)databaseEntry.ToObject();
  18. await setDbErrorMessage(dbValues, clientValues, _context);
  19. // Save the current RowVersion so next postback
  20. // matches unless an new concurrency issue happens.
  21. Department.RowVersion = (byte[])dbValues.RowVersion;
  22. // Must clear the model error for the next postback.
  23. ModelState.Remove("Department.RowVersion");
  24. }

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

更新“编辑”页Update the Edit page

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

  1. @page "{id:int}"
  2. @model ContosoUniversity.Pages.Departments.EditModel
  3. @{
  4. ViewData["Title"] = "Edit";
  5. }
  6. <h2>Edit</h2>
  7. <h4>Department</h4>
  8. <hr />
  9. <div class="row">
  10. <div class="col-md-4">
  11. <form method="post">
  12. <div asp-validation-summary="ModelOnly" class="text-danger"></div>
  13. <input type="hidden" asp-for="Department.DepartmentID" />
  14. <input type="hidden" asp-for="Department.RowVersion" />
  15. <div class="form-group">
  16. <label>RowVersion</label>
  17. @Model.Department.RowVersion[7]
  18. </div>
  19. <div class="form-group">
  20. <label asp-for="Department.Name" class="control-label"></label>
  21. <input asp-for="Department.Name" class="form-control" />
  22. <span asp-validation-for="Department.Name" class="text-danger"></span>
  23. </div>
  24. <div class="form-group">
  25. <label asp-for="Department.Budget" class="control-label"></label>
  26. <input asp-for="Department.Budget" class="form-control" />
  27. <span asp-validation-for="Department.Budget" class="text-danger"></span>
  28. </div>
  29. <div class="form-group">
  30. <label asp-for="Department.StartDate" class="control-label"></label>
  31. <input asp-for="Department.StartDate" class="form-control" />
  32. <span asp-validation-for="Department.StartDate" class="text-danger">
  33. </span>
  34. </div>
  35. <div class="form-group">
  36. <label class="control-label">Instructor</label>
  37. <select asp-for="Department.InstructorID" class="form-control"
  38. asp-items="@Model.InstructorNameSL"></select>
  39. <span asp-validation-for="Department.InstructorID" class="text-danger">
  40. </span>
  41. </div>
  42. <div class="form-group">
  43. <input type="submit" value="Save" class="btn btn-default" />
  44. </div>
  45. </form>
  46. </div>
  47. </div>
  48. <div>
  49. <a asp-page="./Index">Back to List</a>
  50. </div>
  51. @section Scripts {
  52. @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
  53. }

前面的标记:

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

使用编辑页测试并发冲突Test concurrency conflicts with the Edit page

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

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

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

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

更改后的“院系编辑”页 1

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

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

更改后的“院系编辑”页 2

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

“院系编辑”页错误消息

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

“院系编辑”页错误消息

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

更新“删除”页Update the Delete page

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

  1. using ContosoUniversity.Models;
  2. using Microsoft.AspNetCore.Mvc;
  3. using Microsoft.AspNetCore.Mvc.RazorPages;
  4. using Microsoft.EntityFrameworkCore;
  5. using System.Threading.Tasks;
  6. namespace ContosoUniversity.Pages.Departments
  7. {
  8. public class DeleteModel : PageModel
  9. {
  10. private readonly ContosoUniversity.Data.SchoolContext _context;
  11. public DeleteModel(ContosoUniversity.Data.SchoolContext context)
  12. {
  13. _context = context;
  14. }
  15. [BindProperty]
  16. public Department Department { get; set; }
  17. public string ConcurrencyErrorMessage { get; set; }
  18. public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
  19. {
  20. Department = await _context.Departments
  21. .Include(d => d.Administrator)
  22. .AsNoTracking()
  23. .FirstOrDefaultAsync(m => m.DepartmentID == id);
  24. if (Department == null)
  25. {
  26. return NotFound();
  27. }
  28. if (concurrencyError.GetValueOrDefault())
  29. {
  30. ConcurrencyErrorMessage = "The record you attempted to delete "
  31. + "was modified by another user after you selected delete. "
  32. + "The delete operation was canceled and the current values in the "
  33. + "database have been displayed. If you still want to delete this "
  34. + "record, click the Delete button again.";
  35. }
  36. return Page();
  37. }
  38. public async Task<IActionResult> OnPostAsync(int id)
  39. {
  40. try
  41. {
  42. if (await _context.Departments.AnyAsync(
  43. m => m.DepartmentID == id))
  44. {
  45. // Department.rowVersion value is from when the entity
  46. // was fetched. If it doesn't match the DB, a
  47. // DbUpdateConcurrencyException exception is thrown.
  48. _context.Departments.Remove(Department);
  49. await _context.SaveChangesAsync();
  50. }
  51. return RedirectToPage("./Index");
  52. }
  53. catch (DbUpdateConcurrencyException)
  54. {
  55. return RedirectToPage("./Delete",
  56. new { concurrencyError = true, id = id });
  57. }
  58. }
  59. }
  60. }

删除页检测提取实体并更改时的并发冲突。提取实体后,Department.RowVersion 为行版本。EF Core 创建 SQL DELETE 命令时,它包括具有 RowVersion 的 WHERE 子句。如果 SQL DELETE 命令导致零行受影响:

  • SQL DELETE 命令中的 RowVersion 与数据库中的 RowVersion 不匹配。
  • 引发 DbUpdateConcurrencyException 异常。
  • 使用 concurrencyError 调用 OnGetAsync

更新“删除”页Update the Delete page

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

  1. @page "{id:int}"
  2. @model ContosoUniversity.Pages.Departments.DeleteModel
  3. @{
  4. ViewData["Title"] = "Delete";
  5. }
  6. <h2>Delete</h2>
  7. <p class="text-danger">@Model.ConcurrencyErrorMessage</p>
  8. <h3>Are you sure you want to delete this?</h3>
  9. <div>
  10. <h4>Department</h4>
  11. <hr />
  12. <dl class="dl-horizontal">
  13. <dt>
  14. @Html.DisplayNameFor(model => model.Department.Name)
  15. </dt>
  16. <dd>
  17. @Html.DisplayFor(model => model.Department.Name)
  18. </dd>
  19. <dt>
  20. @Html.DisplayNameFor(model => model.Department.Budget)
  21. </dt>
  22. <dd>
  23. @Html.DisplayFor(model => model.Department.Budget)
  24. </dd>
  25. <dt>
  26. @Html.DisplayNameFor(model => model.Department.StartDate)
  27. </dt>
  28. <dd>
  29. @Html.DisplayFor(model => model.Department.StartDate)
  30. </dd>
  31. <dt>
  32. @Html.DisplayNameFor(model => model.Department.RowVersion)
  33. </dt>
  34. <dd>
  35. @Html.DisplayFor(model => model.Department.RowVersion[7])
  36. </dd>
  37. <dt>
  38. @Html.DisplayNameFor(model => model.Department.Administrator)
  39. </dt>
  40. <dd>
  41. @Html.DisplayFor(model => model.Department.Administrator.FullName)
  42. </dd>
  43. </dl>
  44. <form method="post">
  45. <input type="hidden" asp-for="Department.DepartmentID" />
  46. <input type="hidden" asp-for="Department.RowVersion" />
  47. <div class="form-actions no-color">
  48. <input type="submit" value="Delete" class="btn btn-default" /> |
  49. <a asp-page="./Index">Back to List</a>
  50. </div>
  51. </form>
  52. </div>

上面的代码执行以下更改:

  • page 指令从 @page 更新为 @page "{id:int}"
  • 添加错误消息。
  • 将“管理员”字段中的 FirstMidName 替换为 FullName 。
  • 更改 RowVersion 以显示最后一个字节。
  • 添加隐藏的行版本。必须添加 RowVersion,以便回发绑定值。

使用删除页测试并发冲突Test concurrency conflicts with the Delete page

创建测试系。

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

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

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

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

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

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

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

其他资源Additional resources

上一篇