为ASP.NET MVC应用程序处理并发

这是微软官方教程Getting Started with Entity Framework 6 Code First using MVC 5 系列的翻译,这里是第十篇:为ASP.NET MVC应用程序处理并发

原文: Handling Concurrency with the Entity Framework 6 in an ASP.NET MVC 5 Application

在之前的教程中,您已经学习了如何更新数据。在本节教程中将展示当多个用户在同一时间更新同一实体时如何处理冲突。

你将修改web页面来处理Department实体,使得它们能够处理并发错误。下面的截图显示了索引和删除页面,以及一些并发冲突的错误消息。

并发冲突

当一个用户显示实体的数据并对其进行编辑,然后另一个用户在第一个用户的更改写入到数据库之前更新同一实体的数据,将发生并发冲突。如果您不启用这种冲突的检测,最后一次更新数据库的用户将覆盖其他用户对数据库所做的更改。在大部分应用程序中,这种风险是可以接收的:如果仅有几个用户或很少更新,或者数据更新覆盖的问题真的不是很重要,实现并发冲突的开销可能会大于它带来的益处。在这种情况下,您不需要配置应用程序以处理并发冲突。

悲观并发(锁定)

如果您的应用程序需要防止并发带来的意外数据丢失,要做到这一点的一个方法是使用数据库锁。即所谓的悲观并发。例如,您从数据库读取行之前,先请求一个只读或更新的访问锁。如果你锁定了某行的更新访问,没有其他用户可以给该行加锁,无论是只读或是更新。因为他们得到的数据只是变更过程中的一个副本。如果你锁定了某行的只读访问,其他人也可以将其锁定为只读访问,但不能进行更新。

管理锁也有缺点。它会使编程更复杂。并且它需要数据库的管理资源——大量的,以及它可能导致性能的问题比如应用程序的用户数量增加。出于这些原因,并不是所有的数据库管理系统都支持悲观并发。实体框架内置了悲观并发的支持,单本教程中不会讨论如何实现它。

乐观并发

悲观并发的替代方案就是乐观并发。乐观并发意味着运行并发冲突发生,然后对发生的变化做出适当的反应。例如,路人甲在系编辑页面,更改自然科学的预算从50更改为50000。

在路人甲保存该更改之前,路人乙也同样打开了该页面,并更改起始日期字段到2012-12-12。

路人甲首先点击保存,他在索引页面上看到了他所做的修改,之后路人乙也点击了保存。下一步会发生什么取决于你如何处理并发冲突,下面列出了一些选择:

  • 你可以跟踪用户已修改的属性并仅更新数据库中的相应列。在示例中,没有数据会丢失,因为两个不同的属性分别由两个不同的用户更新。路人丙此时浏览页面会同时看到甲和乙所分别做出的变化——2012年的起始日期和0元的预算。这种更新的方法可以减少冲突,但仍可能会导致数据丢失——如果对同一属性进行更改的话。是否采用这种方式来让实体框架工作取决于您如何实现您的更新代码。在实际的web应用程序中这往往不是最佳做法。因为它会要求保持大量的状态以便跟踪实体的所有原始属性和新值。维护大量的状态会影响应用程序性能。因为这需要更多的服务器资源。

  • 您可以让乙的更改覆盖甲的更改,在丙浏览页面时,他会看到2012年的起始日期和还原的50元预算。这被称为客户端通吃或最后一名通吃。(来自客户端的值优先于先保存的值,覆盖全部数据)。下面的截图演示了这种情况:

  • 您也可以阻止乙的更改保存到数据库。通常情况下会显示一条错误信息,显示被覆盖的数据之间有何不同来允许用户重新提交更改——如果用户想要这样做的话。这被称为存储通吃。(已经保存的值优先于客户端提交的值)你会在本教程中实现该方案,以确保在提示用户之前不会覆盖其它用户的更改。

检测并发冲突

您可以通过实体框架引发的 OptimisticConcurrencyException 异常处理来解决冲突。为了知道何时何地会引发这些异常,实体框架必须能够检测到冲突。因此,你必须对数据库和数据模型进行适当的配置,包括以下内容:

  • 在数据表中,包含用于跟踪修改的列。然后,您可以配置实体框架在更新或删除的时候包含该列来进行检测。跟踪列的数据类型通常是rowversion。行版本的值是一个每次在更新时都会递增的顺序编号。在更新或删除命令中,Where字句将包含跟踪列的原始值。如果有另一个用户更改了正在更新的行,行版本中的值会和原来的不一致。因此更新和删除语句无法找到要更新的行。当在更新或删除时没有行被更新时,实体框架将认定该命令为并发冲突。

  • 配置实体框架可以在更新和删除命令的Where子句中包含数据表每个列的原始值。和第一个方式类似,如果数据行被首次读取后发生了更改,Where字句不会找到要更新的行,实体框将解释为并发冲突。对于有多列的数据库表,这种方法可能会导致非常庞大的Where字句,并要求你保持大量的状态。如前所述,保持大量状态可能会影响应用程序的性能。因此该方法一般并不推荐,本教程中也不会使用。如果您想要执行这种方法来实现并发,你必须将ConcurrencyCheck特性添加到实体所有的非主键属性上。这种变化使实体框架可以将所有标记的列包含到更新语句的Where子句中。

在本教程的剩余部分,你会添加行版本用来跟踪Department实体的属性。

将乐观并发的所需属性添加到Department实体

在Models\Department.cs中,添加一个名为RowCersion的跟踪属性:

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 = "起始日期")]
        public DateTime StartDate { get; set; }

        [Display(Name = "系主任")]
        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 特性指定该列将会包含在发送到数据库的更新或删除命令的Where子句中。该属性被称为时间戳,因为之前版本的SQL Server使用SQL Timestamp数据类型。行版本的.Net类型是一个字节数组。

如果您更喜欢使用fluent API,您可以使用 IsConcurrencyToken 方法来指定跟踪属性,如下面的示例:

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

现在您已经更改了数据库模型,所以您需要再做一次迁移。在软件包管理器控制台中,输入以下命令:

Add-Migration RowVersion 
Update-Database

修改Department控制器

在DepartmentController.cs中,添加using语句:

using System.Data.Entity.Infrastructure;

将文件中所有的"LastName"更改为"FullName"以便下拉列表使用教师的全名,而不是姓。

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

使用下面的代码替换HttpPost的Edit方法:

        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<ActionResult> Edit([Bind(Include = "DepartmentID,Name,Budget,StartDate,InstructorID")] Department department)
        {
            try
            {
                if (ModelState.IsValid)
                {
                    db.Entry(department).State = EntityState.Modified;
                    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, "无法保存更改,系已经被其他用户删除。");
                }
                else
                {
                    var databaseValues = (Department)databaseEntry.ToObject();
                    if (databaseValues.Name != clientValues.Name)
                        ModelState.AddModelError("Name", "当前值: "
                            + databaseValues.Name);
                    if (databaseValues.Budget != clientValues.Budget)
                        ModelState.AddModelError("Budget", "当前值: "
                            + String.Format("{0:c}", databaseValues.Budget));
                    if (databaseValues.StartDate != clientValues.StartDate)
                        ModelState.AddModelError("StartDate", "当前值: "
                            + String.Format("{0:d}", databaseValues.StartDate));
                    if (databaseValues.InstructorID != clientValues.InstructorID)
                        ModelState.AddModelError("InstructorID", "当前值: "
                            + db.Instructors.Find(databaseValues.InstructorID).FullName);
                    ModelState.AddModelError(string.Empty, "当前记录已经被其他人更改。如果你仍然想要保存这些数据,"
                    + "重新点击保存按钮或者点击返回列表撤销本次操作。");
                    department.RowVersion = databaseValues.RowVersion;
                }
            }
            catch (RetryLimitExceededException)
            {
                ModelState.AddModelError(string.Empty, "无法保存更改,请重试或联系管理员。");
            }
            ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", department.InstructorID);
            return View(department);
        }

视图在隐藏字段中存储原始的RowVersion值。 当模型绑定器创建系的实例,对象将有原始的RowVersion属性值及其他属性的新值,比如在编辑页面上输入的用户。然后实体框架创建一个更新命令,命令将在Where子句中包括RowVersion值来进行查询。

如果没有任何行被更新(没有找到匹配原始RowVersion值的行),实体框架将引发 DbUpdateConcurrencyException 异常,并从catch代码块中异常对象中获取受影响的Department实体。

var entry = ex.Entries.Single();

该对象的Entity属性拥有用户输入的新值,您也可以调用GetDatabaseValues方法来从数据库中读取原始值。

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

如果有人将行从数据库中删除,GetDataBaseValue方法将返回null,否则,您必须返回的对象强制转换为Department类以访问Department中的属性。

if (databaseEntry == null)
{
    ModelState.AddModelError(string.Empty, "无法保存更改,系已经被其他用户删除。");
}
else
{
    var databaseValues = (Department)databaseEntry.ToObject();                 

下一步,代码将添加每一列数据库和用户输入不同值的自定义错误消息:

if (databaseValues.Name != clientValues.Name)
      ModelState.AddModelError("Name", "当前值: " + databaseValues.Name);

一个较长的错误消息向用户解释发生了什么事情:

ModelState.AddModelError(string.Empty, "当前记录已经被其他人更改。如果你仍然想要保存这些数据,"
+ "重新点击保存按钮或者点击返回列表撤销本次操作。");                

最后,代码将Department对象的RowVersion值设置为从数据库检索到的新值。新的值在重新显示编辑页面时被存储在隐藏字段。下一次用户单击保存时,重新显示的编辑页面会继续捕获并发错误。

在Views\Department\Edit.cshtml中,在DepartmentID隐藏字段后添加一个RowVersion隐藏字段。

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

测试乐观并发处理

运行应用程序,单击系选项卡并复制一个选项卡,重复打开两个系页面。

同时在两个窗口中打开同一系的编辑页面,编辑其中的一个页面并保存。

你会看到值已经被保存到数据库中。

修改第二个窗口中的字段并保存。

你会看到并发错误的消息:

再次单击保存,你在第二个浏览器中数据库值会覆盖掉第一个窗口中的保存到数据库中。

更新删除页

对于删除页面,实体框架使用类似的方式来检测并发冲突。当HttpGet的Delete方法显示确认视图时,视图的隐藏字段中包括了原始RowVersion值。当用户确认删除时,该值在HttpPost的Delete方法中就够被传递并调用。当实体框架创建SQL Delete命令时,Where子句中将包括原始的RowVersion值。如果命令执行后没有行受到影响,就会引发并发异常。HttpGet的Delete方法被调用,标志位将被设置为true以重新显示确认页并显示错误。但同时要考虑如果有另一个用户正好也删除了该行,同样会导致一个0行受影响的结果。在这种情况下,我们将显示一个不同的错误消息。

在DepartmentController.cs中,使用下面的代码替换HttpGet的Delete方法:

        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 == true)
                {
                    return RedirectToAction("Index");
                }
                return HttpNotFound();
            }
            if (concurrencyError.GetValueOrDefault())
            {
                if (department == null)
                {
                    ViewBag.ConcurrencyErrorMessage = "你想要删除的记录"
                        + "已经被另一个用户删除了,点击列表超链接返回。";
                }
                else
                {
                    ViewBag.ConcurrencyErrorMessage = "你想要删除的记录"
                        + "被另一个用户修改了原始值,如果您仍然想要删除该条记录"
                        + "再次点击删除按钮,或者点击列表超链接返回。";
                }
            }
            return View(department);
        }

该方法接受一个可选参数,指示发生并发冲突错误时页面是否将被重新显示。如果此标志为true,将使用ViewBag发送一条错误到视图上。

使用下面的代码替换HttpPost Delete方法(名为DeleteConfirmed):

        [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)
            {
                ModelState.AddModelError(string.Empty, "无法删除,请重试或联系管理员。");
                return View(department);
            }
        }

在您尚未修改的脚手架代码中,该方法接收一个记录ID

        public async Task<ActionResult> DeleteConfirmed(int id)

您更改了此参数,使用模型绑定器来创建一个Department实体,这使您可以访问到RowVersion属性值。

        public async Task<ActionResult> Delete(Department department)

同时您修改了方法名称从DeleteConfirmed到Delete。脚手架代码为HttpPost的Delete方法使用了Delete的名称,因为这样能够给HttpPost方法一个唯一的签名。(CLR需要方法有不同的参数来重载。现在签名是唯一的,你可以保持MVC的约定,在HttpPost和HttpGet方法上使用相同的方法名。)

如果捕捉到并发错误,该代码重新显示删除确认页,并提供了一个标志来指示显示并发错误消息。

在Views\Department\Delete.cshtml,为视图添加错误消息字段和隐藏字段。将脚手架的代码替换为下面的:

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

代码在h2和h3之间添加了一条错误消息:

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

使用FullName替换了LastName:

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

最后,它在Html.BeginForm语句之后添加了隐藏字段:

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

运行应用程序,打开系索引页面,右键点击自然科学的删除超链接,选择在新窗口中打开。然后在第一个窗口上点击编辑,修改预算并保存。

更改已经保存到数据库。

点击第二个窗口中的删除按钮,会看到一个并发错误信息。

如果此时再次点击删除,实体将被删除,你会被重定向到索引页面。

总结

在本节中我们介绍了如何处理并发冲突。关于更多处理并发冲突的信息,请参阅MSDN上的和。下一节中我们将介绍如何实现Instructor和Student实体的表-每个层次继承。

作者信息

Tom Dykstra - Tom Dykstra 是微软Web平台及工具团队的高级程序员,作家。