教程:在 ASP.NET MVC 5 应用中处理 EF 的并发

在前面的教程中,你学习了如何更新数据。 本教程介绍如何使用乐观并发处理多个用户同时更新同一实体时的冲突。 更改处理实体的 Department 网页,以便它们处理并发错误。 下图显示了“编辑”和“删除”页面,包括发生并发冲突时显示的一些消息。

屏幕截图显示了“编辑”页,其中突出显示了“部门名称”、“预算”、“开始日期”和“管理员”的值。

屏幕截图显示了记录的“删除”页,其中包含有关删除操作和“删除”按钮的消息。

在本教程中,你将了解:

  • 了解并发冲突
  • 添加乐观并发
  • 修改部门控制器
  • 测试并发处理
  • 更新“删除”页

先决条件

并发冲突

当某用户显示实体数据以对其进行编辑,而另一用户在上一用户的更改写入数据库之前更新同一实体的数据时,会发生并发冲突。 如果不启用此类冲突的检测,则最后更新数据库的人员将覆盖其他用户的更改。 在许多应用程序中,此风险是可接受的:如果用户很少或更新很少,或者一些更改被覆盖并不重要,则并发编程可能弊大于利。 在此情况下,不必配置应用程序来处理并发冲突。

悲观并发 (锁定)

如果应用程序确实需要防止并发情况下出现意外数据丢失,一种方法是使用数据库锁定。 这称为 悲观并发。 例如,在从数据库读取一行内容之前,请求锁定为只读或更新访问。 如果将一行锁定为更新访问,则其他用户无法将该行锁定为只读或更新访问,因为他们得到的是正在更改的数据的副本。 如果将一行锁定为只读访问,则其他人也可将其锁定为只读访问,但不能进行更新。

管理锁定有缺点。 编程可能很复杂。 它需要大量的数据库管理资源,且随着应用程序用户数量的增加,可能会导致性能问题。 由于这些原因,并不是所有的数据库管理系统都支持悲观并发。 Entity Framework 不提供对它的内置支持,本教程不介绍如何实现它。

开放式并发

悲观并发的替代方法是 乐观并发。 悲观并发是指允许发生并发冲突,并在并发冲突发生时作出正确反应。 例如,John 运行“部门编辑”页,将英语系 的预算 金额从 $350,000.00 更改为 $0.00。

在 John 单击 “保存”之前,Jane 将运行同一页,并将 “开始日期” 字段从 2007 年 9 月 1 日更改为 2013 年 8 月 8 日。

John 首先单击 “保存” ,当浏览器返回到“索引”页时,看到他的更改,然后简单击“ 保存”。 接下来的情况取决于并发冲突的处理方式。 其中一些选项包括:

  • 可以跟踪用户已修改的属性,并仅更新数据库中相应的列。 在示例方案中,不会有数据丢失,因为是由两个用户更新不同的属性。 下次有人浏览英语系时,他们将看到约翰和简的更改- 2013 年 8 月 8 日开始,预算为零美元。

    这种更新方法可减少可能导致数据丢失的冲突次数,但是如果对实体的同一属性进行竞争性更改,则数据难免会丢失。 Entity Framework 是否以这种方式工作取决于更新代码的实现方式。 通常不适合在 Web 应用程序中使用,因为它要求保持大量的状态,以便跟踪实体的所有原始属性值以及新值。 维护大量的状态可能会影响应用程序的性能,因为它需要服务器资源或必须包含在网页本身(例如隐藏字段)或 Cookie 中。

  • 你可以让 Jane 的更改覆盖 John 的更改。 下次有人浏览英语系时,他们将看到 8/8/2013,还原的 350,000.00 美元值。 这称为“客户端优先”或“最后一个优先” 。 (客户端的所有值优先于数据存储的值。)正如本部分的介绍所述,如果不为并发处理编写任何代码,则自动执行此操作。

  • 可以阻止 Jane 更改在数据库中更新。 通常,将显示一条错误消息,显示她的当前数据状态,并允许她重新应用更改(如果她仍想进行更改)。 这称为“存储优先”方案。 (数据存储值优先于客户端提交的值。)本教程将执行“存储优先”方案。 此方法可确保用户在未收到具体发生内容的警报时,不会覆盖任何更改。

检测并发冲突

可以通过处理 Entity Framework 引发的 乐观ConcurrencyException 异常来解决冲突。 为了知道何时引发这些异常,Entity Framework 必须能够检测到冲突。 因此,你必须正确配置数据库和数据模型。 启用冲突检测的某些选项包括:

  • 数据库表中包含一个可用于确定某行更改时间的跟踪列。 然后,可以将 Entity Framework 配置为在 SQL UpdateDelete命令的子句中包含Where该列。

    跟踪列的数据类型通常是 rowversionrowversion 值是每次更新行时递增的序列号。 Update在或Delete命令中Where,子句包含跟踪列的原始值 (原始行版本) 。 如果正在更新的行已被其他用户更改,则 rowversion 列中的值与原始值不同,因此 Update 由于子句,或 Delete 语句找不到要更新的 Where 行。 当 Entity Framework 发现没有由 UpdateDelete 命令 (更新任何行(即受影响行数为零) 时),它会将其解释为并发冲突。

  • 将 Entity Framework 配置为在子句和命令的子句UpdateDelete中包含表中Where每列的原始值。

    与第一个选项一样,如果自首次读取行以来行中的任何内容都发生了更改,则 Where 子句不会返回要更新的行,实体框架将其解释为并发冲突。 对于具有许多列的数据库表,此方法可能会导致非常大 Where 的子句,并且可能需要保持大量的状态。 如前所述,维持大量的状态会影响应用程序的性能。 因此通常不建议使用此方法,并且它也不是本教程中使用的方法。

    如果确实要实现此方法以实现并发,则必须通过在实体中添加 并发Check 属性来标记要跟踪并发的所有非主键属性。 此更改使 Entity Framework 能够在语句的 UPDATE SQL WHERE 子句中包含所有列。

在本教程的其余部分中,你将向实体添加 行version 跟踪属性 Department ,创建控制器和视图,并测试以验证所有内容是否正常工作。

添加乐观并发

Models\Department.cs 中,添加一个名为 RowVersion

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

    [Display(Name = "Administrator")]
    public int? InstructorID { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; }

    public virtual Instructor Administrator { get; set; }
    public virtual ICollection<Course> Courses { get; set; }
}

Timestamp 属性指定此列将包含在发送到数据库的子句UpdateDelete命令中Where。 该属性称为 Timestamp,因为以前版本的SQL Server在 SQL rowversion 替换它之前使用了 SQL 时间戳数据类型。 rowversion 的 .Net 类型是字节数组。

如果想要使用 fluent API,可以使用 IsConcurrencyToken 方法指定跟踪属性,如以下示例所示:

modelBuilder.Entity<Department>()
    .Property(p => p.RowVersion).IsConcurrencyToken();

通过添加属性,更改了数据库模型,因此需要再执行一次迁移。 在包管理器控制台 (PMC) 中输入以下命令:

Add-Migration RowVersion
Update-Database

修改部门控制器

Controllers\DepartmentController.cs 中,添加语句 using

using System.Data.Entity.Infrastructure;

DepartmentController.cs 文件中,将“LastName”的所有四个匹配项更改为“FullName”,以便部门管理员下拉列表将包含讲师的完整名称,而不仅仅是姓氏。

ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName");

将方法的现有代码替换为以下代码 HttpPostEdit

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Edit(int? id, byte[] rowVersion)
{
    string[] fieldsToBind = new string[] { "Name", "Budget", "StartDate", "InstructorID", "RowVersion" };

    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }

    var departmentToUpdate = await db.Departments.FindAsync(id);
    if (departmentToUpdate == null)
    {
        Department deletedDepartment = new Department();
        TryUpdateModel(deletedDepartment, fieldsToBind);
        ModelState.AddModelError(string.Empty,
            "Unable to save changes. The department was deleted by another user.");
        ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", deletedDepartment.InstructorID);
        return View(deletedDepartment);
    }

    if (TryUpdateModel(departmentToUpdate, fieldsToBind))
    {
        try
        {
            db.Entry(departmentToUpdate).OriginalValues["RowVersion"] = rowVersion;
            await db.SaveChangesAsync();

            return RedirectToAction("Index");
        }
        catch (DbUpdateConcurrencyException ex)
        {
            var entry = ex.Entries.Single();
            var clientValues = (Department)entry.Entity;
            var databaseEntry = entry.GetDatabaseValues();
            if (databaseEntry == null)
            {
                ModelState.AddModelError(string.Empty,
                    "Unable to save changes. The department was deleted by another user.");
            }
            else
            {
                var databaseValues = (Department)databaseEntry.ToObject();

                if (databaseValues.Name != clientValues.Name)
                    ModelState.AddModelError("Name", "Current value: "
                        + databaseValues.Name);
                if (databaseValues.Budget != clientValues.Budget)
                    ModelState.AddModelError("Budget", "Current value: "
                        + String.Format("{0:c}", databaseValues.Budget));
                if (databaseValues.StartDate != clientValues.StartDate)
                    ModelState.AddModelError("StartDate", "Current value: "
                        + String.Format("{0:d}", databaseValues.StartDate));
                if (databaseValues.InstructorID != clientValues.InstructorID)
                    ModelState.AddModelError("InstructorID", "Current value: "
                        + db.Instructors.Find(databaseValues.InstructorID).FullName);
                ModelState.AddModelError(string.Empty, "The record you attempted to edit "
                    + "was modified by another user after you got the original value. 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. Otherwise click the Back to List hyperlink.");
                departmentToUpdate.RowVersion = databaseValues.RowVersion;
            }
        }
        catch (RetryLimitExceededException /* dex */)
        {
            //Log the error (uncomment dex variable name and add a line here to write a log.)
            ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator.");
        }
    }
    ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", departmentToUpdate.InstructorID);
    return View(departmentToUpdate);
}

如果 FindAsync 方法返回 NULL,则该院系已被另一用户删除。 显示的代码使用已发布的表单值来创建部门实体,以便可以使用错误消息重新显示“编辑”页面。 或者,如果仅显示错误消息而未重新显示院系字段,则不必重新创建 Department 实体。

该视图将原始 RowVersion 值存储在隐藏字段中,该方法在参数中 rowVersion 接收它。 在调用 SaveChanges 之前,必须将该原始 RowVersion 属性值置于实体的 OriginalValues 集合中。 然后,当 Entity Framework 创建 SQL UPDATE 命令时,该命令将包含一个 WHERE 子句,该子句查找具有原始 RowVersion 值的行。

如果命令 (没有行具有原始RowVersion值) ,UPDATE则 Entity Framework 将引发异常DbUpdateConcurrencyException,并且块中的catch代码从异常对象获取受影响的Department实体。

var entry = ex.Entries.Single();

此对象具有用户在其属性中 Entity 输入的新值,并且可以通过调用 GetDatabaseValues 该方法从数据库读取值。

var clientValues = (Department)entry.Entity;
var databaseEntry = entry.GetDatabaseValues();

如果有人从数据库中删除行,该方法 GetDatabaseValues 返回 null;否则,您必须将返回的对象强制转换为 Department 类才能访问 Department 属性。 (由于已检查是否删除,因此仅在执行后和 executes.) 之后FindAsyncSaveChanges删除部门时,databaseEntry才会为 null

if (databaseEntry == null)
{
    ModelState.AddModelError(string.Empty,
        "Unable to save changes. The department was deleted by another user.");
}
else
{
    var databaseValues = (Department)databaseEntry.ToObject();

接下来,代码为每个列添加一条自定义错误消息,其中包含的数据库值不同于用户在“编辑”页面上输入的内容:

if (databaseValues.Name != currentValues.Name)
    ModelState.AddModelError("Name", "Current value: " + databaseValues.Name);
    // ...

较长的错误消息说明了所发生的事情及其用途:

ModelState.AddModelError(string.Empty, "The record you attempted to edit "
    + "was modified by another user after you got the original value. 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. Otherwise click the Back to List hyperlink.");

最后,代码将 RowVersion 对象的值 Department 设置为从数据库检索到的新值。 重新显示“编辑”页时,这个新的 RowVersion 值将存储在隐藏字段中,当用户下次单击“保存”时,将只捕获自“编辑”页重新显示起发生的并发错误。

Views\Department\Edit.cshtml 中,添加隐藏字段以保存 RowVersion 属性值,紧跟该属性的 DepartmentID 隐藏字段:

@model ContosoUniversity.Models.Department

@{
    ViewBag.Title = "Edit";
}

<h2>Edit</h2>

@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()
    
    <div class="form-horizontal">
        <h4>Department</h4>
        <hr />
        @Html.ValidationSummary(true)
        @Html.HiddenFor(model => model.DepartmentID)
        @Html.HiddenFor(model => model.RowVersion)

测试并发处理

运行站点并单击“ 部门”。

右键单击英语系的 “编辑 超链接”,然后选择“ 在新选项卡中打开”, 然后单击英语系的 “编辑 超链接”。 这两个选项卡显示相同的信息。

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

浏览器显示具有更改值的索引页。

更改第二个浏览器选项卡中的字段,然后单击“ 保存”。 看见一条错误消息:

屏幕截图显示“编辑”页面,其中包含一条消息,说明操作已取消,因为其他用户更改了该值。

再次单击“保存”。 在第二个浏览器选项卡中输入的值与在第一个浏览器中更改的数据的原始值一起保存。 在索引页中出现时,可以看到已保存的值。

更新“删除”页

对于“删除”页,Entity Framework 以类似方式检测其他人编辑院系所引起的并发冲突。 当 HttpGetDelete 该方法显示确认视图时,视图在隐藏字段中包括原始 RowVersion 值。 然后,该值可用于 HttpPostDelete 用户确认删除时调用的方法。 当 Entity Framework 创建 SQL DELETE 命令时,它包含 WHERE 具有原始 RowVersion 值的子句。 如果命令导致零行受到影响 (这意味着在) 显示“删除确认”页后更改了该行,则会引发并发异常, HttpGet Delete 并调用该方法时会设置错误标志 true ,以便重新显示带有错误消息的确认页。 也有可能零行受到影响,因为该行已被其他用户删除,因此在这种情况下会显示不同的错误消息。

DepartmentController.cs 中,将 HttpGetDelete 该方法替换为以下代码:

public async Task<ActionResult> Delete(int? id, bool? concurrencyError)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    Department department = await db.Departments.FindAsync(id);
    if (department == null)
    {
        if (concurrencyError.GetValueOrDefault())
        {
            return RedirectToAction("Index");
        }
        return HttpNotFound();
    }

    if (concurrencyError.GetValueOrDefault())
    {
        ViewBag.ConcurrencyErrorMessage = "The record you attempted to delete "
            + "was modified by another user after you got the original values. "
            + "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. Otherwise "
            + "click the Back to List hyperlink.";
    }

    return View(department);
}

该方法接受可选参数,该参数指示是否在并发错误之后重新显示页面。 如果此标志是 true,则会使用 ViewBag 属性将错误消息发送到视图。

将方法 () DeleteConfirmed 中的HttpPostDelete代码替换为以下代码:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Delete(Department department)
{
    try
    {
        db.Entry(department).State = EntityState.Deleted;
        await db.SaveChangesAsync();
        return RedirectToAction("Index");
    }
    catch (DbUpdateConcurrencyException)
    {
        return RedirectToAction("Delete", new { concurrencyError = true, id=department.DepartmentID });
    }
    catch (DataException /* dex */)
    {
        //Log the error (uncomment dex variable name after DataException and add a line here to write a log.
        ModelState.AddModelError(string.Empty, "Unable to delete. Try again, and if the problem persists contact your system administrator.");
        return View(department);
    }
}

在刚替换的基架代码中,此方法仅接受记录 ID:

public async Task<ActionResult> DeleteConfirmed(int id)

已将此参数更改为由模型绑定器创建的 Department 实体实例。 这样,除了记录键之外,还可以访问 RowVersion 属性值。

public async Task<ActionResult> Delete(Department department)

你还将操作方法名称从 DeleteConfirmed 更改为了 Delete。 命名方法DeleteConfirmedHttpPostDelete基架代码,为该方法提供HttpPost唯一签名。 ( CLR 要求重载的方法具有不同的方法参数。) 签名是唯一的,可以坚持 MVC 约定,对 HttpPostHttpGet 删除方法使用相同的名称。

如果捕获到并发错误,代码将重新显示“删除”确认页,并提供一个指示它应显示并发错误消息的标志。

Views\Department\Delete.cshtml 中,将基架代码替换为以下代码,用于添加 DepartmentID 和 RowVersion 属性的错误消息字段和隐藏字段。 突出显示所作更改。

@model ContosoUniversity.Models.Department

@{
    ViewBag.Title = "Delete";
}

<h2>Delete</h2>

<p class="error">@ViewBag.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="dl-horizontal">
        <dt>
            Administrator
        </dt>

        <dd>
            @Html.DisplayFor(model => model.Administrator.FullName)
        </dd>

        <dt>
            @Html.DisplayNameFor(model => model.Name)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.Name)
        </dd>

        <dt>
            @Html.DisplayNameFor(model => model.Budget)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.Budget)
        </dd>

        <dt>
            @Html.DisplayNameFor(model => model.StartDate)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.StartDate)
        </dd>

    </dl>

    @using (Html.BeginForm()) {
        @Html.AntiForgeryToken()
        @Html.HiddenFor(model => model.DepartmentID)
        @Html.HiddenFor(model => model.RowVersion)

        <div class="form-actions no-color">
            <input type="submit" value="Delete" class="btn btn-default" /> |
            @Html.ActionLink("Back to List", "Index")
        </div>
    }
</div>

此代码在标题之间h2h3添加错误消息:

<p class="error">@ViewBag.ConcurrencyErrorMessage</p>

它在字段中替换为 LastNameFullNameAdministrator

<dt>
  Administrator
</dt>
<dd>
  @Html.DisplayFor(model => model.Administrator.FullName)
</dd>

最后,它会在Html.BeginForm语句后面添加隐藏字段DepartmentIDRowVersion属性:

@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)

运行“部门索引”页。 右键单击英语系的 “删除 ”超链接,然后选择“ 在新选项卡中打开”, 然后在第一个选项卡中单击英语系的 “编辑 超链接”。

在第一个窗口中,更改其中一个值,然后单击“ 保存”。

“索引”页确认更改。

在第二个选项卡中,单击“删除”。

你将看到并发错误消息,且已使用数据库中的当前内容刷新了“院系”值。

Department_Delete_confirmation_page_with_concurrency_error

如果再次单击“删除”,会重定向到已删除显示院系的索引页。

获取代码

下载已完成的项目

其他资源

可以在 ASP.NET 数据访问 - 建议的资源中找到指向其他 Entity Framework 资源的链接。

有关处理各种并发方案的其他方法的信息,请参阅 MSDN 上的乐观并发模式使用属性值 。 下一教程演示如何为 Instructor 实体 Student 实现按层次结构表继承。

后续步骤

在本教程中,你将了解:

  • 已了解并发冲突
  • 添加了乐观并发
  • 修改的部门控制器
  • 测试的并发处理
  • 已更新“删除”页

转到下一篇文章,了解如何在数据模型中实现继承。