教學課程:在 ASP.NET MVC 5 應用程式中使用 EF 處理並發
在前面的教學課程中,您學習如何更新資料。 本教學課程展示了當多個使用者同時更新同一實體時如何使用樂觀並發來處理衝突。 您可以變更與 Department
實體配合使用的網頁,以便它們處理並發錯誤。 下列圖例顯示了 [編輯] 和 [刪除] 頁面,包括一些發生並行衝突時會顯示的訊息。
在本教學課程中,您已:
- 了解並行衝突
- 加入樂觀並發
- 修改部門控制器
- 測試並發處理
- 更新 [刪除] 頁面
必要條件
並行衝突
當一名使用者為了編輯而顯示了實體的資料,然後另一名使用者在第一名使用者所作出的變更寫入到資料庫前便更新了相同實體的資料時,便會發生並行衝突。 若您沒有啟用針對這類衝突的偵測,最後更新資料庫的使用者所作出的變更便會覆寫前一名使用者所作出的變更。 在許多應用程式中,這類風險是可接受的:若僅有幾名使用者或僅有幾項更新,或覆寫變更的風險並不是那麼的重大,則為了處理並行而耗費的程式設計成本可能會大於其所能帶來的利益。 在此情況下,您便不需要設定應用程式來處理並行衝突。
悲觀並發 (鎖定)
若您的應用程式確實需要防止在並行案例下發生的意外資料遺失,其中一個方法便是使用資料庫鎖定。 這稱為悲觀並發。 例如,在您從資料庫讀取一個資料列之前,您會要求唯讀鎖定或更新存取鎖定。 若您鎖定了一個資料列以進行更新存取,其他使用者便無法為了唯讀或更新存取而鎖定該資料列,因為他們會取得一個正在進行變更之資料的複本。 若您鎖定資料列以進行唯讀存取,其他使用者也可以為了唯讀存取將其鎖定,但無法進行更新。
管理鎖定有幾個缺點。 其程式可能相當複雜。 這需要大量的資料庫管理資源,並且可能會隨著應用程式使用者的數量提升而導致效能問題。 基於這些理由,不是所有的資料庫管理系統都支援封閉式並行存取。 實體框架沒有為其提供內建支持,本教學課程也不會向您展示如何實現它。
開放式並行存取
悲觀並發的替代方案是樂觀並發。 開放式並行存取表示允許並行衝突發生,然後在衝突發生時適當的做出反應。 例如,John 運行「部門編輯」頁面,將英語部門的預算金額從 $350,000.00 更改為 $0.00。
在 John 點擊儲存之前,Jane 運行同一頁面並將開始日期欄位從 9/1/2007 變更為 8/8/2013。
John 首先點擊儲存,當瀏覽器返回索引頁面時會看到他的更改,然後 Jane 點擊儲存。 接下來發生的情況便是由您處理並行衝突的方式決定。 一部分選項包括下列項目:
您可以追蹤使用者修改的屬性,然後僅在資料庫中更新相對應的資料行。 在範例案例中,將不會發生資料遺失,因為兩名使用者更新的屬性不同。 下次有人瀏覽英文系時,他們會看到約翰和簡的更改 - 開始日期為 8/8/2013,預算為零。
這個更新方法可減少可能導致資料遺失之衝突發生的次數,但卻無法在實體中的相同屬性遭到變更時避免資料遺失。 Entity Framework 是否會以這種方式處理並行衝突,取決於您實作更新程式碼的方式。 通常在 Web 應用程式中,這種方法並不實用,因為它需要您維持大量的狀態,以追蹤實體所有原始的屬性值和新的值。 維持大量狀態可能會影響應用程式的效能,因為它不是需要伺服器資源,就是必須包含在網頁中 (例如隱藏欄位),或是保存在 Cookie 中。
您可以讓 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 追蹤屬性,建立控制器和檢視,並進行測試以驗證一切是否正常運作。
加入樂觀並發
在 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)]
[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
子句中。 此屬性稱為時間戳記,因為先前版本的 SQL Server 在 SQL rowversion 取代它之前使用了 SQL 時間戳記資料類型。 rowversion 的 .Net 類型是位元組數組。
如果您喜歡使用流暢的 API,則可以使用 IsConcurrencyToken 方法來指定追蹤屬性,如下例所示:
modelBuilder.Entity<Department>()
.Property(p => p.RowVersion).IsConcurrencyToken();
由於新增屬性之後,您也變更了資料庫模型,因此您必須再一次進行移轉。 請在套件管理員主控台 (PMC) 中輸入下列命令:
Add-Migration RowVersion
Update-Database
修改部門控制器
在 Controllers\DepartmentController.cs 中,加入一條 using
語句:
using System.Data.Entity.Infrastructure;
在 DepartmentController.cs 檔案中,將出現的所有四個「LastName」變更為「FullName」,以便部門管理員下拉清單將包含講師的全名,而不僅僅是姓氏。
ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName");
使用以下程式碼替換HttpPost
Edit
方法的現有程式碼:
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Edit(int? id, byte[] rowVersion)
{
string[] fieldsToBind = new string[] { "Name", "Budget", "StartDate", "InstructorID", "RowVersion" };
if (id == null)
{
return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
}
var departmentToUpdate = await db.Departments.FindAsync(id);
if (departmentToUpdate == null)
{
Department deletedDepartment = new Department();
TryUpdateModel(deletedDepartment, fieldsToBind);
ModelState.AddModelError(string.Empty,
"Unable to save changes. The department was deleted by another user.");
ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", deletedDepartment.InstructorID);
return View(deletedDepartment);
}
if (TryUpdateModel(departmentToUpdate, fieldsToBind))
{
try
{
db.Entry(departmentToUpdate).OriginalValues["RowVersion"] = rowVersion;
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.");
departmentToUpdate.RowVersion = databaseValues.RowVersion;
}
}
catch (RetryLimitExceededException /* dex */)
{
//Log the error (uncomment dex variable name and add a line here to write a log.)
ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator.");
}
}
ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", departmentToUpdate.InstructorID);
return View(departmentToUpdate);
}
若 FindAsync
方法傳回 Null,則該部門便已遭其他使用者刪除。 顯示的程式碼使用發佈的表單值來建立部門實體,以便可以重新顯示「編輯」頁面並顯示錯誤訊息。 或者,若您選擇只顯示錯誤訊息,而不重新顯示部門欄位,則您也可以不需要重新建立部門實體。
檢視將原始 RowVersion
值儲存在隱藏欄位中,方法在 rowVersion
參數中接收它。 在您呼叫 SaveChanges
之前,您必須將該原始 RowVersion
屬性值放入實體的 OriginalValues
集合中。 然後,當實體框架建立 SQL UPDATE
命令時,該命令將包含一個 WHERE
子句,用於尋找具有原始 RowVersion
值的行。
如果沒有行受 UPDATE
命令影響 (沒有行具有原始 RowVersion
值),實體框架將引發 DbUpdateConcurrencyException
例外狀況,並且 catch
區塊中的程式碼從例外狀況物件中取得受影響的 Department
實體。
var entry = ex.Entries.Single();
該物件的 Entity
屬性中有使用者輸入的新值,您可以透過呼叫 GetDatabaseValues
方法來取得從資料庫讀取的值。
var clientValues = (Department)entry.Entity;
var databaseEntry = entry.GetDatabaseValues();
如果有人從資料庫中刪除了該行,則 GetDatabaseValues
方法將傳回 null;否則,您必須將傳回的物件強制轉換為 Department
類別才能存取 Department
屬性。 (因為您已經檢查了 databaseEntry
刪除,所以只有在 FindAsync
執行之後和 SaveChanges
執行之前刪除該部門時才會為空。)
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 != 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()
<div class="form-horizontal">
<h4>Department</h4>
<hr />
@Html.ValidationSummary(true)
@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)
測試並發處理
運行該網站並點擊部門。
右鍵點擊英語系的編輯超連結並選擇在新索引標籤中打開,然後點擊英語系的編輯超連結。 這兩個索引標籤顯示相同的資訊。
變更第一個瀏覽器索引標籤中的欄位,然後按一下 [儲存]。
瀏覽器會顯示索引頁面,當中包含了變更之後的值。
變更第二個瀏覽器標籤中的欄位,然後按一下儲存。 您會看到一個錯誤訊息:
再次按一下 [儲存]。 您在第二個瀏覽器標籤中輸入的值將與您在第一個瀏覽器中變更的資料的原始值一起儲存。 您會在索引頁面出現時看到儲存的值。
更新 [刪除] 頁面
針對 [刪除] 頁面,Entity Framework 會偵測由其他對部門進行類似編輯的人員所造成的並行衝突。 當 HttpGet
Delete
方法顯示確認檢視時,該檢視在隱藏欄位中包含原始 RowVersion
值。 然後,HttpPost
Delete
值可用於使用者確認刪除時呼叫的方法。 當實體框架建立 SQL DELETE
指令時,它包含一個具有原始 RowVersion
值的 WHERE
子句。 如果該命令導致零行受到影響 (表示在顯示刪除確認頁面後該行已更改),拋出並發例外狀況,並且呼叫 HttpGet Delete
方法並將錯誤標誌設為 true
,以便重新顯示帶有錯誤訊息的確認頁面。 也可能有零行受到影響,因為該行已被其他用戶刪除,因此在這種情況下會顯示不同的錯誤訊息。
在 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.GetValueOrDefault())
{
return RedirectToAction("Index");
}
return HttpNotFound();
}
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.";
}
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 /* 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 async Task<ActionResult> DeleteConfirmed(int id)
您已將此參數變更為由模型繫結器建立的 Department
實體執行個體。 這使您可以存取除了記錄鍵之外的 RowVersion
屬性值。
public async Task<ActionResult> Delete(Department department)
您也將動作方法的名稱從 DeleteConfirmed
變更為 Delete
。 支架程式碼命名 HttpPost
Delete
方法以賦予 DeleteConfirmed
HttpPost
方法唯一的簽名。 (CLR 要求重載方法具有不同的方法參數。) 既然簽名是唯一的,您可以堅持 MVC 約定並為 HttpPost
和 HttpGet
刪除方法使用相同的名稱。
若捕捉到並行錯誤,程式碼會重新顯示刪除確認頁面,並提供一個旗標指示其應顯示並行錯誤訊息。
在 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
欄位 FullName
中替換為 LastName
:
<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 值已根據資料庫中的內容重新整理。
若您再按一下 [刪除],則您將會重新導向至 [索引] 頁面,並且系統將顯示該部門已遭刪除。
取得程式碼
其他資源
可以在 ASP.NET 資料存取 - 推薦資源中找到其他實體框架資源的連結。
有關處理各種並發場景的其他方法的信息,請參閱 MSDN 上的樂觀並發模式和使用屬性值。 下一個教學課程將展示如何為 Instructor
和 Student
實體實作按層次結構表繼承。
下一步
在本教學課程中,您已:
- 了解並行衝突
- 加入樂觀並發
- 修改部門控制器
- 經過測試的並發處理
- 更新 [刪除] 頁面
繼續閱讀下一篇文章,了解如何在資料模型中實現繼承。