By Tom Dykstra, Tom Dykstra is a Senior Programming Writer on Microsoft's Web Platform & Tools Content Team.
原文地址:http://www.asp.net/mvc/tutorials/getting-started-with-ef-using-mvc/handling-concurrency-with-the-entity-framework-in-an-asp-net-mvc-application
全文目錄:Contoso 大學 - 使用 EF Code First 創建 MVC 應用
在上一次的教程中我們處理了關聯數據問題。這個教程演示如何處理並發問題。你將使用 Department 實體創建一個頁面,這個頁面在支持編輯和刪除的同時,還可以處理並發錯誤。下面的截圖演示了 Index 頁面和 Delete 頁面,包括在出現並發沖突的時候提示的一些信息。
7-1 並發沖突
並發沖突出現在這樣的時候,一個用戶正在顯示並編輯一個實體,但是在這個用戶將修改保存到數據庫之前,另外的一個用戶卻更新了同樣的實體。如果你沒有通過 EF 檢測類似的沖突,最后一個更新數據的用戶將會覆蓋其他用戶的修改。在一些程序中,這樣的風險是可以接受的,如果只有很少的用戶,或者很少的更新,甚至對數據的覆蓋不是真的很關鍵,或者解決並發的代價超過了支持並發所帶來的優勢。在這種情況下,你就不需要讓你的程序支持並發沖突的處理。
7-1-1 悲觀並發 ( 鎖定 )
如果你的應用需要在並發環境下防止偶然的數據丟失,一種方式是通過數據庫的鎖來實現。這種方式被稱為悲觀並發。例如,在從數據庫中讀取一行數據之前,可以申請一個只讀鎖,或者一個更新訪問鎖。如果你對數據行使用了更新訪問鎖,就沒有其他的用戶可以獲取不管是只讀鎖還是更新訪問鎖,因為他們可能獲取正在被修改中的數據。如果你使用只讀鎖來鎖定一行,其他用戶也可以使用只讀訪問,但是不能進行更新。
管理鎖有一些缺點,對程序來說可能很復雜。它需要重要的數據庫管理資源,對於大量用戶的時候可能導致性能問題 ( 擴展性不好 ),由於這些原因,不是所有的數據庫管理系統都支持悲觀鎖。EF 對悲觀鎖沒有提供內建的支持,這個教程也不會演示如何實現它。
7-1-2 樂觀並發
除了悲觀並發之外的另一方案是樂觀並發。樂觀並發意味着允許並發沖突發生,如果出現了就做出適當的反應。例如,John 執行 Department 的編輯頁面,將 English 系的 Budget 從 $350,000.00 修改為 $100,000.00 ( John 管理與 English 有競爭的系,希望將一些資金轉移到他自己的系使用 )。
在 John 點擊保存 Save 之前,Jane 運行同樣的頁面,將開始時間 Start Date 字段從 9/1/2007 修改為 1/1/1999 ( Jane 管理歷史系,希望它的歷史更加悠久 )
John 先點擊保存 Save,然后在回到 Index 頁面的時候看到自己的修改。然后 Jane 點擊保存 Save。下一步發生什么取決於如何處理並發沖突。可能的情況如下:
- 你可以追蹤用戶修改和更新了哪些數據庫中的列。在這個例子的場景下,不會丟失數據,因為兩個用戶更新了不同的屬性。下一次其他人在瀏覽英語系的時候,他們會發現 John 和 Jane 所做的所有修改:開始時間成為 1/1/1999,預算成為 $100,000.00。
這種方法可以減少可能造成數據丟失的沖突次數,但是如果用戶修改同一個實體的相同屬性的話,會丟失數據, EF 具體依賴於你如何實現你的更新代碼。這種方式不適合 Web 應用程序,因為需要你維護大量的狀態,以便追蹤所有新值的原始狀態。維護大量的狀態會影響到程序的性能,因為既需要服務器的資源,又需要將狀態保存在頁面中 ( 例如,使用隱藏域 )。
- 你可以允許 Jane 的修改覆蓋 John 的修改。下一次用戶瀏覽英語系的時候,將會看到 1/1/1999 和恢復的 $350,000.00 值。這被稱為 Client Wins 或者 Last in Wins 場景 ( 客戶端的值優先於保存的值 )。像在這節開始介紹的,如果你沒有使用任何代碼處理並發,這將會自動發生。
- 你可以阻止 Jane 的修改更新到數據庫中。通常情況下,我們希望顯式一個錯誤信息。展示數據當前的狀態,如果她仍然希望做出這樣修改的話,允許她重做修改。這被稱為 Store Wins 場景。( 保存的值優先於客戶提交的值 ) 在這個教程中,你將要實現 Store Wins 場景。這種方法在提示用戶發生什么之前,不會覆蓋其他用戶的修改。
7-1-3 檢測並發沖突
你可以通過處理 EF 拋出的 OptimisticConcurrencyException 異常來處理沖突。為了知道什么時候 EF 拋出了這種異常,EF 必須能夠檢測沖突。因此,你必須合理配置數據庫和數據模型。啟用沖突檢測的一些選項如下:
- 在數據庫的表中,包含用於追蹤修改的列,在行被修改的時候可以用來進行檢測。然后配置 EF 在更新 Update 或者刪除 Delete 的 Where 子句中包含檢測列。用於追蹤的列的數據類型通常是 timestamp,但是其中並不真的包含實際的日期或者時間值。相反,值是在行每次更新的時候的一個遞增值( 因此,在最近的 SQL Server 中,同樣的類型被稱為行版本 rowversion ) 。在更新 Update 或者 Delete 命令中,Where 子句中包含跟蹤列的原始值。如果行被其他用戶更新了,那么,此時跟蹤列中的值就會與原始值不同,由於 Where 子句的作用,Update 或者 Delete 語句就不會取得需要更新的行。當 EF 發現沒有行被 Update 或者 Delete 命令更新的時候 ( 就是說,影響的行數為 0 ),就理解為發生了並發沖突。
- 配置 EF 在 Update 或者 Delete 語句的 Where 中包含所有的原始列。如同第一個方式,如果在數據行被讀取之后,行發生了任何修改,Where 將不能取得需要更新的行,這樣 EF 就理解為發生了並發沖突。這種方式像使用跟蹤列一樣有效。但是,如果數據庫中的表有很多列,就會導致巨大的 Where 子句,你也必須維護大量的狀態。如前所述,維護大量的狀態會影響程序的性能,因為既需要消耗服務器資源,也需要在頁面中包含狀態。因此,不建議使用這種方式,在這個教程中也不使用這種方法。
在本教程剩下的部分,你需要在 Department 實體上增加一個追蹤列,創建控制器和視圖,然后檢查一切是否工作正常。
注意:如果你沒有使用追蹤列來實現並發,你就必須通過使用 ConcurrencyCheck 特性標記所有的非主屬性用在並發跟蹤中。這將會使 EF 將所有的列包含在 Update 語句的 Where 子句中。
7-2 對 Department 實體增加跟蹤屬性
在 Models\Departments.cs 文件中,增加跟蹤屬性。
[Timestamp] public Byte[] Timestamp { get; set; }
Timestamp 特性指定隨后的列將會被包含在 Update 或者 Delete 語句的 Where 子句中。
7-3 創建 Department 控制器
如同創建其他的控制器一樣,創建 Department 控制器和視圖,使用如下的設置。
在 Controllers\DepartmentController.cs 中,增加一個 using 語句。
using System.Data.Entity.Infrastructure;
將文件中所有的 “LastName” 修改為 “FullName” ( 共有 4 處 ),使得系控制器中的下拉列表使用教師的全名而不是名字。
將 HttpPost Edit 方法使用下面的代碼替換掉。
[HttpPost] public ActionResult Edit(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 databaseValues = (Department)entry.GetDatabaseValues().ToObject(); var clientValues = (Department)entry.Entity; 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.Timestamp = databaseValues.Timestamp; } catch (DataException) { //Log the error (add a variable name after Exception) 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); }
視圖通過頁面中的隱藏域保存原始的時間戳。當編輯頁面提交到服務器的時候,通過模型綁定創建 Department 實例的時候,實例將會擁有原始的 Timestamp 屬性值,其他的屬性獲取新值。然后,當 EF 創建 Update 命令時,命令中將包含查詢包含原始 Timestamp 值的 Where 子句。
在執行 Update 語句之后,如果沒有行被更新,EF 將會拋出 DbUpdateConcurrencyException 異常,代碼中的 catch 塊從異常對象中獲取受影響的 Department 實體對象,實體中既有從數據庫中讀取的值,也有用戶新輸入的值。
var entry = ex.Entries.Single(); var databaseValues = (Department)entry.GetDatabaseValues().ToObject(); var clientValues = (Department)entry.Entity;
然后,代碼為用戶在編輯頁面上每一個輸入的值與數據庫中的值不同的列添加自定義的錯誤信息。
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 的 Timestamp 屬性值設置為數據庫中新獲取的值。新的 Timestamp 值被保存在重新顯示頁面的隱藏域中,下一次用戶點擊保存的時候,當前顯示的編輯頁面值會被重新獲取,這樣就可以處理新的並發錯誤。
在 Views\Department\Edit.cshtml 中,增加一個隱藏域來保存 Timestamp 屬性值,緊跟在 DepartmentID 屬性之后。
@Html.HiddenFor(model => model.Timestamp)
在 Views\Department\Index.cshtml 中,使用下面的代碼替換原有的代碼,將鏈接移到左邊,更新頁面標題和列標題,在 Administrator 列中,使用 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>
7-4 測試樂觀並發處理
運行程序,點擊 Departments.
點擊 Edit 超級鏈接,然后再打開一個新的瀏覽器窗口,窗口中使用相同的地址顯示相同的信息。
在第一個瀏覽器的窗口中修改一個字段的內容,然后點擊 Save。
瀏覽器回到 Index 頁面顯示修改之后的值。
在第二個瀏覽器窗口中將同樣的字段修改為不同的值,
在第二個瀏覽器窗口中,點擊 Save,將會看到如下錯誤信息。
再次點擊 Save。在第二個瀏覽器窗口中輸入的值被保存到數據庫中,在 Index 頁面顯示的時候出現在頁面上。
7-5 增加刪除頁面
對於刪除頁面,EF 使用類似的方式檢測並發沖突。當 HttpGet Delete 方法顯示確認頁面的時候,視圖在隱藏域中包含原始的 Timestamp 值,當用戶確認刪除的時候,這個值被傳遞給 HttpPost Delete 方法,當 EF 創建 Delete 命令的時候,在 Where 子句中包含使用原始 Timestamp 值的條件,如果命令影響了 0 行 ( 意味着在顯示刪除確認頁面之后被修改了 ),並發異常被拋出,通過傳遞錯誤標志為 true ,HttpGet Delete 方法被調用,帶有錯誤提示信息的刪除確認頁面被顯示出來。
在 DepartmentController.cs 中,使用如下代碼替換 HttpGet Delete 方法。
public ActionResult Delete(int id, bool? concurrencyError) { if (concurrencyError.GetValueOrDefault()) { 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."; } Department department = db.Departments.Find(id); return View(department); }
方法接收一個可選的表示是否是在並發沖突之后重新顯示頁面的參數,如果這個標志為 true,錯誤信息通過 ViewBag 傳遞到視圖中。
使用下面的代碼替換 HttpPost Delete 方法中的代碼 ( 方法名為 DeleteConfirmed )
[HttpPost, ActionName("Delete")] public ActionResult DeleteConfirmed(Department department) { try { db.Entry(department).State = EntityState.Deleted; db.SaveChanges(); return RedirectToAction("Index"); } catch (DbUpdateConcurrencyException) { return RedirectToAction("Delete", new System.Web.Routing.RouteValueDictionary { { "concurrencyError", true } }); } catch (DataException) { //Log the error (add a variable name after Exception) ModelState.AddModelError(string.Empty, "Unable to save changes. Try again, and if the problem persists contact your system administrator."); return View(department); } }
你剛剛替換的腳手架代碼方法僅僅接收一個記錄的 Id
public ActionResult DeleteConfirmed(int id)
將這個參數替換為通過模型綁定創建的 Department 實體實例。這使得可以訪問額外的 Timestamp 屬性。
public ActionResult DeleteConfirmed(Department department)
如果發生了並發沖突,代碼將會傳遞表示應該顯示錯誤的標志給確認頁面,然后重新顯示確認頁面。
在 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.LabelFor(model => model.Name) </div> <div class="display-field"> @Html.DisplayFor(model => model.Name) </div> <div class="display-label"> @Html.LabelFor(model => model.Budget) </div> <div class="display-field"> @Html.DisplayFor(model => model.Budget) </div> <div class="display-label"> @Html.LabelFor(model => model.StartDate) </div> <div class="display-field"> @Html.DisplayFor(model => model.StartDate) </div> <div class="display-label"> @Html.LabelFor(model => model.InstructorID) </div> <div class="display-field"> @Html.DisplayFor(model => model.Administrator.FullName) </div> </fieldset> @using (Html.BeginForm()) { @Html.HiddenFor(model => model.DepartmentID) @Html.HiddenFor(model => model.Timestamp) <p> <input type="submit" value="Delete" /> | @Html.ActionLink("Back to List", "Index") </p> }
代碼中在 h2 和 h3 之間增加了錯誤信息。
<p class="error">@ViewBag.ConcurrencyErrorMessage</p>
在 Administrator 區域將 LastName 替換為 FullName。
<div class="display-label"> @Html.LabelFor(model => model.InstructorID) </div> <div class="display-field"> @Html.DisplayFor(model => model.Administrator.FullName) </div>
最后,增加了用於 DepartmentId 和 Timestamp 屬性的隱藏域,在 Html.BeginForm 語句之后。
@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.Timestamp)
運行 Departments 的 Index 頁面,使用同樣的 URL 打開第二個瀏覽器窗口。
在第一個窗口中,在某個系上點擊 Edit ,然后修改一個值,先不要點擊 Save。
在第二個窗口中,在同樣的系上,選擇 Delete ,刪除確認窗口出現了。
在第一個窗口中,點擊 Save,在 Index 頁面中確認修改信息。
現在,在第二個瀏覽器窗口中點擊 Delete,你將會看到並發錯誤信息,其中 Department的名稱已經使用當前數據庫中的值刷新了。
如果再次點擊 Delete,你將會被重定向到 Index 頁面,在顯示中 Department 已經被刪除了。
這里完整地介紹了處理並發沖突。對於處理並發沖突的其他場景,可以在 EF 團隊的博客上查閱Optimistic Concurrency Patterns和Working with Property Values。下一次教程將會演示針對教師 Instructor 和學生 Student 實體的表層次的繼承。