這是微軟官方教程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平台及工具團隊的高級程序員,作家。