MVC5 Entity Framework学习之处理并发
之前你已经学习了如何更新数据,那么在本节你将学习如何在当多个用户在同一时间更新同一实体时处理冲突。
修改与Department实体相关的那些页面以便它们能够i处理并发错误。下面的截图是Index 和Delete页面,以及当出现并发冲突时的错误消息。


并发冲突
当一个用户对实体的数据进行编辑,然后另一个用户在前一个用户将更改写入到数据库之前更新同一实体的数据时将发生并发冲突。如果你没有启用冲突检测,那么最后一次对数据库的更新将会覆盖其他用户对数据库所做的更改。在大部分应用程序中,这种风险是可以接受的:如果只有少量的用户,或者很少的更新,或者被覆盖的数据是不太重要的,实现并发冲突可能是得不偿失的。在这种情况下,你不需要配置应用程序以处理并发冲突。
悲观并发(锁定)
如果你的应用程序需要防止由于并发而导致数据意外丢失,你可以使用数据库锁,即所谓的悲观并发。例如,当你从数据库中读取一条记录时,你可以将其锁定为只读或更新状态。如果你将某条记录锁定为更新状态,那么其他用户将无法对该记录再次加锁,无论是读取还是更新操作。如果你将某条记录锁定为只读状态,其他人也可以将其锁定为只读状态,但不能进行更新操作。
管理锁也有缺点,它会导致编程更复杂,它需要大量的数据库管理资源,并且它可能会在用户数量增加时导致性能问题。基于以上种种,并不是所有的数据库管理系统都支持悲观并发。Entity Framework并没有提供内置支持,且本节中不会讨论如何实现它。
乐观并发
悲观并发的替代方案之一就是乐观并发。乐观并发意味着允许发生并发冲突,然后做出适当的反应。例如,John打开Departments Edit页面,将English department的Budget从$350,000.00 修改为$0.00。


在John点击Save之前,一个叫Jane也打开了此页面,并将Start Date修改为 2014/09/01

John首先点击了Save,Index页面显示了被修改的数据,之后Jane也点击了Save,接下来会发生什么取决于你如何处理并发冲突,你可以通过使用下面的方法来处理你的应用:
检测并发冲突
你可以通过处理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在Update 或 Delete 命令的Where子句中包含数据库表中每一列的原始值。
就像第一种方式,如果数据行首次被读取并被修改,Where子句不会返回要更新的行,Entity Framework会将其认定为并发冲突。对于数据库中具有多列的表来说,这种方法可能会产生庞大的Where子句,并要求你维护大量的状态。就像之前提醒过的,维护大量状态可能会影响应用性能。因此该方法一般不推荐使用,在示例中也不会使用。
如果你确实希望使用该方法来处理并发,你必须要通过添加ConcurrencyCheck属性来标记实体的所有非主键属性。这样可以让Entity Framework在Update语句的Where子句中包含所有的列。
接下来你将会在Department实体中添加一个rowversion跟踪属性,并创建一个控制器和视图,最后验证他们。
向Department实体中添加乐观并发属性
打开Models\Department.cs,添加一个名为RowCersion的跟踪属性
- 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属性指定该列将会包含在发送到数据库的Update或Delete命令的Where子句中。该属性被称为Timestamp,因为SQL Server之前的版本使用了SQL timestamp数据类型,之后使用了SQL rowversion来替换它。rowversion的.Net类型是一个字节数组。
如果你更喜欢使用fluent API,你可以使用IsConcurrencyToken方法来指定跟踪属性,如下所示:
- modelBuilder.Entity<Department>()
- .Property(p => p.RowVersion).IsConcurrencyToken();
通过添加属性你已经更改了数据库模型,所以你需要再做一次迁移。打开Package Manager Console (PMC),输入下列命令:
- Add-Migration RowVersion
- Update-Database
修改Department控制器
打开DepartmentController.cs,添加命名空间:
- using System.Data.Entity.Infrastructure;
打开DepartmentController.cs,将所有出现的LastName修改为FullName以便department administrator下列列表包含instructor 的full name而不是last name:
- ViewBag.InstructorID = new SelectList(db.Instructors, "InstructorID", "FullName");
修改 HttpPost Edit方法:
- [HttpPost]
- [ValidateAntiForgeryToken]
- public async Task<ActionResult> Edit(
- [Bind(Include = "DepartmentID, Name, Budget, StartDate, RowVersion, 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,
- "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.");
- department.RowVersion = databaseValues.RowVersion;
- }
- }
- catch (RetryLimitExceededException )
- {
-
- 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, "ID", "FullName", department.InstructorID);
- return View(department);
- }
视图将原始RowVersion值存储至隐藏字段中,模型绑定器创建department实例时,该对象将拥有原始RowVersion属性值和其他属性的新值,比如用户在Edit页面中输入的数据, 然后Entity Framework会生成一个SQL Update命令,该命令包含有一个查找具有RowVersion值的行的Where子句。
如果Update操作没有更新任何行,Entity Framework会抛出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,
- "Unable to save changes. The department was deleted by another user.");
- }
- else
- {
- var databaseValues = (Department)databaseEntry.ToObject();
接下来,如果在Edit页面中用户输入的数据与数据库中的数据不一致,上面的代码为这些数据不一致的列添加了自定义错误信息:
- 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.");
最后,该代码将Department对象的RowVersion值设置为从数据库检索到的新值,当重新呈现Edit页面时RowVersion的新值被存储在隐藏字段中,下一次用户单击Save时,仅在重新显示Edit页面时捕获发生的并发错误。
打开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)
测试乐观并发处理
运行项目,单击Departments选项卡

打开两个English department Edit页面

将第一个Edit页面中的Budget修改为0,单击Save

Index页面显示了修改后的数据

修改第二个Edit页面中的Start Date

点击Save,可以看到错误信息

再次点击Save,将会覆盖在第一个Edit页面修改的数据

更新Delete页面
对于Delete页面,Entity Framework使用类似与上面的编辑department 时的方式来检测并发冲突。当HttpGet Delete方法显示确认视图时,该视图的隐藏字段中包含了原始的RowVersion值。当用户确认删除时,该值会在调用HttpPost Delete方法时传递给该方法。当Entity Framework创建SQL Delete命令时,该命令的Where子句中将包括原始的RowVersion值。如果该命令没有删除任何行,程序就会抛出并发异常,HttpGet Delete方法会被调用,同时一个错误标志位被设置为true,以便重新重新显示确认页面并显示错误信息。Delete命令没有删除任何行也可能是因为有另一个用户正好也删除了该行,在这种情况下,我们应该显示一个不同的错误信息。
打开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 = "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属性将错误信息传递至视图。
修改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, "Unable to delete. Try again, and if the problem persists contact your system administrator.");
- return View(department);
- }
- }
由框架自动生成的Delete方法仅接收一个记录ID参数
- public async Task<ActionResult> DeleteConfirmed(int id)
修改后的方法接收一个由模型绑定器创建的Department 实体参数,这样可以访问到RowVersion属性
- public async Task<ActionResult> Delete(Department department)
你已经将方法名称从DeleteConfirmed修改为Delete,框架代码将HttpPost Delete方法命名为DeleteConfirmed以给予其一个唯一的签名。(CLR需要重载方法具有不同的参数)现在签名是唯一的,你可以遵从MVC的约定,将HttpPost和HttpGet Delete方法使用相同的方法名。
如果捕获到并发错误,该代码将重新显示Delete确认页并提供一个标志位来指明将显示并发错误信息。
打开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>
上面的代码在h2和h3标题之间添加了错误信息
- <p class="error">@ViewBag.ConcurrencyErrorMessage</p>
将Administrator字段的LastName修改为FullName
- <dt>
- Administrator
- </dt>
- <dd>
- @Html.DisplayFor(model => model.Administrator.FullName)
- </dd>
在Html.BeginForm语句之后为DepartmentID 和RowVersion属性添加隐藏字段
- @Html.HiddenFor(model => model.DepartmentID)
- @Html.HiddenFor(model => model.RowVersion)
运行项目,点击Department选项卡,为English department 打开一个Edit页面和一个Delete页面
在Edit页面中,修改Budget的值,点击Save

Index页面显示了修改后的值

在Delete页面中点击Delete

可以看到并发错误信息,并显示了数据库中已经被修改的值

如果你再次点击Delete,你会被重定向Index页面,并且该Department已经被删除。
原文:Handling Concurrency with the Entity Framework 6 in an ASP.NET MVC 5 Application
我的小站:MVC5 Entity Framework学习(10):处理并发
还大家一个健康的网络环境,从你我做起
评论列表