教學課程:在 ASP.NET MVC 5 應用程式中處理與 EF 的並行
在先前的教學課程中,您已瞭解如何更新數據。 本教學課程示範如何在多個用戶同時更新相同的實體時,使用開放式並行存取來處理衝突。 您可以變更使用實體的 Department
網頁,以便處理並行錯誤。 下列圖例顯示了 [編輯] 和 [刪除] 頁面,包括一些發生並行衝突時會顯示的訊息。
在本教學課程中,您已:
- 了解並行衝突
- 新增開放式並行存取
- 修改部門控制器
- 測試並行處理
- 更新 [刪除] 頁面
必要條件
並行衝突
當一名使用者為了編輯而顯示了實體的資料,然後另一名使用者在第一名使用者所作出的變更寫入到資料庫前便更新了相同實體的資料時,便會發生並行衝突。 若您沒有啟用針對這類衝突的偵測,最後更新資料庫的使用者所作出的變更便會覆寫前一名使用者所作出的變更。 在許多應用程式中,這類風險是可接受的:若僅有幾名使用者或僅有幾項更新,或覆寫變更的風險並不是那麼的重大,則為了處理並行而耗費的程式設計成本可能會大於其所能帶來的利益。 在此情況下,您便不需要設定應用程式來處理並行衝突。
悲觀並行存取 (鎖定)
若您的應用程式確實需要防止在並行案例下發生的意外資料遺失,其中一個方法便是使用資料庫鎖定。 這稱為 悲觀並行存取。 例如,在您從資料庫讀取一個資料列之前,您會要求唯讀鎖定或更新存取鎖定。 若您鎖定了一個資料列以進行更新存取,其他使用者便無法為了唯讀或更新存取而鎖定該資料列,因為他們會取得一個正在進行變更之資料的複本。 若您鎖定資料列以進行唯讀存取,其他使用者也可以為了唯讀存取將其鎖定,但無法進行更新。
管理鎖定有幾個缺點。 其程式可能相當複雜。 這需要大量的資料庫管理資源,並且可能會隨著應用程式使用者的數量提升而導致效能問題。 基於這些理由,不是所有的資料庫管理系統都支援封閉式並行存取。 Entity Framework 沒有內建支援,本教學課程不會示範如何實作它。
開放式並行存取
封閉式並行存取的替代方案是 開放式並行存取。 開放式並行存取表示允許並行衝突發生,然後在衝突發生時適當的做出反應。 例如,John 會執行 [部門編輯] 頁面,將 英文部門的預算 金額從 $350,000.00 變更為 $0.00。
在 John 單擊 [儲存] 之前,Jane 會執行相同的頁面,並將 [開始日期] 字段從 2007 年 9 月 1 日變更為 8/8/2013。
John 先按兩下 [ 儲存 ],並在瀏覽器返回 [索引] 頁面時看到他的變更,然後 Jane 單擊 [儲存]。 接下來發生的情況便是由您處理並行衝突的方式決定。 一部分選項包括下列項目:
您可以追蹤使用者修改的屬性,然後僅在資料庫中更新相對應的資料行。 在範例案例中,將不會發生資料遺失,因為兩名使用者更新的屬性不同。 下次有人瀏覽英語部門時,他們會看到約翰和簡的變化—2013 年 8 月 8 日的開始日期和零美元的預算。
這個更新方法可減少可能導致資料遺失之衝突發生的次數,但卻無法在實體中的相同屬性遭到變更時避免資料遺失。 Entity Framework 是否會以這種方式處理並行衝突,取決於您實作更新程式碼的方式。 通常在 Web 應用程式中,這種方法並不實用,因為它需要您維持大量的狀態,以追蹤實體所有原始的屬性值和新的值。 維持大量狀態可能會影響應用程式的效能,因為它不是需要伺服器資源,就是必須包含在網頁中 (例如隱藏欄位),或是保存在 Cookie 中。
您可以讓 Jane 的變更覆寫 John 的變更。 下次有人瀏覽英語部門時,他們會看到 2013/8/8,還原的 $350,000.00 美元值。 這稱之為「用戶端獲勝 (Client Wins)」或「最後寫入為準 (Last in Wins)」案例。 (所有來自用戶端的值都會優先於資料存放區中的資料。)如同本節一開始所描述,若您沒有為並行衝突撰寫任何程式碼,這種情況便會自動發生。
您可以防止 Jane 變更在資料庫中更新。 一般而言,您會顯示錯誤訊息、顯示她目前的數據狀態,並允許她重新套用變更,如果她仍想要進行變更。 這稱之為「存放區獲勝 (Store Wins)」案例。 (資料存放區的值會優先於用戶端所提交的值。)您將在此教學課程中實作存放區獲勝案例。 這個方法可確保沒有任何變更會在使用者收到警示,告知其發生的事情前遭到覆寫。
偵測並行衝突
您可以處理 Entity Framework 擲回的 OptimisticConcurrencyException 例外狀況,以解決衝突。 若要得知何時應擲回這些例外狀況,Entity Framework 必須能夠偵測衝突。 因此,您必須適當的設定資料庫及資料模型。 一部分啟用衝突偵測的選項包括下列選項:
在資料庫資料表中,包含一個追蹤資料行,該資料行可用於決定資料列發生變更的時機。 然後,您可以設定 Entity Framework,以在 SQL
Update
或Delete
命令的 子句中包含Where
該資料行。追蹤數據行的數據類型通常是 rowversion。 rowversion 值是每次更新數據列時遞增的循序數位。
Update
在或Delete
命令中Where
,子句包含追蹤數據行的原始值(原始數據列版本)。 如果另一位使用者已變更要更新的數據列,數據行中的rowversion
值與原始值不同,因此Update
或Delete
語句因為 子句而找不到要更新Where
的數據列。 當 Entity Framework 發現 或Delete
命令未更新Update
任何數據列時(也就是受影響的數據列數目為零時),它會將它解譯為並行衝突。設定 Entity Framework,以在 和
Delete
命令的 子句Update
中包含資料表Where
中每個數據行的原始值。如同第一個選項,如果自第一次讀取數據列之後,數據列中的任何專案都已變更,
Where
子句將不會傳回要更新的數據列,Entity Framework 會將該數據列解譯為並行衝突。 對於具有許多數據行的資料庫數據表,此方法可能會導致非常大Where
的子句,而且可能需要您維護大量的狀態。 如前文所述,維持大量的狀態可能會影響應用程式的效能。 因此通常不建議使用這種方法,並且這種方法也不是此教學課程中所使用的方法。如果您想要對並行存取實作此方法,您必須將 ConcurrencyCheck 屬性新增至它們,以標記您要追蹤之實體中的所有非主鍵屬性。 該變更可讓 Entity Framework 在語句的
UPDATE
SQLWHERE
子句中包含所有數據行。
在本教學課程的其餘部分中,您會將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)]
[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
中Where
,以及Delete
傳送至資料庫的命令。 屬性稱為 Timestamp,因為舊版 SQL Server 在 SQL rowversion 取代 SQL 數據列之前使用了 SQL 時間戳數據類型。 rowversion 的 .Net 類型是位元組陣列。
如果您想要使用 Fluent 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
集合中。 然後,當 Entity Framework 建立 SQL UPDATE
命令時,該命令會包含子 WHERE
句,以尋找具有原始 RowVersion
值的數據列。
如果沒有數據列受到 UPDATE
命令的影響(沒有數據列具有原始 RowVersion
值),Entity Framework 會 DbUpdateConcurrencyException
擲回例外狀況,而 區塊中的 catch
程式代碼會從例外狀況物件取得受影響的 Department
實體。
var entry = ex.Entries.Single();
這個物件具有使用者在其 屬性中 Entity
輸入的新值,而且您可以藉由呼叫 GetDatabaseValues
方法來取得從資料庫讀取的值。
var clientValues = (Department)entry.Entity;
var databaseEntry = entry.GetDatabaseValues();
如果有人已經從資料庫刪除數據列,此方法 GetDatabaseValues
會傳回 Null;否則,您必須將傳回的物件 Department
轉換成 類別,才能存取 Department
屬性。 (因為您已經檢查刪除,只有在執行之後和執行之前SaveChanges
刪除部門時FindAsync
,databaseEntry
才會是 null。
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
,使用者確認刪除時所呼叫的方法可以使用該值。 當 Entity Framework 建立 SQL DELETE
命令時,它會包含具有 WHERE
原始 RowVersion
值的 子句。 如果命令導致零個數據列受到影響(這表示在顯示 [刪除確認] 頁面之後已變更該數據列),則會擲回並行例外狀況,並 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
。 名為 方法DeleteConfirmed
的 HttpPost
Delete
Scaffold 程式代碼,為方法提供HttpPost
唯一的簽章。 (CLR 需要多載的方法具有不同的方法參數。現在簽章是唯一的,您可以堅持MVC慣例,並針對 HttpPost
和 HttpGet
delete方法使用相同的名稱。
若捕捉到並行錯誤,程式碼會重新顯示刪除確認頁面,並提供一個旗標指示其應顯示並行錯誤訊息。
在 Views\Department\Delete.cshtml 中,將 Scaffolded 程式代碼取代為下列程式代碼,以新增 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>
此程式代碼會在和 h3
標題之間h2
新增錯誤訊息:
<p class="error">@ViewBag.ConcurrencyErrorMessage</p>
它會取代 LastName
FullName
為欄位中的 Administrator
:
<dt>
Administrator
</dt>
<dd>
@Html.DisplayFor(model => model.Administrator.FullName)
</dd>
最後,它會在語句之後新增 DepartmentID
和 RowVersion
屬性的 Html.BeginForm
隱藏欄位:
@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)
執行 [部門索引] 頁面。 以滑鼠右鍵按兩下 英文部門的 [刪除 超連結],然後選取 [在新索引卷標中開啟], 然後在第一個索引標籤中按兩下 英文部門的 [編輯 超連結]。
在第一個視窗中,變更其中一個值,然後按兩下 [ 儲存]。
[索引] 頁面會確認變更。
在第二個索引標籤中,按一下 [刪除]。
您會看到並行錯誤訊息,並且 Department 值已根據資料庫中的內容重新整理。
若您再按一下 [刪除],則您將會重新導向至 [索引] 頁面,並且系統將顯示該部門已遭刪除。
取得程式碼
其他資源
您可以在 ASP.NET 數據存取 - 建議的資源中找到 其他 Entity Framework 資源的連結。
如需處理各種並行案例之其他方式的相關信息,請參閱 MSDN 上的開放式並行模式 和使用 屬性值 。 下一個教學課程示範如何實作 Instructor
和 Student
實體的數據表個別階層繼承。
下一步
在本教學課程中,您已:
- 了解並行衝突
- 已新增開放式並行存取
- 修改的部門控制器
- 測試並行處理
- 更新 [刪除] 頁面
請前進到下一篇文章,以瞭解如何在數據模型中實作繼承。
意見反應
https://aka.ms/ContentUserFeedback。
即將登場:在 2024 年,我們將逐步淘汰 GitHub 問題作為內容的意見反應機制,並將它取代為新的意見反應系統。 如需詳細資訊,請參閱:提交並檢視相關的意見反應