[渣译文] 使用 MVC 5 的 EF6 Code First 入门 系列:为ASP.NET MVC应用程序处理并发

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

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

译文版权全部,谢绝全文转载——但您能够在您的网站上添加到该教程的连接。web

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

你将修改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平台及工具团队的高级程序员,做家。

相关文章
相关标签/搜索