在之前的教學中,你建立了一個 MVC 應用程式,透過 Entity Framework (EF) Core 和 SQL Server 本地資料庫來儲存和顯示資料。 在這個練習中,你將複習並自訂 MVC 支架自動在控制器和視圖中為你建立的 CRUD(建立、讀取、更新、刪除)程式碼。
備註
實作存放庫模式,以在您的控制器及資料存取層之間建立抽象層是一種非常常見的做法。 為了讓範例簡單且專注於示範如何使用 Entity Framework 本身,教學中沒有使用倉庫。 關於使用 Entity Framework 的倉庫資訊,請參閱 本系列的最後一個教學。
在本教學課程中,您會:
- 透過快速載入新增註冊資料,自訂詳細頁面
- 更新建立頁面,加入安全性與錯誤處理功能
- 更新編輯頁面以防止過度貼文
- 在刪除頁面新增錯誤回報功能
- 密切的資料庫連結
先決條件
- 完成之前的教學,從 ASP.NET MVC 網頁應用程式開始EF Core。
自訂詳細頁面
學生索引頁面的支架程式碼省略了該Enrollments屬性,因為該屬性包含一個集合。
詳細資訊頁面以 HTML 表格的形式顯示集合內容。
在 Controllers/StudentsController.cs 檔案中,Details 視圖的動作方法會用該 FirstOrDefaultAsync 方法來擷取單一 Student 實體。 你需要新增呼叫 Include、 ThenInclude和 AsNoTracking 方法的程式碼,如以下強調的程式碼所示。
public async Task<IActionResult> Details(int? id)
{
if (id == null)
{
return NotFound();
}
var student = await _context.Students
.Include(s => s.Enrollments)
.ThenInclude(e => e.Course)
.AsNoTracking()
.FirstOrDefaultAsync(m => m.ID == id);
if (student == null)
{
return NotFound();
}
return View(student);
}
Include與ThenInclude方法會導致上下文在每個註冊中載入Student.Enrollments導航屬性與Enrollment.Course導航屬性。 你可以在 閱讀相關數據 的教學中深入了解這些方法。
此 AsNoTracking 方法能在回傳實體在當前上下文生命週期內未被更新的情況中提升效能。 你會在本教學結尾了解更多關於這個 AsNoTracking 方法的資訊。
配置路由資料
傳遞給 Details 方法的關鍵值來自 路由資料。 路由資料是模型綁定器在 URL 的某一段中找到的資料。 例如,預設路由指定 controller、 action,以及 id (識別碼、ID)區段:
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
在以下網址中,預設路由將 Instructor 映射為 controller,將 Index 映射為 action,並將 1 映射為 id。 這些數值構成了路線資料。
http://localhost:1230/Instructor/Index/1?courseID=2021
URL?courseID=2021 的最後部分()是一個查詢字串的值。 模型綁定器也會將 ID 值傳遞給 Index 方法 id 參數,如果您將它作為查詢字串值傳遞。
http://localhost:1230/Instructor/Index?id=1&CourseID=2021
在 索引 頁面中,超連結 URL 是由檢視中的 Razor 標籤輔助語句建立的。 在接下來 Razor 的程式碼中, id 參數與預設路由相符,因此該 id 值會加入路線資料。
<a asp-action="Edit" asp-route-id="@item.ID">Edit</a>
當值 item.ID 為 6 時,此變更會產生以下 HTML:
<a href="/Students/Edit/6">Edit</a>
在接下來 Razor 的程式碼中, studentID 文字與預設路由中的參數不符,因此文字會以查詢字串的形式加入。
<a asp-action="Edit" asp-route-studentID="@item.ID">Edit</a>
當值 item.ID 為 6 時,此變更會產生以下 HTML:
<a href="/Students/Edit?studentID=6">Edit</a>
欲了解更多標籤輔助資訊,請參閱 ASP.NET Core 中的標籤輔助工具。
將登記資料新增到詳細資料檢視
打開 Views/Students/Details.cshtml 檔案。 每個欄位透過 DisplayNameFor 和 DisplayFor 輔助器來顯示,如下範例所示。
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.LastName)
</dt>
<dd class="col-sm-10">
@Html.DisplayFor(model => model.LastName)
</dd>
在最後一個欄位之後,且緊接關閉標籤前 </dl> ,加入以下代碼以顯示註冊清單:
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Enrollments)
</dt>
<dd class="col-sm-10">
<table class="table">
<tr>
<th>Course Title</th>
<th>Grade</th>
</tr>
@foreach (var item in Model.Enrollments)
{
<tr>
<td>
@Html.DisplayFor(modelItem => item.Course.Title)
</td>
<td>
@Html.DisplayFor(modelItem => item.Grade)
</td>
</tr>
}
</table>
</dd>
如果貼上程式碼片段後縮排錯位,請使用 CTRL+K+D 快速鍵修正。
新程式碼會遍歷 navigation 屬性中的 Enrollments 實體。 每筆報名時,代碼會顯示課程名稱及成績。 課程名稱是從 Course 實體的 Course 導航屬性 Enrollments 中擷取的。
啟動應用程式,選擇 學生 標籤,然後選擇學生 的詳細資料 連結。 您會看到選取學生的課程及成績清單:
更新建立頁面
在 StudentsController.cs 檔案中,修改 HttpPost Create 方法,新增try-catch區塊並移除屬性中的ID屬性Bind。
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(
[Bind("EnrollmentDate,FirstMidName,LastName")] Student student)
{
try
{
if (ModelState.IsValid)
{
_context.Add(student);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
}
catch (DbUpdateException /* ex */)
{
//Log the error (uncomment ex variable name and write a log.
ModelState.AddModelError("", "Unable to save changes. " +
"Try again, and if the problem persists " +
"see your system administrator.");
}
return View(student);
}
此程式碼將 ASP.NET Core MVC 模型綁定器建立的實體加入 StudentStudents 實體集,然後將更改儲存至資料庫中。
Model binder 指的是 Core MVC ASP.NET 功能,讓你更容易處理表單提交的資料。 模型綁定器會將已發佈的表單值轉換成 CLR 類型,並以參數方式傳給動作方法。 在這種情況下,模型綁定器會利用集合中的Student屬性值幫你實例化一個Form實體。
你移除 ID 屬性中的 Bind 屬性是因為 ID 是 SQL Server 插入該列時自動設定的主鍵值。 使用者輸入並不會設定 ID 值。
除了 Bind 屬性,區塊 try-catch 是對支架程式碼的唯一變更。 如果在儲存變更時捕捉到來自 DbUpdateException 的例外,則會顯示一般錯誤訊息。 異常 DbUpdateException 的原因可能來自應用程式外部,而非程式錯誤,因此建議使用者重新嘗試。 生產品質的應用程式會記錄此類異常,但此範例中未實作此功能。 如需進一步了解,請參閱 .NET 和 ASP.NET Core 中的日誌功能。
此 ValidateAntiForgeryToken 屬性有助於防止跨站請求偽造(CSRF)攻擊。
表單標籤輔助方法會自動將標記注入檢視中。 使用者提交表單時也會包含該代幣。 屬性會 ValidateAntiForgeryToken 檢查標記。 欲了解更多資訊,請參閱 ASP.NET Core 及 FormTagHelper 類別參考中的防止跨站請求偽造(XSRF/CSRF)攻擊。
防止過度貼圖
Bind支架程式碼在方法上包含Create的屬性,是防止建立情境中過度貼文的一種方式。 舉例來說,假設該 Student 實體包含一個 Secret 屬性,而你不希望這個網頁設定該屬性。
public class Student
{
public int ID { get; set; }
public string LastName { get; set; }
public string FirstMidName { get; set; }
public DateTime EnrollmentDate { get; set; }
public string Secret { get; set; }
}
即使網頁上沒有欄位 Secret ,駭客也可能使用像 Fiddler 這類工具,或撰寫 JavaScript,來發布 Secret 表單值。 如果沒有 Bind 屬性限制模型綁定器建立實例時 Student 使用的欄位,模型裝訂器就能擷取該 Secret 表單值並用它來建立 Student 實體實例。 然後,無論駭客指定的Secret表單欄位的值為何,都會被更新到你的資料庫中。 下列影像顯示 Fiddler 工具將 Secret 欄位 (其值為 "OverPost") 新增到已提交表單的值中。
「OverPost」值會順利新增到插入列的Secret屬性中,儘管你從未打算讓網頁設定該屬性。
你可以先從資料庫讀取實體,再呼叫 TryUpdateModel 該方法,避免編輯情境中過度貼文。 傳遞明確允許的屬性清單,這也是這些教學中方法的使用方式。
許多開發者偏好的另一種防止過度貼文的方法是使用帶有模型綁定的檢視模型而非實體類別。 只包含你想在檢視模型中更新的屬性。 MVC 模型綁定器完成後,可選擇使用 AutoMapper 等工具將檢視模型屬性複製到實體實例。 使用 _context.Entry 實體實例的設定將其狀態設為 Unchanged,然後對檢視模型中包含的每個實體屬性設定 Property("PropertyName").IsModified 為 true。 這種方法同時適用於編輯和創建情境。
測試創建頁面
Views/Students/Create.cshtml 檔案中的程式碼為每個欄位使用了 label、input 和 span(用於驗證訊息)的標籤輔助工具。
啟動應用程式,選擇 「學生 」標籤,然後選擇 「建立新」。
輸入姓名和日期。 試著輸入無效日期,因為瀏覽器允許。 (有些瀏覽器會強制你使用日期選擇器。)選擇 建立 以顯示錯誤訊息。
此情境展示了預設的伺服器端驗證。 在後面的教學中,你會看到如何新增屬性來產生客戶端驗證的程式碼。 以下標示的程式碼展示了該 Create 方法中的模型驗證檢查。
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(
[Bind("EnrollmentDate,FirstMidName,LastName")] Student student)
{
try
{
if (ModelState.IsValid)
{
_context.Add(student);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
}
catch (DbUpdateException /* ex */)
{
//Log the error (uncomment ex variable name and write a log.
ModelState.AddModelError("", "Unable to save changes. " +
"Try again, and if the problem persists " +
"see your system administrator.");
}
return View(student);
}
將日期改為有效值,並選擇 建立 ,以查看新學生出現在 索引 頁面。
更新 [編輯] 頁面
在 StudentController.cs 檔案中,HttpGet Edit 方法(沒有該 HttpPost 屬性的那個)會用這個 FirstOrDefaultAsync 方法來取得選取 Student 的實體,就像你之前在方法 Details 裡複習過的那樣。 您不需要變更這個方法。
使用推薦的 HttpPost 編輯代碼:閱讀並更新
請將 HttpPost Edit 方法替換為以下程式碼。
[HttpPost, ActionName("Edit")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditPost(int? id)
{
if (id == null)
{
return NotFound();
}
var studentToUpdate = await _context.Students.FirstOrDefaultAsync(s => s.ID == id);
if (await TryUpdateModelAsync<Student>(
studentToUpdate,
"",
s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
{
try
{
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
catch (DbUpdateException /* ex */)
{
//Log the error (uncomment ex variable name and write a log.)
ModelState.AddModelError("", "Unable to save changes. " +
"Try again, and if the problem persists, " +
"see your system administrator.");
}
}
return View(studentToUpdate);
}
這些變更實作安全最佳實務以防止過度貼圖。 腳手架工具產生了一個 Bind 屬性,並將模型繫結器創建的實體加入到帶有 Modified 標記的實體集合中。 這段程式碼不建議用於許多情境,因為該 Bind 屬性會清除參數中未列出 Include 欄位中既有的資料。
新程式碼會讀取現有實體,並呼叫 TryUpdateModel 方法,以根據使用者在提交的表單資料中的輸入,更新所取得實體內的欄位。 (更多資訊請參見 ASP.NET Core 中的模型綁定。)Entity Framework 的自動變更追蹤會在表單輸入更新的欄位上設定 Modified 旗標。 當呼叫該 SaveChanges 方法時,實體框架會建立 SQL 語句以更新資料庫列。 並行衝突會被忽略,只有使用者更新的資料表欄位也會在資料庫中更新。 (後續教學會說明如何處理並行衝突。)
為了防止過度發布,應在 TryUpdateModel 參數中宣告您希望由 編輯 頁面可更新的欄位。 (參數列表中欄位列表前的空字串是用於設定表單欄位名稱的前綴字。)目前,你沒有任何額外的欄位需要保護。 如果你列出想讓模型綁定器繫結的欄位,可以避免未來新增欄位的過度繫結。 清單中的欄位會自動受到保護,直到你明確新增它們。
由於這些變更,HttpPost Edit 方法的簽章與 HttpGet Edit 方法相同。 這些變更基本上將方法重新命名為 EditPost。
請使用備用的 HttpPost 編輯代碼:創建並附加
推薦的 HttpPost Edit 程式碼確保只有修改過的欄位會被更新,並保留你不希望被納入模型綁定的屬性中的資料。 然而,優先讀取的方法需要額外的資料庫讀取,且可能產生更複雜的程式碼以處理並行衝突。 另一種方法是將模型綁定器建立的實體附加到實體框架上下文中,並標記為已修改。 (不要用以下程式碼更新你的專案。該程式碼展示了一種可選的做法。)
public async Task<IActionResult> Edit(int id, [Bind("ID,EnrollmentDate,FirstMidName,LastName")] Student student)
{
if (id != student.ID)
{
return NotFound();
}
if (ModelState.IsValid)
{
try
{
_context.Update(student);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
catch (DbUpdateException /* ex */)
{
//Log the error (uncomment ex variable name and write a log.)
ModelState.AddModelError("", "Unable to save changes. " +
"Try again, and if the problem persists, " +
"see your system administrator.");
}
}
return View(student);
}
當網頁介面包含實體中的所有欄位並可更新任一欄位時,你可以使用這種方法。
腳手架程式碼採用 建立並附加(create-and-attach) 方法,但只捕捉 DbUpdateConcurrencyException 例外並回傳 404 錯誤碼。 範例中會捕捉任何資料庫更新的異常,並顯示錯誤訊息。
了解實體狀態
無論記憶體中實體是否與資料庫中相對應資料列同步,資料庫內容都會持續追蹤。 這些資訊決定了你呼叫該 SaveChanges 方法時會發生什麼。 例如,當你將一個新實體傳給該 Add 方法時,該實體的狀態會被設定為 Added。 當你呼叫該 SaveChanges 方法時,資料庫上下文會發出 SQL INSERT 指令。
實體可以處於以下其中一種狀態:
Added:實體尚未存在於資料庫中。SaveChanges方法會發出INSERT陳述式。Unchanged:此實體不需要用SaveChanges方法執行任何操作。 當您從資料庫讀取一個實體時,實體便會以此狀態開始。Modified:實體的部分或全部財產價值會被修改。SaveChanges方法會發出UPDATE陳述式。Deleted: 該實體已被標記為刪除。SaveChanges方法會發出DELETE陳述式。Detached: 資料庫上下文不會追蹤該實體。
在桌面應用程式中,狀態變更通常會自動進行設定。 你讀取一個實體,並對其部分財產價值進行修改。 此行為會使其實體狀態自動轉換為 Modified。 當你呼叫該 SaveChanges 方法時,Entity Framework 會產生一個 SQL UPDATE 陳述式,只更新你實際更改的屬性。
在網頁應用程式中,用來讀取實體並展示可編輯資料的 DbContext 物件會在頁面渲染後被釋放。 當呼叫 HttpPost Edit 動作方法時,會有一個新的網頁請求,你就會有一個新的類別實例 DbContext 。 如果你在那個新情境中重新讀取該實體,你就模擬了桌面處理。
如果你不想做額外的讀取操作,就必須使用 Model binder 建立的實體物件。 最簡單的方法是將實體狀態設定為 Modified ,就像前面展示的替代 HttpPost Edit 代碼一樣。 當你呼叫該 SaveChanges 方法時,實體框架會更新資料庫列中的所有欄位,因為上下文無法知道你更改了哪些屬性。
如果你想避免使用 讀取優先 的方法,但如果又希望 SQL UPDATE 語句僅更新使用者變更的欄位,這樣的程式碼會較為複雜。 你必須以某種方式保存原始值,例如使用隱藏欄位。 當呼叫 HttpPost Edit 方法時,這些值必須可用。 你可以用原始值建立 Student 實體,然後用原始版本呼叫 Attach 方法,將實體的值更新為新值,然後再呼叫該 SaveChanges 方法。
測試編輯頁面
啟動應用程式,選擇 學生 標籤,然後選擇 編輯 超連結。
更改部分資料並選擇 儲存。 打開 索引 頁面,你會看到變更的資料。
更新 [刪除] 頁面
在 StudentController.cs 檔案中,HttpGet Delete 方法的範本程式碼使用FirstOrDefaultAsync方法來檢索所選擇的Student實體,如你在Details和Edit方法中看到的一樣。 然而,若要在呼叫 SaveChanges 方法失敗時實作自訂錯誤訊息,你需要為此方法及其對應檢視新增一些功能。
如您所見,更新操作和創建操作需要兩個行為方法,刪除操作也是如此。 回應 GET 請求時所呼叫的方法會顯示一個檢視圖,讓使用者有機會批准或取消刪除操作。 若使用者批准,則會建立一個 POST 請求。 接著呼叫 HttpPost Delete 方法,這個方法實際執行刪除操作。
你需要在 HttpPost Delete 方法中加入一個嘗試-捕捉區塊,以處理資料庫更新時可能發生的錯誤。 若發生錯誤,HttpPost Delete 方法會呼叫 HttpGet Delete 方法,傳遞一個表示錯誤的參數。 HttpGet Delete 方法會重新顯示確認頁面及錯誤訊息,讓使用者有機會取消或重試。
將 HttpGet Delete 動作方法替換成以下程式碼,該程式碼負責錯誤回報。
public async Task<IActionResult> Delete(int? id, bool? saveChangesError = false)
{
if (id == null)
{
return NotFound();
}
var student = await _context.Students
.AsNoTracking()
.FirstOrDefaultAsync(m => m.ID == id);
if (student == null)
{
return NotFound();
}
if (saveChangesError.GetValueOrDefault())
{
ViewData["ErrorMessage"] =
"Delete failed. Try again, and if the problem persists " +
"see your system administrator.";
}
return View(student);
}
此程式碼接受一個可選參數,指示方法呼叫是否發生在未儲存變更後。 當 HttpGet Delete 方法未曾失敗時,這個參數為假。 當 HttpPost Delete 方法因資料庫更新錯誤而呼叫該方法時,參數為真,並會傳達錯誤訊息給視圖。
採用先讀策略進行 HttpPost 的刪除操作
將 HttpPost Delete 動作方法(名為 DeleteConfirmed)替換為以下程式碼。 這段程式碼執行實際的刪除操作,並捕捉任何資料庫更新錯誤。
[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
var student = await _context.Students.FindAsync(id);
if (student == null)
{
return RedirectToAction(nameof(Index));
}
try
{
_context.Students.Remove(student);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
catch (DbUpdateException /* ex */)
{
//Log the error (uncomment ex variable name and write a log.)
return RedirectToAction(nameof(Delete), new { id = id, saveChangesError = true });
}
}
程式碼會取得所選實體,然後呼叫 Remove 該方法將實體狀態設定為 Deleted。 當呼叫該 SaveChanges 方法時,會產生一個 SQL DELETE 指令。
使用 create-and-attach 方法來處理 HttpPost Delete
如果優先提升高容量應用程式的效能,你可以透過只使用主鍵值實 Student 例化實體,並將實體狀態設為 Deleted來避免不必要的 SQL 查詢。 這些資訊就是實體框架刪除該實體所需的全部資訊。 (不要用以下程式碼更新你的專案。程式碼說明了另一種方法。)
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(int id)
{
try
{
Student studentToDelete = new Student() { ID = id };
_context.Entry(studentToDelete).State = EntityState.Deleted;
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
catch (DbUpdateException /* ex */)
{
//Log the error (uncomment ex variable name and write a log.)
return RedirectToAction(nameof(Delete), new { id = id, saveChangesError = true });
}
}
如果實體也包含需要刪除的相關資料,請確保資料庫中有設定級聯刪除。 採用這種實體刪除方式,Entity Framework 可能不會意識到有相關實體需要刪除。
更新刪除檢視
在 Views/Student/Delete.cshtml 檔案中,請在 h2 標題與 h3 標題之間新增錯誤訊息,如下範例所示:
<h2>Delete</h2>
<p class="text-danger">@ViewData["ErrorMessage"]</p>
<h3>Are you sure you want to delete this?</h3>
執行應用程式,選擇 學生 標籤,並選擇 刪除 超連結:
選擇 刪除。 索引頁面顯示時,不會出現已刪除的學生。 (你可以在並發教學中看到錯誤處理程式碼的實例。)
密切的資料庫連結
為了釋放資料庫連線所佔據的資源,完成後必須盡快丟棄上下文實例。 ASP.NET Core 內建的 相依性注入 負責處理清理工作。
在 Startup.cs 檔案中,你呼叫 AddDbContext 擴充方法 ,在 ASP.NET Core DI 容器中建立該 DbContext 類別。 此方法預設將服務壽命設定為 。Scoped
Scoped 表示上下文物件的壽命與網頁請求的壽命重合, Dispose 且該方法會在網頁請求結束時自動呼叫。
處理交易
預設情況下,實體框架隱含實作交易。 在你對多列或多張資料表做變更後再呼叫該 SaveChanges 方法的情況,實體框架會自動確保你所有變更要麼成功,要麼全部失敗。 當某些變更先完成而後出現錯誤時,已完成的變更會自動回滾。 在需要更多控制的情況下,請參見 交易。 範例說明如何將在 Entity Framework 外完成的操作納入交易中。
停用實體物件追蹤(無追蹤查詢)
當資料庫上下文擷取資料表列並建立代表它們的實體物件時,它會自動追蹤記憶體中的實體是否與資料庫同步。 記憶體中的資料作為快取,在更新實體時使用。 這種快取在網頁應用程式中通常是不必要的,因為上下文實例的壽命通常很短(每個請求都會建立並釋放一個新的)。 讀取實體的上下文通常會在該實體再次使用前被處置。
你可以透過呼叫該 AsNoTracking 方法來關閉記憶體中實體物件的追蹤。 以下是一些常見的情境,適用於此操作:
在上下文生命週期內,你不需要更新任何實體,也不需要 EntityFramework 自動載入由不同查詢取得的實體的導航屬性。 這些條件通常會在控制器的 HttpGet 動作方法中被滿足。
你執行一個查詢,取得大量資料,但只有一小部分回傳的資料會被更新。 關閉大型查詢的追蹤,之後再對需要更新的少數實體執行查詢,會更有效率。
你想附加一個實體來進行更新,但之前你為了不同目的取得了同一個實體。 因為資料庫上下文已經追蹤實體,你無法附加你想更改的實體。 處理這種情況的一種方法是對先前的查詢呼叫
AsNoTracking方法。
欲了解更多資訊,請參閱 「追蹤與無追蹤查詢」。