教學課程:在 ASP.NET MVC 5 應用程式中處理 EF 的平行存取

在先前的教學課程中,您已瞭解如何更新資料。 本教學課程示範如何使用開放式平行存取來處理多個使用者同時更新相同實體時的衝突。 您可以變更使用實體的 Department 網頁,以便處理並行錯誤。 下列圖例顯示了 [編輯] 和 [刪除] 頁面,包括一些發生並行衝突時會顯示的訊息。

顯示 [編輯] 頁面的螢幕擷取畫面,其中已醒目提示 [部門名稱]、[預算]、[開始日期] 和 [系統管理員] 的值。

此螢幕擷取畫面顯示記錄的 [刪除] 頁面,其中包含有關刪除作業和 [刪除] 按鈕的訊息。

在本教學課程中,您:

  • 了解並行衝突
  • 新增開放式平行存取
  • 修改部門控制站
  • 測試並行處理
  • 更新 [刪除] 頁面

必要條件

並行衝突

當一名使用者為了編輯而顯示了實體的資料,然後另一名使用者在第一名使用者所作出的變更寫入到資料庫前便更新了相同實體的資料時,便會發生並行衝突。 若您沒有啟用針對這類衝突的偵測,最後更新資料庫的使用者所作出的變更便會覆寫前一名使用者所作出的變更。 在許多應用程式中,這類風險是可接受的:若僅有幾名使用者或僅有幾項更新,或覆寫變更的風險並不是那麼的重大,則為了處理並行而耗費的程式設計成本可能會大於其所能帶來的利益。 在此情況下,您便不需要設定應用程式來處理並行衝突。

封閉式平行存取 (鎖定)

若您的應用程式確實需要防止在並行案例下發生的意外資料遺失,其中一個方法便是使用資料庫鎖定。 這稱為 封閉式平行存取。 例如,在您從資料庫讀取一個資料列之前,您會要求唯讀鎖定或更新存取鎖定。 若您鎖定了一個資料列以進行更新存取,其他使用者便無法為了唯讀或更新存取而鎖定該資料列,因為他們會取得一個正在進行變更之資料的複本。 若您鎖定資料列以進行唯讀存取,其他使用者也可以為了唯讀存取將其鎖定,但無法進行更新。

管理鎖定有幾個缺點。 其程式可能相當複雜。 這需要大量的資料庫管理資源,並且可能會隨著應用程式使用者的數量提升而導致效能問題。 基於這些理由,不是所有的資料庫管理系統都支援封閉式並行存取。 Entity Framework 沒有內建支援,本教學課程不會示範如何實作它。

開放式並行存取

封閉式平行存取的替代方法是 開放式平行存取。 開放式並行存取表示允許並行衝突發生,然後在衝突發生時適當的做出反應。 例如,John 執行 [部門編輯] 頁面,將英文部門的 預算 金額從 $350,000.00 變更為 $0.00。

在 John 按一下 [ 儲存] 之前,Jane 會執行相同的頁面,並將 [開始日期 ] 欄位從 2007/9/1 變更為 2013/8/8。

John 先按一下 [ 儲存 ],並在瀏覽器返回 [索引] 頁面時看到其變更,然後 Jane 按一下 [ 儲存]。 接下來發生的情況便是由您處理並行衝突的方式決定。 一部分選項包括下列項目:

  • 您可以追蹤使用者修改的屬性,然後僅在資料庫中更新相對應的資料行。 在範例案例中,將不會發生資料遺失,因為兩名使用者更新的屬性不同。 下次有人流覽英文部門時,他們會看到 John 和 Jane 的變更,也就是 2013/8/8 的開始日期,以及零元的預算。

    這個更新方法可減少可能導致資料遺失之衝突發生的次數,但卻無法在實體中的相同屬性遭到變更時避免資料遺失。 Entity Framework 是否會以這種方式處理並行衝突,取決於您實作更新程式碼的方式。 通常在 Web 應用程式中,這種方法並不實用,因為它需要您維持大量的狀態,以追蹤實體所有原始的屬性值和新的值。 維持大量狀態可能會影響應用程式的效能,因為它不是需要伺服器資源,就是必須包含在網頁中 (例如隱藏欄位),或是保存在 Cookie 中。

  • 您可以讓 Jane 的變更覆寫 John 的變更。 下次有人流覽英文部門時,他們會看到 8/8/2013 和還原的 $350,000.00 值。 這稱之為「用戶端獲勝 (Client Wins)」或「最後寫入為準 (Last in Wins)」案例。 (來自用戶端的所有值優先于資料存放區中的內容。) 如本節簡介所述,如果您未對並行處理執行任何程式碼撰寫,則會自動發生這種情況。

  • 您可以防止 Jane 在資料庫中更新變更。 一般而言,您會顯示錯誤訊息、顯示她目前的資料狀態,並在她仍想要進行變更時允許她重新套用變更。 這稱為「存放區獲勝 (Store Wins)」案例。 (資料存放區值優先于 client.) 您將在本教學課程中實作市集 Wins 案例的值。 這個方法可確保沒有任何變更會在使用者收到警示,告知其發生的事情前遭到覆寫。

偵測並行衝突

您可以處理 Entity Framework 擲回的 開放式ConcurrencyException 例外狀況來解決衝突。 若要得知何時應擲回這些例外狀況,Entity Framework 必須能夠偵測衝突。 因此,您必須適當的設定資料庫及資料模型。 一部分啟用衝突偵測的選項包括下列選項:

  • 在資料庫資料表中,包含一個追蹤資料行,該資料行可用於決定資料列發生變更的時機。 然後,您可以設定 Entity Framework,在 SQL UpdateDelete 命令的 子句中包含 Where 該資料行。

    追蹤資料行的資料類型通常是 rowversionrowversion值是每次更新資料列時遞增的循序數位。 Update在 或 Delete 命令中 Where ,子句包含追蹤資料行的原始值, (原始資料列版本) 。 如果另一位使用者已變更要更新的資料列,資料行中的 rowversion 值與原始值不同,因此 UpdateDelete 語句因為 子句而找不到要更新 Where 的資料列。 當 Entity Framework 發現 或 Delete 命令 (未更新 Update 任何資料列,也就是當受影響的資料列數目為零) 時,它會將它解譯為並行衝突。

  • 設定 Entity Framework,以在 和 Delete 命令的 子句 Update 中包含資料表 Where 中每個資料行的原始值。

    如同第一個選項,如果在第一次讀取資料列之後資料列中的任何專案已變更,子 Where 句將不會傳回要更新的資料列,Entity Framework 會將它解譯為並行衝突。 對於具有許多資料行的資料庫資料表,此方法可能會導致非常大 Where 的子句,而且可能需要您維護大量的狀態。 如前文所述,維持大量的狀態可能會影響應用程式的效能。 因此通常不建議使用這種方法,並且這種方法也不是此教學課程中所使用的方法。

    如果您想要實作這種方法來平行存取,您必須將 ConcurrencyCheck 屬性新增至並行檢查屬性,以標記您要追蹤平行存取之實體中的所有非主鍵屬性。 該變更可讓 Entity Framework 在 語句的 UPDATE SQL WHERE 子句中包含所有資料行。

在本教學課程的其餘部分中,您會將 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屬性指定這個資料行會包含在 Where 傳送至資料庫的 子句 UpdateDelete 命令中。 屬性稱為Timestamp,因為舊版的 SQL Server在 SQL rowversion 取代 SQL rowversion之前使用了 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");

以下列程式碼取代 方法的現有程式碼 HttpPostEdit

[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 屬性。 (因為您已經檢查刪除,只有在執行之後和 executes.) 之後 FindAsyncSaveChanges 刪除部門時, 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.");

最後,程式碼會將 RowVersion 物件的值 Department 設定為從資料庫擷取的新值。 這個新的 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 會偵測由其他對部門進行類似編輯的人員所造成的並行衝突。 HttpGetDelete 當方法顯示確認檢視時,檢視會在隱藏欄位中包含原始 RowVersion 值。 然後 HttpPostDelete ,當使用者確認刪除時所呼叫的方法可以使用該值。 當 Entity Framework 建立 SQL DELETE 命令時,它會包含 WHERE 具有原始 RowVersion 值的 子句。 如果命令導致零個數據列受到影響, (表示資料列在 [刪除確認] 頁面顯示後變更) ,則會擲回並行例外狀況,並 HttpGet Delete 呼叫 方法,並將錯誤旗標設定 true 為 ,以重新顯示含有錯誤訊息的確認頁面。 也可能因為另一位使用者刪除資料列而受到影響,因此在該情況下會顯示不同的錯誤訊息。

DepartmentController.cs中,以下列程式碼取代 HttpGetDelete 方法:

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 屬性將錯誤訊息傳送至檢視。

以下列程式碼取代方法 (HttpPostDelete 名為 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 的 Scaffold 程式碼, HttpPostDelete 為方法提供 HttpPost 唯一簽章。 ( CLR 需要多載的方法有不同的方法參數。) 現在簽章是唯一的,您可以繼續使用 MVC 慣例,並針對 HttpPostHttpGet delete 方法使用相同的名稱。

若捕捉到並行錯誤,程式碼會重新顯示刪除確認頁面,並提供一個旗標指示其應顯示並行錯誤訊息。

Views\Department\Delete.cshtml中,將 Scaffold 程式碼取代為下列程式碼,以新增 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>

它會將 取代 LastNameFullName 為 欄位中的 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 值已根據資料庫中的內容重新整理。

Department_Delete_confirmation_page_with_concurrency_error

若您再按一下 [刪除],則您將會重新導向至 [索引] 頁面,並且系統將顯示該部門已遭刪除。

取得程式碼

下載已完成的專案

其他資源

您可以在 ASP.NET 資料存取 - 建議的資源中找到其他 Entity Framework 資源的連結。

如需處理各種並行案例之其他方式的資訊,請參閱 開放式並行模式 和在 MSDN 上使用 屬性值 。 下一個教學課程示範如何實作 和 Student 實體的 Instructor 資料表個別階層繼承。

後續步驟

在本教學課程中,您:

  • 了解並行衝突
  • 已新增開放式平行存取
  • 修改的部門控制器
  • 測試的並行處理
  • 更新 [刪除] 頁面

請前進到下一篇文章,以瞭解如何在資料模型中實作繼承。