在 ASP.NET MVC 应用程序中处理实体框架的并发(共 10 个)
作者:Tom Dykstra
Contoso University 示例 Web 应用程序演示如何使用 Entity Framework 5 Code First 和 Visual Studio 2012 创建 ASP.NET MVC 4 应用程序。 若要了解系列教程,请参阅本系列中的第一个教程。
在前面的两个教程中,你处理了相关数据。 本教程演示如何处理并发。 你将创建使用实体的 Department
网页,编辑和删除 Department
实体的页面将处理并发错误。 下图显示了“索引”和“删除”页,包括发生并发冲突时显示的一些消息。
并发冲突
当某用户显示实体数据以对其进行编辑,而另一用户在上一用户的更改写入数据库之前更新同一实体的数据时,会发生并发冲突。 如果不启用此类冲突的检测,则最后更新数据库的人员将覆盖其他用户的更改。 在许多应用程序中,此风险是可接受的:如果用户很少或更新很少,或者一些更改被覆盖并不重要,则并发编程可能弊大于利。 在此情况下,不必配置应用程序来处理并发冲突。
悲观并发 (锁定)
如果应用程序确实需要防止并发情况下出现意外数据丢失,一种方法是使用数据库锁定。 这称为 悲观并发。 例如,在从数据库读取一行内容之前,请求锁定为只读或更新访问。 如果将一行锁定为更新访问,则其他用户无法将该行锁定为只读或更新访问,因为他们得到的是正在更改的数据的副本。 如果将一行锁定为只读访问,则其他人也可将其锁定为只读访问,但不能进行更新。
管理锁定有缺点。 编程可能很复杂。 它需要大量的数据库管理资源,并且它可能会导致性能问题,因为应用程序的用户数增加(也就是说,它无法很好地缩放)。 由于这些原因,并不是所有的数据库管理系统都支持悲观并发。 Entity Framework 不提供对它的内置支持,本教程不介绍如何实现它。
开放式并发
悲观并发的替代方法是 乐观并发。 悲观并发是指允许发生并发冲突,并在并发冲突发生时作出正确反应。 例如,John 运行“部门编辑”页面,将 英语部门的预算 金额从 350,000.00 美元更改为 0.00 美元。
在 John 单击“保存”之前,Jane 会运行同一页,并将开始日期字段从 2007 年 9 月 1 日更改为 2013 年 8 月 8 日。
John 先单击“保存”,当浏览器返回到“索引”页时,会看到他的更改,然后 Jane 单击“保存”。 接下来的情况取决于并发冲突的处理方式。 其中一些选项包括:
可以跟踪用户已修改的属性,并仅更新数据库中相应的列。 在示例方案中,不会有数据丢失,因为是由两个用户更新不同的属性。 下次有人浏览英国部门时,他们将看到约翰和简的更改 -- 2013 年 8 月 8 日开始,预算为零美元。
这种更新方法可减少可能导致数据丢失的冲突次数,但是如果对实体的同一属性进行竞争性更改,则数据难免会丢失。 Entity Framework 是否以这种方式工作取决于更新代码的实现方式。 通常不适合在 Web 应用程序中使用,因为它要求保持大量的状态,以便跟踪实体的所有原始属性值以及新值。 维护大量的状态可能会影响应用程序性能,因为它要么需要服务器资源,要么必须包含在网页本身(例如隐藏字段中)。
你可以让 Jane 的更改覆盖 John 的更改。 下次有人浏览英语部门时,他们将看到 2013 年 8 月 8 日,还原的值为 350,000.00 美元。 这称为“客户端优先”或“最后一个优先” 。 (客户端的值优先于数据存储中的内容。如本部分简介中所述,如果不对并发处理执行任何编码,则会自动发生这种情况。
可以阻止 Jane 更改在数据库中更新。 通常,将显示一条错误消息,显示她的当前数据状态,并允许她重新应用更改(如果她仍想进行更改)。 这称为“存储优先”方案。 (数据存储值优先于客户端提交的值。)本教程将执行“存储优先”方案。 此方法可确保用户在未收到具体发生内容的警报时,不会覆盖任何更改。
检测并发冲突
可以通过处理 Entity Framework 引发的 OptimisticConcurrencyException 异常来解决冲突。 为了知道何时引发这些异常,Entity Framework 必须能够检测到冲突。 因此,你必须正确配置数据库和数据模型。 启用冲突检测的某些选项包括:
数据库表中包含一个可用于确定某行更改时间的跟踪列。 然后,可以将 Entity Framework 配置为在 SQL
Update
或Delete
命令的子句中包含Where
该列。跟踪列的数据类型通常是 rowversion。 rowversion 值是每次更新行时递增的序列号。 在或
Update
Delete
命令中,子Where
句包括跟踪列的原始值(原始行版本)。 如果正在更新的行已被其他用户更改,则rowversion
列中的值与原始值不同,因此Update
或Delete
语句找不到因子句而更新的Where
行。 当 Entity Framework 发现没有由Update
或Delete
命令更新任何行(即受影响行数为零时),它会将其解释为并发冲突。配置 Entity Framework,以在子句和
Delete
命令的子句Update
中包含表中Where
每个列的原始值。与在第一个选项中一样,如果自第一次读取行以来行中的任何内容发生更改,则
Where
子句不会返回要更新的行,实体框架将其解释为并发冲突。 对于包含多个列的数据库表,此方法可能会导致非常大Where
的子句,并且可能需要保持大量的状态。 如前所述,维护大量状态可能会影响应用程序性能,因为它需要服务器资源,或者必须包含在网页本身中。 因此,通常不建议使用此方法,而不是本教程中使用的方法。如果确实要实现此方法以实现并发,则必须通过在实体中添加 ConcurrencyCheck 属性来标记要跟踪其并发的所有非主键属性。 此更改使 Entity Framework 能够在语句的
UPDATE
SQLWHERE
子句中包含所有列。
在本教程的其余部分中,你将向实体添加 行version 跟踪属性 Department
,创建控制器和视图,并测试以验证一切是否正常工作。
将乐观并发属性添加到 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)]
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 属性指定此列将包含在Where
发送到数据库的子句Update
和Delete
命令中。 该属性称为 Timestamp,因为以前版本的 SQL Server 在 SQL rowversion 替换它之前使用了 SQL 时间戳数据类型。 的 .Net 类型 rowversion
是字节数组。 如果想要使用 fluent API,可以使用 IsConcurrencyToken 方法指定跟踪属性,如以下示例所示:
modelBuilder.Entity<Department>()
.Property(p => p.RowVersion).IsConcurrencyToken();
请参阅 GitHub 问题 Replace IsConcurrencyToken by IsRowVersion。
通过添加属性,更改了数据库模型,因此需要再执行一次迁移。 在包管理器控制台 (PMC) 中输入以下命令:
Add-Migration RowVersion
Update-Database
创建部门控制器
Department
使用以下设置,创建控制器和视图的方式与执行其他控制器的方式相同:
在 Controllers\DepartmentController.cs 中,添加语句 using
:
using System.Data.Entity.Infrastructure;
将此文件(四个匹配项)中的“LastName”更改为“FullName”,以便部门管理员下拉列表将包含讲师的全名,而不仅仅是姓氏。
ViewBag.InstructorID = new SelectList(db.Instructors, "InstructorID", "FullName");
将方法的现有代码HttpPost
Edit
替换为以下代码:
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Edit(
[Bind(Include = "DepartmentID, Name, Budget, StartDate, RowVersion, InstructorID")]
Department department)
{
try
{
if (ModelState.IsValid)
{
db.Entry(department).State = EntityState.Modified;
db.SaveChanges();
return RedirectToAction("Index");
}
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var clientValues = (Department)entry.Entity;
var databaseValues = (Department)entry.GetDatabaseValues().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.");
department.RowVersion = databaseValues.RowVersion;
}
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 save changes. Try again, and if the problem persists contact your system administrator.");
}
ViewBag.InstructorID = new SelectList(db.Instructors, "InstructorID", "FullName", department.InstructorID);
return View(department);
}
该视图将原始 RowVersion
值存储在隐藏字段中。 当模型绑定器创建 department
实例时,该对象将具有原始 RowVersion
属性值和其他属性的新值,由用户在“编辑”页面上输入。 然后,当 Entity Framework 创建 SQL UPDATE
命令时,该命令将包含一个 WHERE
子句,该子句查找具有原始 RowVersion
值的行。
如果命令(没有行 UPDATE
具有原始 RowVersion
值),则 Entity Framework 将引发异常 DbUpdateConcurrencyException
,并且块中的 catch
代码会从异常对象获取受影响的 Department
实体。 此实体包含从数据库读取的值和用户输入的新值:
var entry = ex.Entries.Single();
var clientValues = (Department)entry.Entity;
var databaseValues = (Department)entry.GetDatabaseValues().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()
@Html.ValidationSummary(true)
<fieldset>
<legend>Department</legend>
@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)
<div class="editor-label">
@Html.LabelFor(model => model.Name)
</div>
在 Views\Department\Index.cshtml 中,将现有代码替换为以下代码,以向左移动行链接,并更改要显示的 FullName
页标题和列标题,而不是 LastName
在 管理员 列中显示:
@model IEnumerable<ContosoUniversity.Models.Department>
@{
ViewBag.Title = "Departments";
}
<h2>Departments</h2>
<p>
@Html.ActionLink("Create New", "Create")
</p>
<table>
<tr>
<th></th>
<th>Name</th>
<th>Budget</th>
<th>Start Date</th>
<th>Administrator</th>
</tr>
@foreach (var item in Model) {
<tr>
<td>
@Html.ActionLink("Edit", "Edit", new { id=item.DepartmentID }) |
@Html.ActionLink("Details", "Details", new { id=item.DepartmentID }) |
@Html.ActionLink("Delete", "Delete", new { id=item.DepartmentID })
</td>
<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>
</tr>
}
</table>
测试乐观并发处理
运行站点并单击“ 部门:
右键单击 Kim Abercrombie 的“编辑 超链接”,然后在新选项卡中选择“ 打开”, 然后单击 Kim Abercrombie 的“编辑 超链接”。 这两个窗口显示相同的信息。
在第一个浏览器窗口中更改字段,然后单击“ 保存”。
浏览器显示具有更改值的索引页。
更改第二个浏览器窗口中的任何字段,然后单击“ 保存”。
单击第二个浏览器窗口中的“ 保存 ”。 看见一条错误消息:
再次单击“保存”。 在第二个浏览器中输入的值与在第一个浏览器中更改的数据的原始值一起保存。 在索引页中出现时,可以看到已保存的值。
更新“删除”页
对于“删除”页,Entity Framework 以类似方式检测其他人编辑院系所引起的并发冲突。 HttpGet
Delete
当该方法显示确认视图时,该视图在隐藏字段中包括原始RowVersion
值。 然后,该值对HttpPost
Delete
用户确认删除时调用的方法可用。 实体框架创建 SQL DELETE
命令时,它包含 WHERE
具有原始 RowVersion
值的子句。 如果命令导致零行受到影响(这意味着在显示“删除确认”页后更改了该行),则会引发并发异常,并且 HttpGet Delete
调用方法时会设置错误标志 true
,以便重新显示带有错误消息的确认页。 此外,零行也可能受到影响,因为该行已被其他用户删除,因此在这种情况下会显示不同的错误消息。
在DepartmentController.cs中,将HttpGet
Delete
该方法替换为以下代码:
public ActionResult Delete(int id, bool? concurrencyError)
{
Department department = db.Departments.Find(id);
if (concurrencyError.GetValueOrDefault())
{
if (department == null)
{
ViewBag.ConcurrencyErrorMessage = "The record you attempted to delete "
+ "was deleted by another user after you got the original values. "
+ "Click the Back to List hyperlink.";
}
else
{
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
属性将错误消息发送到视图。
将方法(namedDeleteConfirmed
)中的HttpPost
Delete
代码替换为以下代码:
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Delete(Department department)
{
try
{
db.Entry(department).State = EntityState.Deleted;
db.SaveChanges();
return RedirectToAction("Index");
}
catch (DbUpdateConcurrencyException)
{
return RedirectToAction("Delete", new { concurrencyError=true } );
}
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 ActionResult DeleteConfirmed(int id)
已将此参数更改为由模型绑定器创建的 Department
实体实例。 这样,除了记录键之外,还可以访问 RowVersion
属性值。
public ActionResult Delete(Department department)
你还将操作方法名称从 DeleteConfirmed
更改为了 Delete
。 命名HttpPost
Delete
方法DeleteConfirmed
的基架代码,为方法提供HttpPost
唯一签名。 (CLR 要求重载的方法具有不同的方法参数。现在,签名是唯一的,可以坚持 MVC 约定,对和HttpGet
删除方法使用相同的名称HttpPost
。
如果捕获到并发错误,代码将重新显示“删除”确认页,并提供一个指示它应显示并发错误消息的标志。
在 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>
<fieldset>
<legend>Department</legend>
<div class="display-label">
@Html.DisplayNameFor(model => model.Name)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.Name)
</div>
<div class="display-label">
@Html.DisplayNameFor(model => model.Budget)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.Budget)
</div>
<div class="display-label">
@Html.DisplayNameFor(model => model.StartDate)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.StartDate)
</div>
<div class="display-label">
@Html.DisplayNameFor(model => model.Administrator.FullName)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.Administrator.FullName)
</div>
</fieldset>
@using (Html.BeginForm()) {
@Html.AntiForgeryToken()
@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)
<p>
<input type="submit" value="Delete" /> |
@Html.ActionLink("Back to List", "Index")
</p>
}
此代码在标题之间h2
h3
添加错误消息:
<p class="error">@ViewBag.ConcurrencyErrorMessage</p>
它将替换为LastName
字段中Administrator
的以下项FullName
:
<div class="display-label">
@Html.LabelFor(model => model.InstructorID)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.Administrator.FullName)
</div>
最后,它会在Html.BeginForm
语句后面添加隐藏字段DepartmentID
和RowVersion
属性:
@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)
运行“部门索引”页。 右键单击 英语部门的“删除 ”超链接,然后选择“ 在新窗口中打开”, 然后在第一个窗口中单击 英语系的“编辑 超链接”。
在第一个窗口中,更改其中一个值,然后单击“保存”:
“索引”页确认更改。
在第二个窗口中,单击“ 删除”。
你将看到并发错误消息,且已使用数据库中的当前内容刷新了“院系”值。
如果再次单击“删除”,会重定向到已删除显示院系的索引页。
总结
处理并发冲突已介绍完毕。 有关处理各种并发方案的其他方法的信息,请参阅 Entity Framework 团队博客上的乐观并发模式 和 使用属性值 。 下一教程演示如何为 Instructor
和 Student
实体实现按层次结构表继承。
可以在 ASP.NET 数据访问内容映射中找到指向其他 Entity Framework 资源的链接。