在 ASP.NET MVC 應用程式中處理 Entity Framework 的並行處理 (7/10)
演講者:Tom Dykstra
Contoso University 範例 Web 應用程式示範如何使用 Entity Framework 5 Code First 和 Visual Studio 2012 建立 ASP.NET MVC 4 應用程式。 如需教學課程系列的資訊,請參閱本系列的第一個教學課程。
在前兩個教學課程中,您已使用相關數據。 本教學課程示範如何處理並行。 您將建立使用實體的 Department
網頁,以及編輯和刪除 Department
實體的頁面將處理並行錯誤。 下圖顯示索引和刪除頁面,包括發生並行衝突時所顯示的一些訊息。
並行衝突
當一名使用者為了編輯而顯示了實體的資料,然後另一名使用者在第一名使用者所作出的變更寫入到資料庫前便更新了相同實體的資料時,便會發生並行衝突。 若您沒有啟用針對這類衝突的偵測,最後更新資料庫的使用者所作出的變更便會覆寫前一名使用者所作出的變更。 在許多應用程式中,這類風險是可接受的:若僅有幾名使用者或僅有幾項更新,或覆寫變更的風險並不是那麼的重大,則為了處理並行而耗費的程式設計成本可能會大於其所能帶來的利益。 在此情況下,您便不需要設定應用程式來處理並行衝突。
悲觀並發 (鎖定)
若您的應用程式確實需要防止在並行案例下發生的意外資料遺失,其中一個方法便是使用資料庫鎖定。 這稱為悲觀並發。 例如,在您從資料庫讀取一個資料列之前,您會要求唯讀鎖定或更新存取鎖定。 若您鎖定了一個資料列以進行更新存取,其他使用者便無法為了唯讀或更新存取而鎖定該資料列,因為他們會取得一個正在進行變更之資料的複本。 若您鎖定資料列以進行唯讀存取,其他使用者也可以為了唯讀存取將其鎖定,但無法進行更新。
管理鎖定有幾個缺點。 其程式可能相當複雜。 它需要大量的資料庫管理資源,而且可能會因為應用程式的用戶數目增加而造成效能問題(也就是說,它不會調整良好)。 基於這些理由,不是所有的資料庫管理系統都支援封閉式並行存取。 實體框架沒有為其提供內建支持,本教學課程也不會向您展示如何實現它。
開放式並行存取
悲觀並發的替代方案是樂觀並發。 開放式並行存取表示允許並行衝突發生,然後在衝突發生時適當的做出反應。 例如,John 運行「部門編輯」頁面,將英語部門的預算金額從 $350,000.00 更改為 $0.00。
在 John 點擊儲存之前,Jane 運行同一頁面並將開始日期欄位從 9/1/2007 變更為 8/8/2013。
John 首先點擊儲存,當瀏覽器返回索引頁面時會看到他的更改,然後 Jane 點擊儲存。 接下來發生的情況便是由您處理並行衝突的方式決定。 一部分選項包括下列項目:
您可以追蹤使用者修改的屬性,然後僅在資料庫中更新相對應的資料行。 在範例案例中,將不會發生資料遺失,因為兩名使用者更新的屬性不同。 下次有人瀏覽英文系時,他們會看到約翰和簡的更改 - 開始日期為 8/8/2013,預算為零。
這個更新方法可減少可能導致資料遺失之衝突發生的次數,但卻無法在實體中的相同屬性遭到變更時避免資料遺失。 Entity Framework 是否會以這種方式處理並行衝突,取決於您實作更新程式碼的方式。 通常在 Web 應用程式中,這種方法並不實用,因為它需要您維持大量的狀態,以追蹤實體所有原始的屬性值和新的值。 維護大量的狀態可能會影響應用程式效能,因為它需要伺服器資源,或必須包含在網頁本身(例如隱藏欄位)。
您可以讓 Jane 的變更覆蓋 John 的變更。 下次有人瀏覽英語系時,他們將看到 8/8/2013 以及恢復後的 350,000.00 美元值。 這稱之為「用戶端獲勝 (Client Wins)」或「最後寫入為準 (Last in Wins)」案例。 (用戶端的值優先於資料存放區中的值。如本節簡介所述,如果您未對並行處理進行任何程式代碼撰寫,則會自動發生。
您可以阻止 Jane 的變更在資料庫中更新。 通常,您會顯示錯誤訊息,向她顯示資料的當前狀態,並允許她重新套用變更 (如果她仍想進行變更)。 這稱之為「存放區獲勝 (Store Wins)」案例。 (資料存放區的值會優先於用戶端所提交的值。)您將在此教學課程中實作存放區獲勝案例。 這個方法可確保沒有任何變更會在使用者收到警示,告知其發生的事情前遭到覆寫。
檢測併發衝突
您可以透過處理實體框架引發的 OptimisticConcurrencyException 例外狀況來解決衝突。 若要得知何時應擲回這些例外狀況,Entity Framework 必須能夠偵測衝突。 因此,您必須適當的設定資料庫及資料模型。 一部分啟用衝突偵測的選項包括下列選項:
在資料庫資料表中,包含一個追蹤資料行,該資料行可用於決定資料列發生變更的時機。 然後,您可以設定實體框架以將該列包含在
Where
SQL 或Update
命令的子句Delete
中。追蹤列的資料類型通常是 rowversion。 rowversion 值是一個連續的數字,每次更新行時都會遞增。 在
Update
或Delete
指令中,Where
子句包含追蹤列的原始值 (原始行版本)。 如果正在更新的rowversion
行已被其他使用者更改,則該列中的值與原始值不同,因此Update
或Delete
語句由於Where
子句而無法找到要更新的行。 當實體框架發現Update
或Delete
命令沒有更新任何行時 (即受影響的行數為零時),它會將其解釋為並發衝突。設定實體框架以在
Update
和Delete
命令的Where
子句中包含表中每一列的原始值。與第一個選項一樣,如果自首次讀取該行以來該行中的任何內容發生了更改,則
Where
子句將不會返回要更新的行,實體框架會將其解釋為並發衝突。 對於具有許多列的資料庫表,此方法可能會導致非常大的Where
子句,並且可能需要您維護大量狀態。 如先前所述,維護大量的狀態可能會影響應用程式效能,因為它需要伺服器資源,或必須包含在網頁本身。 因此,通常不建議使用此方法,而且不是本教學課程中使用的方法。如果您確實想要實作這種並發方法,則必須透過新增 ConcurrencyCheck 屬性來標記要追蹤並發性的實體中的所有非主鍵屬性。 此變更使實體框架能夠在
UPDATE
語句的 SQLWHERE
子句中包含所有欄位。
在本教學課程的其餘部分中,您將向 Department
實體新增 rowversion 追蹤屬性,建立控制器和檢視,並進行測試以驗證一切是否正常運作。
將開放式並行屬性新增至 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 屬性指定該欄位將包含在傳送至資料庫的 Update
Delete
指令的 Where
子句中。 此屬性稱為時間戳記,因為先前版本的 SQL Server 在 SQL rowversion 取代它之前使用了 SQL 時間戳記資料類型。 的 .Net 類型 rowversion
是位元組陣列。 如果您喜歡使用流暢的 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
屬性值和其他屬性的新值,如使用者於 [編輯] 頁面上輸入。 然後,當實體框架建立 SQL UPDATE
命令時,該命令將包含一個 WHERE
子句,用於尋找具有原始 RowVersion
值的行。
如果沒有行受 UPDATE
命令影響 (沒有行具有原始 RowVersion
值),實體框架將引發 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.");
最後,程式碼將 Department
物件的 RowVersion
值設定為從資料庫檢索到的新值。 這個新的 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
而不是在 Administrator 數據行中:
@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 的 [編輯超連結],然後選取 [在新索引標籤中開啟],然後按兩下 [編輯金阿伯克羅比] 的超連結。 兩個視窗會顯示相同的資訊。
變更第一個瀏覽器視窗中的欄位,然後按兩下 [ 儲存]。
瀏覽器會顯示索引頁面,當中包含了變更之後的值。
變更第二個瀏覽器視窗中的任何字段,然後按兩下 [ 儲存]。
在第二個瀏覽器視窗中按兩下 [ 儲存 ]。 您會看到一個錯誤訊息:
再次按一下 [儲存]。 您在第二個瀏覽器中輸入的值會連同您在第一個瀏覽器中變更之數據的原始值一起儲存。 您會在索引頁面出現時看到儲存的值。
更新刪除頁面
針對 [刪除] 頁面,Entity Framework 會偵測由其他對部門進行類似編輯的人員所造成的並行衝突。 當 HttpGet
Delete
方法顯示確認檢視時,該檢視在隱藏欄位中包含原始 RowVersion
值。 然後,HttpPost
Delete
值可用於使用者確認刪除時呼叫的方法。 當實體框架建立 SQL DELETE
指令時,它包含一個具有原始 RowVersion
值的 WHERE
子句。 如果該命令導致零行受到影響 (表示在顯示刪除確認頁面後該行已更改),拋出並發例外狀況,並且呼叫 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
屬性將錯誤訊息傳送至檢視。
將方法 HttpPost
Delete
(名為 DeleteConfirmed
) 中的程式碼替換為以下程式碼:
[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);
}
}
在您剛剛取代的 Scaffold 程式碼中,此方法僅會接受一個記錄識別碼:
public ActionResult DeleteConfirmed(int id)
您已將此參數變更為由模型繫結器建立的 Department
實體執行個體。 這使您可以存取除了記錄鍵之外的 RowVersion
屬性值。
public ActionResult Delete(Department department)
您也將動作方法的名稱從 DeleteConfirmed
變更為 Delete
。 支架程式碼命名 HttpPost
Delete
方法以賦予 DeleteConfirmed
HttpPost
方法唯一的簽名。 (CLR 要求重載方法具有不同的方法參數。) 既然簽名是唯一的,您可以堅持 MVC 約定,並對 HttpPost
和 HttpGet
delete 方法使用相同的名稱。
若捕捉到並行錯誤,程式碼會重新顯示刪除確認頁面,並提供一個旗標指示其應顯示並行錯誤訊息。
在 Views\Department\Delete.cshtml 中,將 Scaffolded 程式代碼取代為下列程式碼,讓某些格式變更並新增錯誤訊息欄位。 所做的變更已醒目提示。
@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>
它在 Administrator
欄位 FullName
中替換為 LastName
:
<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)
運行部門索引頁面。 以滑鼠右鍵按兩下 英文部門的 [刪除 超連結],然後選取 [在新視窗中開啟], 然後在第一個視窗中按兩下 英文部門的 [編輯 超連結]。
在第一個視窗中,變更其中一個值,然後按一下 [儲存]:
索引頁確認了變更。
在第二個視窗中,按兩下 [ 刪除]。
您會看到並行錯誤訊息,並且 Department 值已根據資料庫中的內容重新整理。
若您再按一下 [刪除],則您將會重新導向至 [索引] 頁面,並且系統將顯示該部門已遭刪除。
摘要
如此即完成了處理並行衝突的簡介。 如需處理各種並行案例之其他方式的相關信息,請參閱 Entity Framework 小組部落格上的開放式並行模式 和使用 屬性值 。 下一個教學課程將展示如何為 Instructor
和 Student
實體實作按層次結構表繼承。
您可以在 ASP.NET 數據存取內容對應中找到其他 Entity Framework 資源的連結。